@henols/c64-re-tools 2.0.0-rc.11 → 2.0.0-rc.13

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 (54) hide show
  1. package/README.md +7 -5
  2. package/package.json +1 -1
  3. package/skills/c64-assembler/SKILL.md +11 -2
  4. package/skills/c64-assembler/scripts/assemble.ts +4 -4
  5. package/skills/c64-basic/SKILL.md +8 -1
  6. package/skills/c64-basic/scripts/basic.ts +3 -3
  7. package/skills/c64-disk/SKILL.md +10 -3
  8. package/skills/c64-disk/scripts/disk.ts +4 -4
  9. package/skills/c64-emulator/SKILL.md +4 -0
  10. package/skills/c64-knowledge/SKILL.md +62 -12
  11. package/skills/c64-knowledge/scripts/knowledge.ts +352 -102
  12. package/skills/c64-memory-map/SKILL.md +7 -0
  13. package/skills/c64-memory-map/scripts/memmap.ts +1 -1
  14. package/skills/c64-provenance/SKILL.md +56 -3
  15. package/skills/c64-provenance/scripts/library.ts +584 -0
  16. package/skills/c64-provenance/scripts/provenance.ts +201 -9
  17. package/skills/c64-reverse-engineering/SKILL.md +16 -2
  18. package/skills/c64-source/SKILL.md +38 -7
  19. package/skills/c64-source/scripts/generate.ts +213 -29
  20. package/skills/c64-source/scripts/loader.ts +3 -1
  21. package/skills/c64-source/scripts/opcodes.ts +19 -0
  22. package/skills/c64-source/scripts/source.ts +387 -35
  23. package/skills/c64-static-analysis/SKILL.md +11 -2
  24. package/skills/c64-static-analysis/scripts/analyze.ts +95 -8
  25. package/skills/c64-static-analysis/scripts/macros.ts +233 -0
  26. package/skills/c64-testing/SKILL.md +8 -1
  27. package/skills/c64-testing/references/scenario-format.md +1 -1
  28. package/skills/c64-testing/scripts/checklist.ts +2 -2
  29. package/skills/c64-testing/scripts/run.ts +4 -4
  30. package/skills/c64-testing/scripts/scenario.ts +1 -1
  31. package/skills/c64-testing/scripts/test.ts +4 -4
  32. package/skills/c64-unpacker/SKILL.md +11 -4
  33. package/skills/c64-unpacker/scripts/unpack.ts +4 -4
  34. package/src/cli/main.ts +5 -3
  35. package/src/host-client/image.ts +1 -1
  36. package/src/host-client/transfer.ts +34 -10
  37. package/src/host-client/vice-session.ts +9 -9
  38. package/src/knowledge/conflicts.ts +157 -0
  39. package/src/knowledge/database.ts +150 -38
  40. package/src/knowledge/history.ts +54 -20
  41. package/src/knowledge/import.ts +3 -2
  42. package/src/knowledge/read.ts +169 -20
  43. package/src/knowledge/schema.ts +115 -72
  44. package/src/knowledge/text.ts +391 -0
  45. package/src/knowledge/write.ts +508 -51
  46. package/src/mcp/output.ts +14 -7
  47. package/src/mcp/tools/debug.ts +8 -6
  48. package/src/mcp/tools/media.ts +17 -13
  49. package/src/mcp/tools/memory.ts +9 -5
  50. package/src/mcp/tools/video.ts +6 -5
  51. package/src/platform.ts +28 -5
  52. package/src/project.ts +166 -3
  53. package/src/protocol/tools.ts +1 -1
  54. package/src/script.ts +46 -7
@@ -1,5 +1,6 @@
1
- // The c64-knowledge script: reads and writes .c64-re-tools/knowledge.db in the
2
- // project directory (the working directory) and prints one compact JSON result.
1
+ // The c64-knowledge script: reads and writes .c64-re-tools/knowledge/ in the
2
+ // project folder (--project, or the one found from the working directory) and
3
+ // prints one compact JSON result.
3
4
 
4
5
  import { createHash } from "node:crypto";
5
6
  import { readFileSync } from "node:fs";
@@ -9,15 +10,19 @@ import { parseArgs } from "node:util";
9
10
  import { formatC64Address, parseC64Address } from "#src/c64.ts";
10
11
  import { ImageError, readImage } from "#src/host-client/image.ts";
11
12
  import { readProjectFile, writeProjectFile } from "#src/host-client/transfer.ts";
13
+ import { CONFLICT_RULES, conflicts, type Conflict } from "#src/knowledge/conflicts.ts";
12
14
  import { KnowledgeError, openForRead, withWrite } from "#src/knowledge/database.ts";
13
15
  import { operandText } from "#src/knowledge/expression.ts";
