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

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 (159) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +154 -37
  3. package/distribution/package.ts +21 -0
  4. package/distribution/plugin.ts +122 -0
  5. package/package.json +34 -40
  6. package/skills/c64-assembler/SKILL.md +76 -0
  7. package/skills/c64-assembler/scripts/assemble.ts +110 -0
  8. package/skills/c64-assembler/scripts/compare.ts +84 -0
  9. package/skills/c64-basic/SKILL.md +66 -0
  10. package/skills/c64-basic/scripts/basic.ts +46 -0
  11. package/skills/c64-disk/SKILL.md +93 -0
  12. package/skills/c64-disk/scripts/containers.ts +124 -0
  13. package/skills/c64-disk/scripts/disk.ts +120 -0
  14. package/skills/c64-emulator/SKILL.md +134 -0
  15. package/skills/c64-knowledge/SKILL.md +166 -0
  16. package/skills/c64-knowledge/scripts/knowledge.ts +492 -0
  17. package/skills/c64-memory-map/SKILL.md +79 -0
  18. package/skills/c64-memory-map/scripts/memmap.ts +92 -0
  19. package/skills/c64-provenance/SKILL.md +110 -0
  20. package/skills/c64-provenance/scripts/compare.ts +152 -0
  21. package/skills/c64-provenance/scripts/provenance.ts +334 -0
  22. package/skills/c64-provenance/scripts/runtime.ts +191 -0
  23. package/skills/c64-provenance/scripts/separate.ts +140 -0
  24. package/skills/c64-reverse-engineering/SKILL.md +144 -0
  25. package/skills/c64-source/SKILL.md +126 -0
  26. package/skills/c64-source/scripts/generate.ts +490 -0
  27. package/skills/c64-source/scripts/loader.ts +571 -0
  28. package/skills/c64-source/scripts/opcodes.ts +61 -0
  29. package/skills/c64-source/scripts/source.ts +595 -0
  30. package/skills/c64-static-analysis/SKILL.md +119 -0
  31. package/skills/c64-static-analysis/scripts/analyze.ts +275 -0
  32. package/skills/c64-static-analysis/scripts/coverage.ts +78 -0
  33. package/skills/c64-static-analysis/scripts/findings.ts +75 -0
  34. package/skills/c64-static-analysis/scripts/idioms.ts +149 -0
  35. package/skills/c64-static-analysis/scripts/instructions.ts +105 -0
  36. package/skills/c64-testing/SKILL.md +103 -0
  37. package/skills/c64-testing/references/scenario-format.md +116 -0
  38. package/skills/c64-testing/scripts/checklist.ts +51 -0
  39. package/skills/c64-testing/scripts/run.ts +189 -0
  40. package/skills/c64-testing/scripts/scenario.ts +493 -0
  41. package/skills/c64-testing/scripts/test.ts +65 -0
  42. package/skills/c64-unpacker/SKILL.md +135 -0
  43. package/skills/c64-unpacker/scripts/evidence.ts +207 -0
  44. package/skills/c64-unpacker/scripts/loads.ts +209 -0
  45. package/skills/c64-unpacker/scripts/machine.ts +80 -0
  46. package/skills/c64-unpacker/scripts/stages.ts +130 -0
  47. package/skills/c64-unpacker/scripts/unpack.ts +683 -0
  48. package/src/c64.ts +411 -0
  49. package/src/cli/ap-sdk.ts +44 -0
  50. package/src/cli/cleanup.ts +194 -0
  51. package/src/cli/main.ts +125 -0
  52. package/src/cli/status.ts +61 -0
  53. package/src/host/main.ts +134 -0
  54. package/src/host/server.ts +392 -0
  55. package/src/host/tools/c1541.ts +284 -0
  56. package/src/host/tools/index.ts +35 -0
  57. package/src/host/tools/petcat.ts +77 -0
  58. package/src/host/tools/status.ts +47 -0
  59. package/src/host/tools/version.ts +41 -0
  60. package/src/host/vice/adapter.ts +804 -0
  61. package/src/host/vice/binary-monitor.ts +389 -0
  62. package/src/host/vice/chips.ts +118 -0
  63. package/src/host/vice/connect.ts +34 -0
  64. package/src/host/vice/process.ts +413 -0
  65. package/src/host/vice/screen.ts +184 -0
  66. package/src/host/vice/session.ts +1426 -0
  67. package/src/host/vice/text-monitor.ts +223 -0
  68. package/src/host-client/autostart.ts +55 -0
  69. package/src/host-client/connect.ts +241 -0
  70. package/src/host-client/coverage.ts +63 -0
  71. package/src/host-client/steps.ts +170 -0
  72. package/src/host-client/tools.ts +89 -0
  73. package/src/host-client/transfer.ts +126 -0
  74. package/src/host-client/vice-session.ts +316 -0
  75. package/src/knowledge/database.ts +132 -0
  76. package/src/knowledge/history.ts +163 -0
  77. package/src/knowledge/import.ts +391 -0
  78. package/src/knowledge/read.ts +342 -0
  79. package/src/knowledge/schema.ts +229 -0
  80. package/src/knowledge/write.ts +663 -0
  81. package/src/mcp/main.ts +134 -0
  82. package/src/mcp/output.ts +32 -0
  83. package/src/mcp/server.ts +316 -0
  84. package/src/mcp/tools/debug.ts +327 -0
  85. package/src/mcp/tools/execution.ts +117 -0
  86. package/src/mcp/tools/input.ts +72 -0
  87. package/src/mcp/tools/machine.ts +100 -0
  88. package/src/mcp/tools/media.ts +129 -0
  89. package/src/mcp/tools/memory.ts +250 -0
  90. package/src/mcp/tools/video.ts +271 -0
  91. package/src/native/acme.ts +116 -0
  92. package/src/native/discover.ts +179 -0
  93. package/src/native/dxa.ts +166 -0
  94. package/src/native/ghidra/analyze.ts +86 -0
  95. package/src/native/ghidra/index.ts +173 -0
  96. package/src/native/ghidra/language/c64rt_6510.cspec +34 -0
  97. package/src/native/ghidra/language/c64rt_6510.ldefs +15 -0
  98. package/src/native/ghidra/language/c64rt_6510.pspec +15 -0
  99. package/src/native/ghidra/language/c64rt_6510.slaspec +596 -0
  100. package/src/native/ghidra/scripts/C64Export.java +274 -0
  101. package/src/native/ghidra/scripts/C64Prepare.java +60 -0
  102. package/src/native/local.ts +34 -0
  103. package/src/native/processes.ts +350 -0
  104. package/src/native/run.ts +172 -0
  105. package/src/native/staging.ts +83 -0
  106. package/src/native/status.ts +113 -0
  107. package/src/native/types.ts +257 -0
  108. package/src/native/watchdog.ts +78 -0
  109. package/src/package.ts +21 -0
  110. package/src/platform.ts +441 -0
  111. package/src/project.ts +39 -0
  112. package/src/protocol/framing.ts +132 -0
  113. package/src/protocol/messages.ts +259 -0
  114. package/src/protocol/tools.ts +266 -0
  115. package/src/protocol/vice.ts +1348 -0
  116. package/src/script.ts +60 -0
  117. package/src/time.ts +15 -0
  118. package/src/trace.ts +194 -0
  119. package/THIRD-PARTY-NOTICES.md +0 -26
  120. package/bin/cli.mjs +0 -283
  121. package/skills/acme-build/SKILL.md +0 -260
  122. package/skills/acme-build/scripts/acme.mjs +0 -341
  123. package/skills/acme-build/template.a +0 -53
  124. package/skills/c64-disk-access/SKILL.md +0 -159
  125. package/skills/c64-disk-access/scripts/c1541.mjs +0 -569
  126. package/skills/c64-memory-mapping/SKILL.md +0 -631
  127. package/skills/c64-memory-mapping/memmap.json +0 -8800
  128. package/skills/c64-memory-mapping/scripts/driver.mjs +0 -553
  129. package/skills/c64-petcat/SKILL.md +0 -87
  130. package/skills/c64-petcat/scripts/petcat.mjs +0 -221
  131. package/skills/c64-program-recon/SKILL.md +0 -706
  132. package/skills/c64-program-recon/references/control-flow.md +0 -180
  133. package/skills/c64-program-recon/references/graphics.md +0 -79
  134. package/skills/c64-program-recon/references/observation-hazards.md +0 -143
  135. package/skills/c64-program-recon/references/reconstruction.md +0 -133
  136. package/skills/c64-program-recon/references/sound-and-input.md +0 -74
  137. package/skills/c64-program-recon/references/tool-selection.md +0 -78
  138. package/skills/c64-program-recon/scripts/derive.mjs +0 -364
  139. package/skills/c64-program-recon/scripts/packer-finding.mjs +0 -709
  140. package/skills/c64-program-recon/templates/memory-map.template.md +0 -101
  141. package/skills/c64-provenance-diff/SKILL.md +0 -292
  142. package/skills/c64-provenance-diff/scripts/diff-images.mjs +0 -984
  143. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +0 -395
  144. package/skills/c64-ram-capture/RELEASES.json.example +0 -17
  145. package/skills/c64-ram-capture/SKILL.md +0 -474
  146. package/skills/c64-ram-capture/scripts/compare-cross-binary.mjs +0 -525
  147. package/skills/c64-ram-capture/scripts/compare.mjs +0 -258
  148. package/skills/c64-ram-capture/scripts/derive-transients.mjs +0 -575
  149. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +0 -317
  150. package/skills/c64-ram-capture/scripts/mcp-module.mjs +0 -174
  151. package/skills/c64-ram-capture/scripts/project-paths.mjs +0 -81
  152. package/skills/c64-ram-capture/scripts/releases.mjs +0 -109
  153. package/skills/c64-ram-capture/scripts/vsf-slice.mjs +0 -147
  154. package/skills/c64-ram-capture/scripts/watch-loads.mjs +0 -581
  155. package/skills/c64-ram-capture/templates/capture-record.template.md +0 -99
  156. package/skills/c64-ram-capture/transients/README.md +0 -137
  157. package/skills/routine-queue-walker/SKILL.md +0 -394
  158. package/skills/routine-queue-walker/scripts/completeness-report.mjs +0 -465
  159. package/skills/vice-wedge-triage/SKILL.md +0 -269
