@henols/c64-re-tools 2.0.0-rc.9 → 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 (117) hide show
  1. package/README.md +97 -116
  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 +18 -4
  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/skills/c64-assembler/scripts/compare.ts +0 -84
  117. package/skills/c64-source/scripts/source.ts +0 -595
@@ -1,28 +1,51 @@
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";
7
+ import { join } from "node:path";
6
8
  import type { DatabaseSync } from "node:sqlite";
7
9
  import { parseArgs } from "node:util";
8
10
 
9
- import { formatC64Address, parseC64Address } from "#src/c64.ts";
11
+ import { formatC64Address, parseC64Address, SYMBOL_NAME } from "#src/c64.ts";
12
+ import { ImageError, readImage } from "#src/host-client/image.ts";
10
13
  import { readProjectFile, writeProjectFile } from "#src/host-client/transfer.ts";
14
+ import { CONFLICT_RULES, conflicts, type Conflict } from "#src/knowledge/conflicts.ts";
15
+ import { joinBatches, type Batch, type JoinChoice, type JoinClash } from "#src/knowledge/join.ts";
11
16
  import { KnowledgeError, openForRead, withWrite } from "#src/knowledge/database.ts";
12
- import { historyAt, historyNamed, revision, revisionNote, revisions, type HistoryEntry } from "#src/knowledge/history.ts";
17
+ import { operandText } from "#src/knowledge/expression.ts";
18
+ import { historyAt, historyNamed, historyOfMacro, revision, revisionNote, revisions, type HistoryEntry } from "#src/knowledge/history.ts";
13
19
  import {
14
20
  at,
21
+ analyzerNamed,
22
+ analyzerNamesAt,
23
+ commentsAt,
15
24
  currentRevision,
25
+ listAnalyzerNames,
16
26
  listComments,
27
+ listMacros,
28
+ listMacroUses,
17
29
  listModules,
30
+ listOperands,
31
+ listPatches,
18
32
  listSpaces,
19
33
  listReferences,
20
34
  listRegions,
21
35
  listSuppressions,
22
36
  listSymbols,
37
+ operandAt,
38
+ referencesFrom,
39
+ referencesTo,
23
40
  search,
41
+ symbolAt,
42
+ symbolNamed,
24
43
  type CommentRow,
44
+ type MacroRow,
45
+ type MacroUseRow,
25
46
  type ModuleRow,
47
+ type OperandRow,
48
+ type PatchRow,
26
49
  type ReferenceRow,
27
50
  type RegionRow,
28
51
  type SuppressionRow,
@@ -32,12 +55,14 @@ import {
32
55
  COMMENT_PLACEMENTS,
33
56
  DEFAULT_SPACE,
34
57
  EVIDENCE_KINDS,
58
+ OPERAND_SELECTS,
35
59
  ORIGINS,
36
60
  REFERENCE_KINDS,
37
61
  REGION_TYPES,
38
62
  SPACE,
39
63
  SYMBOL_KINDS,
40
64
  type CommentPlacement,
65
+ type OperandSelect,
41
66
  type Origin,
42
67
  type ReferenceKind,
43
68
  type RegionType,
@@ -47,37 +72,70 @@ import {
47
72
  addReference,
48
73
  applyBatch,
49
74
  classifyRegion,
75
+ mergeConflicts,
50
76
  removeComment,
77
+ removeMacro,
78
+ removeMacroUse,
51
79
  removeModule,
80
+ removeOperand,
81
+ removePatch,
52
82
  removeReference,
53
83
  removeSymbol,
54
84
  renameSymbol,
55
85
  revert,
56
86
  setComment,
87
+ setMacro,
88
+ setMacroUse,
57
89
  setModule,
90
+ setOperand,
91
+ setPatch,
58
92
  unclassifyRegion,
59
93
  type BatchOperation,
94
+ type BatchStep,
60
95
  type Evidence,
96
+ type MergeChoice,
97
+ type OperandImage,
61
98
  type WriteContext,
62
99
  } from "#src/knowledge/write.ts";
100
+ import { platformNames } from "#src/platform.ts";
101
+ import { addCoverage, coverageRuns, programCoverage, programImage, requireProgram, setImage, type ImageRequest } from "#src/knowledge/program.ts";
102
+ import { WireFailure } from "#src/host-client/tools.ts";
103
+ import { isInside, LOCAL_DIRECTORY, PROJECT_DIRECTORY, projectRelative, projectRoot, resolveProjectPath } from "#src/project.ts";
104
+ import { listing, listingText, type ListingSource } from "./lib/listing.ts";
63
105
  import { runScript, UsageError } from "#src/script.ts";
64
106
 
65
107
  const USAGE = `knowledge.ts <command> [arguments] [options]
66
108
 
67
109
  Read (no database needed):
68
110
  at <address> everything known at one address
69
- search <text> symbol names and comment texts
70
- symbols [--start A --end A --kind K --origin O]
111
+ search <text> symbol names, analyzer names and comment texts
112
+ symbols [--start A --end A --kind K --origin O] [--analyzer]
113
+ --analyzer: the second names that DXA, Ghidra
114
+ and the idioms gave
71
115
  regions [--start A --end A --type T --origin O]
72
116
  comments [--start A --end A --origin O]
73
117
  references [--start A --end A --kind K --origin O]
74
118
  suppressions [--start A --end A] places that removals keep free of analyzer facts
75
119
  modules [--start A --end A --origin O] where the files of generated source start
120
+ operands [--start A --end A --origin O] operand expressions of instructions
121
+ patches [--start A --end A --switch S --origin O] code that a switch puts in
122
+ switches each switch with the ranges of its patches
123
+ macros [--origin O] the macros of the generated source
124
+ uses [--start A --end A --macro M --origin O] instructions written as a macro
76
125
  spaces the address spaces that hold knowledge
77
- history <address> | history --name <name>
78
- revisions [--limit N --before N]
79
- revision <id>
126
+ program the program in the knowledge: its
127
+ segments and entry, and the coverage maps
128
+ that coverage imported, with the ranges
129
+ history <address> | history --name <name> | history --macro <name>
130
+ revisions [--limit N --before R]
131
+ revision <revision>
80
132
  export --out <file> [--start A --end A --origin O] all current knowledge to a project file
133
+ listing <start> <end> [--out <file>]
134
+ the program as ACME lines with names, operand
135
+ expressions, comments, the access of each line
136
+ in the coverage (x executed, r read, w written)
137
+ and the references in and out; --out writes it
138
+ as text
81
139
 
82
140
  Every command takes --space S: c64 (the default), drive8 for code in the 1541
83
141
  drive, or bank0 to bank255 for cartridge banks. A write in a batch can name its
@@ -86,7 +144,7 @@ own space.
86
144
  Read option --origin O: user, llm, dxa, ghidra, heuristic, semantic (user and
87
145
  llm) or analyzer (dxa, ghidra and heuristic), or a comma list. search takes it too.
88
146
 
89
- Write (one revision each; creates the database when needed):
147
+ Write (one revision each; creates .c64-re-tools/knowledge/ when needed):
90
148
  rename <address> <name> [--kind K]
91
149
  remove-symbol <address>
92
150
  classify <start> <end> <type> [--element N]
@@ -97,25 +155,86 @@ Write (one revision each; creates the database when needed):
97
155
  unreference <from> <to> <kind>
98
156
  module <start> <file.asm> [--title T] a file of generated source starts here
99
157
  unmodule <start>
158
+ operand <address> --lo|--hi|--value <symbol> [--offset N]
159
+ operand <address> --expr <text>
160
+ write the operand of the instruction at <address> as
161
+ an expression: the low or high byte of symbol + N, the
162
+ value symbol + N, or ACME expression text; refused when
163
+ it does not give the operand in the program of the
164
+ knowledge; give a negative offset as --offset=-1
165
+ unoperand <address>
166
+ patch <start> <end> <SWITCH> --code <text>|--code-file <file>|--nop [--comment T]
167
+ ACME code that replaces <start>-<end> when the build
168
+ defines SWITCH as 1 (c64-assembler --define SWITCH=1);
169
+ NOP fills the rest of the range; --nop puts only NOP
170
+ unpatch <start>
171
+ macro <name> [--params a,b] --body <text>|--body-file <file> [--comment T]
172
+ an ACME macro; the body names a param with a dot: .a
173
+ unmacro <name>
174
+ use <start> <end> <macro> [--args "x, y"]
175
+ write the instructions <start>-<end> as one use of
176
+ the macro, with one expression for each param
177
+ unuse <start>
178
+ image <image.json|prg> put the program into the knowledge: its bytes, and
179
+ for an image.json of c64-unpacker capture also the
180
+ entry, the entry state and the stages; with --space for
181
+ drive code or a cartridge bank. Only changed bytes write
182
+ coverage <map.bin>... join coverage maps of c64_memmap or a run into the
183
+ coverage of the knowledge; each map is evidence
184
+ of the revision, named by its path
100
185
  revert <revision>
186
+ merge decide the conflicts that a git merge of two
187
+ computers' work left: of the rows of one conflict,
188
+ the newest stays; one revision for all
189
+ join <batch>... --out <file>
190
+ join the batches of agents that worked in parallel into
191
+ one batch for apply; writes no knowledge. A batch is a
192
+ list of writes, or {"owner": "$1000-$1fff", "ops": [...]}:
193
+ at an address in its owner range its name, comment,
194
+ operand and module win, and its regions come last
101
195
  apply <file> many writes from a JSON file ('-' reads standard input),
102
- all as one revision, or none when one is refused
196
+ all as one revision, or none when one is refused;
197
+ operand, patch and use writes are checked against the
198
+ program of --space in the knowledge
103
199
 
104
200
  Write options:
105
201
  --reason <text> why, kept with the revision
106
- --evidence <kind:ref> what the change rests on: coverage:<file>, test:<file>,
107
- file:<file> or note:<text> (repeatable)
108
- --expect-revision <N> refuse the write if knowledge changed after revision N
202
+ --evidence <kind:ref> what the change rests on: coverage:<map> (a map that
203
+ coverage imported), test:<file>, file:<file> or
204
+ note:<text> (repeatable)
205
+ --expect-revision <R> refuse the write if knowledge changed after revision R
109
206
  --origin user|llm who decided (default llm)
110
207
 
111
- Addresses are $ and four hex digits; quote them in a shell: '$2100'.`;
208
+ Addresses are $ and four hex digits; quote them in a shell: '$2100'. A name
209
+ also gives an address: a symbol name first, then an analyzer name.
210
+ A revision is its id, for example 20261015T214501.123-k3f9qz, or a start of
211
+ the id that only one revision has; 0 is the knowledge before every revision.`;
112
212
 
213
+ /** The space whose names give addresses: the --space of the command. */
214
+ let nameSpace = DEFAULT_SPACE;
215
+
216
+ /** An address as $ and four hex digits, or the address of a symbol name, else of an analyzer name. */
113
217
  function address(value: string | undefined, what = "address"): number {
114
218
  if (value === undefined) throw new UsageError(`${what} is missing`);
219
+ if (SYMBOL_NAME.test(value)) return addressOfName(value, what);
115
220
  try {
116
221
  return parseC64Address(value);
117
222
  } catch {
118
- throw new UsageError(`${what} must be $ followed by four hex digits, for example '$2100', not ${value}`);
223
+ throw new UsageError(`${what} must be $ followed by four hex digits, for example '$2100', or a name, not ${value}`);
224
+ }
225
+ }
226
+
227
+ function addressOfName(name: string, what: string): number {
228
+ const db = openForRead();
229
+ try {
230
+ const symbol = symbolNamed(db, name, nameSpace);
231
+ if (symbol !== undefined) return symbol.address;
232
+ const second = analyzerNamed(db, name, nameSpace);
233
+ if (second.length === 1) return second[0]!.address;
234
+ if (second.length > 1) throw new UsageError(`${what}: the analyzers give ${name} to ${second.map((row) => `${formatC64Address(row.address)} (${row.origin})`).join(" and ")}; give the address`);
235
+ throw new UsageError(`${what}: no symbol or analyzer name is ${name}`);
236
+ } finally {
237
+ db?.close();
119
238
  }
120
239
  }
121
240
 
@@ -130,7 +249,7 @@ function integer(value: string | undefined, what: string): number {
130
249
  }
131
250
 
132
251
  // Output shapes: addresses as $xxxx, nothing internal.
133
- /** The space of a row, shown only when it is not the C64 (D53). */
252
+ /** The space of a row, shown only when it is not the C64. */
134
253
  const spaceOf = (row: { space: string }) => (row.space === "c64" ? {} : { space: row.space });
135
254
  const symbol = (row: SymbolRow) => ({ ...spaceOf(row), address: formatC64Address(row.address), name: row.name, kind: row.kind, origin: row.origin, revision: row.revision });
136
255
  const region = (row: RegionRow) => ({
@@ -144,6 +263,39 @@ const region = (row: RegionRow) => ({
144
263
  });
145
264
  const comment = (row: CommentRow) => ({ ...spaceOf(row), address: formatC64Address(row.address), placement: row.placement, text: row.text, origin: row.origin, revision: row.revision });
146
265
  const module = (row: ModuleRow) => ({ ...spaceOf(row), start: formatC64Address(row.start), file: row.file, ...(row.title === undefined ? {} : { title: row.title }), origin: row.origin, revision: row.revision });
266
+ const operand = (row: OperandRow) => ({
267
+ ...spaceOf(row),
268
+ address: formatC64Address(row.address),
269
+ select: row.select,
270
+ ...(row.symbol === undefined ? {} : { symbol: row.symbol }),
271
+ ...(row.offset === 0 ? {} : { offset: row.offset }),
272
+ ...(row.expression === undefined ? {} : { expression: row.expression }),
273
+ text: operandText(row),
274
+ origin: row.origin,
275
+ revision: row.revision,
276
+ });
277
+ const patch = (row: PatchRow) => ({
278
+ ...spaceOf(row),
279
+ start: formatC64Address(row.start),
280
+ end: formatC64Address(row.end),
281
+ bytes: row.end - row.start + 1,
282
+ switch: row.switch,
283
+ code: row.code,
284
+ ...(row.comment === undefined ? {} : { comment: row.comment }),
285
+ origin: row.origin,
286
+ revision: row.revision,
287
+ });
288
+ 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 });
289
+ const macroUse = (row: MacroUseRow) => ({
290
+ ...spaceOf(row),
291
+ start: formatC64Address(row.start),
292
+ end: formatC64Address(row.end),
293
+ macro: row.macro,
294
+ args: row.args,
295
+ text: `+${row.macro}${row.args.length === 0 ? "" : ` ${row.args.join(", ")}`}`,
296
+ origin: row.origin,
297
+ revision: row.revision,
298
+ });
147
299
  const reference = (row: ReferenceRow) => ({ ...spaceOf(row), from: formatC64Address(row.from), to: formatC64Address(row.to), kind: row.kind, origin: row.origin, revision: row.revision });
148
300
  function suppression(row: SuppressionRow) {
149
301
  const place =
@@ -157,7 +309,7 @@ function suppression(row: SuppressionRow) {
157
309
 
158
310
  function historyEntry(entry: HistoryEntry) {
159
311
  const shown =
160
- entry.entity === "symbol"
312
+ entry.entity === "symbol" || entry.entity === "analyzer-name"
161
313
  ? symbol(entry.row)
162
314
  : entry.entity === "region"
163
315
  ? region(entry.row)
@@ -167,14 +319,32 @@ function historyEntry(entry: HistoryEntry) {
167
319
  ? reference(entry.row)
168
320
  : entry.entity === "module"
169
321
  ? module(entry.row)
170
- : suppression(entry.row);
322
+ : entry.entity === "operand"
323
+ ? operand(entry.row)
324
+ : entry.entity === "patch"
325
+ ? patch(entry.row)
326
+ : entry.entity === "macro"
327
+ ? macro(entry.row)
328
+ : entry.entity === "macro-use"
329
+ ? macroUse(entry.row)
330
+ : suppression(entry.row);
171
331
  const { revision: _revision, ...row } = shown;
172
332
  return { entity: entry.entity, ...row, fromRevision: entry.from, ...(entry.to === undefined ? {} : { toRevision: entry.to }) };
173
333
  }
174
334
 
335
+ /** A conflict of a git merge: what is wrong, and the rows, oldest first. */
336
+ function showConflict(conflict: Conflict) {
337
+ return { conflict: CONFLICT_RULES[conflict.rule], ...spaceOf(conflict), rows: conflict.rows.map(historyEntry) };
338
+ }
339
+
340
+ /** What merge decided for one conflict: the row that stays and the rows it closed. */
341
+ function showChoice(choice: MergeChoice) {
342
+ return { conflict: CONFLICT_RULES[choice.rule], ...spaceOf(choice), kept: historyEntry(choice.kept), closed: choice.closed.map(historyEntry) };
343
+ }
344
+
175
345
  /** The reason of each revision that wrote one of the rows. */
176
- function reasonsOf(db: DatabaseSync | undefined, rows: SuppressionRow[]): Map<number, string> {
177
- const reasons = new Map<number, string>();
346
+ function reasonsOf(db: DatabaseSync | undefined, rows: SuppressionRow[]): Map<string, string> {
347
+ const reasons = new Map<string, string>();
178
348
  const query = db?.prepare("SELECT description FROM revisions WHERE id = ?");
179
349
  for (const { revision } of rows) {
180
350
  if (query === undefined || reasons.has(revision)) continue;
@@ -205,17 +375,36 @@ function run(argv: string[]): unknown {
205
375
  out: { type: "string" },
206
376
  title: { type: "string" },
207
377
  space: { type: "string" },
378
+ lo: { type: "string" },
379
+ hi: { type: "string" },
380
+ value: { type: "string" },
381
+ expr: { type: "string" },
382
+ offset: { type: "string" },
383
+ code: { type: "string" },
384
+ "code-file": { type: "string" },
385
+ nop: { type: "boolean" },
386
+ comment: { type: "string" },
387
+ params: { type: "string" },
388
+ body: { type: "string" },
389
+ "body-file": { type: "string" },
390
+ args: { type: "string" },
391
+ switch: { type: "string" },
392
+ macro: { type: "string" },
393
+ analyzer: { type: "boolean" },
208
394
  help: { type: "boolean" },
209
395
  },
210
396
  });
211
397
  const [command, ...args] = positionals;
212
398
  if (values.help || command === undefined) throw new UsageError("");
213
- const range = { start: values.start === undefined ? 0x0000 : address(values.start, "--start"), end: values.end === undefined ? 0xffff : address(values.end, "--end") };
214
399
  if (values.space !== undefined && !SPACE.test(values.space)) throw new UsageError("--space must be c64, drive8, or bank0 to bank255");
215
400
  const space = values.space ?? DEFAULT_SPACE;
401
+ nameSpace = space;
402
+ const range = { start: values.start === undefined ? 0x0000 : address(values.start, "--start"), end: values.end === undefined ? 0xffff : address(values.end, "--end") };
216
403
 
217
- const read = new Set(["at", "search", "symbols", "regions", "comments", "references", "suppressions", "modules", "spaces", "history", "revisions", "revision", "export"]);
218
- const write = new Set(["rename", "remove-symbol", "classify", "unclassify", "comment", "uncomment", "reference", "unreference", "module", "unmodule", "revert", "apply"]);
404
+ const read = new Set(["at", "search", "symbols", "regions", "comments", "references", "suppressions", "modules", "operands", "patches", "switches", "macros", "uses", "spaces", "program", "history", "revisions", "revision", "export", "listing"]);
405
+ 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", "image", "coverage"]);
406
+ // join reads batch files and writes one: no knowledge opens.
407
+ if (command === "join") return joinCommand(args, values.out, space);
219
408
  // Refuse a mistyped command before anything opens, so no database appears by accident.
220
409
  if (!read.has(command) && !write.has(command)) throw new UsageError(`unknown command: ${command}`);
221
410
  if (read.has(command)) {
@@ -223,79 +412,10 @@ function run(argv: string[]): unknown {
223
412
  if (command === "export" && values.out === undefined) throw new UsageError("export needs --out <file>");
224
413
  const db = openForRead();
225
414
  try {
226
- switch (command) {
227
- case "at": {
228
- const known = at(db, address(args[0]), space);
229
- return {
230
- ...(space === DEFAULT_SPACE ? {} : { space }),
231
- address: formatC64Address(known.address),
232
- ...(known.symbol === undefined ? {} : { symbol: symbol(known.symbol) }),
233
- ...(known.region === undefined ? {} : { region: region(known.region) }),
234
- comments: known.comments.map(comment),
235
- referencesFrom: known.referencesFrom.map(reference),
236
- referencesTo: known.referencesTo.map(reference),
237
- ...(known.suppressions.length === 0 ? {} : { suppressions: known.suppressions.map(suppression) }),
238
- revision: known.revision,
239
- };
240
- }
241
- case "search": {
242
- if (args[0] === undefined || args[0] === "") throw new UsageError("search needs text");
243
- const found = search(db, args[0], values.limit === undefined ? 50 : integer(values.limit, "--limit"), origins, space);
244
- return { symbols: found.symbols.map(symbol), comments: found.comments.map(comment) };
245
- }
246
- case "symbols":
247
- return { symbols: listSymbols(db, range, values.kind === undefined ? undefined : oneOf(SYMBOL_KINDS, values.kind, "--kind"), origins, space).map(symbol) };
248
- case "regions":
249
- return { regions: listRegions(db, range, values.type === undefined ? undefined : oneOf(REGION_TYPES, values.type, "--type"), origins, space).map(region) };
250
- case "comments":
251
- return { comments: listComments(db, range, origins, space).map(comment) };
252
- case "references":
253
- return { references: listReferences(db, range, values.kind === undefined ? undefined : oneOf(REFERENCE_KINDS, values.kind, "--kind"), origins, space).map(reference) };
254
- case "export": {
255
- const exported = {
256
- revision: currentRevision(db),
257
- ...(space === DEFAULT_SPACE ? {} : { space }),
258
- symbols: listSymbols(db, range, undefined, origins, space).map(symbol),
259
- regions: listRegions(db, range, undefined, origins, space).map(region),
260
- comments: listComments(db, range, origins, space).map(comment),
261
- references: listReferences(db, range, undefined, origins, space).map(reference),
262
- suppressions: listSuppressions(db, range, space).map(suppression),
263
- modules: listModules(db, range, origins, space).map(module),
264
- };
265
- const bytes = Buffer.from(`${JSON.stringify(exported, null, 2)}\n`, "utf8");
266
- writeProjectFile(values.out!, bytes, { what: "--out" });
267
- return {
268
- out: values.out,
269
- bytes: bytes.length,
270
- 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 },
271
- revision: exported.revision,
272
- };
273
- }
274
- case "suppressions": {
275
- const rows = listSuppressions(db, range, space);
276
- const reasons = reasonsOf(db, rows);
277
- return { suppressions: rows.map((row) => ({ ...suppression(row), ...(reasons.has(row.revision) ? { reason: reasons.get(row.revision) } : {}) })) };
278
- }
279
- case "modules":
280
- return { modules: listModules(db, range, origins, space).map(module) };
281
- case "spaces":
282
- return { spaces: listSpaces(db) };
283
- case "history":
284
- // Each row with the reason and the evidence of the revision that wrote it (F-26).
285
- return {
286
- history: (values.name === undefined ? historyAt(db, address(args[0]), space) : historyNamed(db, values.name, space)).map((entry) => ({ ...historyEntry(entry), ...revisionNote(db, entry.from) })),
287
- };
288
- case "revisions": {
289
- const options: { limit?: number; before?: number } = {};
290
- if (values.limit !== undefined) options.limit = integer(values.limit, "--limit");
291
- if (values.before !== undefined) options.before = integer(values.before, "--before");
292
- return { revisions: revisions(db, options) };
293
- }
294
- default: {
295
- const changes = revision(db, integer(args[0], "revision"));
296
- return { revision: changes.revision, added: changes.added.map(historyEntry), closed: changes.closed.map(historyEntry) };
297
- }
298
- }
415
+ const answer = readAnswer(db, command, args, values, range, origins, space);
416
+ const found = conflicts(db);
417
+ // A git merge can leave conflicts: every read shows them until merge decides them.
418
+ return found.length === 0 ? answer : { ...answer, conflicts: found.map(showConflict) };
299
419
  } finally {
300
420
  db?.close();
301
421
  }
@@ -303,14 +423,216 @@ function run(argv: string[]): unknown {
303
423
 
304
424
  const context: WriteContext = { origin: values.origin === undefined ? "llm" : oneOf(["user", "llm"] as const, values.origin, "--origin") };
305
425
  if (values.reason !== undefined) context.description = values.reason;
306
- if (values["expect-revision"] !== undefined) context.expectedRevision = integer(values["expect-revision"], "--expect-revision");
307
- if (values.evidence !== undefined) context.evidence = values.evidence.map(evidenceOf);
426
+ if (values["expect-revision"] !== undefined) context.expectedRevision = values["expect-revision"];
427
+ if (values.evidence !== undefined) context.evidence = values.evidence.map((value) => evidenceOf(value, space));
308
428
  // Every argument is checked before the database opens: a refused write creates no database.
309
429
  if (values.title !== undefined && command !== "module") throw new UsageError("--title works only with module");
310
- const operation = command === "apply" ? applyOperation(args, context, space) : writeOperation(command, args, values.kind, values.element, context, values.title, space);
430
+ for (const option of ["lo", "hi", "value", "expr", "offset"] as const) {
431
+ if (values[option] !== undefined && command !== "operand") throw new UsageError(`--${option} works only with operand`);
432
+ }
433
+ const only: Array<[keyof typeof values, readonly string[]]> = [
434
+ ["code", ["patch"]],
435
+ ["code-file", ["patch"]],
436
+ ["nop", ["patch"]],
437
+ ["comment", ["patch", "macro"]],
438
+ ["params", ["macro"]],
439
+ ["body", ["macro"]],
440
+ ["body-file", ["macro"]],
441
+ ["args", ["use"]],
442
+ ];
443
+ for (const [option, commands] of only) {
444
+ if (values[option] !== undefined && !commands.includes(command)) throw new UsageError(`--${option} works only with ${commands.join(", ")}`);
445
+ }
446
+ const operation =
447
+ command === "image" || command === "coverage"
448
+ ? programOperation(command, args, context, space)
449
+ : command === "apply"
450
+ ? applyOperation(args, context, space)
451
+ : command === "operand"
452
+ ? operandOperation(args, values, context, space)
453
+ : command === "patch" || command === "macro" || command === "use"
454
+ ? sourceOperation(command, args, values, context, space)
455
+ : writeOperation(command, args, values.kind, values.element, context, values.title, space);
311
456
  return withWrite(operation);
312
457
  }
313
458
 
459
+ type ReadValues = {
460
+ analyzer?: boolean | undefined;
461
+ kind?: string | undefined;
462
+ type?: string | undefined;
463
+ name?: string | undefined;
464
+ limit?: string | undefined;
465
+ before?: string | undefined;
466
+ out?: string | undefined;
467
+ switch?: string | undefined;
468
+ macro?: string | undefined;
469
+ };
470
+
471
+ /** The lookups of a listing: the program and the coverage in the knowledge, the knowledge of the space and the platform names of the C64. */
472
+ function listingSource(db: DatabaseSync | undefined, space: string): ListingSource {
473
+ const image = requireProgram(db, space);
474
+ const coverage = programCoverage(db, space)?.map;
475
+ const inside = (at: number) => image.segments.some((segment) => at >= segment.start && at <= segment.end);
476
+ const platform = new Map(space === DEFAULT_SPACE ? platformNames().map((entry) => [entry.address, entry.name]) : []);
477
+ return {
478
+ byteAt: (at) => (inside(at) ? image.byteAt(at) : undefined),
479
+ // A user or LLM name, else the platform name; an analyzer name in symbols (knowledge from an older version) gives way to it.
480
+ nameOf: (at) => {
481
+ const own = symbolAt(db, at, space);
482
+ return own !== undefined && (own.origin === "user" || own.origin === "llm") ? own.name : (platform.get(at) ?? own?.name);
483
+ },
484
+ secondNames: (at) => analyzerNamesAt(db, at, space).map((row) => row.name),
485
+ regions: listRegions(db, undefined, undefined, undefined, space),
486
+ comments: (at) => {
487
+ const rows = commentsAt(db, at, space);
488
+ const side = rows.find((row) => row.placement === "side")?.text;
489
+ return { line: rows.filter((row) => row.placement === "line").flatMap((row) => row.text.split(/\r?\n/)), ...(side === undefined ? {} : { side }) };
490
+ },
491
+ operand: (at) => {
492
+ const row = operandAt(db, at, space);
493
+ return row === undefined ? undefined : operandText(row);
494
+ },
495
+ references: (at) => ({ in: referencesTo(db, at, space).length, out: referencesFrom(db, at, space).length }),
496
+ ...(coverage === undefined ? {} : { coverage }),
497
+ };
498
+ }
499
+
500
+ /** The answer of one read command. */
501
+ function readAnswer(db: DatabaseSync | undefined, command: string, args: string[], values: ReadValues, range: { start: number; end: number }, origins: Origin[] | undefined, space: string): Record<string, unknown> {
502
+ switch (command) {
503
+ case "listing": {
504
+ if (args.length !== 2) throw new UsageError("listing needs <start> <end>");
505
+ const start = address(args[0], "start");
506
+ const end = address(args[1], "end");
507
+ if (end < start) throw new UsageError("the end must not be before the start");
508
+ const source = listingSource(db, space);
509
+ const lines = listing(source, start, end);
510
+ if (values.out !== undefined) {
511
+ writeProjectFile(values.out, Buffer.from(listingText(lines), "utf8"), { what: "--out" });
512
+ return { out: values.out, lines: lines.length };
513
+ }
514
+ return { lines: lines.map((row) => ({ ...row, address: formatC64Address(row.address) })) };
515
+ }
516
+ case "at": {
517
+ const known = at(db, address(args[0]), space);
518
+ return {
519
+ ...(space === DEFAULT_SPACE ? {} : { space }),
520
+ address: formatC64Address(known.address),
521
+ ...(known.symbol === undefined ? {} : { symbol: symbol(known.symbol) }),
522
+ ...(known.analyzerNames.length === 0 ? {} : { analyzerNames: known.analyzerNames.map(symbol) }),
523
+ ...(known.region === undefined ? {} : { region: region(known.region) }),
524
+ comments: known.comments.map(comment),
525
+ referencesFrom: known.referencesFrom.map(reference),
526
+ referencesTo: known.referencesTo.map(reference),
527
+ ...(known.operand === undefined ? {} : { operand: operand(known.operand) }),
528
+ ...(known.patch === undefined ? {} : { patch: patch(known.patch) }),
529
+ ...(known.macroUse === undefined ? {} : { macroUse: macroUse(known.macroUse) }),
530
+ ...(known.suppressions.length === 0 ? {} : { suppressions: known.suppressions.map(suppression) }),
531
+ revision: known.revision,
532
+ };
533
+ }
534
+ case "search": {
535
+ if (args[0] === undefined || args[0] === "") throw new UsageError("search needs text");
536
+ const found = search(db, args[0], values.limit === undefined ? 50 : integer(values.limit, "--limit"), origins, space);
537
+ return { symbols: found.symbols.map(symbol), analyzerNames: found.analyzerNames.map(symbol), comments: found.comments.map(comment) };
538
+ }
539
+ case "symbols": {
540
+ const kind = values.kind === undefined ? undefined : oneOf(SYMBOL_KINDS, values.kind, "--kind");
541
+ if (values.analyzer) return { analyzerNames: listAnalyzerNames(db, range, kind, origins, space).map(symbol) };
542
+ return { symbols: listSymbols(db, range, kind, origins, space).map(symbol) };
543
+ }
544
+ case "regions":
545
+ return { regions: listRegions(db, range, values.type === undefined ? undefined : oneOf(REGION_TYPES, values.type, "--type"), origins, space).map(region) };
546
+ case "comments":
547
+ return { comments: listComments(db, range, origins, space).map(comment) };
548
+ case "references":
549
+ return { references: listReferences(db, range, values.kind === undefined ? undefined : oneOf(REFERENCE_KINDS, values.kind, "--kind"), origins, space).map(reference) };
550
+ case "program": {
551
+ const image = programImage(db, space);
552
+ const ranges = db === undefined ? [] : (db.prepare("SELECT kind, count(*) AS count FROM current_coverage WHERE space = ? GROUP BY kind ORDER BY kind").all(space) as Array<{ kind: string; count: number }>);
553
+ return {
554
+ ...(space === DEFAULT_SPACE ? {} : { space }),
555
+ image:
556
+ image === undefined
557
+ ? null
558
+ : {
559
+ segments: image.segments.map((segment) => ({ start: formatC64Address(segment.start), end: formatC64Address(segment.end) })),
560
+ bytes: image.segments.reduce((sum, segment) => sum + segment.end - segment.start + 1, 0),
561
+ ...(image.entry === undefined ? {} : { entry: formatC64Address(image.entry) }),
562
+ state: image.state !== undefined,
563
+ stages: image.stages?.length ?? 0,
564
+ },
565
+ coverage: { maps: [...new Set(coverageRuns(db).filter((run) => run.space === space).map((run) => run.name))], ranges: Object.fromEntries(ranges.map((row) => [row.kind, row.count])) },
566
+ };
567
+ }
568
+ case "export": {
569
+ const exported = {
570
+ revision: currentRevision(db),
571
+ ...(space === DEFAULT_SPACE ? {} : { space }),
572
+ symbols: listSymbols(db, range, undefined, origins, space).map(symbol),
573
+ analyzerNames: listAnalyzerNames(db, range, undefined, origins, space).map(symbol),
574
+ regions: listRegions(db, range, undefined, origins, space).map(region),
575
+ comments: listComments(db, range, origins, space).map(comment),
576
+ references: listReferences(db, range, undefined, origins, space).map(reference),
577
+ suppressions: listSuppressions(db, range, space).map(suppression),
578
+ modules: listModules(db, range, origins, space).map(module),
579
+ operands: listOperands(db, range, origins, space).map(operand),
580
+ patches: listPatches(db, range, undefined, origins, space).map(patch),
581
+ macros: listMacros(db, origins, space).map(macro),
582
+ macroUses: listMacroUses(db, range, undefined, origins, space).map(macroUse),
583
+ };
584
+ const bytes = Buffer.from(`${JSON.stringify(exported, null, 2)}\n`, "utf8");
585
+ writeProjectFile(values.out!, bytes, { what: "--out" });
586
+ return {
587
+ out: values.out,
588
+ bytes: bytes.length,
589
+ counts: { symbols: exported.symbols.length, analyzerNames: exported.analyzerNames.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 },
590
+ revision: exported.revision,
591
+ };
592
+ }
593
+ case "suppressions": {
594
+ const rows = listSuppressions(db, range, space);
595
+ const reasons = reasonsOf(db, rows);
596
+ return { suppressions: rows.map((row) => ({ ...suppression(row), ...(reasons.has(row.revision) ? { reason: reasons.get(row.revision) } : {}) })) };
597
+ }
598
+ case "modules":
599
+ return { modules: listModules(db, range, origins, space).map(module) };
600
+ case "operands":
601
+ return { operands: listOperands(db, range, origins, space).map(operand) };
602
+ case "patches":
603
+ return { patches: listPatches(db, range, values.switch, origins, space).map(patch) };
604
+ case "switches": {
605
+ const switches = new Map<string, PatchRow[]>();
606
+ for (const row of listPatches(db, undefined, undefined, undefined, space)) switches.set(row.switch, [...(switches.get(row.switch) ?? []), row]);
607
+ return {
608
+ 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) })) })),
609
+ };
610
+ }
611
+ case "macros":
612
+ return { macros: listMacros(db, origins, space).map(macro) };
613
+ case "uses":
614
+ return { uses: listMacroUses(db, range, values.macro, origins, space).map(macroUse) };
615
+ case "spaces":
616
+ return { spaces: listSpaces(db) };
617
+ case "history":
618
+ // Each row with the reason and the evidence of the revision that wrote it.
619
+ return {
620
+ 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) })),
621
+ };
622
+ case "revisions": {
623
+ const options: { limit?: number; before?: string } = {};
624
+ if (values.limit !== undefined) options.limit = integer(values.limit, "--limit");
625
+ if (values.before !== undefined) options.before = values.before;
626
+ return { revisions: revisions(db, options) };
627
+ }
628
+ default: {
629
+ if (args[0] === undefined) throw new UsageError("revision is missing");
630
+ const changes = revision(db, args[0]);
631
+ return { revision: changes.revision, added: changes.added.map(historyEntry), closed: changes.closed.map(historyEntry) };
632
+ }
633
+ }
634
+ }
635
+
314
636
  /** --origin for reads: one origin, semantic (user and llm), analyzer (dxa and ghidra), or a comma list. */
315
637
  function originFilter(value: string): Origin[] {
316
638
  const origins = new Set<Origin>();
@@ -322,14 +644,185 @@ function originFilter(value: string): Origin[] {
322
644
  return [...origins];
323
645
  }
324
646
 
325
- /** --evidence kind:ref. A file kind names a project file and keeps its SHA-256, so a later change of the file shows. */
326
- function evidenceOf(value: string): Evidence {
647
+ /** A project path as the knowledge names it: one spelling for each file, so ./runs/a.bin and runs/a.bin are the same file. */
648
+ function knowledgePath(path: string, what: string): string {
649
+ try {
650
+ return projectRelative(path);
651
+ } catch {
652
+ throw new UsageError(`${what} must be a path relative to the project folder that stays inside it, not ${path}`);
653
+ }
654
+ }
655
+
656
+ /**
657
+ * --evidence kind:ref. A file kind names a project file and keeps its SHA-256, so a later change of the file shows.
658
+ * A file in .c64-re-tools/local/ is refused: git ignores it, and the move that keeps it changes the path.
659
+ */
660
+ function evidenceOf(value: string, space: string): Evidence {
327
661
  const colon = value.indexOf(":");
328
662
  const kind = oneOf(EVIDENCE_KINDS, colon < 0 ? value : value.slice(0, colon), "--evidence kind");
329
- const ref = colon < 0 ? "" : value.slice(colon + 1);
330
- if (ref === "") throw new UsageError(`--evidence needs ${kind}:<${kind === "note" ? "text" : "file"}>`);
331
- if (kind === "note") return { kind, ref };
332
- return { kind, ref, sha256: createHash("sha256").update(readProjectFile(ref).bytes).digest("hex") };
663
+ const given = colon < 0 ? "" : value.slice(colon + 1);
664
+ if (given === "") throw new UsageError(`--evidence needs ${kind}:<${kind === "note" ? "text" : "file"}>`);
665
+ if (kind === "note") return { kind, ref: given };
666
+ const ref = knowledgePath(given, `--evidence ${kind}:<file>`);
667
+ // A coverage map is evidence by the name that coverage imported it with: the latest import into the space of the write.
668
+ if (kind === "coverage") {
669
+ const db = openForRead();
670
+ try {
671
+ const run = coverageRuns(db).filter((item) => item.name === ref && item.space === space).at(-1);
672
+ const where = space === DEFAULT_SPACE ? "" : ` in ${space}`;
673
+ if (run === undefined) throw new UsageError(`--evidence coverage:${given} names no coverage map of the knowledge${where}: import the map with coverage${space === DEFAULT_SPACE ? "" : ` --space ${space}`} first`);
674
+ return { kind, ref, sha256: run.sha256, ...(run.space === DEFAULT_SPACE ? {} : { space: run.space }) };
675
+ } finally {
676
+ db?.close();
677
+ }
678
+ }
679
+ const bytes = readProjectFile(ref).bytes;
680
+ const root = projectRoot();
681
+ if (isInside(join(root, PROJECT_DIRECTORY, LOCAL_DIRECTORY), resolveProjectPath(ref, root))) {
682
+ throw new UsageError(`--evidence ${kind}:${ref} is in ${PROJECT_DIRECTORY}/${LOCAL_DIRECTORY}/, which git ignores: move it to a folder that git keeps (a run folder to runs/<name>/) and give the new path`);
683
+ }
684
+ return { kind, ref, sha256: createHash("sha256").update(bytes).digest("hex") };
685
+ }
686
+
687
+ /** An image.json of c64-unpacker capture or a PRG as a write of the program. */
688
+ function imageRequest(path: string, space: string): ImageRequest {
689
+ let image: ReturnType<typeof readImage>;
690
+ try {
691
+ image = readImage(path);
692
+ } catch (error) {
693
+ if (error instanceof ImageError) throw new UsageError(error.message);
694
+ throw error;
695
+ }
696
+ const request: ImageRequest = { space, segments: image.segments, byteAt: image.byteAt };
697
+ if (image.entry !== undefined) request.entry = image.entry;
698
+ if (image.stages !== undefined) request.stages = image.stages;
699
+ if (image.statePath !== undefined) {
700
+ let bytes: Buffer | undefined;
701
+ try {
702
+ bytes = readProjectFile(image.statePath).bytes;
703
+ } catch (error) {
704
+ if (!(error instanceof WireFailure && error.code === "not-found")) throw error;
705
+ }
706
+ if (bytes !== undefined) {
707
+ let state: unknown;
708
+ try {
709
+ state = JSON.parse(bytes.toString("utf8"));
710
+ } catch (error) {
711
+ throw new UsageError(`${image.statePath} is not JSON: ${(error as Error).message}`);
712
+ }
713
+ if (typeof state !== "object" || state === null || Array.isArray(state)) throw new UsageError(`${image.statePath} is not an entry state of c64-unpacker capture`);
714
+ // The comparison with a clean reset only tells the capture what it found.
715
+ const { differsFromReset: _differs, ...kept } = state as Record<string, unknown>;
716
+ request.state = kept;
717
+ }
718
+ }
719
+ return request;
720
+ }
721
+
722
+ /** image and coverage: the program and what the runs did go into the knowledge, so no project file is made again. */
723
+ function programOperation(command: "image" | "coverage", args: string[], context: WriteContext, space: string): WriteOperation {
724
+ if (command === "image") {
725
+ if (args.length !== 1) throw new UsageError("image needs one image.json or PRG");
726
+ const request = imageRequest(args[0]!, space);
727
+ return (db) => {
728
+ const { revision, current } = setImage(db, context, request);
729
+ return { revision, ...spaceOf(current), bytes: current.bytes, rowsAdded: current.rowsAdded, rowsClosed: current.rowsClosed, entryChanged: current.entry };
730
+ };
731
+ }
732
+ if (args.length === 0) throw new UsageError("coverage needs one or more coverage maps");
733
+ const maps = args.map((path) => ({ bytes: readProjectFile(path).bytes, name: knowledgePath(path, "coverage <map>") }));
734
+ return (db) => {
735
+ const { revision, current } = addCoverage(db, context, { space, maps });
736
+ return { revision, ...spaceOf(current), imported: current.imported, known: current.known, ranges: current.ranges };
737
+ };
738
+ }
739
+
740
+ /**
741
+ * The program in the knowledge that operand, patch and macro use writes
742
+ * are checked against, with the platform names. A byte outside
743
+ * the segments of the program is not in it.
744
+ */
745
+ function operandImage(db: DatabaseSync, space: string): OperandImage {
746
+ const image = requireProgram(db, space);
747
+ const inside = (at: number) => image.segments.some((segment) => at >= segment.start && at <= segment.end);
748
+ return { byteAt: (at) => (inside(at) ? image.byteAt(at) : undefined), platform: new Map(platformNames().map((entry) => [entry.name, entry.address])) };
749
+ }
750
+
751
+ /** An offset: a whole number in decimal or $ hex, with an optional minus sign. */
752
+ function offsetOf(value: unknown, what: string): number {
753
+ if (typeof value === "number" && Number.isInteger(value)) return value;
754
+ const match = typeof value === "string" ? /^(-?)(\$[0-9A-Fa-f]{1,4}|[0-9]{1,5})$/.exec(value) : null;
755
+ if (match === null) throw new UsageError(`${what} must be a whole number such as 1, -1 or '$1000'`);
756
+ const size = match[2]!.startsWith("$") ? Number.parseInt(match[2]!.slice(1), 16) : Number(match[2]);
757
+ return match[1] === "-" ? -size : size;
758
+ }
759
+
760
+ /** operand <address> with one of --lo, --hi, --value or --expr. */
761
+ function operandOperation(args: string[], values: Record<string, unknown>, context: WriteContext, space: string): WriteOperation {
762
+ const given = (["lo", "hi", "value", "expr"] as const).filter((option) => values[option] !== undefined);
763
+ if (given.length !== 1) throw new UsageError("operand needs exactly one of --lo, --hi, --value or --expr");
764
+ const select: OperandSelect = given[0]!;
765
+ const request: { space: string; address: number; select: OperandSelect; symbol?: string; offset?: number; expression?: string } = { space, address: address(args[0]), select };
766
+ if (select === "expr") {
767
+ if (values.offset !== undefined) throw new UsageError("--offset works with --lo, --hi and --value, not with --expr");
768
+ request.expression = values.expr as string;
769
+ } else {
770
+ request.symbol = values[select] as string;
771
+ if (values.offset !== undefined) request.offset = offsetOf(values.offset, "--offset");
772
+ }
773
+ return (db) => {
774
+ const result = setOperand(db, { ...context, image: operandImage(db, space) }, request);
775
+ return { revision: result.revision, operand: operand(result.current), ...(result.previous === undefined ? {} : { previous: operand(result.previous) }) };
776
+ };
777
+ }
778
+
779
+ /** A comma list of params or args; an empty text is an empty list. */
780
+ const commaList = (value: string) => (value.trim() === "" ? [] : value.split(",").map((item) => item.trim()));
781
+
782
+ /** The text of an option, or of the project file that a --*-file option names; exactly one of them. */
783
+ function textOrFile(values: Record<string, unknown>, option: string, what: string): string | undefined {
784
+ const inline = values[option] as string | undefined;
785
+ const file = values[`${option}-file`] as string | undefined;
786
+ if (inline !== undefined && file !== undefined) throw new UsageError(`give --${option} or --${option}-file, not both`);
787
+ if (file !== undefined) return readProjectFile(file).bytes.toString("utf8");
788
+ if (inline === undefined) return undefined;
789
+ if (inline === "") throw new UsageError(`${what} is empty`);
790
+ return inline;
791
+ }
792
+
793
+ /** patch, macro and use: the writes of the generated source. */
794
+ function sourceOperation(command: "patch" | "macro" | "use", args: string[], values: Record<string, unknown>, context: WriteContext, space: string): WriteOperation {
795
+ const comment = values.comment as string | undefined;
796
+ switch (command) {
797
+ case "patch": {
798
+ if (args[2] === undefined) throw new UsageError("the switch name is missing");
799
+ const code = textOrFile(values, "code", "--code");
800
+ if ((code === undefined) === (values.nop !== true)) throw new UsageError("patch needs exactly one of --code, --code-file or --nop");
801
+ const request = { space, start: address(args[0], "start"), end: address(args[1], "end"), switch: args[2], code: code ?? "", ...(comment === undefined ? {} : { comment }) };
802
+ return (db) => {
803
+ const result = setPatch(db, { ...context, image: operandImage(db, space) }, request);
804
+ return { revision: result.revision, patch: patch(result.current), ...(result.previous === undefined ? {} : { previous: patch(result.previous) }) };
805
+ };
806
+ }
807
+ case "macro": {
808
+ if (args[0] === undefined) throw new UsageError("the macro name is missing");
809
+ const body = textOrFile(values, "body", "--body");
810
+ if (body === undefined) throw new UsageError("macro needs --body or --body-file");
811
+ const request = { space, name: args[0], params: commaList((values.params as string | undefined) ?? ""), body, ...(comment === undefined ? {} : { comment }) };
812
+ return (db) => {
813
+ const result = setMacro(db, context, request);
814
+ return { revision: result.revision, macro: macro(result.current), ...(result.previous === undefined ? {} : { previous: macro(result.previous) }) };
815
+ };
816
+ }
817
+ case "use": {
818
+ if (args[2] === undefined) throw new UsageError("the macro name is missing");
819
+ const request = { space, start: address(args[0], "start"), end: address(args[1], "end"), macro: args[2], args: commaList((values.args as string | undefined) ?? "") };
820
+ return (db) => {
821
+ const result = setMacroUse(db, { ...context, image: operandImage(db, space) }, request);
822
+ return { revision: result.revision, use: macroUse(result.current), ...(result.previous === undefined ? {} : { previous: macroUse(result.previous) }) };
823
+ };
824
+ }
825
+ }
333
826
  }
334
827
 
335
828
  /** apply <file>: the batch as BatchOperation values, checked before the database opens. */
@@ -344,16 +837,29 @@ function applyOperation(args: string[], context: WriteContext, space: string): W
344
837
  }
345
838
  if (!Array.isArray(parsed)) throw new UsageError("the batch is a JSON list of writes, for example [{\"op\": \"rename\", \"address\": \"$2100\", \"name\": \"update_player\"}]");
346
839
  const operations = parsed.map((item, index) => batchOperation(item, `write ${index + 1}`, space));
840
+ const checked = operations.some((operation) => operation.op === "operand" || operation.op === "patch" || operation.op === "use");
347
841
  return (db) => {
348
- const applied = applyBatch(db, context, operations);
349
- // replaced: each write that replaced a row of another origin, with that row as previous gives it for one write (b2/07).
842
+ const applied = applyBatch(db, checked ? { ...context, image: operandImage(db, space) } : context, operations);
843
+ // replaced: each write that replaced a row of another origin, with that row as previous gives it for one write.
350
844
  const replaced = applied.steps.flatMap((step, index) =>
351
- step.previous === undefined ? [] : [{ write: index + 1, op: operations[index]!.op, previous: Array.isArray(step.previous) ? step.previous.map(region) : symbol(step.previous) }],
845
+ step.previous === undefined
846
+ ? []
847
+ : [{ write: index + 1, op: operations[index]!.op, previous: shownPrevious(step.previous) }],
352
848
  );
353
849
  return { revision: applied.revision, writes: applied.steps.length, replaced };
354
850
  };
355
851
  }
356
852
 
853
+ /** A row that a write of a batch replaced, in its output shape. */
854
+ function shownPrevious(row: NonNullable<BatchStep["previous"]>) {
855
+ if (Array.isArray(row)) return row.map(region);
856
+ if ("select" in row) return operand(row);
857
+ if ("switch" in row) return patch(row);
858
+ if ("body" in row) return macro(row);
859
+ if ("macro" in row) return macroUse(row);
860
+ return symbol(row);
861
+ }
862
+
357
863
  const BATCH_FIELDS: Record<BatchOperation["op"], string[]> = {
358
864
  rename: ["address", "name", "kind"],
359
865
  "remove-symbol": ["address"],
@@ -365,8 +871,67 @@ const BATCH_FIELDS: Record<BatchOperation["op"], string[]> = {
365
871
  unreference: ["from", "to", "kind"],
366
872
  module: ["start", "file", "title"],
367
873
  unmodule: ["start"],
874
+ operand: ["address", "select", "symbol", "offset", "expression"],
875
+ unoperand: ["address"],
876
+ patch: ["start", "end", "switch", "code", "comment"],
877
+ unpatch: ["start"],
878
+ macro: ["name", "params", "body", "comment"],
879
+ unmacro: ["name"],
880
+ use: ["start", "end", "macro", "args"],
881
+ unuse: ["start"],
368
882
  };
369
883
 
884
+ /** A write of a batch as JSON again: addresses as $ and four hex digits, the C64 space left out. */
885
+ function batchJson(write: BatchOperation): Record<string, unknown> {
886
+ const out: Record<string, unknown> = {};
887
+ for (const [key, value] of Object.entries(write)) {
888
+ if (key === "space" && value === DEFAULT_SPACE) continue;
889
+ out[key] = ["address", "start", "end", "from", "to"].includes(key) && typeof value === "number" ? formatC64Address(value) : value;
890
+ }
891
+ return out;
892
+ }
893
+
894
+ /** Reads the batches, joins them, and writes the joined batch to --out. One name or one module file at two addresses writes nothing. */
895
+ function joinCommand(files: string[], out: string | undefined, space: string): Record<string, unknown> {
896
+ if (files.length < 2) throw new UsageError("join needs two or more batch files");
897
+ if (out === undefined) throw new UsageError("join needs --out <file>");
898
+ const batches: Batch[] = files.map((file) => {
899
+ let value: unknown;
900
+ try {
901
+ value = JSON.parse(readProjectFile(file).bytes.toString("utf8"));
902
+ } catch (error) {
903
+ throw new UsageError(`${file}: ${(error as Error).message}`);
904
+ }
905
+ const shaped = Array.isArray(value) ? { ops: value } : (value as { owner?: unknown; ops?: unknown });
906
+ if (typeof shaped !== "object" || shaped === null || !Array.isArray(shaped.ops)) throw new UsageError(`${file}: a batch is a list of writes, or {"owner": "$1000-$1fff", "ops": [...]}`);
907
+ let owner: Batch["owner"];
908
+ if (shaped.owner !== undefined) {
909
+ const parts = typeof shaped.owner === "string" ? shaped.owner.split("-") : [];
910
+ if (parts.length !== 2) throw new UsageError(`${file}: owner must be "<start>-<end>", for example "$1000-$1fff"`);
911
+ owner = { start: address(parts[0], `${file}: owner`), end: address(parts[1], `${file}: owner`) };
912
+ if (owner.end < owner.start) throw new UsageError(`${file}: the owner range ends before it starts`);
913
+ }
914
+ const ops = (shaped.ops as unknown[]).map((item, index) => batchOperation(item, `${file}: write ${index + 1}`, space));
915
+ return { file, ...(owner === undefined ? {} : { owner }), ops };
916
+ });
917
+ const owned = batches.filter((batch) => batch.owner !== undefined);
918
+ for (const [index, batch] of owned.entries()) {
919
+ const other = owned.slice(index + 1).find((next) => next.owner!.start <= batch.owner!.end && batch.owner!.start <= next.owner!.end);
920
+ if (other !== undefined) throw new UsageError(`the owner ranges of ${batch.file} and ${other.file} overlap; give each address one owner`);
921
+ }
922
+ const joined = joinBatches(batches);
923
+ const choice = (item: JoinChoice) => ({ ...item, at: typeof item.at === "number" ? formatC64Address(item.at) : item.at, ...(item.space === DEFAULT_SPACE ? { space: undefined } : {}) });
924
+ const clash = (item: JoinClash) => ({ ...item, ...(item.space === DEFAULT_SPACE ? { space: undefined } : {}), places: item.places.map((place) => ({ address: formatC64Address(place.address), file: place.file })) });
925
+ const choices = joined.choices.map(choice);
926
+ if (joined.clashes.length > 0) {
927
+ process.exitCode = 1;
928
+ return { joined: false, clashes: joined.clashes.map(clash), choices, remedy: "Give each name and each module file one address in the batches, then join again." };
929
+ }
930
+ const bytes = Buffer.from(`${JSON.stringify(joined.ops.map(batchJson), null, 1)}\n`, "utf8");
931
+ writeProjectFile(out, bytes, { what: "--out" });
932
+ return { joined: true, out, writes: joined.ops.length, repeated: joined.repeated, choices, apply: `knowledge.ts apply ${out}` };
933
+ }
934
+
370
935
  /** One write of a batch. A write names its own space, or takes the one of --space. */
371
936
  function batchOperation(item: unknown, at: string, space: string): BatchOperation {
372
937
  const operation = batchFields(item, at);
@@ -387,6 +952,15 @@ function batchFields(item: unknown, at: string): BatchOperation {
387
952
  return fields[key] as string;
388
953
  };
389
954
  const place = (key: string) => address(text(key), `${at}: ${key}`);
955
+ // A list is a JSON list of strings or a comma list.
956
+ const list = (key: string) => {
957
+ const value = fields[key];
958
+ if (value === undefined) return [];
959
+ if (typeof value === "string") return commaList(value);
960
+ 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`);
961
+ return (value as string[]).map((item) => item.trim());
962
+ };
963
+ const optional = (key: string) => (fields[key] === undefined ? {} : { [key]: text(key) });
390
964
  switch (op) {
391
965
  case "rename":
392
966
  return { op, address: place("address"), name: text("name"), ...(fields.kind === undefined ? {} : { kind: oneOf(SYMBOL_KINDS, text("kind"), `${at}: kind`) }) };
@@ -406,7 +980,32 @@ function batchFields(item: unknown, at: string): BatchOperation {
406
980
  return { op, start: place("start"), file: text("file"), ...(fields.title === undefined ? {} : { title: text("title") }) };
407
981
  case "unmodule":
408
982
  return { op, start: place("start") };
409
- default:
983
+ case "operand": {
984
+ const select = oneOf(OPERAND_SELECTS, text("select"), `${at}: select`);
985
+ return {
986
+ op,
987
+ address: place("address"),
988
+ select,
989
+ ...(fields.symbol === undefined ? {} : { symbol: text("symbol") }),
990
+ ...(fields.offset === undefined ? {} : { offset: offsetOf(fields.offset, `${at}: offset`) }),
991
+ ...(fields.expression === undefined ? {} : { expression: text("expression") }),
992
+ };
993
+ }
994
+ case "unoperand":
995
+ return { op, address: place("address") };
996
+ case "patch":
997
+ return { op, start: place("start"), end: place("end"), switch: text("switch"), code: fields.code === undefined ? "" : text("code"), ...optional("comment") };
998
+ case "macro":
999
+ return { op, name: text("name"), params: list("params"), body: text("body"), ...optional("comment") };
1000
+ case "unmacro":
1001
+ return { op, name: text("name") };
1002
+ case "use":
1003
+ return { op, start: place("start"), end: place("end"), macro: text("macro"), args: list("args") };
1004
+ case "unpatch":
1005
+ case "unuse":
1006
+ return { op, start: place("start") };
1007
+ case "reference":
1008
+ case "unreference":
410
1009
  return { op, from: place("from"), to: place("to"), kind: oneOf(REFERENCE_KINDS, text("kind"), `${at}: kind`) };
411
1010
  }
412
1011
  }
