@henols/c64-re-tools 2.0.0-rc.8 → 2.0.0

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 (118) hide show
  1. package/README.md +98 -115
  2. package/package.json +1 -1
  3. package/skills/c64-assembler/SKILL.md +22 -6
  4. package/skills/c64-assembler/scripts/assemble.ts +42 -14
  5. package/skills/c64-assembler/scripts/lib/compare.ts +48 -0
  6. package/skills/c64-basic/SKILL.md +10 -1
  7. package/skills/c64-basic/scripts/basic.ts +3 -3
  8. package/skills/c64-disk/SKILL.md +13 -4
  9. package/skills/c64-disk/scripts/disk.ts +7 -7
  10. package/skills/c64-disk/scripts/{containers.ts → lib/containers.ts} +2 -2
  11. package/skills/c64-docs/SKILL.md +138 -0
  12. package/skills/c64-docs/references/page-types.md +141 -0
  13. package/skills/c64-docs/references/style.md +43 -0
  14. package/skills/c64-docs/scripts/generate.ts +109 -0
  15. package/skills/c64-docs/scripts/lib/links.ts +167 -0
  16. package/skills/c64-docs/scripts/lib/markdown.ts +79 -0
  17. package/skills/c64-docs/scripts/lib/reference.ts +406 -0
  18. package/skills/c64-docs/scripts/lib/source-map.ts +81 -0
  19. package/skills/c64-docs/scripts/link.ts +239 -0
  20. package/skills/c64-emulator/SKILL.md +16 -5
  21. package/skills/c64-knowledge/SKILL.md +140 -18
  22. package/skills/c64-knowledge/scripts/knowledge.ts +737 -112
  23. package/skills/c64-knowledge/scripts/lib/listing.ts +162 -0
  24. package/skills/c64-memory-map/SKILL.md +11 -2
  25. package/skills/c64-memory-map/scripts/memmap.ts +2 -2
  26. package/skills/c64-provenance/SKILL.md +66 -11
  27. package/skills/c64-provenance/scripts/{compare.ts → lib/compare.ts} +3 -3
  28. package/skills/c64-provenance/scripts/lib/library.ts +584 -0
  29. package/skills/c64-provenance/scripts/{runtime.ts → lib/runtime.ts} +2 -2
  30. package/skills/c64-provenance/scripts/{separate.ts → lib/separate.ts} +3 -3
  31. package/skills/c64-provenance/scripts/provenance.ts +242 -40
  32. package/skills/c64-reverse-engineering/SKILL.md +51 -26
  33. package/skills/c64-source/SKILL.md +76 -28
  34. package/skills/c64-source/scripts/generate.ts +425 -127
  35. package/skills/c64-source/scripts/{loader.ts → lib/loader.ts} +84 -25
  36. package/skills/c64-source/scripts/lib/source.ts +1121 -0
  37. package/skills/c64-static-analysis/SKILL.md +35 -18
  38. package/skills/c64-static-analysis/scripts/analyze.ts +184 -55
  39. package/skills/c64-static-analysis/scripts/{coverage.ts → lib/coverage.ts} +19 -25
  40. package/skills/c64-static-analysis/scripts/{findings.ts → lib/findings.ts} +6 -2
  41. package/skills/c64-static-analysis/scripts/{idioms.ts → lib/idioms.ts} +21 -12
  42. package/skills/c64-static-analysis/scripts/{instructions.ts → lib/instructions.ts} +1 -1
  43. package/skills/c64-static-analysis/scripts/lib/macros.ts +233 -0
  44. package/skills/c64-static-analysis/scripts/lib/pitfalls.ts +68 -0
  45. package/skills/c64-static-analysis/scripts/lib/pointers.ts +108 -0
  46. package/skills/c64-testing/SKILL.md +36 -7
  47. package/skills/c64-testing/references/scenario-format.md +12 -8
  48. package/skills/c64-testing/scripts/checklist.ts +3 -3
  49. package/skills/c64-testing/scripts/diff.ts +167 -0
  50. package/skills/c64-testing/scripts/lib/diff.ts +70 -0
  51. package/skills/c64-testing/scripts/{scenario.ts → lib/scenario.ts} +45 -34
  52. package/skills/c64-testing/scripts/run.ts +73 -21
  53. package/skills/c64-testing/scripts/test.ts +5 -5
  54. package/skills/c64-unpacker/SKILL.md +23 -9
  55. package/skills/c64-unpacker/scripts/{evidence.ts → lib/evidence.ts} +1 -1
  56. package/skills/c64-unpacker/scripts/{loads.ts → lib/loads.ts} +4 -4
  57. package/skills/c64-unpacker/scripts/{machine.ts → lib/machine.ts} +4 -4
  58. package/skills/c64-unpacker/scripts/{stages.ts → lib/stages.ts} +17 -5
  59. package/skills/c64-unpacker/scripts/unpack.ts +41 -24
  60. package/src/c64.ts +103 -5
  61. package/src/cli/cleanup.ts +1 -1
  62. package/src/cli/main.ts +7 -4
  63. package/src/cli/status.ts +70 -3
  64. package/src/graphics.ts +158 -0
  65. package/src/host/main.ts +43 -8
  66. package/src/host/server.ts +10 -3
  67. package/src/host/tools/petcat.ts +1 -1
  68. package/src/host/tools/status.ts +1 -1
  69. package/src/host/vice/adapter.ts +4 -3
  70. package/src/host/vice/process.ts +2 -2
  71. package/src/host/vice/screen.ts +1 -42
  72. package/src/host/vice/session.ts +196 -23
  73. package/src/host-client/autostart.ts +1 -1
  74. package/src/host-client/connect.ts +1 -1
  75. package/src/host-client/coverage.ts +2 -29
  76. package/src/host-client/image.ts +66 -0
  77. package/src/host-client/steps.ts +9 -4
  78. package/src/host-client/tools.ts +1 -0
  79. package/src/host-client/transfer.ts +39 -11
  80. package/src/host-client/vice-session.ts +33 -16
  81. package/src/knowledge/conflicts.ts +161 -0
  82. package/src/knowledge/database.ts +150 -38
  83. package/src/knowledge/expression.ts +209 -0
  84. package/src/knowledge/graph.ts +104 -0
  85. package/src/knowledge/history.ts +70 -22
  86. package/src/knowledge/import.ts +64 -55
  87. package/src/knowledge/join.ts +211 -0
  88. package/src/knowledge/program.ts +266 -0
  89. package/src/knowledge/read.ts +258 -27
  90. package/src/knowledge/schema.ts +199 -75
  91. package/src/knowledge/text.ts +415 -0
  92. package/src/knowledge/write.ts +740 -57
  93. package/src/mcp/main.ts +1 -1
  94. package/src/mcp/output.ts +15 -8
  95. package/src/mcp/server.ts +3 -1
  96. package/src/mcp/tools/debug.ts +97 -16
  97. package/src/mcp/tools/execution.ts +38 -5
  98. package/src/mcp/tools/machine.ts +11 -2
  99. package/src/mcp/tools/media.ts +18 -13
  100. package/src/mcp/tools/memory.ts +17 -8
  101. package/src/mcp/tools/video.ts +9 -9
  102. package/src/native/discover.ts +2 -2
  103. package/src/native/ghidra/analyze.ts +2 -2
  104. package/src/native/ghidra/index.ts +1 -1
  105. package/src/native/ghidra/language/c64rt_6510.slaspec +4 -1
  106. package/src/native/ghidra/scripts/C64Export.java +1 -1
  107. package/src/native/ghidra/scripts/C64Prepare.java +7 -1
  108. package/src/native/types.ts +14 -8
  109. package/{skills/c64-source/scripts → src}/opcodes.ts +75 -3
  110. package/src/platform.ts +70 -7
  111. package/src/project.ts +180 -3
  112. package/src/protocol/messages.ts +1 -1
  113. package/src/protocol/tools.ts +2 -16
  114. package/src/protocol/vice.ts +124 -15
  115. package/src/script.ts +46 -7
  116. package/src/trace.ts +55 -23
  117. package/skills/c64-assembler/scripts/compare.ts +0 -84
  118. package/skills/c64-source/scripts/source.ts +0 -595