@@ -0,0 +1,116 @@
1
+ // The ACME adapter: argv from the typed request only, structured
2
+ // diagnostics and symbols, and a validated C64 PRG as the result.
3
+
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { isAbsolute, join, relative, sep } from "node:path";
6
+
7
+ import { WireFailure } from "../protocol/messages.ts";
8
+ import { isRelativePath } from "../protocol/tools.ts";
9
+ import type { AcmeParams, AcmeResult, AssembledSymbol, Diagnostic } from "./types.ts";
10
+ import { Workspace } from "./staging.ts";
11
+ import { ACME, findTool } from "./discover.ts";
12
+ import { outputTail, runToolOrFail, type ToolContext } from "./run.ts";
13
+
14
+ const PROGRAM = "program.prg";
15
+ const SYMBOLS = "symbols.txt";
16
+ const TIMEOUT_MS = 60_000;
17
+
18
+ /** True for a relative path that stays inside the source root: no absolute path, no drive letter and no "..". */
19
+ const insideSourceRoot = (path: string) => isRelativePath(path) && !/^[A-Za-z]:/.test(path);
20
+
21
+ /** Refuses an entry source or include directory that ACME could read outside the source root, by name. */
22
+ export function checkAcmeParams(params: AcmeParams): void {
23
+ if (!insideSourceRoot(params.entrySource)) throw new WireFailure("invalid-input", `The entry source ${params.entrySource} is not a relative path inside the source root.`);
24
+ if (params.entrySource.startsWith("-")) throw new WireFailure("invalid-input", `The entry source ${params.entrySource} starts with a hyphen, which ACME reads as an option. Give the file a different name.`);
25
+ for (const directory of params.includeDirs) {
26
+ if (!insideSourceRoot(directory)) throw new WireFailure("invalid-input", `The include directory ${directory} is not a relative path inside the source root.`);
27
+ }
28
+ }
29
+
30
+ /** The argv for one assembly; every value comes from the request that checkAcmeParams accepted. */
31
+ export function acmeArguments(executable: string, params: AcmeParams, outputDirectory: string): string[] {
32
+ const argv = [executable, "--format", "cbm", "--cpu", "6510", "--maxerrors", "100", "-o", join(outputDirectory, PROGRAM), "--symbollist", join(outputDirectory, SYMBOLS)];
33
+ for (const directory of params.includeDirs) argv.push("-I", directory);
34
+ for (const [name, value] of Object.entries(params.defines)) argv.push(`-D${name}=${typeof value === "boolean" ? (value ? 1 : 0) : value}`);
35
+ if (params.setPc !== undefined) argv.push("--setpc", String(params.setPc));
36
+ argv.push(params.entrySource);
37
+ return argv;
38
+ }
39
+
40
+ const DIAGNOSTIC = /^(Error|Warning|Serious error) - File (.+?), line (\d+)(?: \(.*?\))?: (.*)$/;
41
+
42
+ /** Parses ACME's "Error - File main.a, line 4 (Zone ...): message" lines. Files become source-root relative. */
43
+ export function parseDiagnostics(output: string, sourceRoot: string): Diagnostic[] {
44
+ const diagnostics: Diagnostic[] = [];
45
+ for (const line of output.split(/\r?\n/)) {
46
+ const match = DIAGNOSTIC.exec(line.trim());
47
+ if (match !== null) {
48
+ let file = match[2]!;
49
+ if (isAbsolute(file)) {
50
+ const inside = relative(sourceRoot, file);
51
+ file = inside.startsWith("..") || isAbsolute(inside) ? "(outside the source root)" : inside.split(sep).join("/");
52
+ }
53
+ diagnostics.push({ severity: match[1] === "Warning" ? "warning" : "error", file, line: Number(match[3]), message: match[4]! });
54
+ } else if (/^(Error|Serious error|Warning)\b/.test(line.trim())) {
55
+ diagnostics.push({ severity: line.trim().startsWith("Warning") ? "warning" : "error", message: line.trim() });
56
+ }
57
+ }
58
+ return diagnostics;
59
+ }
60
+
61
+ /**
62
+ * Parses ACME's symbol list ("\tname\t= $2100\t; unused"). A value inside the
63
+ * assembled program is an address; any other value is a constant.
64
+ */
65
+ export function parseSymbols(list: string, program: { start: number; end: number }): AssembledSymbol[] {
66
+ const symbols: AssembledSymbol[] = [];
67
+ for (const line of list.split(/\r?\n/)) {
68
+ const match = /^\s*([A-Za-z_][\w.]*)\s*=\s*\$([0-9a-fA-F]+)\s*(?:;\s*(.*?))?\s*$/.exec(line);
69
+ if (match === null) continue;
70
+ const name = match[1]!;
71
+ const value = Number.parseInt(match[2]!, 16);
72
+ const used = match[3] !== "unused";
73
+ symbols.push(value >= program.start && value <= program.end ? { name, kind: "address", address: value, used } : { name, kind: "constant", value, used });
74
+ }
75
+ // Code-point order: the same on every host, whatever its locale.
76
+ return symbols.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
77
+ }
78
+
79
+ export async function assemble(
80
+ params: AcmeParams,
81
+ files: Buffer[],
82
+ context: ToolContext,
83
+ ): Promise<{ result: AcmeResult; attachments?: Buffer[] }> {
84
+ checkAcmeParams(params);
85
+ const executable = findTool(ACME, context.env);
86
+ const workspace = Workspace.create(context.supervisor);
87
+ try {
88
+ const source = workspace.materialize("source", params.files, files);
89
+ // A fresh output directory, so no stale file can pass for this run's output.
90
+ const output = workspace.directory("out");
91
+ const run = await runToolOrFail("ACME", "The assembly", { argv: acmeArguments(executable, params, output), cwd: source, timeoutMs: TIMEOUT_MS }, context);
92
+ const diagnostics = parseDiagnostics(`${run.stdout}\n${run.stderr}`, source);
93
+ const programPath = join(output, PROGRAM);
94
+ if (run.code !== 0) {
95
+ if (diagnostics.every((diagnostic) => diagnostic.severity !== "error")) {
96
+ context.log?.(`ACME exited ${run.code} without a parsed error:\n${run.stdout}\n${run.stderr}`);
97
+ diagnostics.push({ severity: "error", message: `ACME stopped with exit code ${run.code} and gave no error in its usual form.${outputTail("ACME", run, workspace.root)}` });
98
+ }
99
+ return { result: { assembled: false, diagnostics } };
100
+ }
101
+ if (!existsSync(programPath)) throw new WireFailure("operation-failed", "ACME reported success but wrote no program.");
102
+ const program = readFileSync(programPath);
103
+ if (program.length < 3) throw new WireFailure("operation-failed", "ACME wrote a program without bytes.");
104
+ const start = program.readUInt16LE(0);
105
+ const bytes = program.length - 2;
106
+ if (start + bytes > 0x10000) throw new WireFailure("operation-failed", "ACME wrote a program that runs past $ffff.");
107
+ const end = start + bytes - 1;
108
+ const symbolList = existsSync(join(output, SYMBOLS)) ? readFileSync(join(output, SYMBOLS), "latin1") : "";
109
+ return {
110
+ result: { assembled: true, loadRange: { start, end, bytes }, symbols: parseSymbols(symbolList, { start, end }), diagnostics },
111
+ attachments: [program],
112
+ };
113
+ } finally {
114
+ workspace.remove();
115
+ }
116
+ }
@@ -0,0 +1,179 @@
1
+ // One discovery rule for every native tool: C64RT_<TOOL> names
2
+ // the executable, else the tool's usual name on PATH. A missing tool is
3
+ // refused by name with the remedy; nothing is ever installed.
4
+
5
+ import { accessSync, constants, statSync } from "node:fs";
6
+ import { isAbsolute, join, posix, win32 } from "node:path";
7
+
8
+ import { WireFailure } from "../protocol/messages.ts";
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
+
13
+ export interface ToolSpec {
14
+ /** Human name, for messages. */
15
+ name: string;
16
+ /** Environment variable that can name the executable. */
17
+ envVar: string;
18
+ /** Executable names to look for on PATH, in order. */
19
+ binaries: string[];
20
+ /** Where the tool runs: the Host Runtime's machine, or the skill script's. */
21
+ where: "on the host" | "on this machine";
22
+ /** What the user does when the tool is missing. */
23
+ remedy: string;
24
+ }
25
+
26
+ export const ACME: ToolSpec = {
27
+ name: "ACME",
28
+ envVar: "C64RT_ACME",
29
+ binaries: ["acme"],
30
+ where: "on this machine",
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}`,
32
+ };
33
+
34
+ export const C1541: ToolSpec = {
35
+ name: "c1541",
36
+ envVar: "C64RT_C1541",
37
+ binaries: ["c1541"],
38
+ where: "on the host",
39
+ remedy: "Install VICE 3.9 or later on the host (c1541 comes with it) so that c1541 is on PATH, or set C64RT_C1541 to its full path, then restart the Host Runtime.",
40
+ };
41
+
42
+ export const PETCAT: ToolSpec = {
43
+ name: "petcat",
44
+ envVar: "C64RT_PETCAT",
45
+ binaries: ["petcat"],
46
+ where: "on the host",
47
+ remedy: "Install VICE 3.9 or later on the host (petcat comes with it) so that petcat is on PATH, or set C64RT_PETCAT to its full path, then restart the Host Runtime.",
48
+ };
49
+
50
+ export const DXA: ToolSpec = {
51
+ name: "dxa",
52
+ envVar: "C64RT_DXA",
53
+ binaries: ["dxa"],
54
+ where: "on this machine",
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}`,
56
+ };
57
+
58
+ export const VICE: ToolSpec = {
59
+ name: "VICE (x64sc)",
60
+ envVar: "C64RT_VICE",
61
+ binaries: ["x64sc"],
62
+ where: "on the host",
63
+ remedy: "Install VICE 3.9 or later on the host so that x64sc is on PATH, or set C64RT_VICE to the full path of x64sc, then restart the Host Runtime.",
64
+ };
65
+
66
+ /**
67
+ * The oldest VICE that works: 3.7 has no monitor profiler, and its CPU
68
+ * history, until-return and c1541 chain output differ.
69
+ */
70
+ export const MIN_VICE = { major: 3, minor: 9 } as const;
71
+
72
+ /** A VICE version; `revision` is the SVN revision of a build from VICE's source tree (nightly builds), absent for a release. */
73
+ export interface ViceVersion {
74
+ major: number;
75
+ minor: number;
76
+ revision?: number;
77
+ }
78
+
79
+ /** "x64sc (VICE 3.10)", "c1541 (VICE 3.9)", "VICE 3.9.0" or "x64sc (VICE 3.10 SVN r45999)": the major and minor version, and the SVN revision. */
80
+ export function viceVersionOf(text: string): ViceVersion | undefined {
81
+ const match = /VICE (\d+)\.(\d+)(?:[.\d]*)(?: SVN r(\d+))?/.exec(text);
82
+ if (match === null) return undefined;
83
+ return { major: Number(match[1]), minor: Number(match[2]), ...(match[3] === undefined ? {} : { revision: Number(match[3]) }) };
84
+ }
85
+
86
+ /**
87
+ * Known flaws of VICE versions that c64-re-tools still runs on (D56). A flaw
88
+ * is not worked around: it is named with the VICE version that fixes it.
89
+ * `upTo` is the last flawed major.minor; `fixedRevision` the first SVN
90
+ * revision with the fix, for builds from VICE's source tree.
91
+ */
92
+ const VICE_FLAWS = [
93
+ {
94
+ upTo: { major: 3, minor: 10 },
95
+ fixedRevision: 45971,
96
+ text:
97
+ "VICE 3.10 and older hold bits 5-7 of CIA 1 ports A and B low with the joystick device that c64-re-tools uses (VICE bug #2202). " +
98
+ "$DC00 and $DC01 read $1F with no input. A program that waits for one key of the keyboard matrix (for example LDA $DC01 / CMP #$EF for SPACE) waits forever. " +
99
+ "A program that compares the full byte of a joystick port also waits forever. " +
100
+ "A KERNAL tape LOAD of a .tap image stops after FOUND, with no LOADING, and the C64 shows READY. The fix is in the next VICE release, and in VICE nightly builds now (SVN r45971 or later). " +
101
+ "Install the latest VICE when this matters.",
102
+ },
103
+ ] as const;
104
+
105
+ /** The known flaws of this VICE version, as text for a person or an agent. */
106
+ export function viceFlaws(version: ViceVersion): string[] {
107
+ return VICE_FLAWS.filter((flaw) => {
108
+ const newer = version.major > flaw.upTo.major || (version.major === flaw.upTo.major && version.minor > flaw.upTo.minor);
109
+ return !newer && (version.revision === undefined || version.revision < flaw.fixedRevision);
110
+ }).map((flaw) => flaw.text);
111
+ }
112
+
113
+ export function isOlderThanMinimum(version: { major: number; minor: number }): boolean {
114
+ return version.major < MIN_VICE.major || (version.major === MIN_VICE.major && version.minor < MIN_VICE.minor);
115
+ }
116
+
117
+ /** A file this process may run. Windows has no execute permission (X_OK checks nothing there): a file is enough. */
118
+ export function isExecutableFile(path: string): boolean {
119
+ try {
120
+ if (!statSync(path).isFile()) return false;
121
+ if (process.platform !== "win32") accessSync(path, constants.X_OK);
122
+ return true;
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * The value of an environment variable. Windows ignores the case of the name,
130
+ * also in a copied environment, where PATH is often named "Path".
131
+ */
132
+ export function environmentValue(env: NodeJS.ProcessEnv, name: string, platform: NodeJS.Platform = process.platform): string | undefined {
133
+ if (platform !== "win32" || env[name] !== undefined) return env[name];
134
+ const key = Object.keys(env).find((candidate) => candidate.toUpperCase() === name.toUpperCase());
135
+ return key === undefined ? undefined : env[key];
136
+ }
137
+
138
+ /** The extensions that the supervisor can start on Windows: programs directly, batch files through cmd.exe. */
139
+ const STARTABLE = [".com", ".exe", ".bat", ".cmd"];
140
+
141
+ /**
142
+ * The suffixes that a name on PATH can have. On Windows, the PATHEXT
143
+ * extensions that the supervisor can start, in PATHEXT order, and never the
144
+ * bare name: Windows does not start a file without an extension. Elsewhere
145
+ * only the bare name.
146
+ */
147
+ export function pathSuffixes(env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform): string[] {
148
+ if (platform !== "win32") return [""];
149
+ const listed = (environmentValue(env, "PATHEXT", platform) ?? "").split(";").map((suffix) => suffix.trim().toLowerCase());
150
+ const suffixes = listed.filter((suffix, index) => STARTABLE.includes(suffix) && listed.indexOf(suffix) === index);
151
+ return suffixes.length === 0 ? STARTABLE : suffixes;
152
+ }
153
+
154
+ /** The first executable named `binary + suffix` in a PATH directory, in PATH order; undefined when there is none. */
155
+ export function findOnPath(binaries: readonly string[], env: NodeJS.ProcessEnv, suffixes: readonly string[] = pathSuffixes(env), platform: NodeJS.Platform = process.platform): string | undefined {
156
+ const delimiter = platform === "win32" ? win32.delimiter : posix.delimiter;
157
+ for (const dir of (environmentValue(env, "PATH", platform) ?? "").split(delimiter)) {
158
+ if (dir === "") continue;
159
+ for (const binary of binaries) {
160
+ for (const suffix of suffixes) {
161
+ const candidate = join(dir, binary + suffix);
162
+ if (isExecutableFile(candidate)) return candidate;
163
+ }
164
+ }
165
+ }
166
+ return undefined;
167
+ }
168
+
169
+ /** Finds a tool's executable. Throws WireFailure(installation-incomplete) naming the tool and the remedy. */
170
+ export function findTool(tool: ToolSpec, env: NodeJS.ProcessEnv = process.env): string {
171
+ const configured = env[tool.envVar];
172
+ if (configured !== undefined && configured !== "") {
173
+ if (isAbsolute(configured) && isExecutableFile(configured)) return configured;
174
+ throw new WireFailure("installation-incomplete", `${tool.envVar} ${tool.where} does not name an executable ${tool.name} file. ${tool.remedy}`);
175
+ }
176
+ const found = findOnPath(tool.binaries, env);
177
+ if (found !== undefined) return found;
178
+ throw new WireFailure("installation-incomplete", `${tool.name} is not installed ${tool.where}. ${tool.remedy}`);
179
+ }
@@ -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
+ }