14
- import { historyAt, historyNamed, revision, revisionNote, revisions, type HistoryEntry } from "#src/knowledge/history.ts";
16
+ import { historyAt, historyNamed, historyOfMacro, revision, revisionNote, revisions, type HistoryEntry } from "#src/knowledge/history.ts";
15
17
  import {
16
18
  at,
17
19
  currentRevision,
18
20
  listComments,
21
+ listMacros,
22
+ listMacroUses,
19
23
  listModules,
20
24
  listOperands,
25
+ listPatches,
21
26
  listSpaces,
22
27
  listReferences,
23
28
  listRegions,
@@ -25,8 +30,11 @@ import {
25
30
  listSymbols,
26
31
  search,
27
32
  type CommentRow,
33
+ type MacroRow,
34
+ type MacroUseRow,
28
35
  type ModuleRow,
29
36
  type OperandRow,
37
+ type PatchRow,
30
38
  type ReferenceRow,
31
39
  type RegionRow,
32
40
  type SuppressionRow,
@@ -53,19 +61,28 @@ import {
53
61
  addReference,
54
62
  applyBatch,
55
63
  classifyRegion,
64
+ mergeConflicts,
56
65
  removeComment,
66
+ removeMacro,
67
+ removeMacroUse,
57
68
  removeModule,
58
69
  removeOperand,
70
+ removePatch,
59
71
  removeReference,
60
72
  removeSymbol,
61
73
  renameSymbol,
62
74
  revert,
63
75
  setComment,
76
+ setMacro,
77
+ setMacroUse,
64
78
  setModule,
65
79
  setOperand,
80
+ setPatch,
66
81
  unclassifyRegion,
67
82
  type BatchOperation,
83
+ type BatchStep,
68
84
  type Evidence,
85
+ type MergeChoice,
69
86
  type OperandImage,
70
87
  type WriteContext,
71
88
  } from "#src/knowledge/write.ts";
@@ -84,10 +101,14 @@ Read (no database needed):
84
101
  suppressions [--start A --end A] places that removals keep free of analyzer facts
85
102
  modules [--start A --end A --origin O] where the files of generated source start
86
103
  operands [--start A --end A --origin O] operand expressions of instructions
104
+ patches [--start A --end A --switch S --origin O] code that a switch puts in
105
+ switches each switch with the ranges of its patches
106
+ macros [--origin O] the macros of the generated source
107
+ uses [--start A --end A --macro M --origin O] instructions written as a macro
87
108
  spaces the address spaces that hold knowledge
88
- history <address> | history --name <name>
89
- revisions [--limit N --before N]
90
- revision <id>
109
+ history <address> | history --name <name> | history --macro <name>
110
+ revisions [--limit N --before R]
111
+ revision <revision>
91
112
  export --out <file> [--start A --end A --origin O] all current knowledge to a project file
92
113
 
93
114
  Every command takes --space S: c64 (the default), drive8 for code in the 1541
@@ -97,7 +118,7 @@ own space.
97
118
  Read option --origin O: user, llm, dxa, ghidra, heuristic, semantic (user and
98
119
  llm) or analyzer (dxa, ghidra and heuristic), or a comma list. search takes it too.
99
120
 
100
- Write (one revision each; creates the database when needed):
121
+ Write (one revision each; creates .c64-re-tools/knowledge/ when needed):
101
122
  rename <address> <name> [--kind K]
102
123
  remove-symbol <address>
103
124
  classify <start> <end> <type> [--element N]
@@ -116,19 +137,36 @@ Write (one revision each; creates the database when needed):
116
137
  it does not give the operand in the image (image.json
117
138
  or PRG); give a negative offset as --offset=-1
118
139
  unoperand <address>
140
+ patch <start> <end> <SWITCH> --code <text>|--code-file <file>|--nop [--comment T] --image <file>
141
+ ACME code that replaces <start>-<end> when the build
142
+ defines SWITCH as 1 (c64-assembler --define SWITCH=1);
143
+ NOP fills the rest of the range; --nop puts only NOP
144
+ unpatch <start>
145
+ macro <name> [--params a,b] --body <text>|--body-file <file> [--comment T]
146
+ an ACME macro; the body names a param with a dot: .a
147
+ unmacro <name>
148
+ use <start> <end> <macro> [--args "x, y"] --image <file>
149
+ write the instructions <start>-<end> as one use of
150
+ the macro, with one expression for each param
151
+ unuse <start>
119
152
  revert <revision>
153
+ merge decide the conflicts that a git merge of two
154
+ computers' work left: of the rows of one conflict,
155
+ the newest stays; one revision for all
120
156
  apply <file> many writes from a JSON file ('-' reads standard input),
121
157
  all as one revision, or none when one is refused;
122
- operand writes need --image <file>
158
+ operand, patch and use writes need --image <file>
123
159
 
124
160
  Write options:
125
161
  --reason <text> why, kept with the revision
126
162
  --evidence <kind:ref> what the change rests on: coverage:<file>, test:<file>,
127
163
  file:<file> or note:<text> (repeatable)
128
- --expect-revision <N> refuse the write if knowledge changed after revision N
164
+ --expect-revision <R> refuse the write if knowledge changed after revision R
129
165
  --origin user|llm who decided (default llm)
130
166
 
131
- Addresses are $ and four hex digits; quote them in a shell: '$2100'.`;
167
+ Addresses are $ and four hex digits; quote them in a shell: '$2100'.
168
+ A revision is its id, for example 20261015T214501.123-k3f9qz, or a start of
169
+ the id that only one revision has; 0 is the knowledge before every revision.`;
132
170
 
133
171
  function address(value: string | undefined, what = "address"): number {
134
172
  if (value === undefined) throw new UsageError(`${what} is missing`);
@@ -175,6 +213,28 @@ const operand = (row: OperandRow) => ({
175
213
  origin: row.origin,
176
214
  revision: row.revision,
177
215
  });
216
+ const patch = (row: PatchRow) => ({
217
+ ...spaceOf(row),
218
+ start: formatC64Address(row.start),
219
+ end: formatC64Address(row.end),
220
+ bytes: row.end - row.start + 1,
221
+ switch: row.switch,
222
+ code: row.code,
223
+ ...(row.comment === undefined ? {} : { comment: row.comment }),
224
+ origin: row.origin,
225
+ revision: row.revision,
226
+ });
227
+ const macro = (row: MacroRow) => ({ ...spaceOf(row), name: row.name, params: row.params, body: row.body, ...(row.comment === undefined ? {} : { comment: row.comment }), origin: row.origin, revision: row.revision });
228
+ const macroUse = (row: MacroUseRow) => ({
229
+ ...spaceOf(row),
230
+ start: formatC64Address(row.start),
231
+ end: formatC64Address(row.end),
232
+ macro: row.macro,
233
+ args: row.args,
234
+ text: `+${row.macro}${row.args.length === 0 ? "" : ` ${row.args.join(", ")}`}`,
235
+ origin: row.origin,
236
+ revision: row.revision,
237
+ });
178
238
  const reference = (row: ReferenceRow) => ({ ...spaceOf(row), from: formatC64Address(row.from), to: formatC64Address(row.to), kind: row.kind, origin: row.origin, revision: row.revision });
179
239
  function suppression(row: SuppressionRow) {
180
240
  const place =
@@ -200,14 +260,30 @@ function historyEntry(entry: HistoryEntry) {
200
260
  ? module(entry.row)
201
261
  : entry.entity === "operand"
202
262
  ? operand(entry.row)
203
- : suppression(entry.row);
263
+ : entry.entity === "patch"
264
+ ? patch(entry.row)
265
+ : entry.entity === "macro"
266
+ ? macro(entry.row)
267
+ : entry.entity === "macro-use"
268
+ ? macroUse(entry.row)
269
+ : suppression(entry.row);
204
270
  const { revision: _revision, ...row } = shown;
205
271
  return { entity: entry.entity, ...row, fromRevision: entry.from, ...(entry.to === undefined ? {} : { toRevision: entry.to }) };
206
272
  }
207
273
 
274
+ /** A conflict of a git merge (D69): what is wrong, and the rows, oldest first. */
275
+ function showConflict(conflict: Conflict) {
276
+ return { conflict: CONFLICT_RULES[conflict.rule], ...spaceOf(conflict), rows: conflict.rows.map(historyEntry) };
277
+ }
278
+
279
+ /** What merge decided for one conflict: the row that stays and the rows it closed. */
280
+ function showChoice(choice: MergeChoice) {
281
+ return { conflict: CONFLICT_RULES[choice.rule], ...spaceOf(choice), kept: historyEntry(choice.kept), closed: choice.closed.map(historyEntry) };
282
+ }
283
+
208
284
  /** The reason of each revision that wrote one of the rows. */
209
- function reasonsOf(db: DatabaseSync | undefined, rows: SuppressionRow[]): Map<number, string> {
210
- const reasons = new Map<number, string>();
285
+ function reasonsOf(db: DatabaseSync | undefined, rows: SuppressionRow[]): Map<string, string> {
286
+ const reasons = new Map<string, string>();
211
287
  const query = db?.prepare("SELECT description FROM revisions WHERE id = ?");
212
288
  for (const { revision } of rows) {
213
289
  if (query === undefined || reasons.has(revision)) continue;
@@ -244,6 +320,16 @@ function run(argv: string[]): unknown {
244
320
  expr: { type: "string" },
245
321
  offset: { type: "string" },
246
322
  image: { type: "string" },
323
+ code: { type: "string" },
324
+ "code-file": { type: "string" },
325
+ nop: { type: "boolean" },
326
+ comment: { type: "string" },
327
+ params: { type: "string" },
328
+ body: { type: "string" },
329
+ "body-file": { type: "string" },
330
+ args: { type: "string" },
331
+ switch: { type: "string" },
332
+ macro: { type: "string" },
247
333
  help: { type: "boolean" },
248
334
  },
249
335
  });
@@ -253,8 +339,8 @@ function run(argv: string[]): unknown {
253
339
  if (values.space !== undefined && !SPACE.test(values.space)) throw new UsageError("--space must be c64, drive8, or bank0 to bank255");
254
340
  const space = values.space ?? DEFAULT_SPACE;
255
341
 
256
- const read = new Set(["at", "search", "symbols", "regions", "comments", "references", "suppressions", "modules", "operands", "spaces", "history", "revisions", "revision", "export"]);
257
- const write = new Set(["rename", "remove-symbol", "classify", "unclassify", "comment", "uncomment", "reference", "unreference", "module", "unmodule", "operand", "unoperand", "revert", "apply"]);
342
+ const read = new Set(["at", "search", "symbols", "regions", "comments", "references", "suppressions", "modules", "operands", "patches", "switches", "macros", "uses", "spaces", "history", "revisions", "revision", "export"]);
343
+ const write = new Set(["rename", "remove-symbol", "classify", "unclassify", "comment", "uncomment", "reference", "unreference", "module", "unmodule", "operand", "unoperand", "patch", "unpatch", "macro", "unmacro", "use", "unuse", "revert", "apply", "merge"]);
258
344
  // Refuse a mistyped command before anything opens, so no database appears by accident.
259
345
  if (!read.has(command) && !write.has(command)) throw new UsageError(`unknown command: ${command}`);
260
346
  if (read.has(command)) {
@@ -262,83 +348,10 @@ function run(argv: string[]): unknown {
262
348
  if (command === "export" && values.out === undefined) throw new UsageError("export needs --out <file>");
263
349
  const db = openForRead();
264
350
  try {
265
- switch (command) {
266
- case "at": {
267
- const known = at(db, address(args[0]), space);
268
- return {
269
- ...(space === DEFAULT_SPACE ? {} : { space }),
270
- address: formatC64Address(known.address),
271
- ...(known.symbol === undefined ? {} : { symbol: symbol(known.symbol) }),
272
- ...(known.region === undefined ? {} : { region: region(known.region) }),
273
- comments: known.comments.map(comment),
274
- referencesFrom: known.referencesFrom.map(reference),
275
- referencesTo: known.referencesTo.map(reference),
276
- ...(known.operand === undefined ? {} : { operand: operand(known.operand) }),
277
- ...(known.suppressions.length === 0 ? {} : { suppressions: known.suppressions.map(suppression) }),
278
- revision: known.revision,
279
- };
280
- }
281
- case "search": {
282
- if (args[0] === undefined || args[0] === "") throw new UsageError("search needs text");
283
- const found = search(db, args[0], values.limit === undefined ? 50 : integer(values.limit, "--limit"), origins, space);
284
- return { symbols: found.symbols.map(symbol), comments: found.comments.map(comment) };
285
- }
286
- case "symbols":
287
- return { symbols: listSymbols(db, range, values.kind === undefined ? undefined : oneOf(SYMBOL_KINDS, values.kind, "--kind"), origins, space).map(symbol) };
288
- case "regions":
289
- return { regions: listRegions(db, range, values.type === undefined ? undefined : oneOf(REGION_TYPES, values.type, "--type"), origins, space).map(region) };
290
- case "comments":
291
- return { comments: listComments(db, range, origins, space).map(comment) };
292
- case "references":
293
- return { references: listReferences(db, range, values.kind === undefined ? undefined : oneOf(REFERENCE_KINDS, values.kind, "--kind"), origins, space).map(reference) };
294
- case "export": {
295
- const exported = {
296
- revision: currentRevision(db),
297
- ...(space === DEFAULT_SPACE ? {} : { space }),
298
- symbols: listSymbols(db, range, undefined, origins, space).map(symbol),
299
- regions: listRegions(db, range, undefined, origins, space).map(region),
300
- comments: listComments(db, range, origins, space).map(comment),
301
- references: listReferences(db, range, undefined, origins, space).map(reference),
302
- suppressions: listSuppressions(db, range, space).map(suppression),
303
- modules: listModules(db, range, origins, space).map(module),
304
- operands: listOperands(db, range, origins, space).map(operand),
305
- };
306
- const bytes = Buffer.from(`${JSON.stringify(exported, null, 2)}\n`, "utf8");
307
- writeProjectFile(values.out!, bytes, { what: "--out" });
308
- return {
309
- out: values.out,
310
- bytes: bytes.length,
311
- counts: { symbols: exported.symbols.length, regions: exported.regions.length, comments: exported.comments.length, references: exported.references.length, suppressions: exported.suppressions.length, modules: exported.modules.length, operands: exported.operands.length },
312
- revision: exported.revision,
313
- };
314
- }
315
- case "suppressions": {
316
- const rows = listSuppressions(db, range, space);
317
- const reasons = reasonsOf(db, rows);
318
- return { suppressions: rows.map((row) => ({ ...suppression(row), ...(reasons.has(row.revision) ? { reason: reasons.get(row.revision) } : {}) })) };
319
- }
320
- case "modules":
321
- return { modules: listModules(db, range, origins, space).map(module) };
322
- case "operands":
323
- return { operands: listOperands(db, range, origins, space).map(operand) };
324
- case "spaces":
325
- return { spaces: listSpaces(db) };
326
- case "history":
327
- // Each row with the reason and the evidence of the revision that wrote it (F-26).
328
- return {
329
- history: (values.name === undefined ? historyAt(db, address(args[0]), space) : historyNamed(db, values.name, space)).map((entry) => ({ ...historyEntry(entry), ...revisionNote(db, entry.from) })),
330
- };
331
- case "revisions": {
332
- const options: { limit?: number; before?: number } = {};
333
- if (values.limit !== undefined) options.limit = integer(values.limit, "--limit");
334
- if (values.before !== undefined) options.before = integer(values.before, "--before");
335
- return { revisions: revisions(db, options) };
336
- }
337
- default: {
338
- const changes = revision(db, integer(args[0], "revision"));
339
- return { revision: changes.revision, added: changes.added.map(historyEntry), closed: changes.closed.map(historyEntry) };
340
- }
341
- }
351
+ const answer = readAnswer(db, command, args, values, range, origins, space);
352
+ const found = conflicts(db);
353
+ // A git merge can leave conflicts: every read shows them until merge decides them (D69).
354
+ return found.length === 0 ? answer : { ...answer, conflicts: found.map(showConflict) };
342
355
  } finally {
343
356
  db?.close();
344
357
  }
@@ -346,23 +359,149 @@ function run(argv: string[]): unknown {
346
359
 
347
360
  const context: WriteContext = { origin: values.origin === undefined ? "llm" : oneOf(["user", "llm"] as const, values.origin, "--origin") };
348
361
  if (values.reason !== undefined) context.description = values.reason;
349
- if (values["expect-revision"] !== undefined) context.expectedRevision = integer(values["expect-revision"], "--expect-revision");
362
+ if (values["expect-revision"] !== undefined) context.expectedRevision = values["expect-revision"];
350
363
  if (values.evidence !== undefined) context.evidence = values.evidence.map(evidenceOf);
351
364
  // Every argument is checked before the database opens: a refused write creates no database.
352
365
  if (values.title !== undefined && command !== "module") throw new UsageError("--title works only with module");
353
366
  for (const option of ["lo", "hi", "value", "expr", "offset"] as const) {
354
367
  if (values[option] !== undefined && command !== "operand") throw new UsageError(`--${option} works only with operand`);
355
368
  }
356
- if (values.image !== undefined && command !== "operand" && command !== "apply") throw new UsageError("--image works only with operand and apply");
369
+ const only: Array<[keyof typeof values, readonly string[]]> = [
370
+ ["image", ["operand", "patch", "use", "apply"]],
371
+ ["code", ["patch"]],
372
+ ["code-file", ["patch"]],
373
+ ["nop", ["patch"]],
374
+ ["comment", ["patch", "macro"]],
375
+ ["params", ["macro"]],
376
+ ["body", ["macro"]],
377
+ ["body-file", ["macro"]],
378
+ ["args", ["use"]],
379
+ ];
380
+ for (const [option, commands] of only) {
381
+ if (values[option] !== undefined && !commands.includes(command)) throw new UsageError(`--${option} works only with ${commands.join(", ")}`);
382
+ }
357
383
  const operation =
358
384
  command === "apply"
359
385
  ? applyOperation(args, context, space, values.image)
360
386
  : command === "operand"
361
387
  ? operandOperation(args, values, context, space)
362
- : writeOperation(command, args, values.kind, values.element, context, values.title, space);
388
+ : command === "patch" || command === "macro" || command === "use"
389
+ ? sourceOperation(command, args, values, context, space)
390
+ : writeOperation(command, args, values.kind, values.element, context, values.title, space);
363
391
  return withWrite(operation);
364
392
  }
365
393
 
394
+ type ReadValues = {
395
+ kind?: string | undefined;
396
+ type?: string | undefined;
397
+ name?: string | undefined;
398
+ limit?: string | undefined;
399
+ before?: string | undefined;
400
+ out?: string | undefined;
401
+ switch?: string | undefined;
402
+ macro?: string | undefined;
403
+ };
404
+
405
+ /** The answer of one read command. */
406
+ function readAnswer(db: DatabaseSync | undefined, command: string, args: string[], values: ReadValues, range: { start: number; end: number }, origins: Origin[] | undefined, space: string): Record<string, unknown> {
407
+ switch (command) {
408
+ case "at": {
409
+ const known = at(db, address(args[0]), space);
410
+ return {
411
+ ...(space === DEFAULT_SPACE ? {} : { space }),
412
+ address: formatC64Address(known.address),
413
+ ...(known.symbol === undefined ? {} : { symbol: symbol(known.symbol) }),
414
+ ...(known.region === undefined ? {} : { region: region(known.region) }),
415
+ comments: known.comments.map(comment),
416
+ referencesFrom: known.referencesFrom.map(reference),
417
+ referencesTo: known.referencesTo.map(reference),
418
+ ...(known.operand === undefined ? {} : { operand: operand(known.operand) }),
419
+ ...(known.patch === undefined ? {} : { patch: patch(known.patch) }),
420
+ ...(known.macroUse === undefined ? {} : { macroUse: macroUse(known.macroUse) }),
421
+ ...(known.suppressions.length === 0 ? {} : { suppressions: known.suppressions.map(suppression) }),
422
+ revision: known.revision,
423
+ };
424
+ }
425
+ case "search": {
426
+ if (args[0] === undefined || args[0] === "") throw new UsageError("search needs text");
427
+ const found = search(db, args[0], values.limit === undefined ? 50 : integer(values.limit, "--limit"), origins, space);
428
+ return { symbols: found.symbols.map(symbol), comments: found.comments.map(comment) };
429
+ }
430
+ case "symbols":
431
+ return { symbols: listSymbols(db, range, values.kind === undefined ? undefined : oneOf(SYMBOL_KINDS, values.kind, "--kind"), origins, space).map(symbol) };
432
+ case "regions":
433
+ return { regions: listRegions(db, range, values.type === undefined ? undefined : oneOf(REGION_TYPES, values.type, "--type"), origins, space).map(region) };
434
+ case "comments":
435
+ return { comments: listComments(db, range, origins, space).map(comment) };
436
+ case "references":
437
+ return { references: listReferences(db, range, values.kind === undefined ? undefined : oneOf(REFERENCE_KINDS, values.kind, "--kind"), origins, space).map(reference) };
438
+ case "export": {
439
+ const exported = {
440
+ revision: currentRevision(db),
441
+ ...(space === DEFAULT_SPACE ? {} : { space }),
442
+ symbols: listSymbols(db, range, undefined, origins, space).map(symbol),
443
+ regions: listRegions(db, range, undefined, origins, space).map(region),
444
+ comments: listComments(db, range, origins, space).map(comment),
445
+ references: listReferences(db, range, undefined, origins, space).map(reference),
446
+ suppressions: listSuppressions(db, range, space).map(suppression),
447
+ modules: listModules(db, range, origins, space).map(module),
448
+ operands: listOperands(db, range, origins, space).map(operand),
449
+ patches: listPatches(db, range, undefined, origins, space).map(patch),
450
+ macros: listMacros(db, origins, space).map(macro),
451
+ macroUses: listMacroUses(db, range, undefined, origins, space).map(macroUse),
452
+ };
453
+ const bytes = Buffer.from(`${JSON.stringify(exported, null, 2)}\n`, "utf8");
454
+ writeProjectFile(values.out!, bytes, { what: "--out" });
455
+ return {
456
+ out: values.out,
457
+ bytes: bytes.length,
458
+ counts: { symbols: exported.symbols.length, regions: exported.regions.length, comments: exported.comments.length, references: exported.references.length, suppressions: exported.suppressions.length, modules: exported.modules.length, operands: exported.operands.length, patches: exported.patches.length, macros: exported.macros.length, macroUses: exported.macroUses.length },
459
+ revision: exported.revision,
460
+ };
461
+ }
462
+ case "suppressions": {
463
+ const rows = listSuppressions(db, range, space);
464
+ const reasons = reasonsOf(db, rows);
465
+ return { suppressions: rows.map((row) => ({ ...suppression(row), ...(reasons.has(row.revision) ? { reason: reasons.get(row.revision) } : {}) })) };
466
+ }
467
+ case "modules":
468
+ return { modules: listModules(db, range, origins, space).map(module) };
469
+ case "operands":
470
+ return { operands: listOperands(db, range, origins, space).map(operand) };
471
+ case "patches":
472
+ return { patches: listPatches(db, range, values.switch, origins, space).map(patch) };
473
+ case "switches": {
474
+ const switches = new Map<string, PatchRow[]>();
475
+ for (const row of listPatches(db, undefined, undefined, undefined, space)) switches.set(row.switch, [...(switches.get(row.switch) ?? []), row]);
476
+ return {
477
+ switches: [...switches].sort(([a], [b]) => (a < b ? -1 : 1)).map(([name, rows]) => ({ name, define: `--define ${name}=1`, patches: rows.map((row) => ({ start: formatC64Address(row.start), end: formatC64Address(row.end) })) })),
478
+ };
479
+ }
480
+ case "macros":
481
+ return { macros: listMacros(db, origins, space).map(macro) };
482
+ case "uses":
483
+ return { uses: listMacroUses(db, range, values.macro, origins, space).map(macroUse) };
484
+ case "spaces":
485
+ return { spaces: listSpaces(db) };
486
+ case "history":
487
+ // Each row with the reason and the evidence of the revision that wrote it (F-26).
488
+ return {
489
+ history: (values.macro !== undefined ? historyOfMacro(db, values.macro, space) : values.name === undefined ? historyAt(db, address(args[0]), space) : historyNamed(db, values.name, space)).map((entry) => ({ ...historyEntry(entry), ...revisionNote(db, entry.from) })),
490
+ };
491
+ case "revisions": {
492
+ const options: { limit?: number; before?: string } = {};
493
+ if (values.limit !== undefined) options.limit = integer(values.limit, "--limit");
494
+ if (values.before !== undefined) options.before = values.before;
495
+ return { revisions: revisions(db, options) };
496
+ }
497
+ default: {
498
+ if (args[0] === undefined) throw new UsageError("revision is missing");
499
+ const changes = revision(db, args[0]);
500
+ return { revision: changes.revision, added: changes.added.map(historyEntry), closed: changes.closed.map(historyEntry) };
501
+ }
502
+ }
503
+ }
504
+
366
505
  /** --origin for reads: one origin, semantic (user and llm), analyzer (dxa and ghidra), or a comma list. */
367
506
  function originFilter(value: string): Origin[] {
368
507
  const origins = new Set<Origin>();
@@ -385,11 +524,12 @@ function evidenceOf(value: string): Evidence {
385
524
  }
386
525
 
387
526
  /**
388
- * The image that operand writes are checked against (D65), with the platform
389
- * names. A byte outside the segments of the image is no operand.
527
+ * The image that operand, patch and macro use writes are checked against (D65,
528
+ * D72, D73), with the platform names. A byte outside the segments of the image
529
+ * is not in the image.
390
530
  */
391
531
  function operandImage(path: string | undefined, what: string): OperandImage {
392
- if (path === undefined) throw new UsageError(`${what} checks each operand expression against the image: give --image <image.json|prg>`);
532
+ if (path === undefined) throw new UsageError(`${what} checks the write against the image: give --image <image.json|prg>`);
393
533
  let image: ReturnType<typeof readImage>;
394
534
  try {
395
535
  image = readImage(path);
@@ -430,6 +570,57 @@ function operandOperation(args: string[], values: Record<string, unknown>, conte
430
570
  };
431
571
  }
432
572
 
573
+ /** A comma list of params or args; an empty text is an empty list. */
574
+ const commaList = (value: string) => (value.trim() === "" ? [] : value.split(",").map((item) => item.trim()));
575
+
576
+ /** The text of an option, or of the project file that a --*-file option names; exactly one of them. */
577
+ function textOrFile(values: Record<string, unknown>, option: string, what: string): string | undefined {
578
+ const inline = values[option] as string | undefined;
579
+ const file = values[`${option}-file`] as string | undefined;
580
+ if (inline !== undefined && file !== undefined) throw new UsageError(`give --${option} or --${option}-file, not both`);
581
+ if (file !== undefined) return readProjectFile(file).bytes.toString("utf8");
582
+ if (inline === undefined) return undefined;
583
+ if (inline === "") throw new UsageError(`${what} is empty`);
584
+ return inline;
585
+ }
586
+
587
+ /** patch, macro and use: the writes of the generated source (D72, D73). */
588
+ function sourceOperation(command: "patch" | "macro" | "use", args: string[], values: Record<string, unknown>, context: WriteContext, space: string): WriteOperation {
589
+ const comment = values.comment as string | undefined;
590
+ switch (command) {
591
+ case "patch": {
592
+ if (args[2] === undefined) throw new UsageError("the switch name is missing");
593
+ const code = textOrFile(values, "code", "--code");
594
+ if ((code === undefined) === (values.nop !== true)) throw new UsageError("patch needs exactly one of --code, --code-file or --nop");
595
+ const request = { space, start: address(args[0], "start"), end: address(args[1], "end"), switch: args[2], code: code ?? "", ...(comment === undefined ? {} : { comment }) };
596
+ const image = operandImage(values.image as string | undefined, "patch");
597
+ return (db) => {
598
+ const result = setPatch(db, { ...context, image }, request);
599
+ return { revision: result.revision, patch: patch(result.current), ...(result.previous === undefined ? {} : { previous: patch(result.previous) }) };
600
+ };
601
+ }
602
+ case "macro": {
603
+ if (args[0] === undefined) throw new UsageError("the macro name is missing");
604
+ const body = textOrFile(values, "body", "--body");
605
+ if (body === undefined) throw new UsageError("macro needs --body or --body-file");
606
+ const request = { space, name: args[0], params: commaList((values.params as string | undefined) ?? ""), body, ...(comment === undefined ? {} : { comment }) };
607
+ return (db) => {
608
+ const result = setMacro(db, context, request);
609
+ return { revision: result.revision, macro: macro(result.current), ...(result.previous === undefined ? {} : { previous: macro(result.previous) }) };
610
+ };
611
+ }
612
+ case "use": {
613
+ if (args[2] === undefined) throw new UsageError("the macro name is missing");
614
+ const request = { space, start: address(args[0], "start"), end: address(args[1], "end"), macro: args[2], args: commaList((values.args as string | undefined) ?? "") };
615
+ const image = operandImage(values.image as string | undefined, "use");
616
+ return (db) => {
617
+ const result = setMacroUse(db, { ...context, image }, request);
618
+ return { revision: result.revision, use: macroUse(result.current), ...(result.previous === undefined ? {} : { previous: macroUse(result.previous) }) };
619
+ };
620
+ }
621
+ }
622
+ }
623
+
433
624
  /** apply <file>: the batch as BatchOperation values, checked before the database opens. */
434
625
  function applyOperation(args: string[], context: WriteContext, space: string, imagePath: string | undefined): WriteOperation {
435
626
  if (args.length !== 1) throw new UsageError("apply needs one file, or - for standard input");
@@ -442,19 +633,30 @@ function applyOperation(args: string[], context: WriteContext, space: string, im
442
633
  }
443
634
  if (!Array.isArray(parsed)) throw new UsageError("the batch is a JSON list of writes, for example [{\"op\": \"rename\", \"address\": \"$2100\", \"name\": \"update_player\"}]");
444
635
  const operations = parsed.map((item, index) => batchOperation(item, `write ${index + 1}`, space));
445
- const withImage = operations.some((operation) => operation.op === "operand") ? { ...context, image: operandImage(imagePath, "apply with operand writes") } : context;
636
+ const checked = operations.some((operation) => operation.op === "operand" || operation.op === "patch" || operation.op === "use");
637
+ const withImage = checked ? { ...context, image: operandImage(imagePath, "apply with operand, patch or use writes") } : context;
446
638
  return (db) => {
447
639
  const applied = applyBatch(db, withImage, operations);
448
640
  // replaced: each write that replaced a row of another origin, with that row as previous gives it for one write (b2/07).
449
641
  const replaced = applied.steps.flatMap((step, index) =>
450
642
  step.previous === undefined
451
643
  ? []
452
- : [{ write: index + 1, op: operations[index]!.op, previous: Array.isArray(step.previous) ? step.previous.map(region) : "select" in step.previous ? operand(step.previous) : symbol(step.previous) }],
644
+ : [{ write: index + 1, op: operations[index]!.op, previous: shownPrevious(step.previous) }],
453
645
  );
454
646
  return { revision: applied.revision, writes: applied.steps.length, replaced };
455
647
  };
456
648
  }
457
649
 
650
+ /** A row that a write of a batch replaced, in its output shape. */
651
+ function shownPrevious(row: NonNullable<BatchStep["previous"]>) {
652
+ if (Array.isArray(row)) return row.map(region);
653
+ if ("select" in row) return operand(row);
654
+ if ("switch" in row) return patch(row);
655
+ if ("body" in row) return macro(row);
656
+ if ("macro" in row) return macroUse(row);
657
+ return symbol(row);
658
+ }
659
+
458
660
  const BATCH_FIELDS: Record<BatchOperation["op"], string[]> = {
459
661
  rename: ["address", "name", "kind"],
460
662
  "remove-symbol": ["address"],
@@ -468,6 +670,12 @@ const BATCH_FIELDS: Record<BatchOperation["op"], string[]> = {
468
670
  unmodule: ["start"],
469
671
  operand: ["address", "select", "symbol", "offset", "expression"],
470
672
  unoperand: ["address"],
673
+ patch: ["start", "end", "switch", "code", "comment"],
674
+ unpatch: ["start"],
675
+ macro: ["name", "params", "body", "comment"],
676
+ unmacro: ["name"],
677
+ use: ["start", "end", "macro", "args"],
678
+ unuse: ["start"],
471
679
  };
472
680
 
473
681
  /** One write of a batch. A write names its own space, or takes the one of --space. */
@@ -490,6 +698,15 @@ function batchFields(item: unknown, at: string): BatchOperation {
490
698
  return fields[key] as string;
491
699
  };
492
700
  const place = (key: string) => address(text(key), `${at}: ${key}`);
701
+ // A list is a JSON list of strings or a comma list.
702
+ const list = (key: string) => {
703
+ const value = fields[key];
704
+ if (value === undefined) return [];
705
+ if (typeof value === "string") return commaList(value);
706
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw new UsageError(`${at}: ${key} must be a list of strings or a comma list`);
707
+ return (value as string[]).map((item) => item.trim());
708
+ };
709
+ const optional = (key: string) => (fields[key] === undefined ? {} : { [key]: text(key) });
493
710
  switch (op) {
494
711
  case "rename":
495
712
  return { op, address: place("address"), name: text("name"), ...(fields.kind === undefined ? {} : { kind: oneOf(SYMBOL_KINDS, text("kind"), `${at}: kind`) }) };
@@ -522,6 +739,17 @@ function batchFields(item: unknown, at: string): BatchOperation {
522
739
  }
523
740
  case "unoperand":
524
741
  return { op, address: place("address") };
742
+ case "patch":
743
+ return { op, start: place("start"), end: place("end"), switch: text("switch"), code: fields.code === undefined ? "" : text("code"), ...optional("comment") };
744
+ case "macro":
745
+ return { op, name: text("name"), params: list("params"), body: text("body"), ...optional("comment") };
746
+ case "unmacro":
747
+ return { op, name: text("name") };
748
+ case "use":
749
+ return { op, start: place("start"), end: place("end"), macro: text("macro"), args: list("args") };
750
+ case "unpatch":
751
+ case "unuse":
752
+ return { op, start: place("start") };
525
753
  case "reference":
526
754
  case "unreference":
527
755
  return { op, from: place("from"), to: place("to"), kind: oneOf(REFERENCE_KINDS, text("kind"), `${at}: kind`) };
@@ -601,8 +829,30 @@ function writeOperation(command: string, args: string[], kind: string | undefine
601
829
  const request = { space, address: address(args[0]) };
602
830
  return (db) => ({ revision: removeOperand(db, context, request).revision });
603
831
  }
832
+ case "unpatch": {
833
+ const request = { space, start: address(args[0], "start") };
834
+ return (db) => ({ revision: removePatch(db, context, request).revision });
835
+ }
836
+ case "unmacro": {
837
+ if (args[0] === undefined) throw new UsageError("the macro name is missing");
838
+ const request = { space, name: args[0] };
839
+ return (db) => ({ revision: removeMacro(db, context, request).revision });
840
+ }
841
+ case "unuse": {
842
+ const request = { space, start: address(args[0], "start") };
843
+ return (db) => ({ revision: removeMacroUse(db, context, request).revision });
844
+ }
845
+ case "merge": {
846
+ if (args.length > 0) throw new UsageError("merge takes no arguments");
847
+ const platform = new Map(platformNames().map((entry) => [entry.name, entry.address]));
848
+ return (db) => {
849
+ const merged = mergeConflicts(db, context, platform);
850
+ return { revision: merged.revision, choices: merged.current.map(showChoice) };
851
+ };
852
+ }
604
853
  case "revert": {
605
- const request = { revision: integer(args[0], "revision") };
854
+ if (args[0] === undefined) throw new UsageError("revision is missing");
855
+ const request = { revision: args[0] };
606
856
  return (db) => ({ revision: revert(db, context, request).revision });
607
857
  }
608
858
  default:
@@ -610,4 +860,4 @@ function writeOperation(command: string, args: string[], kind: string | undefine
610
860
  }
611
861
  }
612
862
 
613
- await runScript(() => run(process.argv.slice(2)), { usage: USAGE, refusals: [KnowledgeError] });
863
+ await runScript((argv) => run(argv), { usage: USAGE, refusals: [KnowledgeError] });