@henols/c64-re-tools 2.0.0-rc.9 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +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 +14 -5
  63. package/src/cli/status.ts +70 -3
  64. package/src/graphics.ts +158 -0
  65. package/src/host/main.ts +25 -5
  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 +8 -2
  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/package.ts +13 -3
  111. package/src/platform.ts +70 -7
  112. package/src/project.ts +180 -3
  113. package/src/protocol/messages.ts +1 -1
  114. package/src/protocol/tools.ts +2 -16
  115. package/src/protocol/vice.ts +124 -15
  116. package/src/script.ts +46 -7
  117. package/skills/c64-assembler/scripts/compare.ts +0 -84
  118. package/skills/c64-source/scripts/source.ts +0 -595
@@ -7,7 +7,7 @@
7
7
  // another analyzer are never replaced: a finding that contradicts one is
8
8
  // reported as a conflict. Code against data is a contradiction; a different
9
9
  // name or a finer data type for the same kind of memory is not. One exception
10
- // (D58): where a run executed an instruction start, a code finding replaces
10
+ //: where a run executed an instruction start, a code finding replaces
11
11
  // the data of another analyzer, never of the user, the LLM or a heuristic.
12
12
 
13
13
  import { createHash } from "node:crypto";
@@ -15,21 +15,20 @@ import type { DatabaseSync } from "node:sqlite";
15
15
 
16
16
  import { KnowledgeError, transaction } from "./database.ts";
17
17
  import {
18
+ analyzerNamed,
18
19
  REFERENCE_COLUMNS,
19
20
  referenceSuppressed,
20
21
  regionsOverlapping,
21
22
  suppressionsOverlapping,
22
23
  SYMBOL_COLUMNS,
23
24
  symbolAt,
24
- symbolNamed,
25
- symbolSuppressed,
26
25
  toReference,
27
26
  toSymbol,
28
27
  type ReferenceRow,
29
28
  type RegionRow,
30
29
  type SymbolRow,
31
30
  } from "./read.ts";
32
- import { CODE_REGION_TYPES, DEFAULT_SPACE, REFERENCE_KINDS, REGION_TYPES, SYMBOL_KINDS, type Origin, type ReferenceKind, type RegionType, type Space, type SymbolKind } from "./schema.ts";
31
+ import { CODE_REGION_TYPES, DEFAULT_SPACE, POINTER_KIND, REFERENCE_KINDS, REGION_TYPES, SYMBOL_KINDS, type Origin, type ReferenceKind, type RegionType, type Space, type SymbolKind } from "./schema.ts";
33
32
  import { Change, checkAddress, checkSpace, hex, subtract, SYMBOL_NAME, type AddressRange, type Evidence } from "./write.ts";
34
33
 
35
34
  export type { AddressRange };
@@ -39,7 +38,7 @@ export type Analyzer = (typeof ANALYZERS)[number];
39
38
 