@@ -3,10 +3,10 @@
3
3
 
4
4
  import { z } from "zod";
5
5
 
6
- import { formatC64Address } from "../../c64.ts";
6
+ import { formatC64Address, formatSprite, formatVicii } 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
+ import { OutInput, ProjectInput, writeOut, WrittenOutput } from "../output.ts";
10
10
  import { AddressInput, AddressOutput, Byte, defineTool, HexData, MemoryViewInput, requireFields, SpaceInput, ToolOutput, TransientName } from "../server.ts";
11
11
 
12
12
  const Color = z.number().int().min(0).max(15);
@@ -31,8 +31,7 @@ export const c64Vicii = defineTool({
31
31
  }),
32
32
  readOnly: true,
33
33
  async run(_input, session) {
34
- const state = await session.vicii();
35
- return { ...state, screenAddress: formatC64Address(state.screenAddress), graphicsAddress: formatC64Address(state.graphicsAddress) };
34
+ return formatVicii(await session.vicii());
36
35
  },
37
36
  });
38
37
 
@@ -62,7 +61,7 @@ export const c64Sprite = defineTool({
62
61
  readOnly: true,
63
62
  async run(input, session) {
64
63
  const result = await session.sprites(input.sprites ?? []);
65
- return { sprites: result.sprites.map((sprite) => ({ ...sprite, dataAddress: formatC64Address(sprite.dataAddress) })) };
64
+ return { sprites: result.sprites.map(formatSprite) };
66
65
  },
67
66
  });
68
67
 
@@ -150,6 +149,7 @@ export const c64Screen = defineTool({
150
149
  mask: z.array(Rectangle).max(MAX_MASK_RECTANGLES).optional().describe("compare only: rectangles to ignore, in frame pixels"),
151
150
  includeDiff: z.boolean().optional().describe("compare only: also return a difference image"),
152
151
  out: OutInput.optional(),
152
+ project: ProjectInput.optional().describe("capture and compare with out only: the project folder, relative to the folder where the agent started. Give it when that folder is not in a project and has projects below it"),
153
153
  })
154
154
  .strict(),