@@ -480,8 +1079,34 @@ function writeOperation(command: string, args: string[], kind: string | undefine
480
1079
  const request = { space, start: address(args[0], "start") };
481
1080
  return (db) => ({ revision: removeModule(db, context, request).revision });
482
1081
  }
1082
+ case "unoperand": {
1083
+ const request = { space, address: address(args[0]) };
1084
+ return (db) => ({ revision: removeOperand(db, context, request).revision });
1085
+ }
1086
+ case "unpatch": {
1087
+ const request = { space, start: address(args[0], "start") };
1088
+ return (db) => ({ revision: removePatch(db, context, request).revision });
1089
+ }
1090
+ case "unmacro": {
1091
+ if (args[0] === undefined) throw new UsageError("the macro name is missing");
1092
+ const request = { space, name: args[0] };
1093
+ return (db) => ({ revision: removeMacro(db, context, request).revision });
1094
+ }
1095
+ case "unuse": {
1096
+ const request = { space, start: address(args[0], "start") };
1097
+ return (db) => ({ revision: removeMacroUse(db, context, request).revision });
1098
+ }
1099
+ case "merge": {
1100
+ if (args.length > 0) throw new UsageError("merge takes no arguments");
1101
+ const platform = new Map(platformNames().map((entry) => [entry.name, entry.address]));
1102
+ return (db) => {
1103
+ const merged = mergeConflicts(db, context, platform);
1104
+ return { revision: merged.revision, choices: merged.current.map(showChoice) };
1105
+ };
1106
+ }
483
1107
  case "revert": {
484
- const request = { revision: integer(args[0], "revision") };
1108
+ if (args[0] === undefined) throw new UsageError("revision is missing");
1109
+ const request = { revision: args[0] };
485
1110
  return (db) => ({ revision: revert(db, context, request).revision });
486
1111
  }
487
1112
  default:
@@ -489,4 +1114,4 @@ function writeOperation(command: string, args: string[], kind: string | undefine
489
1114
  }
490
1115
  }
491
1116
 
492
- await runScript(() => run(process.argv.slice(2)), { usage: USAGE, refusals: [KnowledgeError] });
1117
+ await runScript((argv) => run(argv), { usage: USAGE, refusals: [KnowledgeError] });