40
39
  export interface NormalizedFindings {
41
40
  analyzer: Analyzer;
42
- /** The address space that the analyzed image belongs to (D53); the C64 when absent. */
41
+ /** The address space that the analyzed image belongs to; the C64 when absent. */
43
42
  space?: Space;
44
43
  /** The address ranges the analyzer examined; it makes claims only there. */
45
44
  coverage: AddressRange[];
@@ -49,13 +48,13 @@ export interface NormalizedFindings {
49
48
  regions: Array<{ start: number; end: number; type: RegionType }>;
50
49
  /** Owned and covered by their source address. */
51
50
  references: Array<{ from: number; to: number; kind: ReferenceKind }>;
52
- /** True for an address where a coverage run executed an instruction start (D58). */
51
+ /** True for an address where a coverage run executed an instruction start. */
53
52
  executed?: (address: number) => boolean;
54
53
  }
55
54
 
56
55
  export interface ImportContext {
57
56
  /** The revision the caller read; the import is refused if knowledge changed since. */
58
- expectedRevision?: number;
57
+ expectedRevision?: string;
59
58
  description?: string;
60
59
  /** The SHA-256 of the analyzed image, as lowercase hex. */
61
60
  inputHash?: string;
@@ -86,13 +85,16 @@ export interface ImportCounts {
86
85
  changed: number;
87
86
  retired: number;
88
87
  unchanged: number;
89
- /** Findings at a place that a user/LLM removal blocks (D36); not written. */
88
+ /** Findings at a place that a user/LLM removal blocks; not written. */
90
89
  suppressed: number;
91
90
  }
92
91
 
93
92
  export interface ImportResult {
94
93
  /** The new revision, or null when nothing changed. */
95
- revision: number | null;
94
+ revision: string | null;
95
+ /** The analyzer names. */
96
+ names: ImportCounts;
97
+ /** The analyzer's own rows from an older version that the run closed in symbols: only `retired` counts. */
96
98
  symbols: ImportCounts;
97
99
  regions: ImportCounts;
98
100
  references: ImportCounts;
@@ -163,6 +165,7 @@ export function validateFindings(findings: NormalizedFindings): AddressRange[] {
163
165
  checkAddress(reference.to, "reference target");
164
166
  if (!covering(reference.from, reference.from)) invalid(`the reference from ${hex(reference.from)} is outside the coverage.`);
165
167
  if (!(REFERENCE_KINDS as readonly string[]).includes(reference.kind)) invalid(`the reference kind ${reference.kind} is not known.`);
168
+ if (reference.kind === POINTER_KIND) invalid(`only an operand expression writes a pointer reference, not an analyzer.`);
166
169
  }
167
170
  return coverage;
168
171
  }
@@ -181,68 +184,73 @@ function ownReferences(db: DatabaseSync, analyzer: Analyzer, range: AddressRange
181
184
  .map((row) => toReference(row as Record<string, unknown>));
182
185
  }
183
186
 
184
- function importSymbols(db: DatabaseSync, change: Change, findings: NormalizedFindings, coverage: AddressRange[], result: ImportResult): void {
187
+ function ownNames(db: DatabaseSync, analyzer: Analyzer, range: AddressRange, space: Space): SymbolRow[] {
188
+ return db
189
+ .prepare(`SELECT ${SYMBOL_COLUMNS} FROM current_analyzer_names WHERE space = ? AND origin = ? AND address BETWEEN ? AND ? ORDER BY address`)
190
+ .all(space, analyzer, range.start, range.end)
191
+ .map((row) => toSymbol(row as Record<string, unknown>));
192
+ }
193
+
194
+ /**
195
+ * The names of an analyzer are second names: they go to analyzer_names,
196
+ * one for each analyzer at an address, and never to symbols. A run replaces
197
+ * the analyzer's own names in its coverage, and closes the analyzer's old rows
198
+ * in symbols there (knowledge from an older version). A name that the user or the LLM
199
+ * gave the address already is not kept twice. A code or data kind that
200
+ * disagrees with the name of the address is a conflict.
201
+ */
202
+ function importNames(db: DatabaseSync, change: Change, findings: NormalizedFindings, coverage: AddressRange[], result: ImportResult): void {
185
203
  const { analyzer } = findings;
186
204
  const space = findings.space ?? DEFAULT_SPACE;
205
+ for (const range of coverage) {
206
+ for (const own of ownSymbols(db, analyzer, range, space)) {
207
+ change.close("symbols", "space = ? AND address = ? AND origin = ?", space, own.address, analyzer);
208
+ result.symbols.retired++;
209
+ }
210
+ }
187
211
  const wanted = new Map(findings.symbols.map((symbol) => [symbol.address, symbol]));
188
212
  const settled = new Set<number>();
189
213
  const replaced = new Set<number>();
190
- // Close first, so a name can move between addresses within one import.
191
214
  for (const range of coverage) {
192
- for (const own of ownSymbols(db, analyzer, range, space)) {
215
+ for (const own of ownNames(db, analyzer, range, space)) {
193
216
  const finding = wanted.get(own.address);
194
- if (finding === undefined) {
195
- if (!findings.authoritative.symbols) continue;
196
- change.close("symbols", "space = ? AND address = ?", space, own.address);
197
- result.symbols.retired++;
198
- } else if (finding.name === own.name && finding.kind === own.kind) {
217
+ // A seeded run gives back the name of the user or the LLM: the analyzer's own name there stays.
218
+ const echo = finding !== undefined && symbolAt(db, own.address, space)?.name === finding.name;
219
+ if (echo || (finding !== undefined && finding.name === own.name && finding.kind === own.kind)) {
199
220
  settled.add(own.address);
200
- result.symbols.unchanged++;
201
- } else {
202
- change.close("symbols", "space = ? AND address = ?", space, own.address);
203
- replaced.add(own.address);
221
+ result.names.unchanged++;
222
+ continue;
204
223
  }
224
+ change.close("analyzer_names", "space = ? AND address = ? AND origin = ?", space, own.address, analyzer);
225
+ if (finding === undefined) result.names.retired++;
226
+ else replaced.add(own.address);
205
227
  }
206
228
  }
207
229
  for (const finding of findings.symbols) {
208
- if (settled.has(finding.address)) continue;
209
- if (symbolSuppressed(db, finding.address, space)) {
210
- result.symbols.suppressed++;
211
- continue;
230
+ const current = symbolAt(db, finding.address, space);
231
+ if (current !== undefined && isCode(current.kind) !== isCode(finding.kind)) {
232
+ result.conflicts.push({ category: "symbol", address: finding.address, reason: "kind", current, finding: { name: finding.name, kind: finding.kind } });
212
233
  }
213
- let current = symbolAt(db, finding.address, space);
214
- if (current !== undefined && isCode(finding.kind) && !isCode(current.kind) && YIELDS_TO_EXECUTED_CODE.has(current.origin) && findings.executed?.(finding.address) === true) {
215
- // A run executed this address: the data symbol of another analyzer gives way (D58).
216
- change.close("symbols", "space = ? AND address = ?", space, current.address);
217
- result.symbols.retired++;
218
- current = undefined;
219
- }
220
- if (current !== undefined) {
221
- // A semantic or other-analyzer row stays; only a code/data disagreement matters.
222
- if (isCode(current.kind) !== isCode(finding.kind)) {
223
- result.conflicts.push({ category: "symbol", address: finding.address, reason: "kind", current, finding: { name: finding.name, kind: finding.kind } });
224
- }
234
+ if (settled.has(finding.address)) continue;
235
+ if (current?.name === finding.name) {
236
+ // The analyzer only gives back the name of the address.
237
+ if (replaced.has(finding.address)) result.names.retired++;
225
238
  continue;
226
239
  }
227
- const owner = symbolNamed(db, finding.name, space);
228
- if (owner?.origin === analyzer) {
229
- // The analyzer moved its own name: its newer finding replaces its own older row.
230
- change.close("symbols", "space = ? AND address = ?", space, owner.address);
231
- result.symbols.retired++;
232
- } else if (owner !== undefined) {
233
- result.conflicts.push({ category: "symbol", address: finding.address, reason: "name-taken", current: owner, finding: { name: finding.name, kind: finding.kind } });
234
- if (replaced.has(finding.address)) result.symbols.retired++;
235
- continue;
240
+ // The analyzer moved its own name from an address outside the coverage.
241
+ for (const moved of analyzerNamed(db, finding.name, space).filter((row) => row.origin === analyzer)) {
242
+ change.close("analyzer_names", "space = ? AND address = ? AND origin = ?", space, moved.address, analyzer);
243
+ result.names.retired++;
236
244
  }
237
- change.insertSymbol({ space, address: finding.address, name: finding.name, kind: finding.kind, origin: analyzer });
238
- if (replaced.has(finding.address)) result.symbols.changed++;
239
- else result.symbols.added++;
245
+ change.insertAnalyzerName({ space, address: finding.address, name: finding.name, kind: finding.kind, origin: analyzer });
246
+ if (replaced.has(finding.address)) result.names.changed++;
247
+ else result.names.added++;
240
248
  }
241
249
  }
242
250
 
243
- /** Analyzers whose regions a heuristic finding replaces where it lies (D44): user/LLM > heuristic > DXA/Ghidra. */
251
+ /** Analyzers whose regions a heuristic finding replaces where it lies: user/LLM > heuristic > DXA/Ghidra. */
244
252
  const YIELDS_TO_HEURISTIC = new Set<Origin>(["dxa", "ghidra"]);
245
- /** Analyzers whose data gives way to a code finding over an executed instruction start (D58). */
253
+ /** Analyzers whose data gives way to a code finding over an executed instruction start. */
246
254
  const YIELDS_TO_EXECUTED_CODE = new Set<Origin>(["dxa", "ghidra"]);
247
255
 
248
256
  /** True when an address in the range is an executed instruction start. */
@@ -264,7 +272,7 @@ function importRegions(db: DatabaseSync, change: Change, findings: NormalizedFin
264
272
  }
265
273
  }
266
274
  }
267
- // Executed code is evidence about its bytes (D58): another analyzer's data region under a code finding gives way where a run executed an instruction start.
275
+ // Executed code is evidence about its bytes: another analyzer's data region under a code finding gives way where a run executed an instruction start.
268
276
  for (const region of findings.regions.filter((candidate) => isCode(candidate.type))) {
269
277
  for (const row of regionsOverlapping(db, region.start, region.end, space)) {
270
278
  if (row.origin === analyzer || !YIELDS_TO_EXECUTED_CODE.has(row.origin) || isCode(row.type) || row.type === "dual-use") continue;
@@ -275,7 +283,7 @@ function importRegions(db: DatabaseSync, change: Change, findings: NormalizedFin
275
283
  }
276
284
  }
277
285
  const conflictWith = (row: RegionRow, region: { start: number; end: number; type: RegionType }) => {
278
- // dual-use bytes are code at one time and data at another: neither finding contradicts them (D42).
286
+ // dual-use bytes are code at one time and data at another: neither finding contradicts them.
279
287
  if (isCode(row.type) === isCode(region.type) || row.type === "dual-use") return;
280
288
  result.conflicts.push({
281
289
  category: "region",
@@ -381,10 +389,11 @@ export function importFindings(db: DatabaseSync, findings: NormalizedFindings, c
381
389
  const coverage = validateFindings(findings);
382
390
  return transaction(db, () => {
383
391
  const change = new Change(db, { origin: findings.analyzer, ...context }, `import-${findings.analyzer}`);
384
- const result: ImportResult = { revision: null, symbols: counts(), regions: counts(), references: counts(), conflicts: [] };
385
- importSymbols(db, change, findings, coverage, result);
392
+ const result: ImportResult = { revision: null, names: counts(), symbols: counts(), regions: counts(), references: counts(), conflicts: [] };
393
+ importNames(db, change, findings, coverage, result);
386
394
  importRegions(db, change, findings, coverage, result);
387
395
  importReferences(db, change, findings, coverage, result);
396
+ change.check();
388
397
  result.revision = change.revision;
389
398
  return result;
390
399
  });
@@ -0,0 +1,211 @@
1
+ // Joins the batches of agents that worked in parallel, each on its own range
2
+ // of addresses, into one batch for apply. The owner of an address is
3
+ // the batch whose range holds it: its name, comment, operand and module win
4
+ // there; at an address that no batch owns, the first batch in the order given
5
+ // wins. A name that a batch gave to an address, and that the join replaced
6
+ // with the name of another batch, is replaced in that batch's operand
7
+ // expressions and macro args too. The join writes no knowledge.
8
+
9
+ import { replaceIdentifiers } from "./expression.ts";
10
+ import type { BatchOperation } from "./write.ts";
11
+
12
+ export interface Batch {
13
+ /** The file the batch came from, for the report. */
14
+ file: string;
15
+ /** The range of addresses that the batch owns. */
16
+ owner?: { start: number; end: number };
17
+ ops: BatchOperation[];
18
+ }
19
+
20
+ /** One place where batches gave different values: the value that stays, and the batches whose value the join left out. */
21
+ export interface JoinChoice {
22
+ what: "name" | "comment" | "operand" | "module" | "macro";
23
+ space: string;
24
+ /** The address, or for a macro its name. */
25
+ at: number | string;
26
+ kept: { file: string; value: string };
27
+ left: Array<{ file: string; value: string }>;
28
+ }
29
+
30
+ /** A clash that the join cannot decide: one name, or one module file, at two addresses. */
31
+ export interface JoinClash {
32
+ what: "name" | "file";
33
+ value: string;
34
+ space: string;
35
+ places: Array<{ address: number; file: string }>;
36
+ }
37
+
38
+ export interface JoinResult {
39
+ ops: BatchOperation[];
40
+ choices: JoinChoice[];
41
+ clashes: JoinClash[];
42
+ /** Writes that were the same in more than one batch, and that the join wrote once. */
43
+ repeated: number;
44
+ }
45
+
46
+ type Op<K extends BatchOperation["op"]> = Extract<BatchOperation, { op: K }>;
47
+ const spaceOf = (op: { space?: string }) => op.space ?? "c64";
48
+
49
+ /** The value of a keyed write as text, to compare and report it. */
50
+ function valueOf(op: BatchOperation): string {
51
+ switch (op.op) {
52
+ case "rename":
53
+ return op.kind === undefined ? op.name : `${op.name} (${op.kind})`;
54
+ case "comment":
55
+ return op.text;
56
+ case "operand":
57
+ return op.expression ?? `${op.select} ${op.symbol ?? ""}${op.offset === undefined || op.offset === 0 ? "" : op.offset > 0 ? `+${op.offset}` : op.offset}`;
58
+ case "module":
59
+ return op.title === undefined ? op.file : `${op.file} (${op.title})`;
60
+ case "macro":
61
+ return `${op.name}(${(op.params ?? []).join(", ")})`;
62
+ default:
63
+ return JSON.stringify(op);
64
+ }
65
+ }
66
+
67
+ export function joinBatches(batches: readonly Batch[]): JoinResult {
68
+ const owns = (batch: Batch, address: number) => batch.owner !== undefined && address >= batch.owner.start && address <= batch.owner.end;
69
+ const choices: JoinChoice[] = [];
70
+ const clashes: JoinClash[] = [];
71
+
72
+ /** For each key, the write that stays: the owner's last write, else the first batch's last write. */
73
+ function choose<K extends "rename" | "comment" | "operand" | "module" | "macro">(op: K, what: JoinChoice["what"], keyOf: (write: Op<K>) => string, at: (write: Op<K>) => number | string) {
74
+ const all = new Map<string, Array<{ batch: Batch; write: Op<K> }>>();
75
+ for (const batch of batches) {
76
+ for (const write of batch.ops) {
77
+ if (write.op !== op) continue;
78
+ const key = keyOf(write as Op<K>);
79
+ all.set(key, [...(all.get(key) ?? []), { batch, write: write as Op<K> }]);
80
+ }
81
+ }
82
+ const kept = new Map<string, { batch: Batch; write: Op<K> }>();
83
+ for (const [key, candidates] of all) {
84
+ const place = at(candidates[0]!.write);
85
+ const owner = typeof place === "number" ? candidates.filter((candidate) => owns(candidate.batch, place)) : [];
86
+ // A batch can write a place twice; its last write counts, as in apply.
87
+ const pick = (owner.length > 0 ? owner : candidates.filter((candidate) => candidate.batch === candidates[0]!.batch)).at(-1)!;
88
+ kept.set(key, pick);
89
+ const left = candidates.filter((candidate) => candidate.batch !== pick.batch && valueOf(candidate.write) !== valueOf(pick.write));
90
+ const distinct = [...new Map(left.map((candidate) => [`${candidate.batch.file}|${valueOf(candidate.write)}`, candidate])).values()];
91
+ if (distinct.length > 0) {
92
+ choices.push({
93
+ what,
94
+ space: spaceOf(pick.write),
95
+ at: place,
96
+ kept: { file: pick.batch.file, value: valueOf(pick.write) },
97
+ left: distinct.map((candidate) => ({ file: candidate.batch.file, value: valueOf(candidate.write) })),
98
+ });
99
+ }
100
+ }
101
+ return kept;
102
+ }
103
+
104
+ const names = choose("rename", "name", (write) => `${spaceOf(write)}|${write.address}`, (write) => write.address);
105
+ // Name clashes: one name for two addresses after the choice.
106
+ const byName = new Map<string, Array<{ address: number; file: string }>>();
107
+ for (const { batch, write } of names.values()) {
108
+ const key = `${spaceOf(write)}|${write.name}`;
109
+ byName.set(key, [...(byName.get(key) ?? []), { address: write.address, file: batch.file }]);
110
+ }
111
+ for (const [key, places] of byName) {
112
+ if (places.length > 1) clashes.push({ what: "name", value: key.split("|")[1]!, space: key.split("|")[0]!, places: places.sort((a, b) => a.address - b.address) });
113
+ }
114
+ // A name that a batch gave and the join replaced: each batch's names follow the join's names.
115
+ const finalName = new Map<string, string>();
116
+ for (const write of names.values()) finalName.set(`${spaceOf(write.write)}|${write.write.address}`, write.write.name);
117
+ const renamedIn = new Map<Batch, Map<string, string>>();
118
+ for (const batch of batches) {
119
+ const map = new Map<string, string>();
120
+ for (const write of batch.ops) {
121
+ if (write.op !== "rename") continue;
122
+ const now = finalName.get(`${spaceOf(write)}|${write.address}`);
123
+ if (now !== undefined && now !== write.name) map.set(`${spaceOf(write)}|${write.name}`, now);
124
+ }
125
+ renamedIn.set(batch, map);
126
+ }
127
+ const follow = (batch: Batch, space: string, text: string) => {
128
+ const map = renamedIn.get(batch)!;
129
+ if (map.size === 0) return text;
130
+ try {
131
+ return replaceIdentifiers(text, (name) => map.get(`${space}|${name}`) ?? name);
132
+ } catch {
133
+ // Text that is no expression stays as the batch wrote it; apply refuses it then.
134
+ return text;
135
+ }
136
+ };
137
+
138
+ const comments = choose("comment", "comment", (write) => `${spaceOf(write)}|${write.address}|${write.placement}`, (write) => write.address);
139
+ const operands = choose("operand", "operand", (write) => `${spaceOf(write)}|${write.address}`, (write) => write.address);
140
+ const modules = choose("module", "module", (write) => `${spaceOf(write)}|${write.start}`, (write) => write.start);
141
+ const macros = choose("macro", "macro", (write) => `${spaceOf(write)}|${write.name}`, (write) => write.name);
142
+ const byFile = new Map<string, Array<{ address: number; file: string }>>();
143
+ for (const { batch, write } of modules.values()) {
144
+ const key = `${spaceOf(write)}|${write.file}`;
145
+ byFile.set(key, [...(byFile.get(key) ?? []), { address: write.start, file: batch.file }]);
146
+ }
147
+ for (const [key, places] of byFile) {
148
+ if (places.length > 1) clashes.push({ what: "file", value: key.split("|")[1]!, space: key.split("|")[0]!, places: places.sort((a, b) => a.address - b.address) });
149
+ }
150
+
151
+ // The order of the joined batch: removals, the regions of other batches and then the owner's (a later classify wins
152
+ // where they overlap), the names, the comments, the modules, the references, the macros, then the writes that use names.
153
+ let repeated = 0;
154
+ const seen = new Set<string>();
155
+ const once = (write: BatchOperation) => {
156
+ const key = JSON.stringify(write);
157
+ if (seen.has(key)) {
158
+ repeated++;
159
+ return false;
160
+ }
161
+ seen.add(key);
162
+ return true;
163
+ };
164
+ const named = new Set([...names.values()].map(({ write }) => `${spaceOf(write)}|${write.address}`));
165
+ const removals: BatchOperation[] = [];
166
+ const foreignRegions: BatchOperation[] = [];
167
+ const ownRegions: BatchOperation[] = [];
168
+ const references: BatchOperation[] = [];
169
+ const rest: BatchOperation[] = [];
170
+ for (const batch of batches) {
171
+ for (const write of batch.ops) {
172
+ switch (write.op) {
173
+ case "remove-symbol":
174
+ // A name from any batch wins over a removal at the same address.
175
+ if (!named.has(`${spaceOf(write)}|${write.address}`) && once(write)) removals.push(write);
176
+ break;
177
+ case "classify":
178
+ case "unclassify":
179
+ if (once(write)) (owns(batch, write.start) ? ownRegions : foreignRegions).push(write);
180
+ break;
181
+ case "reference":
182
+ case "unreference":
183
+ if (once(write)) references.push(write);
184
+ break;
185
+ case "rename":
186
+ case "comment":
187
+ case "operand":
188
+ case "module":
189
+ case "macro":
190
+ break;
191
+ case "use":
192
+ if (once(write)) rest.push({ ...write, args: (write.args ?? []).map((arg) => follow(batch, spaceOf(write), arg)) });
193
+ break;
194
+ default:
195
+ if (once(write)) rest.push(write);
196
+ }
197
+ }
198
+ }
199
+ const kept = <T extends { batch: Batch; write: BatchOperation }>(map: Map<string, T>) => [...map.values()].map(({ write }) => write);
200
+ const operandWrites = [...operands.values()].map(({ batch, write }) => ({
201
+ ...write,
202
+ ...(write.symbol === undefined ? {} : { symbol: follow(batch, spaceOf(write), write.symbol) }),
203
+ ...(write.expression === undefined ? {} : { expression: follow(batch, spaceOf(write), write.expression) }),
204
+ }));
205
+ return {
206
+ ops: [...removals, ...foreignRegions, ...ownRegions, ...kept(names), ...kept(comments), ...kept(modules), ...references, ...kept(macros), ...operandWrites, ...rest],
207
+ choices,
208
+ clashes,
209
+ repeated,
210
+ };
211
+ }