155
155
  outputSchema: z.object({
@@ -169,21 +169,21 @@ export const c64Screen = defineTool({
169
169
  async run(input, session) {
170
170
  const { action, ...fields } = input;
171
171
  if (action === "capture") {
172
- requireFields(action, fields, ["baseline", "out"]);
172
+ requireFields(action, fields, fields.out === undefined ? ["baseline"] : ["baseline", "out", "project"]);
173
173
  const shot = await session.screenCapture(fields.baseline);
174
174
  const result = { width: shot.width, height: shot.height, ...(shot.baseline === undefined ? {} : { baseline: shot.baseline }) };
175
- if (fields.out !== undefined) return { ...result, ...writeOut(fields.out, Buffer.from(shot.png, "base64")) };
175
+ if (fields.out !== undefined) return { ...result, ...writeOut(fields.out, Buffer.from(shot.png, "base64"), fields.project) };
176
176
  return new ToolOutput(result, [{ type: "image", data: shot.png, mimeType: "image/png" }]);
177
177
  }
178
178
  if (action === "compare") {
179
- requireFields(action, fields, ["baseline", "maxMismatchRatio", "mask", "includeDiff", "out"], ["baseline"]);
179
+ requireFields(action, fields, ["baseline", "maxMismatchRatio", "mask", "includeDiff", ...(fields.out === undefined ? [] : ["out", "project"])], ["baseline"]);
180
180
  const { diffPng, ...result } = await session.screenCompare({
181
181
  baseline: fields.baseline!,
182
182
  maxMismatchRatio: fields.maxMismatchRatio ?? 0,
183
183
  mask: fields.mask ?? [],
184
184
  includeDiff: (fields.includeDiff ?? false) || fields.out !== undefined,
185
185
  });
186
- if (fields.out !== undefined && diffPng !== undefined) return { ...result, ...writeOut(fields.out, Buffer.from(diffPng, "base64")) };
186
+ if (fields.out !== undefined && diffPng !== undefined) return { ...result, ...writeOut(fields.out, Buffer.from(diffPng, "base64"), fields.project) };
187
187
  return diffPng === undefined ? result : new ToolOutput(result, [{ type: "image", data: diffPng, mimeType: "image/png" }]);
188
188
  }
189
189
  if (action === "list") {
@@ -7,7 +7,7 @@ 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). */
10
+ /** Where the user finds the install steps of the native tools for each system. */
11
11
  export const INSTALL_STEPS = "Steps for each system: https://github.com/henols/c64-re-tools/blob/main/docs/install-tools.md";
12
12
 
13
13
  export interface ToolSpec {
@@ -84,7 +84,7 @@ export function viceVersionOf(text: string): ViceVersion | undefined {
84
84
  }
85
85
 
86
86
  /**
87
- * Known flaws of VICE versions that c64-re-tools still runs on (D56). A flaw
87
+ * Known flaws of VICE versions that c64-re-tools still runs on. A flaw
88
88
  * is not worked around: it is named with the VICE version that fixes it.
89
89
  * `upTo` is the last flawed major.minor; `fixedRevision` the first SVN
90
90
  * revision with the fix, for builds from VICE's source tree.
@@ -25,7 +25,7 @@ export async function analyze(
25
25
  const base = start;
26
26
 
27
27
  // Ghidra disassembles and defines data only in the loaded bytes; labels may name any address.
28
- const { entryPoints, dataRanges } = seedsInside(params, start, end);
28
+ const { entryPoints, codeStarts, dataRanges } = seedsInside(params, start, end);
29
29
  if (entryPoints.length === 0) {
30
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
31
  }
@@ -33,7 +33,7 @@ export async function analyze(
33
33
  const workspace = Workspace.create(context.supervisor);
34
34
  try {
35
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 }));
36
+ writeFileSync(workspace.path("seeds.json"), JSON.stringify({ start, end, entryPoints, codeStarts, dataRanges, labels: params.labels }));
37
37
  writeFileSync(
38
38
  workspace.path("request.json"),
39
39
  JSON.stringify({
@@ -129,7 +129,7 @@ export interface HeadlessOptions {
129
129
  /** What Ghidra's launcher (launch.sh, launch.bat) prints when it finds no Java that it can use. */
130
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
131
 
132
- /** A Ghidra without a usable JDK is an incomplete installation that names the Java version it needs (D38). */
132
+ /** A Ghidra without a usable JDK is an incomplete installation that names the Java version it needs. */
133
133
  export function noJdk(ghidra: GhidraInstallation): WireFailure {
134
134
  const java = ghidra.javaMin === undefined ? "a JDK" : `JDK ${ghidra.javaMin} or later`;
135
135
  return new WireFailure(
@@ -263,13 +263,16 @@ REL: target is rel [ target = inst_next + rel; ] { export *:2 target; }
263
263
  }
264
264
 
265
265
  # BRK skips its padding byte: the return address is two bytes on.
266
+ # The vector at $FFFE is in ROM unless the program banks the KERNAL out, and
267
+ # the analysis cannot know the banking. Thus BRK ends the flow, and Ghidra
268
+ # does not follow the RAM bytes at $FFFE of a flat image.
266
269
  :BRK is op=0x00 {
267
270
  *:2 (SP - 1) = inst_next + 1;
268
271
  SP = SP - 2;
269
272
  pushP(1);
270
273
  I = 1;
271
274
  local vector:2 = 0xfffe;
272
- goto [*:2 vector];
275
+ return [*:2 vector];
273
276
  }
274
277
 
275
278
  :CLC is op=0x18 { C = 0; }
@@ -3,7 +3,7 @@
3
3
  // Writes the structural result for the loaded image: functions with the
4
4
  // source of their names, code and defined-data regions, memory references
5
5
  // from instructions, every instruction with its bytes, mnemonic, operand
6
- // text and flow targets (D44), and the requested decompilations within their bounds.
6
+ // text and flow targets, and the requested decompilations within their bounds.
7
7
  //@category c64-re-tools
8
8
 
9
9
  import java.io.FileReader;
@@ -3,7 +3,8 @@
3
3
  // Fills the rest of the 64 KiB address space with uninitialized memory, so
4
4
  // references to I/O and other RAM resolve, then applies the knowledge seeds:
5
5
  // labels (user-defined names), data ranges (defined bytes that analysis does
6
- // not disassemble) and entry points (disassembled functions).
6
+ // not disassemble), entry points (disassembled functions) and code starts
7
+ // (disassembled code without a function: a branch target is no routine).
7
8
  //@category c64-re-tools
8
9
 
9
10
  import java.io.FileReader;
@@ -56,5 +57,10 @@ public class C64Prepare extends GhidraScript {
56
57
  createFunction(entry, null);
57
58
  }
58
59
  }
60
+ if (seeds.has("codeStarts")) {
61
+ for (JsonElement element : seeds.getAsJsonArray("codeStarts")) {
62
+ disassemble(toAddr(element.getAsLong()));
63
+ }
64
+ }
59
65
  }
60
66
  }
@@ -39,13 +39,15 @@ export interface AcmeResult {
39
39
  export const IMAGE_KINDS = ["prg", "flat64k"] as const;
40
40
  export type ImageKind = (typeof IMAGE_KINDS)[number];
41
41
 
42
- export const GHIDRA_LIMITS = { entryPoints: 1024, dataRanges: 1024, labels: 4096, decompile: 32, decompiledChars: 16_000, decompiledTotalChars: 128_000 } as const;
42
+ export const GHIDRA_LIMITS = { entryPoints: 1024, codeStarts: 8192, dataRanges: 1024, labels: 4096, decompile: 32, decompiledChars: 16_000, decompiledTotalChars: 128_000 } as const;
43
43
 
44
44
  /** The Ghidra analysis. Its image is given next to these fields: a PRG, or 64 KiB from $0000. */
45
45
  export interface GhidraParams {
46
46
  imageKind: ImageKind;
47
47
  /** Seeds from current knowledge. */
48
48
  entryPoints: number[];
49
+ /** Executed instruction starts that Ghidra disassembles without making a function: a branch target is no routine. */
50
+ codeStarts?: number[];
49
51
  dataRanges: Array<{ start: number; end: number }>;
50
52
  labels: Array<{ address: number; name: string }>;
51
53
  /** Routine entries to decompile for immediate reasoning; the text is never stored. */
@@ -71,7 +73,7 @@ export interface GhidraResult {
71
73
  decompilations: Array<{ entry: number; text: string; truncated: boolean }>;
72
74
  /** Requested routines without decompiled code, with the decompiler's reason. */
73
75
  notDecompiled?: Array<{ entry: number; reason: string }>;
74
- /** Every instruction in the coverage, as Ghidra decoded it (D44). */
76
+ /** Every instruction in the coverage, as Ghidra decoded it. */
75
77
  instructions: GhidraInstruction[];
76
78
  /** Which categories are complete inside the coverage; private to the importer. */
77
79
  completeness: { functions: boolean; regions: boolean; references: boolean };
@@ -80,9 +82,10 @@ export interface GhidraResult {
80
82
  }
81
83
 
82
84
  /** Refuses a DXA or Ghidra request with more seeds or decompilations than the request bounds allow, by name. */
83
- export function checkRequestBounds(params: DxaParams & { decompile?: number[] }): void {
85
+ export function checkRequestBounds(params: DxaParams & { decompile?: number[]; codeStarts?: number[] }): void {
84
86
  const bounds = [
85
87
  ["entry points", params.entryPoints.length, GHIDRA_LIMITS.entryPoints],
88
+ ["code starts", params.codeStarts?.length ?? 0, GHIDRA_LIMITS.codeStarts],
86
89
  ["data ranges", params.dataRanges.length, GHIDRA_LIMITS.dataRanges],
87
90
  ["labels", params.labels.length, GHIDRA_LIMITS.labels],
88
91
  ["routines to decompile", params.decompile?.length ?? 0, GHIDRA_LIMITS.decompile],
@@ -93,7 +96,7 @@ export function checkRequestBounds(params: DxaParams & { decompile?: number[] })
93
96
  }
94
97
 
95
98
  /** The DXA analysis: the same image and seeds as Ghidra, without decompilation. */
96
- export type DxaParams = Omit<GhidraParams, "decompile">;
99
+ export type DxaParams = Omit<GhidraParams, "decompile" | "codeStarts">;
97
100
 
98
101
  export interface DxaResult {
99
102
  coverage: Array<{ start: number; end: number }>;
@@ -247,11 +250,14 @@ export function imageRange(kind: ImageKind, image: Uint8Array): { start: number;
247
250
  * it, and the data ranges cut to it. A data range that holds an entry point
248
251
  * is left out, so that the entry point is disassembled.
249
252
  */
250
- export function seedsInside(params: Pick<DxaParams, "entryPoints" | "dataRanges">, start: number, end: number): Pick<DxaParams, "entryPoints" | "dataRanges"> {
251
- const entryPoints = params.entryPoints.filter((address) => address >= start && address <= end);
253
+ export function seedsInside(params: Pick<GhidraParams, "entryPoints" | "dataRanges" | "codeStarts">, start: number, end: number): { entryPoints: number[]; codeStarts: number[]; dataRanges: GhidraParams["dataRanges"] } {
254
+ const inside = (address: number) => address >= start && address <= end;
255
+ const entryPoints = params.entryPoints.filter(inside);
256
+ const codeStarts = (params.codeStarts ?? []).filter(inside);
257
+ const code = [...entryPoints, ...codeStarts];
252
258
  const dataRanges = params.dataRanges
253
259
  .filter((range) => range.end >= start && range.start <= end)
254
260
  .map((range) => ({ start: Math.max(range.start, start), end: Math.min(range.end, end) }))
255
- .filter((range) => !entryPoints.some((entry) => entry >= range.start && entry <= range.end));
256
- return { entryPoints, dataRanges };
261
+ .filter((range) => !code.some((entry) => entry >= range.start && entry <= range.end));
262
+ return { entryPoints, codeStarts, dataRanges };
257
263
  }
@@ -1,4 +1,4 @@
1
- // The ACME spelling of each 6510 opcode (D48): the mnemonic and the addressing
1
+ // The ACME spelling of each 6510 opcode: the mnemonic and the addressing
2
2
  // mode, undocumented opcodes included. Ghidra gives the instruction
3
3
  // boundaries; this table gives the text. Where several opcodes share one
4
4
  // spelling, ACME writes only one of them: the generator finds those with a
@@ -56,6 +56,78 @@ export function takesAddress(mode: Mode): boolean {
56
56
  return mode !== "imp" && mode !== "imm";
57
57
  }
58
58
 
59
- export function isZeroPageMode(mode: Mode): boolean {
60
- return mode === "zp" || mode === "zpx" || mode === "zpy" || mode === "izx" || mode === "izy";
59
+ /** How an instruction uses the address of its operand, in the order a summary lists them. */
60
+ export const USE_KINDS = ["read", "written", "modified", "called", "jumped to", "jumped through", "branched to", "pointer", "address", "table", "macro argument"] as const;
61
+ export type UseKind = (typeof USE_KINDS)[number];
62
+
63
+ const WRITES = new Set(["sta", "stx", "sty", "sax", "sha", "shx", "shy", "tas"]);
64
+ const MODIFIES = new Set(["inc", "dec", "asl", "lsr", "rol", "ror", "slo", "rla", "sre", "rra", "dcp", "isc"]);
65
+
66
+ /** The use of the operand address of an instruction that takes an address. Through (zp),y and (zp,x) the address is a pointer. */
67
+ export function useKind(opcode: number): UseKind {
68
+ const { mnemonic, mode } = OPCODES[opcode]!;
69
+ if (mode === "izx" || mode === "izy") return "pointer";
70
+ if (mode === "rel") return "branched to";
71
+ if (mnemonic === "jsr") return "called";
72
+ if (mnemonic === "jmp") return mode === "ind" ? "jumped through" : "jumped to";
73
+ if (WRITES.has(mnemonic)) return "written";
74
+ if (MODIFIES.has(mnemonic)) return "modified";
75
+ return "read";
76
+ }
77
+
78
+ const hex2 = (value: number) => `$${value.toString(16).padStart(2, "0")}`;
79
+ const hex4 = (value: number) => `$${value.toString(16).padStart(4, "0")}`;
80
+
81
+ /** The text of the instruction in `bytes` at `pc`, for example "sta $d011" or "bne $c002". */
82
+ export function instructionText(bytes: readonly number[], pc: number): string {
83
+ const { mnemonic, mode } = OPCODES[bytes[0]!]!;
84
+ const low = bytes[1] ?? 0;
85
+ const word = low | ((bytes[2] ?? 0) << 8);
86
+ const operand: Record<Mode, string> = {
87
+ imp: "",
88
+ imm: `#${hex2(low)}`,
89
+ zp: hex2(low),
90
+ zpx: `${hex2(low)},x`,
91
+ zpy: `${hex2(low)},y`,
92
+ abs: hex4(word),
93
+ abx: `${hex4(word)},x`,
94
+ aby: `${hex4(word)},y`,
95
+ ind: `(${hex4(word)})`,
96
+ izx: `(${hex2(low)},x)`,
97
+ izy: `(${hex2(low)}),y`,
98
+ rel: hex4((pc + 2 + (low < 0x80 ? low : low - 0x100)) & 0xffff),
99
+ };
100
+ return operand[mode] === "" ? mnemonic : `${mnemonic} ${operand[mode]}`;
101
+ }
102
+
103
+ /**
104
+ * The address that a read or write of the instruction in `bytes` uses, with X
105
+ * and Y as they were before it; `byteAt` reads a zero-page pointer. Undefined
106
+ * for a mode that uses no memory operand.
107
+ */
108
+ export function operandAddress(bytes: readonly number[], x: number, y: number, byteAt: (address: number) => number): number | undefined {
109
+ const { mode } = OPCODES[bytes[0]!]!;
110
+ const low = bytes[1] ?? 0;
111
+ const word = low | ((bytes[2] ?? 0) << 8);
112
+ const pointer = (at: number) => byteAt(at & 0xff) | (byteAt((at + 1) & 0xff) << 8);
113
+ switch (mode) {
114
+ case "zp":
115
+ return low;
116
+ case "zpx":
117
+ return (low + x) & 0xff;
118
+ case "zpy":
119
+ return (low + y) & 0xff;
120
+ case "abs":
121
+ return word;
122
+ case "abx":
123
+ return (word + x) & 0xffff;
124
+ case "aby":
125
+ return (word + y) & 0xffff;
126
+ case "izx":
127
+ return pointer(low + x);
128
+ case "izy":
129
+ return (pointer(low) + y) & 0xffff;
130
+ default:
131
+ return undefined;
132
+ }
61
133
  }
package/src/platform.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // C64 platform reference data and decoders: the c64-memory-map script, and the
2
- // platform names that c64-source uses (D52).
2
+ // platform names that c64-source uses.
3
3
  // Names follow "Mapping the Commodore 64". Only platform facts live here:
4
4
  // an address without one belongs to the application.
5
5
 
@@ -323,10 +323,33 @@ const AREAS: Array<{ start: number; end: number; area: string; kind: Lookup["kin
323
323
 
324
324
  const hex = (value: number) => `$${value.toString(16).padStart(4, "0")}`;
325
325
 
326
+ export interface Area {
327
+ name: string;
328
+ start: number;
329
+ end: number;
330
+ kind: Lookup["kind"];
331
+ /** What a memory area holds. A chip register block has none: its name says it. */
332
+ meaning?: string;
333
+ }
334
+
335
+ /** The memory area or the I/O register block of an address. */
336
+ export function areaOf(address: number): Area {
337
+ const block = IO.find((candidate) => address >= candidate.start && address <= candidate.end);
338
+ if (block !== undefined) return { name: block.area, start: block.start, end: block.end, kind: "platform" };
339
+ const area = AREAS.find((candidate) => address >= candidate.start && address <= candidate.end)!;
340
+ return { name: area.area, start: area.start, end: area.end, kind: area.kind, meaning: area.meaning };
341
+ }
342
+
343
+ /** The area of an address, and the register that an I/O mirror repeats: c64-source groups and describes its constants with them. */
344
+ export function describeAddress(address: number): { area: Area; mirrorOf?: number } {
345
+ const { mirrorOf } = lookup(address);
346
+ return { area: areaOf(address), ...(mirrorOf === undefined ? {} : { mirrorOf }) };
347
+ }
348
+
326
349
  /** The platform meaning of one address, or its application area. */
327
350
  export function lookup(address: number): Lookup {
328
- for (const block of IO) {
329
- if (address < block.start || address > block.end) continue;
351
+ const block = IO.find((candidate) => address >= candidate.start && address <= candidate.end);
352
+ if (block !== undefined) {
330
353
  const base = block.start + ((address - block.start) % block.period);
331
354
  const register = block.registers.find((entry) => entry.start === base);
332
355
  const mirror = base !== address ? { mirrorOf: base } : {};
@@ -334,15 +357,15 @@ export function lookup(address: number): Lookup {
334
357
  return { address, kind: "platform", area: block.area, name: register.name, meaning: register.meaning, ...mirror };
335
358
  }
336
359
  const entry = [...SYSTEM, ...ROM].find((candidate) => address >= candidate.start && address <= candidate.end);
337
- const area = AREAS.find((candidate) => address >= candidate.start && address <= candidate.end)!;
360
+ const area = areaOf(address);
338
361
  if (entry !== undefined) {
339
- return { address, kind: "platform", area: area.area, name: entry.name, meaning: entry.meaning, ...(entry.start !== address ? { entryStart: entry.start } : {}) };
362
+ return { address, kind: "platform", area: area.name, name: entry.name, meaning: entry.meaning, ...(entry.start !== address ? { entryStart: entry.start } : {}) };
340
363
  }
341
- return { address, kind: area.kind, area: area.area, meaning: area.meaning };
364
+ return { address, kind: area.kind, area: area.name, meaning: area.meaning! };
342
365
  }
343
366
 
344
367
  /**
345
- * The platform name of each named address (D52): system variables, ROM
368
+ * The platform name of each named address: system variables, ROM
346
369
  * entries and I/O registers, not mirrors, in address order. Only names that
347
370
  * an assembler can use as a symbol, and each name once, at its lowest address.
348
371
  * `end` is the last address of the entry: a vector has two bytes.
@@ -357,6 +380,46 @@ export function platformNames(): Array<{ address: number; end: number; name: str
357
380
  .filter((entry) => !seen.has(entry.name) && seen.add(entry.name) !== undefined);
358
381
  }
359
382
 
383
+ /** Which ROMs a program uses: a ROM it does not use gives no names, and without both the system area gives none. */
384
+ export interface RomUse {
385
+ basic: boolean;
386
+ kernal: boolean;
387
+ }
388
+
389
+ /**
390
+ * The platform names of a program that uses only some ROMs: the BASIC
391
+ * ROM entries need the BASIC ROM, the KERNAL ROM entries the KERNAL, and the
392
+ * system variables of $0002-$03FF (the workspace of both) one of them. The
393
+ * processor port and the I/O registers always stay.
394
+ */
395
+ export function platformNamesFor(use: RomUse): ReturnType<typeof platformNames> {
396
+ return platformNames().filter((entry) => {
397
+ if (entry.address >= 0xa000 && entry.address <= 0xbfff) return use.basic;
398
+ if (entry.address >= 0xe000) return use.kernal;
399
+ if (entry.address >= 0x0002 && entry.address <= 0x03ff) return use.basic || use.kernal;
400
+ return true;
401
+ });
402
+ }
403
+
404
+ /**
405
+ * The ROMs that a coverage map shows in use: code of the ROM executed. A read
406
+ * of ROM bytes is data, for example a table or random numbers, and does not use
407
+ * the routines of the ROM or their workspace (Bruce Lee reads 1,571 bytes of the
408
+ * BASIC ROM and executes none).
409
+ */
410
+ export function romUseOf(coverage: Uint8Array, romExecute: number): RomUse {
411
+ const used = (start: number, end: number) => {
412
+ for (let address = start; address <= end; address++) if ((coverage[address]! & romExecute) !== 0) return true;
413
+ return false;
414
+ };
415
+ return { basic: used(0xa000, 0xbfff), kernal: used(0xe000, 0xffff) };
416
+ }
417
+
418
+ /** The ROMs that a value of $01 switches in: HIRAM for the KERNAL, LORAM and HIRAM for BASIC. */
419
+ export function romUseOfPort(port: number): RomUse {
420
+ return { basic: (port & 0x03) === 0x03, kernal: (port & 0x02) !== 0 };
421
+ }
422
+
360
423
  export type Decoded = Record<string, unknown>;
361
424
 
362
425
  const bits = (value: number) => [0, 1, 2, 3, 4, 5, 6, 7].filter((bit) => (value & (1 << bit)) !== 0);
package/src/project.ts CHANGED
@@ -1,8 +1,180 @@
1
- import { realpathSync } from "node:fs";
2
- import { isAbsolute, relative, resolve, sep } from "node:path";
1
+ // The project: the nearest folder at or above the working directory that
2
+ // holds .c64-re-tools/, below the home folder, the way git finds .git. A skill
3
+ // script names it with --project <folder> and an MCP tool with `project`. Every
4
+ // project path is relative to that folder. .c64-re-tools/local/ holds what
5
+ // the agent saves for itself and ignores itself in git.
3
6
 
7
+ import { existsSync, mkdirSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
10
+
11
+ /** The folder that marks a project and holds its knowledge. */
12
+ export const PROJECT_DIRECTORY = ".c64-re-tools";
13
+
14
+ /** The folder for machine-local scratch: saved results, batches and step files. */
15
+ export const LOCAL_DIRECTORY = "local";
16
+
17
+ /** The .gitignore of LOCAL_DIRECTORY: git ignores everything in it, the .gitignore too. */
18
+ const LOCAL_GITIGNORE = "# Machine-local scratch of c64-re-tools (saved results, batches, step files). Never committed.\n*\n";
19
+
20
+ /** How deep a working directory without a project is searched for projects below it. */
21
+ const SEARCH_DEPTH = 3;
22
+
23
+ /** At most this many projects are named in a refusal. */
24
+ const LISTED_PROJECTS = 20;
25
+
26
+ /** A refusal of the project rules, with a stable code, shown to the LLM unchanged. */
27
+ export class ProjectError extends Error {
28
+ override name = "ProjectError";
29
+ readonly code: "invalid-input" | "not-found";
30
+
31
+ constructor(code: "invalid-input" | "not-found", message: string) {
32
+ super(message);
33
+ this.code = code;
34
+ }
35
+ }
36
+
37
+ /** How a refusal tells the caller to name a project: "--project <folder>" for a script, "project" for an MCP tool. */
38
+ export interface ProjectRemedy {
39
+ remedy?: string;
40
+ }
41
+
42
+ const SCRIPT_REMEDY = "Give --project <folder>";
43
+
44
+ /** The folder that --project chose for this process, or undefined. */
45
+ let chosen: string | undefined;
46
+
47
+ /** The project that the project lookup found from a working directory, kept for the next call from the same directory. */
48
+ let found: { cwd: string; root: string } | undefined;
49
+
50
+ function isProject(folder: string): boolean {
51
+ return statSync(join(folder, PROJECT_DIRECTORY), { throwIfNoEntry: false })?.isDirectory() === true;
52
+ }
53
+
54
+ /** `start` and each folder above it, nearest first, below the home folder when `start` is under it. */
55
+ function foldersUpFrom(start: string): string[] {
56
+ const home = homedir();
57
+ const underHome = start !== home && isInside(home, start);
58
+ if (start === home) return [];
59
+ const folders: string[] = [];
60
+ for (let folder = start; ; folder = dirname(folder)) {
61
+ if (underHome && folder === home) break;
62
+ folders.push(folder);
63
+ if (dirname(folder) === folder) break;
64
+ }
65
+ return folders;
66
+ }
67
+
68
+ /** The projects below `start` to SEARCH_DEPTH, relative to it and sorted. Dot folders and node_modules are skipped. */
69
+ function projectsBelow(start: string): string[] {
70
+ const found: string[] = [];
71
+ const visit = (folder: string, depth: number) => {
72
+ let entries;
73
+ try {
74
+ entries = readdirSync(folder, { withFileTypes: true });
75
+ } catch {
76
+ return;
77
+ }
78
+ for (const entry of entries) {
79
+ if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
80
+ const path = join(folder, entry.name);
81
+ if (isProject(path)) found.push(relative(start, path));
82
+ else if (depth < SEARCH_DEPTH) visit(path, depth + 1);
83
+ }
84
+ };
85
+ visit(start, 1);
86
+ return found.sort();
87
+ }
88
+
89
+ function listed(projects: readonly string[]): string {
90
+ const shown = projects.slice(0, LISTED_PROJECTS).join(", ");
91
+ return projects.length > LISTED_PROJECTS ? `${shown} and ${projects.length - LISTED_PROJECTS} more` : shown;
92
+ }
93
+
94
+ /**
95
+ * The project of the working directory `cwd` by the project rules: the nearest
96
+ * folder at or above it with .c64-re-tools/, below the home folder. Without
97
+ * one, `cwd` itself, unless projects are below it or it is the home folder:
98
+ * then a ProjectError names them and the remedy, so no write lands outside a
99
+ * project.
100
+ */
101
+ export function findProject(cwd: string = process.cwd(), options: ProjectRemedy = {}): string {
102
+ const start = resolve(cwd);
103
+ const remedy = options.remedy ?? SCRIPT_REMEDY;
104
+ for (const folder of foldersUpFrom(start)) {
105
+ if (isProject(folder)) return folder;
106
+ }
107
+ if (start === homedir()) {
108
+ throw new ProjectError("invalid-input", `The home folder is not a project folder. Start in a project folder, or ${remedy.charAt(0).toLowerCase()}${remedy.slice(1)}.`);
109
+ }
110
+ const below = projectsBelow(start);
111
+ if (below.length > 0) {
112
+ throw new ProjectError("invalid-input", `${start} is not a project folder. These folders below it are projects: ${listed(below)}. ${remedy} with one of them.`);
113
+ }
114
+ return start;
115
+ }
116
+
117
+ /**
118
+ * The project folder that `folder` names, relative to `cwd` or absolute. It
119
+ * must be a folder. It is the project as given, also without .c64-re-tools/
120
+ * (the first write makes it), but not a folder inside another project or the
121
+ * home folder.
122
+ */
123
+ export function namedProject(folder: string, cwd: string = process.cwd()): string {
124
+ if (folder.length === 0) throw new ProjectError("invalid-input", "The project folder must not be empty.");
125
+ const path = resolve(cwd, folder);
126
+ const stat = statSync(path, { throwIfNoEntry: false });
127
+ if (stat === undefined) throw new ProjectError("not-found", `There is no folder ${folder}.`);
128
+ if (!stat.isDirectory()) throw new ProjectError("invalid-input", `${folder} is not a folder.`);
129
+ if (path === homedir()) throw new ProjectError("invalid-input", "The home folder is not a project folder.");
130
+ const outer = foldersUpFrom(path).slice(1).find(isProject);
131
+ if (outer !== undefined) {
132
+ throw new ProjectError("invalid-input", `${folder} is inside the project ${relative(cwd, outer) || "."}. Give that project folder.`);
133
+ }
134
+ return path;
135
+ }
136
+
137
+ /**
138
+ * Makes .c64-re-tools/local/ with its .gitignore when the project has
139
+ * .c64-re-tools/ and local/ has no .gitignore yet. A folder that is not
140
+ * yet a project gets nothing. A failure (for example a read-only project) is
141
+ * left to the write into local/ that needs the folder.
142
+ */
143
+ export function ensureLocal(root: string): void {
144
+ const local = join(root, PROJECT_DIRECTORY, LOCAL_DIRECTORY);
145
+ const gitignore = join(local, ".gitignore");
146
+ if (existsSync(gitignore) || !isProject(root)) return;
147
+ try {
148
+ mkdirSync(local, { recursive: true });
149
+ writeFileSync(gitignore, LOCAL_GITIGNORE, { flag: "wx" });
150
+ } catch {
151
+ // Another process made it, or the project is read-only.
152
+ }
153
+ }
154
+
155
+ /** The project of an MCP call or another caller without process state: `folder` when given, else the project lookup from `cwd`. */
156
+ export function resolveProject(folder: string | undefined, cwd: string = process.cwd(), options: ProjectRemedy = {}): string {
157
+ const root = folder === undefined ? findProject(cwd, options) : namedProject(folder, cwd);
158
+ ensureLocal(root);
159
+ return root;
160
+ }
161
+
162
+ /** Sets the project of this process from --project. Throws ProjectError for a folder that the rules refuse. */
163
+ export function useProject(folder: string): void {
164
+ chosen = namedProject(folder);
165
+ }
166
+
167
+ /** The project folder of this process: the --project choice, else the project lookup from the working directory. */
4
168
  export function projectRoot(): string {
5
- return process.cwd();
169
+ let root = chosen;
170
+ if (root === undefined) {
171
+ const cwd = process.cwd();
172
+ if (found?.cwd !== cwd) found = { cwd, root: findProject(cwd) };
173
+ root = found.root;
174
+ }
175
+ // Each call: the first write of a process can make .c64-re-tools/ after the lookup.
176
+ ensureLocal(root);
177
+ return root;
6
178
  }
7
179
 
8
180
  export function resolveProjectPath(path: string, root = projectRoot()): string {
@@ -18,6 +190,11 @@ export function resolveProjectPath(path: string, root = projectRoot()): string {
18
190
  throw new TypeError("Project path escapes the project root");
19
191
  }
20
192
 
193
+ /** A project path in one spelling: relative to the project folder, with / and without ./, .. or a double /. Throws TypeError as resolveProjectPath does. */
194
+ export function projectRelative(path: string, root = projectRoot()): string {
195
+ return relative(root, resolveProjectPath(path, root)).split(sep).join("/");
196
+ }
197
+
21
198
  /** True when `candidate` is `root` or below it. Both are absolute paths. */
22
199
  export function isInside(root: string, candidate: string): boolean {
23
200
  const fromRoot = relative(root, candidate);
@@ -108,7 +108,7 @@ export class ProtocolError extends Error {
108
108
  /** Thrown by parameter/result validators; carries the wire code to answer with. */
109
109
  /**
110
110
  * The instruction at the end of a refusal that only the user can fix: a
111
- * missing or broken tool, or a Host Runtime that does not run (D37).
111
+ * missing or broken tool, or a Host Runtime that does not run.
112
112
  */
113
113
  export const STOP_AND_ASK = "Stop the task. Give this message to the user and wait for the user. Do not make a replacement for the tool or the host runtime.";
114
114