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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +7 -5
  2. package/package.json +1 -1
  3. package/skills/c64-assembler/SKILL.md +11 -2
  4. package/skills/c64-assembler/scripts/assemble.ts +4 -4
  5. package/skills/c64-basic/SKILL.md +8 -1
  6. package/skills/c64-basic/scripts/basic.ts +3 -3
  7. package/skills/c64-disk/SKILL.md +10 -3
  8. package/skills/c64-disk/scripts/disk.ts +4 -4
  9. package/skills/c64-emulator/SKILL.md +4 -0
  10. package/skills/c64-knowledge/SKILL.md +62 -12
  11. package/skills/c64-knowledge/scripts/knowledge.ts +352 -102
  12. package/skills/c64-memory-map/SKILL.md +7 -0
  13. package/skills/c64-memory-map/scripts/memmap.ts +1 -1
  14. package/skills/c64-provenance/SKILL.md +56 -3
  15. package/skills/c64-provenance/scripts/library.ts +584 -0
  16. package/skills/c64-provenance/scripts/provenance.ts +201 -9
  17. package/skills/c64-reverse-engineering/SKILL.md +16 -2
  18. package/skills/c64-source/SKILL.md +38 -7
  19. package/skills/c64-source/scripts/generate.ts +213 -29
  20. package/skills/c64-source/scripts/loader.ts +3 -1
  21. package/skills/c64-source/scripts/opcodes.ts +19 -0
  22. package/skills/c64-source/scripts/source.ts +387 -35
  23. package/skills/c64-static-analysis/SKILL.md +11 -2
  24. package/skills/c64-static-analysis/scripts/analyze.ts +95 -8
  25. package/skills/c64-static-analysis/scripts/macros.ts +233 -0
  26. package/skills/c64-testing/SKILL.md +8 -1
  27. package/skills/c64-testing/references/scenario-format.md +1 -1
  28. package/skills/c64-testing/scripts/checklist.ts +2 -2
  29. package/skills/c64-testing/scripts/run.ts +4 -4
  30. package/skills/c64-testing/scripts/scenario.ts +1 -1
  31. package/skills/c64-testing/scripts/test.ts +4 -4
  32. package/skills/c64-unpacker/SKILL.md +11 -4
  33. package/skills/c64-unpacker/scripts/unpack.ts +4 -4
  34. package/src/cli/main.ts +5 -3
  35. package/src/host-client/image.ts +1 -1
  36. package/src/host-client/transfer.ts +34 -10
  37. package/src/host-client/vice-session.ts +9 -9
  38. package/src/knowledge/conflicts.ts +157 -0
  39. package/src/knowledge/database.ts +150 -38
  40. package/src/knowledge/history.ts +54 -20
  41. package/src/knowledge/import.ts +3 -2
  42. package/src/knowledge/read.ts +169 -20
  43. package/src/knowledge/schema.ts +115 -72
  44. package/src/knowledge/text.ts +391 -0
  45. package/src/knowledge/write.ts +508 -51
  46. package/src/mcp/output.ts +14 -7
  47. package/src/mcp/tools/debug.ts +8 -6
  48. package/src/mcp/tools/media.ts +17 -13
  49. package/src/mcp/tools/memory.ts +9 -5
  50. package/src/mcp/tools/video.ts +6 -5
  51. package/src/platform.ts +28 -5
  52. package/src/project.ts +166 -3
  53. package/src/protocol/tools.ts +1 -1
  54. package/src/script.ts +46 -7
@@ -5,37 +5,54 @@ import type { DatabaseSync } from "node:sqlite";
5
5
 
6
6
  import { instructionLength, SYMBOL_NAME } from "../c64.ts";
7
7
  import { isoCet } from "../time.ts";
8
+ import { CONFLICT_RULES, conflicts, conflictTable, type Conflict, type ConflictRow } from "./conflicts.ts";
8
9
  import { KnowledgeError, transaction } from "./database.ts";
9
10
  import { checkExpression, evaluate, ExpressionError, identifiers, operandText, replaceIdentifiers } from "./expression.ts";
10
11
  import {
11
12
  COMMENT_COLUMNS,
12
13
  commentsAt,
13
14
  currentRevision,
15
+ listMacroUses,
14
16
  listOperands,
17
+ listPatches,
18
+ listText,
19
+ MACRO_COLUMNS,
20
+ MACRO_USE_COLUMNS,
21
+ macroNamed,
22
+ macroUsesOverlapping,
15
23
  MODULE_COLUMNS,
16
24
  moduleAt,
17
25
  moduleWithFile,
18
26
  OPERAND_COLUMNS,
19
27
  operandAt,
28
+ PATCH_COLUMNS,
29
+ patchesOverlapping,
20
30
  REFERENCE_COLUMNS,
21
31
  referencesFrom,
22
32
  REGION_COLUMNS,
23
33
  regionsOverlapping,
34
+ resolveRevision,
24
35
  SUPPRESSION_COLUMNS,
25
36
  suppressionsOverlapping,
26
37
  SYMBOL_COLUMNS,
27
38
  symbolAt,
28
39
  symbolNamed,
29
40
  toComment,
41
+ toMacro,
42
+ toMacroUse,
30
43
  toModule,
31
44
  toOperand,
45
+ toPatch,
32
46
  toReference,
33
47
  toRegion,
34
48
  toSuppression,
35
49
  toSymbol,
36
50
  type CommentRow,
51
+ type MacroRow,
52
+ type MacroUseRow,
37
53
  type ModuleRow,
38
54
  type OperandRow,
55
+ type PatchRow,
39
56
  type ReferenceRow,
40
57
  type RegionRow,
41
58
  type SuppressionRow,
@@ -49,7 +66,10 @@ import {
49
66
  DEFAULT_SPACE,
50
67
  GENERATED_FILES,
51
68
  isGeneratedFile,
69
+ MACRO_NAME,
70
+ MACRO_PARAM,
52
71
  SPACE,
72
+ SWITCH_NAME,
53
73
  NO_ELEMENT_TYPES,
54
74
  OPERAND_SELECTS,
55
75
  POINTER_KIND,
@@ -65,6 +85,7 @@ import {
65
85
  type Space,
66
86
  type SymbolKind,
67
87
  } from "./schema.ts";
88
+ import { newRevisionId, rowId, sqlName } from "./text.ts";
68
89
 
69
90
  /** What a change rests on (D42): a coverage run, a test or another project file with its SHA-256, or a note. */
70
91
  export interface Evidence {
@@ -79,14 +100,14 @@ export interface WriteContext {
79
100
  /** A short reason, kept with the revision for later review. */
80
101
  description?: string;
81
102
  /** The revision the caller read; the write is refused if knowledge changed since. */
82
- expectedRevision?: number;
103
+ expectedRevision?: string;
83
104
  /** Kept with the revision when the write changes something. */
84
105
  evidence?: Evidence[];
85
- /** The image that an operand write is checked against (D65). */
106
+ /** The image that an operand, patch or macro use write is checked against (D65, D72, D73). */
86
107
  image?: OperandImage;
87
108
  }
88
109
 
89
- /** The bytes of the image and the platform names (C64 space only) that operand expressions are checked against. */
110
+ /** The bytes of the image and the platform names (C64 space only) that operand expressions, patches and macro uses are checked against. */
90
111
  export interface OperandImage {
91
112
  byteAt(address: number): number | undefined;
92
113
  platform: ReadonlyMap<string, number>;
@@ -94,7 +115,7 @@ export interface OperandImage {
94
115
 
95
116
  /** The revision a write created, or null when nothing needed to change. */
96
117
  export interface WriteResult<T, P = never> {
97
- revision: number | null;
118
+ revision: string | null;
98
119
  current: T;
99
120
  /** The current rows of another origin that this write replaced, as they were before it. */
100
121
  previous?: P;
@@ -102,6 +123,10 @@ export interface WriteResult<T, P = never> {
102
123
 
103
124
  export { SYMBOL_NAME };
104
125
  const MAX_COMMENT = 4000;
126
+ /** The most characters of patch code and of a macro body. */
127
+ const MAX_CODE = 8000;
128
+ /** The most parameters of a macro. */
129
+ const MAX_PARAMS = 16;
105
130
  const MAX_EVIDENCE_REF = 1000;
106
131
 
107
132
  export function checkAddress(value: number, what = "address"): void {
@@ -139,7 +164,7 @@ export function subtract(range: AddressRange, holes: AddressRange[]): AddressRan
139
164
  export interface ChangeContext {
140
165
  origin: Origin;
141
166
  description?: string;
142
- expectedRevision?: number;
167
+ expectedRevision?: string;
143
168
  inputHash?: string;
144
169
  toolVersion?: string;
145
170
  evidence?: Evidence[];
@@ -150,7 +175,9 @@ export interface ChangeContext {
150
175
  * only when the first actual change happens, so a no-op leaves no revision.
151
176
  */
152
177
  export class Change {
153
- revision: number | null = null;
178
+ revision: string | null = null;
179
+ /** The rows this write inserted, for their ids. */
180
+ #rows = 0;
154
181
  readonly #db: DatabaseSync;
155
182
  readonly #context: ChangeContext;
156
183
  readonly #operation: string;
@@ -159,8 +186,16 @@ export class Change {
159
186
  this.#db = db;
160
187
  this.#context = context;
161
188
  this.#operation = operation;
189
+ // A merge of two computers' work can leave conflicts: only a merge revision writes then (D69).
190
+ const found = operation === "merge" ? [] : conflicts(db);
191
+ if (found.length > 0) {
192
+ throw new KnowledgeError(
193
+ "conflict",
194
+ `The knowledge has ${found.length} conflict${found.length === 1 ? "" : "s"} from a git merge, for example ${describe(found[0]!)}. Nothing was written. Run knowledge.ts merge to decide them, then write again.`,
195
+ );
196
+ }
162
197
  const current = currentRevision(db);
163
- if (context.expectedRevision !== undefined && context.expectedRevision !== current) {
198
+ if (context.expectedRevision !== undefined && resolveRevision(db, context.expectedRevision) !== current) {
164
199
  throw new KnowledgeError(
165
200
  "stale-revision",
166
201
  `Knowledge changed since revision ${context.expectedRevision}; it is now at revision ${current}. Read it again and decide again.`,
@@ -168,24 +203,33 @@ export class Change {
168
203
  }
169
204
  }
170
205
 
171
- /** The id of this write's revision, created on first use with its evidence. */
172
- id(): number {
206
+ /** The id of this write's revision, created on first use with its evidence (D69: an id that sorts after every revision). */
207
+ id(): string {
173
208
  if (this.revision !== null) return this.revision;
174
- const created = this.#db
175
- .prepare("INSERT INTO revisions (created_at, origin, operation, input_hash, tool_version, description) VALUES (?, ?, ?, ?, ?, ?) RETURNING id")
176
- .get(
177
- isoCet(new Date()),
178
- this.#context.origin,
179
- this.#operation,
180
- this.#context.inputHash ?? null,
181
- this.#context.toolVersion ?? null,
182
- this.#context.description ?? null,
183
- ) as { id: number };
184
- this.revision = created.id;
185
- this.#db.prepare("UPDATE meta SET value = ? WHERE key = 'current_revision'").run(String(created.id));
209
+ const now = new Date();
210
+ const id = newRevisionId(currentRevision(this.#db), now);
211
+ this.#db
212
+ .prepare("INSERT INTO revisions (id, created_at, origin, operation, input_hash, tool_version, description) VALUES (?, ?, ?, ?, ?, ?, ?)")
213
+ .run(id, isoCet(now), this.#context.origin, this.#operation, this.#context.inputHash ?? null, this.#context.toolVersion ?? null, this.#context.description ?? null);
214
+ this.revision = id;
186
215
  const evidence = this.#db.prepare("INSERT INTO evidence (revision, kind, ref, sha256) VALUES (?, ?, ?, ?)");
187
- for (const item of this.#context.evidence ?? []) evidence.run(created.id, item.kind, item.ref, item.sha256 ?? null);
188
- return created.id;
216
+ for (const item of this.#context.evidence ?? []) evidence.run(id, item.kind, item.ref, item.sha256 ?? null);
217
+ return id;
218
+ }
219
+
220
+ /**
221
+ * Refuses a write that leaves two current rows where one is allowed: the
222
+ * index has no unique indexes, because a merged text can hold such rows.
223
+ */
224
+ check(): void {
225
+ if (this.revision === null) return;
226
+ const [found] = conflicts(this.#db);
227
+ if (found !== undefined) throw new KnowledgeError("conflict", `The write would leave ${describe(found)}. Nothing was written.`);
228
+ }
229
+
230
+ /** The id of the next row this write inserts. */
231
+ #rowId(): string {
232
+ return rowId(this.id(), ++this.#rows);
189
233
  }
190
234
 
191
235
  /** Closes the current rows of `table` that `where` selects. */
@@ -194,46 +238,76 @@ export class Change {
194
238
  }
195
239
 
196
240
  insertSymbol(row: Omit<SymbolRow, "revision">): void {
197
- this.#db.prepare("INSERT INTO symbols (space, address, name, kind, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?)").run(row.space, row.address, row.name, row.kind, row.origin, this.id());
241
+ this.#db.prepare("INSERT INTO symbols (id, space, address, name, kind, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?)").run(this.#rowId(), row.space, row.address, row.name, row.kind, row.origin, this.id());
198
242
  }
199
243
 
200
244
  insertRegion(row: Omit<RegionRow, "revision">): void {
201
245
  this.#db
202
- .prepare("INSERT INTO regions (space, start_address, end_address, type, element, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?)")
203
- .run(row.space, row.start, row.end, row.type, row.element ?? null, row.origin, this.id());
246
+ .prepare("INSERT INTO regions (id, space, start_address, end_address, type, element, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
247
+ .run(this.#rowId(), row.space, row.start, row.end, row.type, row.element ?? null, row.origin, this.id());
204
248
  }
205
249
 
206
250
  insertComment(row: Omit<CommentRow, "revision">): void {
207
251
  this.#db
208
- .prepare("INSERT INTO comments (space, address, placement, text, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?)")
209
- .run(row.space, row.address, row.placement, row.text, row.origin, this.id());
252
+ .prepare("INSERT INTO comments (id, space, address, placement, text, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?)")
253
+ .run(this.#rowId(), row.space, row.address, row.placement, row.text, row.origin, this.id());
210
254
  }
211
255
 
212
256
  insertReference(row: Omit<ReferenceRow, "revision">): void {
213
257
  this.#db
214
- .prepare('INSERT INTO "references" (space, from_address, to_address, kind, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?)')
215
- .run(row.space, row.from, row.to, row.kind, row.origin, this.id());
258
+ .prepare('INSERT INTO "references" (id, space, from_address, to_address, kind, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?)')
259
+ .run(this.#rowId(), row.space, row.from, row.to, row.kind, row.origin, this.id());
216
260
  }
217
261
 
218
262
  insertModule(row: Omit<ModuleRow, "revision">): void {
219
263
  this.#db
220
- .prepare("INSERT INTO modules (space, start_address, file, title, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?)")
221
- .run(row.space, row.start, row.file, row.title ?? null, row.origin, this.id());
264
+ .prepare("INSERT INTO modules (id, space, start_address, file, title, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?)")
265
+ .run(this.#rowId(), row.space, row.start, row.file, row.title ?? null, row.origin, this.id());
222
266
  }
223
267
 
224
268
  insertOperand(row: Omit<OperandRow, "revision">): void {
225
269
  this.#db
226
- .prepare("INSERT INTO operands (space, address, selector, symbol, offset, expression, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
227
- .run(row.space, row.address, row.select, row.symbol ?? null, row.offset, row.expression ?? null, row.origin, this.id());
270
+ .prepare("INSERT INTO operands (id, space, address, selector, symbol, offset, expression, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
271
+ .run(this.#rowId(), row.space, row.address, row.select, row.symbol ?? null, row.offset, row.expression ?? null, row.origin, this.id());
272
+ }
273
+
274
+ insertPatch(row: Omit<PatchRow, "revision">): void {
275
+ this.#db
276
+ .prepare("INSERT INTO patches (id, space, start_address, end_address, switch_name, code, comment, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
277
+ .run(this.#rowId(), row.space, row.start, row.end, row.switch, row.code, row.comment ?? null, row.origin, this.id());
278
+ }
279
+
280
+ insertMacro(row: Omit<MacroRow, "revision">): void {
281
+ this.#db
282
+ .prepare("INSERT INTO macros (id, space, name, params, body, comment, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
283
+ .run(this.#rowId(), row.space, row.name, listText(row.params), row.body, row.comment ?? null, row.origin, this.id());
284
+ }
285
+
286
+ insertMacroUse(row: Omit<MacroUseRow, "revision">): void {
287
+ this.#db
288
+ .prepare("INSERT INTO macro_uses (id, space, start_address, end_address, macro, args, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
289
+ .run(this.#rowId(), row.space, row.start, row.end, row.macro, listText(row.args), row.origin, this.id());
228
290
  }
229
291
 
230
292
  insertSuppression(row: Omit<SuppressionRow, "revision">): void {
231
293
  this.#db
232
- .prepare("INSERT INTO suppressions (space, entity, start_address, end_address, to_address, kind, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
233
- .run(row.space, row.entity, row.start, row.end, row.to ?? null, row.kind ?? null, row.origin, this.id());
294
+ .prepare("INSERT INTO suppressions (id, space, entity, start_address, end_address, to_address, kind, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
295
+ .run(this.#rowId(), row.space, row.entity, row.start, row.end, row.to ?? null, row.kind ?? null, row.origin, this.id());
234
296
  }
235
297
  }
236
298
 
299
+ /** A conflict in words: its rule, its space and the addresses of its rows. */
300
+ function describe(conflict: Conflict): string {
301
+ const place = (entry: ConflictRow) => {
302
+ if (entry.entity === "macro") return entry.row.name;
303
+ const row = entry.row as { address?: number; start?: number; end?: number; from?: number };
304
+ if (row.address !== undefined) return hex(row.address);
305
+ if (row.from !== undefined && entry.entity === "reference") return hex(row.from);
306
+ return row.end === undefined || row.end === row.start ? hex(row.start!) : `${hex(row.start!)}-${hex(row.end)}`;
307
+ };
308
+ return `${CONFLICT_RULES[conflict.rule]} in ${conflict.space} (${[...new Set(conflict.rows.map(place))].join(", ")})`;
309
+ }
310
+
237
311
  /** Closes the current region suppressions over [start, end] and keeps their parts outside it: a user/LLM region there decides now. */
238
312
  function liftRegionSuppressions(db: DatabaseSync, change: Change, start: number, end: number, space: Space): void {
239
313
  const overlapping = suppressionsOverlapping(db, "region", start, end, space);
@@ -254,7 +328,7 @@ function checkEvidence(evidence: Evidence[] | undefined): void {
254
328
  }
255
329
 
256
330
  /** Runs semantic writes in one transaction, as one revision. */
257
- function write<T>(db: DatabaseSync, context: WriteContext, operation: string, work: (change: Change) => T): { revision: number | null; current: T } {
331
+ function write<T>(db: DatabaseSync, context: WriteContext, operation: string, work: (change: Change) => T): { revision: string | null; current: T } {
258
332
  if (context.origin !== "user" && context.origin !== "llm") {
259
333
  throw new KnowledgeError("invalid-input", "A semantic write comes from the user or the LLM.");
260
334
  }
@@ -262,6 +336,7 @@ function write<T>(db: DatabaseSync, context: WriteContext, operation: string, wo
262
336
  return transaction(db, () => {
263
337
  const change = new Change(db, context, operation);
264
338
  const current = work(change);
339
+ change.check();
265
340
  return { revision: change.revision, current };
266
341
  });
267
342
  }
@@ -272,7 +347,7 @@ interface Step<T, P = never> {
272
347
  previous?: P;
273
348
  }
274
349
 
275
- const result = <T, P>(written: { revision: number | null; current: Step<T, P> }): WriteResult<T, P> =>
350
+ const result = <T, P>(written: { revision: string | null; current: Step<T, P> }): WriteResult<T, P> =>
276
351
  written.current.previous === undefined ? { revision: written.revision, current: written.current.current } : { revision: written.revision, current: written.current.current, previous: written.current.previous };
277
352
 
278
353
  // Each write has a check, which refuses bad input before anything opens, and a
@@ -312,9 +387,14 @@ function renameStep(db: DatabaseSync, change: Change, origin: Origin, request: S
312
387
  if (owner !== undefined && owner.address !== request.address) {
313
388
  throw new KnowledgeError("conflict", `The name ${request.name} already belongs to ${hex(owner.address)}. Rename or remove that symbol first.`);
314
389
  }
390
+ const [patch] = listPatches(db, undefined, request.name, undefined, space);
391
+ if (patch !== undefined) throw new KnowledgeError("conflict", `${request.name} is the switch of the patch at ${hex(patch.start)}-${hex(patch.end)}. Give the symbol another name.`);
315
392
  if (existing !== undefined) change.close("symbols", "space = ? AND address = ?", space, request.address);
316
393
  change.insertSymbol({ space, address: request.address, name: request.name, kind, origin });
317
- if (existing !== undefined && existing.name !== request.name) renameInOperands(db, change, space, existing.name, request.name);
394
+ if (existing !== undefined && existing.name !== request.name) {
395
+ renameInOperands(db, change, space, existing.name, request.name);
396
+ renameInUses(db, change, space, existing.name, request.name);
397
+ }
318
398
  change.close("suppressions", "space = ? AND entity = 'symbol' AND start_address = ?", space, request.address);
319
399
  const current = symbolAt(db, request.address, space)!;
320
400
  return existing !== undefined && existing.origin !== origin ? { current, previous: existing } : { current };
@@ -331,6 +411,10 @@ function removeSymbolStep(db: DatabaseSync, change: Change, origin: Origin, requ
331
411
  `The operand expressions at ${users.map((row) => hex(row.address)).join(", ")} use ${existing.name}. Change or remove them first (unoperand), or rename the symbol.`,
332
412
  );
333
413
  }
414
+ const uses = usesNaming(db, space, existing.name);
415
+ if (uses.length > 0) {
416
+ throw new KnowledgeError("conflict", `The macro uses at ${uses.map((row) => hex(row.start)).join(", ")} use ${existing.name}. Change or remove them first (unuse), or rename the symbol.`);
417
+ }
334
418
  change.close("symbols", "space = ? AND address = ?", space, request.address);
335
419
  change.insertSuppression({ space, entity: "symbol", start: request.address, end: request.address, origin: origin as SuppressionRow["origin"] });
336
420
  return { current: null };
@@ -600,6 +684,222 @@ function unmoduleStep(db: DatabaseSync, change: Change, _origin: Origin, request
600
684
  return { current: null };
601
685
  }
602
686
 
687
+ /**
688
+ * Refuses ACME text whose braces do not pair outside comments and strings:
689
+ * c64-source writes patch code and a macro body inside { }.
690
+ */
691
+ function checkBraces(text: string, what: string): void {
692
+ let depth = 0;
693
+ for (const line of text.split("\n")) {
694
+ let quote: string | undefined;
695
+ for (let index = 0; index < line.length; index++) {
696
+ const character = line[index]!;
697
+ if (quote !== undefined) {
698
+ if (character === "\\") index++;
699
+ else if (character === quote) quote = undefined;
700
+ continue;
701
+ }
702
+ if (character === ";") break;
703
+ if (character === '"' || character === "'") quote = character;
704
+ else if (character === "{") depth++;
705
+ else if (character === "}" && --depth < 0) throw new KnowledgeError("invalid-input", `${what} has a } with no { before it.`);
706
+ }
707
+ }
708
+ if (depth !== 0) throw new KnowledgeError("invalid-input", `${what} has a { with no }.`);
709
+ }
710
+
711
+ function checkCommentText(comment: string | undefined, what: string): void {
712
+ if (comment !== undefined && (comment.trim().length === 0 || comment.length > MAX_COMMENT)) throw new KnowledgeError("invalid-input", `${what} has 1 to ${MAX_COMMENT} characters.`);
713
+ }
714
+
715
+ /** Refuses a range with a byte outside the image. */
716
+ function checkInImage(image: OperandImage, start: number, end: number, what: string): void {
717
+ for (let address = start; address <= end; address++) {
718
+ if (image.byteAt(address) === undefined) throw new KnowledgeError("invalid-input", `${hex(address)} is outside the image: ${what} is a range of the image.`);
719
+ }
720
+ }
721
+
722
+ type PatchRequest = InSpace & { start: number; end: number; switch: string; code: string; comment?: string };
723
+
724
+ function checkPatch(request: PatchRequest): void {
725
+ checkRange(request);
726
+ if (typeof request.switch !== "string" || !SWITCH_NAME.test(request.switch)) {
727
+ throw new KnowledgeError("invalid-input", "A switch name is an upper-case letter, then 1 to 31 upper-case letters, digits or underscores, for example CRACK.");
728
+ }
729
+ if (typeof request.code !== "string" || request.code.length > MAX_CODE) throw new KnowledgeError("invalid-input", `Patch code has at most ${MAX_CODE} characters; empty code fills the range with NOP.`);
730
+ checkBraces(request.code, "The patch code");
731
+ checkCommentText(request.comment, "A patch comment");
732
+ }
733
+
734
+ function patchStep(db: DatabaseSync, change: Change, context: ChangeContext & { image?: OperandImage }, request: PatchRequest): Step<PatchRow, PatchRow> {
735
+ const space = spaceOf(request);
736
+ const range = `${hex(request.start)}-${hex(request.end)}`;
737
+ if (context.image === undefined) throw new KnowledgeError("invalid-input", "A patch is checked against the image, and no image was given.");
738
+ checkInImage(context.image, request.start, request.end, "a patch");
739
+ const symbol = symbolNamed(db, request.switch, space);
740
+ if (symbol !== undefined) throw new KnowledgeError("conflict", `The switch name ${request.switch} is the name of the symbol at ${hex(symbol.address)}. Give the switch another name.`);
741
+ if (space === DEFAULT_SPACE && context.image.platform.has(request.switch)) throw new KnowledgeError("conflict", `The switch name ${request.switch} is a C64 platform name. Give the switch another name.`);
742
+ const other = patchesOverlapping(db, request.start, request.end, space).find((row) => row.start !== request.start);
743
+ if (other !== undefined) {
744
+ throw new KnowledgeError("conflict", `The patch ${range} overlaps the patch of ${other.switch} at ${hex(other.start)}-${hex(other.end)}. Change or remove that patch first (unpatch).`);
745
+ }
746
+ const [use] = macroUsesOverlapping(db, request.start, request.end, space);
747
+ if (use !== undefined) {
748
+ throw new KnowledgeError("conflict", `The patch ${range} overlaps the use of the macro ${use.macro} at ${hex(use.start)}-${hex(use.end)}. Remove that use first (unuse).`);
749
+ }
750
+ const existing = patchesOverlapping(db, request.start, request.start, space).find((row) => row.start === request.start);
751
+ const code = request.code.trimEnd();
752
+ // A patch write without a comment keeps the comment it has, as a module keeps its title.
753
+ const comment = request.comment === undefined ? existing?.comment : request.comment.trim();
754
+ if (existing !== undefined && existing.end === request.end && existing.switch === request.switch && existing.code === code && existing.comment === comment) return { current: existing };
755
+ const origin = context.origin as PatchRow["origin"];
756
+ if (existing !== undefined) change.close("patches", "space = ? AND start_address = ?", space, request.start);
757
+ change.insertPatch({ space, start: request.start, end: request.end, switch: request.switch, code, ...(comment === undefined ? {} : { comment }), origin });
758
+ const current = patchesOverlapping(db, request.start, request.start, space).find((row) => row.start === request.start)!;
759
+ return existing !== undefined && existing.origin !== origin ? { current, previous: existing } : { current };
760
+ }
761
+
762
+ function unpatchStep(db: DatabaseSync, change: Change, _origin: Origin, request: InSpace & { start: number }): Step<null> {
763
+ const space = spaceOf(request);
764
+ if (!patchesOverlapping(db, request.start, request.start, space).some((row) => row.start === request.start)) throw new KnowledgeError("not-found", `No patch starts at ${hex(request.start)}.`);
765
+ change.close("patches", "space = ? AND start_address = ?", space, request.start);
766
+ return { current: null };
767
+ }
768
+
769
+ type MacroRequest = InSpace & { name: string; params?: string[]; body: string; comment?: string };
770
+
771
+ function checkMacroName(name: unknown, what = "A macro name"): void {
772
+ if (typeof name !== "string" || !MACRO_NAME.test(name)) throw new KnowledgeError("invalid-input", `${what} is a letter or underscore, then at most 31 letters, digits or underscores.`);
773
+ }
774
+
775
+ function checkMacro(request: MacroRequest): void {
776
+ checkSpace(request);
777
+ checkMacroName(request.name);
778
+ const params = request.params ?? [];
779
+ if (!Array.isArray(params) || params.length > MAX_PARAMS) throw new KnowledgeError("invalid-input", `A macro has 0 to ${MAX_PARAMS} params.`);
780
+ for (const param of params) {
781
+ if (typeof param !== "string" || !MACRO_PARAM.test(param)) {
782
+ throw new KnowledgeError("invalid-input", "A macro param is a lower-case letter or underscore, then lower-case letters, digits or underscores, without a dot. The body names it with a dot: .dst.");
783
+ }
784
+ }
785
+ if (new Set(params).size !== params.length) throw new KnowledgeError("invalid-input", "The params of a macro have different names.");
786
+ if (typeof request.body !== "string" || request.body.trim().length === 0 || request.body.length > MAX_CODE) throw new KnowledgeError("invalid-input", `A macro body has 1 to ${MAX_CODE} characters.`);
787
+ checkBraces(request.body, "The macro body");
788
+ checkCommentText(request.comment, "A macro comment");
789
+ }
790
+
791
+ function macroStep(db: DatabaseSync, change: Change, origin: Origin, request: MacroRequest): Step<MacroRow, MacroRow> {
792
+ const space = spaceOf(request);
793
+ const existing = macroNamed(db, request.name, space);
794
+ const params = request.params ?? [];
795
+ const body = request.body.trimEnd();
796
+ const comment = request.comment === undefined ? existing?.comment : request.comment.trim();
797
+ if (existing !== undefined && listText(existing.params) === listText(params) && existing.body === body && existing.comment === comment) return { current: existing };
798
+ if (existing !== undefined && existing.params.length !== params.length) {
799
+ const uses = listMacroUses(db, undefined, request.name, undefined, space);
800
+ if (uses.length > 0) {
801
+ throw new KnowledgeError(
802
+ "conflict",
803
+ `The macro ${request.name} has ${existing.params.length} params, and its uses at ${uses.map((row) => hex(row.start)).join(", ")} give that many args. Remove the uses first (unuse), or keep the number of params.`,
804
+ );
805
+ }
806
+ }
807
+ if (existing !== undefined) change.close("macros", "space = ? AND name = ?", space, request.name);
808
+ change.insertMacro({ space, name: request.name, params, body, ...(comment === undefined ? {} : { comment }), origin: origin as MacroRow["origin"] });
809
+ const current = macroNamed(db, request.name, space)!;
810
+ return existing !== undefined && existing.origin !== origin ? { current, previous: existing } : { current };
811
+ }
812
+
813
+ function unmacroStep(db: DatabaseSync, change: Change, _origin: Origin, request: InSpace & { name: string }): Step<null> {
814
+ const space = spaceOf(request);
815
+ if (macroNamed(db, request.name, space) === undefined) throw new KnowledgeError("not-found", `There is no macro ${request.name}.`);
816
+ const uses = listMacroUses(db, undefined, request.name, undefined, space);
817
+ if (uses.length > 0) throw new KnowledgeError("conflict", `The macro ${request.name} has uses at ${uses.map((row) => hex(row.start)).join(", ")}. Remove them first (unuse).`);
818
+ change.close("macros", "space = ? AND name = ?", space, request.name);
819
+ return { current: null };
820
+ }
821
+
822
+ type MacroUseRequest = InSpace & { start: number; end: number; macro: string; args?: string[] };
823
+
824
+ function checkMacroUse(request: MacroUseRequest): void {
825
+ checkRange(request);
826
+ checkMacroName(request.macro, "The macro of a use");
827
+ const args = request.args ?? [];
828
+ if (!Array.isArray(args) || args.length > MAX_PARAMS) throw new KnowledgeError("invalid-input", `A macro use has 0 to ${MAX_PARAMS} args.`);
829
+ for (const arg of args) {
830
+ if (typeof arg !== "string") throw new KnowledgeError("invalid-input", "Each arg of a macro use is expression text.");
831
+ try {
832
+ checkExpression(arg);
833
+ } catch (error) {
834
+ if (error instanceof ExpressionError) throw new KnowledgeError("invalid-input", error.message);
835
+ throw error;
836
+ }
837
+ }
838
+ }
839
+
840
+ /** Current macro uses that name a symbol in an arg. */
841
+ function usesNaming(db: DatabaseSync, space: Space, name: string): MacroUseRow[] {
842
+ return listMacroUses(db, undefined, undefined, undefined, space).filter((row) => row.args.some((arg) => identifiers(arg).includes(name)));
843
+ }
844
+
845
+ /** A rename of a symbol renames it in the args of every macro use; the rows keep their origin. */
846
+ function renameInUses(db: DatabaseSync, change: Change, space: Space, from: string, to: string): void {
847
+ for (const row of usesNaming(db, space, from)) {
848
+ change.close("macro_uses", "space = ? AND start_address = ?", space, row.start);
849
+ change.insertMacroUse({ ...row, args: row.args.map((arg) => replaceIdentifiers(arg, (name) => (name === from ? to : name))) });
850
+ }
851
+ }
852
+
853
+ function useStep(db: DatabaseSync, change: Change, context: ChangeContext & { image?: OperandImage }, request: MacroUseRequest): Step<MacroUseRow, MacroUseRow> {
854
+ const space = spaceOf(request);
855
+ const image = context.image;
856
+ const range = `${hex(request.start)}-${hex(request.end)}`;
857
+ if (image === undefined) throw new KnowledgeError("invalid-input", "A macro use is checked against the image, and no image was given.");
858
+ const macro = macroNamed(db, request.macro, space);
859
+ if (macro === undefined) throw new KnowledgeError("not-found", `There is no macro ${request.macro}. Write it first (macro).`);
860
+ const args = (request.args ?? []).map((arg) => arg.trim());
861
+ if (args.length !== macro.params.length) {
862
+ throw new KnowledgeError("invalid-input", `The macro ${macro.name} has ${macro.params.length} params (${listText(macro.params)}), and the use gives ${args.length} args.`);
863
+ }
864
+ const resolve = (name: string) => symbolNamed(db, name, space)?.address ?? (space === DEFAULT_SPACE ? image.platform.get(name) : undefined);
865
+ for (const arg of args) {
866
+ try {
867
+ evaluate(arg, resolve);
868
+ } catch (error) {
869
+ if (error instanceof ExpressionError) throw new KnowledgeError("invalid-input", `${range}: ${error.message}`);
870
+ throw error;
871
+ }
872
+ }
873
+ checkInImage(image, request.start, request.end, "a macro use");
874
+ let next = request.start;
875
+ while (next <= request.end) next += instructionLength(image.byteAt(next)!);
876
+ if (next !== request.end + 1) {
877
+ throw new KnowledgeError("invalid-input", `A macro use is a run of whole instructions, but the instructions from ${hex(request.start)} end at ${hex(next - 1)}, not at ${hex(request.end)}.`);
878
+ }
879
+ const [patch] = patchesOverlapping(db, request.start, request.end, space);
880
+ if (patch !== undefined) {
881
+ throw new KnowledgeError("conflict", `The macro use ${range} overlaps the patch of ${patch.switch} at ${hex(patch.start)}-${hex(patch.end)}. Change or remove that patch first (unpatch).`);
882
+ }
883
+ const other = macroUsesOverlapping(db, request.start, request.end, space).find((row) => row.start !== request.start);
884
+ if (other !== undefined) {
885
+ throw new KnowledgeError("conflict", `The macro use ${range} overlaps the use of ${other.macro} at ${hex(other.start)}-${hex(other.end)}. Remove that use first (unuse).`);
886
+ }
887
+ const existing = macroUsesOverlapping(db, request.start, request.start, space).find((row) => row.start === request.start);
888
+ if (existing !== undefined && existing.end === request.end && existing.macro === request.macro && listText(existing.args) === listText(args)) return { current: existing };
889
+ const origin = context.origin as MacroUseRow["origin"];
890
+ if (existing !== undefined) change.close("macro_uses", "space = ? AND start_address = ?", space, request.start);
891
+ change.insertMacroUse({ space, start: request.start, end: request.end, macro: request.macro, args, origin });
892
+ const current = macroUsesOverlapping(db, request.start, request.start, space).find((row) => row.start === request.start)!;
893
+ return existing !== undefined && existing.origin !== origin ? { current, previous: existing } : { current };
894
+ }
895
+
896
+ function unuseStep(db: DatabaseSync, change: Change, _origin: Origin, request: InSpace & { start: number }): Step<null> {
897
+ const space = spaceOf(request);
898
+ if (!macroUsesOverlapping(db, request.start, request.start, space).some((row) => row.start === request.start)) throw new KnowledgeError("not-found", `No macro use starts at ${hex(request.start)}.`);
899
+ change.close("macro_uses", "space = ? AND start_address = ?", space, request.start);
900
+ return { current: null };
901
+ }
902
+
603
903
  /** Names the symbol at an address (one current symbol per address, unique names). */
604
904
  export function renameSymbol(db: DatabaseSync, context: WriteContext, request: SymbolRequest): WriteResult<SymbolRow, SymbolRow> {
605
905
  checkSymbol(request);
@@ -671,6 +971,46 @@ export function removeOperand(db: DatabaseSync, context: WriteContext, request:
671
971
  return result(write(db, context, "remove-operand", (change) => unoperandStep(db, change, context.origin, request)));
672
972
  }
673
973
 
974
+ /**
975
+ * Writes code that replaces [start, end] when the build defines the switch
976
+ * (D72), after a check that the range is in the image and overlaps no other
977
+ * patch or macro use.
978
+ */
979
+ export function setPatch(db: DatabaseSync, context: WriteContext, request: PatchRequest): WriteResult<PatchRow, PatchRow> {
980
+ checkPatch(request);
981
+ return result(write(db, context, "set-patch", (change) => patchStep(db, change, context, request)));
982
+ }
983
+
984
+ export function removePatch(db: DatabaseSync, context: WriteContext, request: InSpace & { start: number }): WriteResult<null> {
985
+ checkSpace(request);
986
+ checkAddress(request.start, "start");
987
+ return result(write(db, context, "remove-patch", (change) => unpatchStep(db, change, context.origin, request)));
988
+ }
989
+
990
+ /** Writes a macro of the generated source (D73). */
991
+ export function setMacro(db: DatabaseSync, context: WriteContext, request: MacroRequest): WriteResult<MacroRow, MacroRow> {
992
+ checkMacro(request);
993
+ return result(write(db, context, "set-macro", (change) => macroStep(db, change, context.origin, request)));
994
+ }
995
+
996
+ export function removeMacro(db: DatabaseSync, context: WriteContext, request: InSpace & { name: string }): WriteResult<null> {
997
+ checkSpace(request);
998
+ checkMacroName(request.name);
999
+ return result(write(db, context, "remove-macro", (change) => unmacroStep(db, change, context.origin, request)));
1000
+ }
1001
+
1002
+ /** Writes the instructions in [start, end] as one use of a macro (D73), after a check against the image. */
1003
+ export function setMacroUse(db: DatabaseSync, context: WriteContext, request: MacroUseRequest): WriteResult<MacroUseRow, MacroUseRow> {
1004
+ checkMacroUse(request);
1005
+ return result(write(db, context, "set-macro-use", (change) => useStep(db, change, context, request)));
1006
+ }
1007
+
1008
+ export function removeMacroUse(db: DatabaseSync, context: WriteContext, request: InSpace & { start: number }): WriteResult<null> {
1009
+ checkSpace(request);
1010
+ checkAddress(request.start, "start");
1011
+ return result(write(db, context, "remove-macro-use", (change) => unuseStep(db, change, context.origin, request)));
1012
+ }
1013
+
674
1014
  /** One write of a batch (D42), named as the command that does it alone. */
675
1015
  export type BatchOperation =
676
1016
  | ({ op: "rename" } & SymbolRequest)
@@ -684,18 +1024,27 @@ export type BatchOperation =
684
1024
  | ({ op: "module" } & ModuleRequest)
685
1025
  | ({ op: "unmodule" } & InSpace & { start: number })
686
1026
  | ({ op: "operand" } & OperandRequest)
687
- | ({ op: "unoperand" } & AddressRequest);
1027
+ | ({ op: "unoperand" } & AddressRequest)
1028
+ | ({ op: "patch" } & PatchRequest)
1029
+ | ({ op: "unpatch" } & InSpace & { start: number })
1030
+ | ({ op: "macro" } & MacroRequest)
1031
+ | ({ op: "unmacro" } & InSpace & { name: string })
1032
+ | ({ op: "use" } & MacroUseRequest)
1033
+ | ({ op: "unuse" } & InSpace & { start: number });
688
1034
 
689
1035
  export const MAX_BATCH = 10_000;
690
1036
 
691
1037
  /** The result of each write of a batch, in order. */
692
- export type BatchStep = Step<SymbolRow | RegionRow[] | CommentRow | ReferenceRow | ModuleRow | OperandRow | null, SymbolRow | RegionRow[] | OperandRow>;
1038
+ export type BatchStep = Step<
1039
+ SymbolRow | RegionRow[] | CommentRow | ReferenceRow | ModuleRow | OperandRow | PatchRow | MacroRow | MacroUseRow | null,
1040
+ SymbolRow | RegionRow[] | OperandRow | PatchRow | MacroRow | MacroUseRow
1041
+ >;
693
1042
 
694
1043
  /**
695
1044
  * Applies many writes as one revision in one transaction (D42): all of them,
696
1045
  * or none when one is refused. Every write is checked before anything opens.
697
1046
  */
698
- export function applyBatch(db: DatabaseSync, context: WriteContext, operations: BatchOperation[]): { revision: number | null; steps: BatchStep[] } {
1047
+ export function applyBatch(db: DatabaseSync, context: WriteContext, operations: BatchOperation[]): { revision: string | null; steps: BatchStep[] } {
699
1048
  if (operations.length === 0 || operations.length > MAX_BATCH) throw new KnowledgeError("invalid-input", `A batch has 1 to ${MAX_BATCH} writes.`);
700
1049
  operations.forEach((operation, index) => {
701
1050
  try {
@@ -726,8 +1075,24 @@ export function applyBatch(db: DatabaseSync, context: WriteContext, operations:
726
1075
  case "unoperand":
727
1076
  checkSpace(operation);
728
1077
  return checkAddress(operation.address);
1078
+ case "patch":
1079
+ return checkPatch(operation);
1080
+ case "unpatch":
1081
+ case "unuse":
1082
+ checkSpace(operation);
1083
+ return checkAddress(operation.start, "start");
1084
+ case "macro":
1085
+ return checkMacro(operation);
1086
+ case "unmacro":
1087
+ checkSpace(operation);
1088
+ return checkMacroName(operation.name);
1089
+ case "use":
1090
+ return checkMacroUse(operation);
729
1091
  default:
730
- throw new KnowledgeError("invalid-input", `op must be one of rename, remove-symbol, classify, unclassify, comment, uncomment, reference, unreference, module, unmodule, operand, unoperand.`);
1092
+ throw new KnowledgeError(
1093
+ "invalid-input",
1094
+ `op must be one of rename, remove-symbol, classify, unclassify, comment, uncomment, reference, unreference, module, unmodule, operand, unoperand, patch, unpatch, macro, unmacro, use, unuse.`,
1095
+ );
731
1096
  }
732
1097
  } catch (error) {
733
1098
  if (error instanceof KnowledgeError) throw new KnowledgeError(error.code, `Write ${index + 1}: ${error.message} Nothing was written.`);
@@ -762,6 +1127,18 @@ export function applyBatch(db: DatabaseSync, context: WriteContext, operations:
762
1127
  return operandStep(db, change, context, operation);
763
1128
  case "unoperand":
764
1129
  return unoperandStep(db, change, context.origin, operation);
1130
+ case "patch":
1131
+ return patchStep(db, change, context, operation);
1132
+ case "unpatch":
1133
+ return unpatchStep(db, change, context.origin, operation);
1134
+ case "macro":
1135
+ return macroStep(db, change, context.origin, operation);
1136
+ case "unmacro":
1137
+ return unmacroStep(db, change, context.origin, operation);
1138
+ case "use":
1139
+ return useStep(db, change, context, operation);
1140
+ case "unuse":
1141
+ return unuseStep(db, change, context.origin, operation);
765
1142
  }
766
1143
  } catch (error) {
767
1144
  if (error instanceof KnowledgeError) throw new KnowledgeError(error.code, `Write ${index + 1}: ${error.message} Nothing was written.`);
@@ -784,6 +1161,13 @@ const KEYS = {
784
1161
  map: toOperand,
785
1162
  key: (row: OperandRow) => `${row.space}|${row.address}|${row.select}|${row.symbol ?? ""}|${row.offset}|${row.expression ?? ""}|${row.origin}`,
786
1163
  },
1164
+ patches: {
1165
+ columns: PATCH_COLUMNS,
1166
+ map: toPatch,
1167
+ key: (row: PatchRow) => `${row.space}|${row.start}|${row.end}|${row.switch}|${row.code}|${row.comment ?? ""}|${row.origin}`,
1168
+ },
1169
+ macros: { columns: MACRO_COLUMNS, map: toMacro, key: (row: MacroRow) => `${row.space}|${row.name}|${listText(row.params)}|${row.body}|${row.comment ?? ""}|${row.origin}` },
1170
+ macro_uses: { columns: MACRO_USE_COLUMNS, map: toMacroUse, key: (row: MacroUseRow) => `${row.space}|${row.start}|${row.end}|${row.macro}|${listText(row.args)}|${row.origin}` },
787
1171
  suppressions: {
788
1172
  columns: SUPPRESSION_COLUMNS,
789
1173
  map: toSuppression,
@@ -795,22 +1179,19 @@ const KEYS = {
795
1179
  * Makes the current knowledge equal to what it was at `revision`, as a new
796
1180
  * revision. History is never erased; restored rows keep their origin.
797
1181
  */
798
- export function revert(db: DatabaseSync, context: WriteContext, request: { revision: number }): WriteResult<{ restoredRevision: number }> {
1182
+ export function revert(db: DatabaseSync, context: WriteContext, request: { revision: string }): WriteResult<{ restoredRevision: string }> {
799
1183
  return write(db, context, "revert", (change) => {
800
- const current = currentRevision(db);
801
- if (!Number.isInteger(request.revision) || request.revision < 0 || request.revision > current) {
802
- throw new KnowledgeError("not-found", `There is no revision ${request.revision}; knowledge is at revision ${current}.`);
803
- }
1184
+ const restored = resolveRevision(db, request.revision);
804
1185
  const at = "valid_from_revision <= ? AND (valid_to_revision IS NULL OR valid_to_revision > ?)";
805
1186
  for (const [table, spec] of Object.entries(KEYS)) {
806
1187
  const name = table === "references" ? '"references"' : table;
807
- const then = db.prepare(`SELECT ${spec.columns}, id FROM ${name} WHERE ${at}`).all(request.revision, request.revision) as Array<Record<string, unknown>>;
1188
+ const then = db.prepare(`SELECT ${spec.columns}, id FROM ${name} WHERE ${at}`).all(restored, restored) as Array<Record<string, unknown>>;
808
1189
  const now = db.prepare(`SELECT ${spec.columns}, id FROM ${name} WHERE valid_to_revision IS NULL`).all() as Array<Record<string, unknown>>;
809
1190
  const key = (row: Record<string, unknown>) => (spec.key as (row: unknown) => string)(spec.map(row as never));
810
1191
  const thenKeys = new Set(then.map(key));
811
1192
  const nowKeys = new Set(now.map(key));
812
1193
  // Close first, so restored rows never collide with the current-row rules.
813
- for (const row of now) if (!thenKeys.has(key(row))) change.close(name, "id = ?", row.id as number);
1194
+ for (const row of now) if (!thenKeys.has(key(row))) change.close(name, "id = ?", row.id as string);
814
1195
  for (const row of then) {
815
1196
  if (nowKeys.has(key(row))) continue;
816
1197
  if (table === "suppressions") {
@@ -825,6 +1206,18 @@ export function revert(db: DatabaseSync, context: WriteContext, request: { revis
825
1206
  change.insertOperand(spec.map(row as never) as OperandRow);
826
1207
  continue;
827
1208
  }
1209
+ if (table === "patches") {
1210
+ change.insertPatch(spec.map(row as never) as PatchRow);
1211
+ continue;
1212
+ }
1213
+ if (table === "macros") {
1214
+ change.insertMacro(spec.map(row as never) as MacroRow);
1215
+ continue;
1216
+ }
1217
+ if (table === "macro_uses") {
1218
+ change.insertMacroUse(spec.map(row as never) as MacroUseRow);
1219
+ continue;
1220
+ }
828
1221
  const mapped = spec.map(row as never) as SymbolRow & RegionRow & CommentRow & ReferenceRow;
829
1222
  if (table === "symbols") change.insertSymbol(mapped);
830
1223
  else if (table === "regions") change.insertRegion(mapped);
@@ -832,6 +1225,70 @@ export function revert(db: DatabaseSync, context: WriteContext, request: { revis
832
1225
  else change.insertReference(mapped);
833
1226
  }
834
1227
  }
835
- return { restoredRevision: request.revision };
1228
+ return { restoredRevision: restored };
1229
+ });
1230
+ }
1231
+
1232
+ /** What a merge decided for one conflict: the newest row stays and the older rows close. */
1233
+ export interface MergeChoice {
1234
+ rule: Conflict["rule"];
1235
+ space: Space;
1236
+ kept: ConflictRow;
1237
+ closed: ConflictRow[];
1238
+ }
1239
+
1240
+ /** The most choices one merge makes; more means the knowledge text is broken. */
1241
+ const MAX_CHOICES = 100_000;
1242
+
1243
+ /**
1244
+ * Decides every conflict of a git merge in one merge revision (D69). Of the
1245
+ * rows of one conflict, the row of the newest revision stays and the others
1246
+ * close; an older region keeps its parts outside the newer regions. Where an
1247
+ * operand expression closes, the pointer references of its address follow the
1248
+ * expression that stays. `platform` gives the C64 platform names.
1249
+ */
1250
+ export function mergeConflicts(db: DatabaseSync, context: WriteContext, platform: ReadonlyMap<string, number>): WriteResult<MergeChoice[]> {
1251
+ return write(db, context, "merge", (change) => {
1252
+ const choices: MergeChoice[] = [];
1253
+ for (let found = conflicts(db); found.length > 0; found = conflicts(db)) {
1254
+ if (choices.length >= MAX_CHOICES) throw new KnowledgeError("invalid-database", `The merge made ${MAX_CHOICES} choices and still finds conflicts. Nothing was written.`);
1255
+ const conflict = found[0]!;
1256
+ const newest = conflict.rows.at(-1)!;
1257
+ const older = conflict.rows.slice(0, -1);
1258
+ const table = sqlName(conflictTable(conflict.rule));
1259
+ for (const entry of older) change.close(table, "id = ?", entry.id);
1260
+ if (conflict.rule === "region" || conflict.rule === "suppression-region") keepOutside(change, conflict, conflict.rule === "region");
1261
+ if (conflict.rule === "operand") followOperand(db, change, newest.row as OperandRow, platform);
1262
+ choices.push({ rule: conflict.rule, space: conflict.space, kept: newest, closed: older });
1263
+ }
1264
+ return choices;
836
1265
  });
837
1266
  }
1267
+
1268
+ /** Puts back the parts of the older ranges of a conflict that no newer range covers. */
1269
+ function keepOutside(change: Change, conflict: Conflict, regions: boolean): void {
1270
+ const newer: AddressRange[] = [];
1271
+ for (const entry of [...conflict.rows].reverse()) {
1272
+ const row = entry.row as RegionRow & SuppressionRow;
1273
+ if (newer.length > 0) {
1274
+ for (const piece of subtract({ start: row.start, end: row.end }, newer)) {
1275
+ if (regions) change.insertRegion({ space: row.space, ...piece, type: row.type, ...elementOf(piece, row.element), origin: row.origin });
1276
+ else change.insertSuppression({ space: row.space, entity: "region", ...piece, origin: row.origin });
1277
+ }
1278
+ }
1279
+ newer.push({ start: row.start, end: row.end });
1280
+ }
1281
+ }
1282
+
1283
+ /** Makes the pointer references of an address the ones of the operand expression that stays there. */
1284
+ function followOperand(db: DatabaseSync, change: Change, kept: OperandRow, platform: ReadonlyMap<string, number>): void {
1285
+ const resolve = (name: string) => symbolNamed(db, name, kept.space)?.address ?? (kept.space === DEFAULT_SPACE ? platform.get(name) : undefined);
1286
+ const targets = new Set(identifiers(operandText(kept)).flatMap((name) => resolve(name) ?? []));
1287
+ const pointers = referencesFrom(db, kept.address, kept.space).filter((reference) => reference.kind === POINTER_KIND);
1288
+ for (const reference of pointers) {
1289
+ if (!targets.has(reference.to)) change.close('"references"', "space = ? AND from_address = ? AND to_address = ? AND kind = ?", kept.space, kept.address, reference.to, POINTER_KIND);
1290
+ }
1291
+ for (const to of targets) {
1292
+ if (!pointers.some((reference) => reference.to === to)) change.insertReference({ space: kept.space, from: kept.address, to, kind: POINTER_KIND, origin: kept.origin });
1293
+ }
1294
+ }