@henols/c64-re-tools 2.0.0-rc.2 → 2.0.0-rc.21
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.
- package/README.md +28 -62
- package/distribution/package.ts +1 -16
- package/distribution/plugin.ts +5 -3
- package/package.json +1 -1
- package/skills/c64-assembler/SKILL.md +25 -6
- package/skills/c64-assembler/scripts/assemble.ts +45 -10
- package/skills/c64-assembler/scripts/lib/compare.ts +48 -0
- package/skills/c64-basic/SKILL.md +15 -7
- package/skills/c64-basic/scripts/basic.ts +4 -4
- package/skills/c64-disk/SKILL.md +34 -11
- package/skills/c64-disk/scripts/disk.ts +65 -10
- package/skills/c64-disk/scripts/lib/containers.ts +124 -0
- package/skills/c64-docs/SKILL.md +138 -0
- package/skills/c64-docs/references/page-types.md +141 -0
- package/skills/c64-docs/references/style.md +43 -0
- package/skills/c64-docs/scripts/generate.ts +102 -0
- package/skills/c64-docs/scripts/lib/links.ts +155 -0
- package/skills/c64-docs/scripts/lib/markdown.ts +79 -0
- package/skills/c64-docs/scripts/lib/reference.ts +406 -0
- package/skills/c64-docs/scripts/lib/source-map.ts +81 -0
- package/skills/c64-docs/scripts/link.ts +207 -0
- package/skills/c64-emulator/SKILL.md +29 -8
- package/skills/c64-knowledge/SKILL.md +201 -15
- package/skills/c64-knowledge/scripts/knowledge.ts +927 -78
- package/skills/c64-knowledge/scripts/lib/listing.ts +162 -0
- package/skills/c64-memory-map/SKILL.md +14 -2
- package/skills/c64-memory-map/scripts/memmap.ts +15 -4
- package/skills/c64-provenance/SKILL.md +100 -11
- package/skills/c64-provenance/scripts/lib/compare.ts +152 -0
- package/skills/c64-provenance/scripts/lib/library.ts +584 -0
- package/skills/c64-provenance/scripts/lib/runtime.ts +191 -0
- package/skills/c64-provenance/scripts/lib/separate.ts +140 -0
- package/skills/c64-provenance/scripts/provenance.ts +477 -24
- package/skills/c64-reverse-engineering/SKILL.md +90 -11
- package/skills/c64-source/SKILL.md +174 -0
- package/skills/c64-source/scripts/generate.ts +788 -0
- package/skills/c64-source/scripts/lib/loader.ts +630 -0
- package/skills/c64-source/scripts/lib/source.ts +1121 -0
- package/skills/c64-static-analysis/SKILL.md +53 -13
- package/skills/c64-static-analysis/scripts/analyze.ts +260 -31
- package/skills/c64-static-analysis/scripts/lib/coverage.ts +72 -0
- package/skills/c64-static-analysis/scripts/{findings.ts → lib/findings.ts} +11 -5
- package/skills/c64-static-analysis/scripts/lib/idioms.ts +158 -0
- package/skills/c64-static-analysis/scripts/lib/instructions.ts +105 -0
- package/skills/c64-static-analysis/scripts/lib/macros.ts +233 -0
- package/skills/c64-static-analysis/scripts/lib/pitfalls.ts +68 -0
- package/skills/c64-static-analysis/scripts/lib/pointers.ts +108 -0
- package/skills/c64-testing/SKILL.md +57 -4
- package/skills/c64-testing/references/scenario-format.md +50 -6
- package/skills/c64-testing/scripts/checklist.ts +3 -3
- package/skills/c64-testing/scripts/diff.ts +167 -0
- package/skills/c64-testing/scripts/lib/diff.ts +70 -0
- package/skills/c64-testing/scripts/{scenario.ts → lib/scenario.ts} +183 -152
- package/skills/c64-testing/scripts/run.ts +241 -0
- package/skills/c64-testing/scripts/test.ts +20 -10
- package/skills/c64-unpacker/SKILL.md +88 -21
- package/skills/c64-unpacker/scripts/lib/evidence.ts +207 -0
- package/skills/c64-unpacker/scripts/lib/loads.ts +209 -0
- package/skills/c64-unpacker/scripts/lib/machine.ts +80 -0
- package/skills/c64-unpacker/scripts/lib/stages.ts +142 -0
- package/skills/c64-unpacker/scripts/unpack.ts +545 -61
- package/src/c64.ts +240 -12
- package/src/cli/cleanup.ts +1 -1
- package/src/cli/main.ts +14 -8
- package/src/cli/status.ts +105 -11
- package/src/graphics.ts +158 -0
- package/src/host/main.ts +57 -31
- package/src/host/server.ts +30 -47
- package/src/host/tools/c1541.ts +34 -5
- package/src/host/tools/index.ts +6 -2
- package/src/host/tools/petcat.ts +10 -3
- package/src/host/tools/status.ts +23 -6
- package/src/host/vice/adapter.ts +64 -20
- package/src/host/vice/chips.ts +11 -1
- package/src/host/vice/process.ts +19 -8
- package/src/host/vice/screen.ts +4 -44
- package/src/host/vice/session.ts +343 -60
- package/src/host-client/autostart.ts +55 -0
- package/src/host-client/connect.ts +14 -14
- package/src/host-client/coverage.ts +36 -0
- package/src/host-client/image.ts +63 -0
- package/src/host-client/steps.ts +175 -0
- package/src/host-client/tools.ts +7 -6
- package/src/host-client/transfer.ts +60 -10
- package/src/host-client/vice-session.ts +107 -28
- package/src/knowledge/conflicts.ts +161 -0
- package/src/knowledge/database.ts +150 -38
- package/src/knowledge/expression.ts +209 -0
- package/src/knowledge/graph.ts +104 -0
- package/src/knowledge/history.ts +105 -27
- package/src/knowledge/import.ts +141 -85
- package/src/knowledge/join.ts +211 -0
- package/src/knowledge/program.ts +258 -0
- package/src/knowledge/read.ts +445 -63
- package/src/knowledge/schema.ts +277 -65
- package/src/knowledge/text.ts +415 -0
- package/src/knowledge/write.ts +1145 -150
- package/src/mcp/main.ts +44 -19
- package/src/mcp/output.ts +39 -0
- package/src/mcp/server.ts +15 -4
- package/src/mcp/tools/debug.ts +202 -31
- package/src/mcp/tools/execution.ts +39 -5
- package/src/mcp/tools/input.ts +7 -4
- package/src/mcp/tools/machine.ts +14 -2
- package/src/mcp/tools/media.ts +44 -16
- package/src/mcp/tools/memory.ts +68 -12
- package/src/mcp/tools/video.ts +20 -9
- package/src/native/discover.ts +44 -6
- package/src/native/ghidra/analyze.ts +2 -2
- package/src/native/ghidra/index.ts +19 -3
- package/src/native/ghidra/language/c64rt_6510.slaspec +4 -1
- package/src/native/ghidra/scripts/C64Export.java +33 -1
- package/src/native/ghidra/scripts/C64Prepare.java +7 -1
- package/src/native/status.ts +15 -3
- package/src/native/types.ts +43 -8
- package/src/opcodes.ts +133 -0
- package/src/package.ts +21 -0
- package/{skills/c64-memory-map/scripts → src}/platform.ts +87 -7
- package/src/project.ts +166 -3
- package/src/protocol/messages.ts +17 -10
- package/src/protocol/tools.ts +27 -22
- package/src/protocol/vice.ts +255 -33
- package/src/script.ts +49 -9
- package/src/trace.ts +55 -23
- package/skills/c64-provenance/scripts/compare.ts +0 -92
- package/skills/c64-unpacker/scripts/evidence.ts +0 -135
package/src/knowledge/write.ts
CHANGED
|
@@ -3,40 +3,96 @@
|
|
|
3
3
|
|
|
4
4
|
import type { DatabaseSync } from "node:sqlite";
|
|
5
5
|
|
|
6
|
-
import { SYMBOL_NAME } from "../c64.ts";
|
|
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";
|
|
10
|
+
import { checkExpression, evaluate, ExpressionError, identifiers, operandText, replaceIdentifiers } from "./expression.ts";
|
|
9
11
|
import {
|
|
10
12
|
COMMENT_COLUMNS,
|
|
11
13
|
commentsAt,
|
|
12
14
|
currentRevision,
|
|
15
|
+
listMacroUses,
|
|
16
|
+
listOperands,
|
|
17
|
+
listPatches,
|
|
18
|
+
listText,
|
|
19
|
+
MACRO_COLUMNS,
|
|
20
|
+
MACRO_USE_COLUMNS,
|
|
21
|
+
macroNamed,
|
|
22
|
+
macroUsesOverlapping,
|
|
23
|
+
MODULE_COLUMNS,
|
|
24
|
+
moduleAt,
|
|
25
|
+
moduleWithFile,
|
|
26
|
+
OPERAND_COLUMNS,
|
|
27
|
+
operandAt,
|
|
28
|
+
PATCH_COLUMNS,
|
|
29
|
+
patchesOverlapping,
|
|
13
30
|
REFERENCE_COLUMNS,
|
|
14
31
|
referencesFrom,
|
|
15
32
|
REGION_COLUMNS,
|
|
16
33
|
regionsOverlapping,
|
|
34
|
+
resolveRevision,
|
|
35
|
+
SUPPRESSION_COLUMNS,
|
|
36
|
+
suppressionsOverlapping,
|
|
17
37
|
SYMBOL_COLUMNS,
|
|
18
38
|
symbolAt,
|
|
19
39
|
symbolNamed,
|
|
20
40
|
toComment,
|
|
41
|
+
toMacro,
|
|
42
|
+
toMacroUse,
|
|
43
|
+
toModule,
|
|
44
|
+
toOperand,
|
|
45
|
+
toPatch,
|
|
21
46
|
toReference,
|
|
22
47
|
toRegion,
|
|
48
|
+
toSuppression,
|
|
23
49
|
toSymbol,
|
|
24
50
|
type CommentRow,
|
|
51
|
+
type MacroRow,
|
|
52
|
+
type MacroUseRow,
|
|
53
|
+
type ModuleRow,
|
|
54
|
+
type OperandRow,
|
|
55
|
+
type PatchRow,
|
|
25
56
|
type ReferenceRow,
|
|
26
57
|
type RegionRow,
|
|
58
|
+
type SuppressionRow,
|
|
27
59
|
type SymbolRow,
|
|
28
60
|
} from "./read.ts";
|
|
29
61
|
import {
|
|
30
62
|
COMMENT_PLACEMENTS,
|
|
63
|
+
EVIDENCE_KINDS,
|
|
64
|
+
MAX_ELEMENT,
|
|
65
|
+
MODULE_FILE,
|
|
66
|
+
DEFAULT_SPACE,
|
|
67
|
+
GENERATED_FILES,
|
|
68
|
+
isGeneratedFile,
|
|
69
|
+
MACRO_NAME,
|
|
70
|
+
MACRO_PARAM,
|
|
71
|
+
SPACE,
|
|
72
|
+
SWITCH_NAME,
|
|
73
|
+
NO_ELEMENT_TYPES,
|
|
74
|
+
OPERAND_SELECTS,
|
|
75
|
+
POINTER_KIND,
|
|
31
76
|
REFERENCE_KINDS,
|
|
32
77
|
REGION_TYPES,
|
|
33
78
|
SYMBOL_KINDS,
|
|
34
79
|
type CommentPlacement,
|
|
80
|
+
type EvidenceKind,
|
|
81
|
+
type OperandSelect,
|
|
35
82
|
type Origin,
|
|
36
83
|
type ReferenceKind,
|
|
37
84
|
type RegionType,
|
|
85
|
+
type Space,
|
|
38
86
|
type SymbolKind,
|
|
39
87
|
} from "./schema.ts";
|
|
88
|
+
import { newRevisionId, rowId, sqlName } from "./text.ts";
|
|
89
|
+
|
|
90
|
+
/** What a change rests on (D42): a coverage run, a test or another project file with its SHA-256, or a note. */
|
|
91
|
+
export interface Evidence {
|
|
92
|
+
kind: EvidenceKind;
|
|
93
|
+
ref: string;
|
|
94
|
+
sha256?: string;
|
|
95
|
+
}
|
|
40
96
|
|
|
41
97
|
/** Who writes and why. Semantic writes come from the user or the LLM, never as an analyzer. */
|
|
42
98
|
export interface WriteContext {
|
|
@@ -44,12 +100,22 @@ export interface WriteContext {
|
|
|
44
100
|
/** A short reason, kept with the revision for later review. */
|
|
45
101
|
description?: string;
|
|
46
102
|
/** The revision the caller read; the write is refused if knowledge changed since. */
|
|
47
|
-
expectedRevision?:
|
|
103
|
+
expectedRevision?: string;
|
|
104
|
+
/** Kept with the revision when the write changes something. */
|
|
105
|
+
evidence?: Evidence[];
|
|
106
|
+
/** The image that an operand, patch or macro use write is checked against (D65, D72, D73). */
|
|
107
|
+
image?: OperandImage;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The bytes of the image and the platform names (C64 space only) that operand expressions, patches and macro uses are checked against. */
|
|
111
|
+
export interface OperandImage {
|
|
112
|
+
byteAt(address: number): number | undefined;
|
|
113
|
+
platform: ReadonlyMap<string, number>;
|
|
48
114
|
}
|
|
49
115
|
|
|
50
116
|
/** The revision a write created, or null when nothing needed to change. */
|
|
51
117
|
export interface WriteResult<T, P = never> {
|
|
52
|
-
revision:
|
|
118
|
+
revision: string | null;
|
|
53
119
|
current: T;
|
|
54
120
|
/** The current rows of another origin that this write replaced, as they were before it. */
|
|
55
121
|
previous?: P;
|
|
@@ -57,6 +123,11 @@ export interface WriteResult<T, P = never> {
|
|
|
57
123
|
|
|
58
124
|
export { SYMBOL_NAME };
|
|
59
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;
|
|
130
|
+
const MAX_EVIDENCE_REF = 1000;
|
|
60
131
|
|
|
61
132
|
export function checkAddress(value: number, what = "address"): void {
|
|
62
133
|
if (!Number.isInteger(value) || value < 0 || value > 0xffff) throw new KnowledgeError("invalid-input", `${what} must be from $0000 to $ffff.`);
|
|
@@ -70,13 +141,33 @@ export function hex(value: number): string {
|
|
|
70
141
|
return `$${value.toString(16).padStart(4, "0")}`;
|
|
71
142
|
}
|
|
72
143
|
|
|
144
|
+
export interface AddressRange {
|
|
145
|
+
start: number;
|
|
146
|
+
end: number;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Parts of `range` that no range in `holes` covers. */
|
|
150
|
+
export function subtract(range: AddressRange, holes: AddressRange[]): AddressRange[] {
|
|
151
|
+
let pieces = [range];
|
|
152
|
+
for (const hole of holes) {
|
|
153
|
+
pieces = pieces.flatMap((piece) => {
|
|
154
|
+
if (hole.end < piece.start || hole.start > piece.end) return [piece];
|
|
155
|
+
const left = hole.start > piece.start ? [{ start: piece.start, end: hole.start - 1 }] : [];
|
|
156
|
+
const right = hole.end < piece.end ? [{ start: hole.end + 1, end: piece.end }] : [];
|
|
157
|
+
return [...left, ...right];
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return pieces;
|
|
161
|
+
}
|
|
162
|
+
|
|
73
163
|
/** What a revision records about its writer. Analyzer imports may add tool details. */
|
|
74
164
|
export interface ChangeContext {
|
|
75
165
|
origin: Origin;
|
|
76
166
|
description?: string;
|
|
77
|
-
expectedRevision?:
|
|
167
|
+
expectedRevision?: string;
|
|
78
168
|
inputHash?: string;
|
|
79
169
|
toolVersion?: string;
|
|
170
|
+
evidence?: Evidence[];
|
|
80
171
|
}
|
|
81
172
|
|
|
82
173
|
/**
|
|
@@ -84,7 +175,9 @@ export interface ChangeContext {
|
|
|
84
175
|
* only when the first actual change happens, so a no-op leaves no revision.
|
|
85
176
|
*/
|
|
86
177
|
export class Change {
|
|
87
|
-
revision:
|
|
178
|
+
revision: string | null = null;
|
|
179
|
+
/** The rows this write inserted, for their ids. */
|
|
180
|
+
#rows = 0;
|
|
88
181
|
readonly #db: DatabaseSync;
|
|
89
182
|
readonly #context: ChangeContext;
|
|
90
183
|
readonly #operation: string;
|
|
@@ -93,8 +186,16 @@ export class Change {
|
|
|
93
186
|
this.#db = db;
|
|
94
187
|
this.#context = context;
|
|
95
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
|
+
}
|
|
96
197
|
const current = currentRevision(db);
|
|
97
|
-
if (context.expectedRevision !== undefined && context.expectedRevision !== current) {
|
|
198
|
+
if (context.expectedRevision !== undefined && resolveRevision(db, context.expectedRevision) !== current) {
|
|
98
199
|
throw new KnowledgeError(
|
|
99
200
|
"stale-revision",
|
|
100
201
|
`Knowledge changed since revision ${context.expectedRevision}; it is now at revision ${current}. Read it again and decide again.`,
|
|
@@ -102,22 +203,33 @@ export class Change {
|
|
|
102
203
|
}
|
|
103
204
|
}
|
|
104
205
|
|
|
105
|
-
/** The id of this write's revision, created on first use. */
|
|
106
|
-
id():
|
|
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 {
|
|
107
208
|
if (this.revision !== null) return this.revision;
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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;
|
|
215
|
+
const evidence = this.#db.prepare("INSERT INTO evidence (revision, kind, ref, sha256) VALUES (?, ?, ?, ?)");
|
|
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);
|
|
121
233
|
}
|
|
122
234
|
|
|
123
235
|
/** Closes the current rows of `table` that `where` selects. */
|
|
@@ -126,223 +238,1106 @@ export class Change {
|
|
|
126
238
|
}
|
|
127
239
|
|
|
128
240
|
insertSymbol(row: Omit<SymbolRow, "revision">): void {
|
|
129
|
-
this.#db.prepare("INSERT INTO symbols (address, name, kind, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?)").run(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());
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** A second name of an address from an analyzer (D74). */
|
|
245
|
+
insertAnalyzerName(row: Omit<SymbolRow, "revision">): void {
|
|
246
|
+
this.#db.prepare("INSERT INTO analyzer_names (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());
|
|
130
247
|
}
|
|
131
248
|
|
|
132
249
|
insertRegion(row: Omit<RegionRow, "revision">): void {
|
|
133
250
|
this.#db
|
|
134
|
-
.prepare("INSERT INTO regions (start_address, end_address, type, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?)")
|
|
135
|
-
.run(row.start, row.end, row.type, row.origin, this.id());
|
|
251
|
+
.prepare("INSERT INTO regions (id, space, start_address, end_address, type, element, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
|
|
252
|
+
.run(this.#rowId(), row.space, row.start, row.end, row.type, row.element ?? null, row.origin, this.id());
|
|
136
253
|
}
|
|
137
254
|
|
|
138
255
|
insertComment(row: Omit<CommentRow, "revision">): void {
|
|
139
|
-
this.#db
|
|
256
|
+
this.#db
|
|
257
|
+
.prepare("INSERT INTO comments (id, space, address, placement, text, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
|
258
|
+
.run(this.#rowId(), row.space, row.address, row.placement, row.text, row.origin, this.id());
|
|
140
259
|
}
|
|
141
260
|
|
|
142
261
|
insertReference(row: Omit<ReferenceRow, "revision">): void {
|
|
143
262
|
this.#db
|
|
144
|
-
.prepare('INSERT INTO "references" (from_address, to_address, kind, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?)')
|
|
145
|
-
.run(row.from, row.to, row.kind, row.origin, this.id());
|
|
263
|
+
.prepare('INSERT INTO "references" (id, space, from_address, to_address, kind, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
264
|
+
.run(this.#rowId(), row.space, row.from, row.to, row.kind, row.origin, this.id());
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
insertModule(row: Omit<ModuleRow, "revision">): void {
|
|
268
|
+
this.#db
|
|
269
|
+
.prepare("INSERT INTO modules (id, space, start_address, file, title, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
|
270
|
+
.run(this.#rowId(), row.space, row.start, row.file, row.title ?? null, row.origin, this.id());
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
insertOperand(row: Omit<OperandRow, "revision">): void {
|
|
274
|
+
this.#db
|
|
275
|
+
.prepare("INSERT INTO operands (id, space, address, selector, symbol, offset, expression, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
276
|
+
.run(this.#rowId(), row.space, row.address, row.select, row.symbol ?? null, row.offset, row.expression ?? null, row.origin, this.id());
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
insertPatch(row: Omit<PatchRow, "revision">): void {
|
|
280
|
+
this.#db
|
|
281
|
+
.prepare("INSERT INTO patches (id, space, start_address, end_address, switch_name, code, comment, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
282
|
+
.run(this.#rowId(), row.space, row.start, row.end, row.switch, row.code, row.comment ?? null, row.origin, this.id());
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
insertMacro(row: Omit<MacroRow, "revision">): void {
|
|
286
|
+
this.#db
|
|
287
|
+
.prepare("INSERT INTO macros (id, space, name, params, body, comment, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
|
|
288
|
+
.run(this.#rowId(), row.space, row.name, listText(row.params), row.body, row.comment ?? null, row.origin, this.id());
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
insertMacroUse(row: Omit<MacroUseRow, "revision">): void {
|
|
292
|
+
this.#db
|
|
293
|
+
.prepare("INSERT INTO macro_uses (id, space, start_address, end_address, macro, args, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
|
|
294
|
+
.run(this.#rowId(), row.space, row.start, row.end, row.macro, listText(row.args), row.origin, this.id());
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
insertSuppression(row: Omit<SuppressionRow, "revision">): void {
|
|
298
|
+
this.#db
|
|
299
|
+
.prepare("INSERT INTO suppressions (id, space, entity, start_address, end_address, to_address, kind, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
300
|
+
.run(this.#rowId(), row.space, row.entity, row.start, row.end, row.to ?? null, row.kind ?? null, row.origin, this.id());
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Bytes of the program (D83), as hex digits. */
|
|
304
|
+
insertMemory(row: { space: Space; address: number; bytes: string; origin: Origin }): void {
|
|
305
|
+
this.#db.prepare("INSERT INTO memory (id, space, address, bytes, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?)").run(this.#rowId(), row.space, row.address, row.bytes, row.origin, this.id());
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The entry of the program, with the machine state and the unpacker stages as JSON text when it has them (D83). */
|
|
309
|
+
insertEntry(row: { space: Space; address: number; state: string | null; stages: string | null; origin: Origin }): void {
|
|
310
|
+
this.#db
|
|
311
|
+
.prepare("INSERT INTO entries (id, space, address, state, stages, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
|
312
|
+
.run(this.#rowId(), row.space, row.address, row.state, row.stages, row.origin, this.id());
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** One coverage range of one kind (D83). */
|
|
316
|
+
insertCoverage(row: { space: Space; kind: string; start: number; end: number; origin: Origin }): void {
|
|
317
|
+
this.#db
|
|
318
|
+
.prepare("INSERT INTO coverage (id, space, kind, start_address, end_address, origin, valid_from_revision) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
|
319
|
+
.run(this.#rowId(), row.space, row.kind, row.start, row.end, row.origin, this.id());
|
|
146
320
|
}
|
|
147
321
|
}
|
|
148
322
|
|
|
149
|
-
/**
|
|
150
|
-
function
|
|
323
|
+
/** A conflict in words: its rule, its space and the addresses of its rows. */
|
|
324
|
+
function describe(conflict: Conflict): string {
|
|
325
|
+
const place = (entry: ConflictRow) => {
|
|
326
|
+
if (entry.entity === "macro") return entry.row.name;
|
|
327
|
+
const row = entry.row as { address?: number; start?: number; end?: number; from?: number };
|
|
328
|
+
if (row.address !== undefined) return hex(row.address);
|
|
329
|
+
if (row.from !== undefined && entry.entity === "reference") return hex(row.from);
|
|
330
|
+
return row.end === undefined || row.end === row.start ? hex(row.start!) : `${hex(row.start!)}-${hex(row.end)}`;
|
|
331
|
+
};
|
|
332
|
+
return `${CONFLICT_RULES[conflict.rule]} in ${conflict.space} (${[...new Set(conflict.rows.map(place))].join(", ")})`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Closes the current region suppressions over [start, end] and keeps their parts outside it: a user/LLM region there decides now. */
|
|
336
|
+
function liftRegionSuppressions(db: DatabaseSync, change: Change, start: number, end: number, space: Space): void {
|
|
337
|
+
const overlapping = suppressionsOverlapping(db, "region", start, end, space);
|
|
338
|
+
if (overlapping.length === 0) return;
|
|
339
|
+
change.close("suppressions", "space = ? AND entity = 'region' AND start_address <= ? AND end_address >= ?", space, end, start);
|
|
340
|
+
for (const row of overlapping) {
|
|
341
|
+
if (row.start < start) change.insertSuppression({ ...row, end: start - 1 });
|
|
342
|
+
if (row.end > end) change.insertSuppression({ ...row, start: end + 1 });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function checkEvidence(evidence: Evidence[] | undefined): void {
|
|
347
|
+
for (const item of evidence ?? []) {
|
|
348
|
+
checkOneOf(EVIDENCE_KINDS, item.kind, "evidence kind");
|
|
349
|
+
if (item.ref.trim().length === 0 || item.ref.length > MAX_EVIDENCE_REF) throw new KnowledgeError("invalid-input", `evidence has 1 to ${MAX_EVIDENCE_REF} characters after its kind.`);
|
|
350
|
+
if (item.sha256 !== undefined && !/^[0-9a-f]{64}$/.test(item.sha256)) throw new KnowledgeError("invalid-input", "evidence sha256 must be 64 lowercase hex digits.");
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Runs semantic writes in one transaction, as one revision. */
|
|
355
|
+
export function write<T>(db: DatabaseSync, context: WriteContext, operation: string, work: (change: Change) => T): { revision: string | null; current: T } {
|
|
151
356
|
if (context.origin !== "user" && context.origin !== "llm") {
|
|
152
357
|
throw new KnowledgeError("invalid-input", "A semantic write comes from the user or the LLM.");
|
|
153
358
|
}
|
|
359
|
+
checkEvidence(context.evidence);
|
|
154
360
|
return transaction(db, () => {
|
|
155
361
|
const change = new Change(db, context, operation);
|
|
156
362
|
const current = work(change);
|
|
363
|
+
change.check();
|
|
157
364
|
return { revision: change.revision, current };
|
|
158
365
|
});
|
|
159
366
|
}
|
|
160
367
|
|
|
161
|
-
/**
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
368
|
+
/** One step of a write inside a change: its current value, and the rows of another origin it replaced. */
|
|
369
|
+
interface Step<T, P = never> {
|
|
370
|
+
current: T;
|
|
371
|
+
previous?: P;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const result = <T, P>(written: { revision: string | null; current: Step<T, P> }): WriteResult<T, P> =>
|
|
375
|
+
written.current.previous === undefined ? { revision: written.revision, current: written.current.current } : { revision: written.revision, current: written.current.current, previous: written.current.previous };
|
|
376
|
+
|
|
377
|
+
// Each write has a check, which refuses bad input before anything opens, and a
|
|
378
|
+
// step, which runs inside a change. A single write and a batch share both (D42).
|
|
379
|
+
|
|
380
|
+
/** Every request names its space (D53); without one it is the C64. */
|
|
381
|
+
type InSpace = { space?: Space };
|
|
382
|
+
type SymbolRequest = InSpace & { address: number; name: string; kind?: SymbolKind };
|
|
383
|
+
type RegionRequest = InSpace & { start: number; end: number; type: RegionType; element?: number };
|
|
384
|
+
type RangeRequest = InSpace & { start: number; end: number };
|
|
385
|
+
type CommentRequest = InSpace & { address: number; placement: CommentPlacement; text: string };
|
|
386
|
+
type PlacementRequest = InSpace & { address: number; placement: CommentPlacement };
|
|
387
|
+
type ReferenceRequest = InSpace & { from: number; to: number; kind: ReferenceKind };
|
|
388
|
+
type AddressRequest = InSpace & { address: number };
|
|
389
|
+
|
|
390
|
+
const spaceOf = (request: InSpace): Space => request.space ?? DEFAULT_SPACE;
|
|
391
|
+
|
|
392
|
+
export function checkSpace(request: InSpace): void {
|
|
393
|
+
if (request.space !== undefined && !SPACE.test(request.space)) throw new KnowledgeError("invalid-input", "space must be c64, drive8, or bank0 to bank255.");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function checkSymbol(request: SymbolRequest): void {
|
|
397
|
+
checkSpace(request);
|
|
167
398
|
checkAddress(request.address);
|
|
168
399
|
if (!SYMBOL_NAME.test(request.name)) {
|
|
169
400
|
throw new KnowledgeError("invalid-input", "A symbol name is a letter or underscore, then letters, digits or underscores (at most 64), with an optional leading dot.");
|
|
170
401
|
}
|
|
171
402
|
if (request.kind !== undefined) checkOneOf(SYMBOL_KINDS, request.kind, "kind");
|
|
172
|
-
let previous: SymbolRow | undefined;
|
|
173
|
-
const result = write(db, context, "rename-symbol", (change) => {
|
|
174
|
-
const existing = symbolAt(db, request.address);
|
|
175
|
-
const kind = request.kind ?? existing?.kind ?? "label";
|
|
176
|
-
if (existing !== undefined && existing.name === request.name && existing.kind === kind) return existing;
|
|
177
|
-
const owner = symbolNamed(db, request.name);
|
|
178
|
-
if (owner !== undefined && owner.address !== request.address) {
|
|
179
|
-
throw new KnowledgeError("conflict", `The name ${request.name} already belongs to ${hex(owner.address)}. Rename or remove that symbol first.`);
|
|
180
|
-
}
|
|
181
|
-
if (existing !== undefined) {
|
|
182
|
-
change.close("symbols", "address = ?", request.address);
|
|
183
|
-
if (existing.origin !== context.origin) previous = existing;
|
|
184
|
-
}
|
|
185
|
-
change.insertSymbol({ address: request.address, name: request.name, kind, origin: context.origin });
|
|
186
|
-
return symbolAt(db, request.address)!;
|
|
187
|
-
});
|
|
188
|
-
return previous === undefined ? result : { ...result, previous };
|
|
189
403
|
}
|
|
190
404
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
405
|
+
function renameStep(db: DatabaseSync, change: Change, origin: Origin, request: SymbolRequest): Step<SymbolRow, SymbolRow> {
|
|
406
|
+
const space = spaceOf(request);
|
|
407
|
+
const existing = symbolAt(db, request.address, space);
|
|
408
|
+
const kind = request.kind ?? existing?.kind ?? "label";
|
|
409
|
+
if (existing !== undefined && existing.name === request.name && existing.kind === kind) return { current: existing };
|
|
410
|
+
const owner = symbolNamed(db, request.name, space);
|
|
411
|
+
if (owner !== undefined && owner.address !== request.address) {
|
|
412
|
+
throw new KnowledgeError("conflict", `The name ${request.name} already belongs to ${hex(owner.address)}. Rename or remove that symbol first.`);
|
|
413
|
+
}
|
|
414
|
+
const [patch] = listPatches(db, undefined, request.name, undefined, space);
|
|
415
|
+
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.`);
|
|
416
|
+
if (existing !== undefined) change.close("symbols", "space = ? AND address = ?", space, request.address);
|
|
417
|
+
change.insertSymbol({ space, address: request.address, name: request.name, kind, origin });
|
|
418
|
+
if (existing !== undefined && existing.name !== request.name) {
|
|
419
|
+
renameInOperands(db, change, space, existing.name, request.name);
|
|
420
|
+
renameInUses(db, change, space, existing.name, request.name);
|
|
421
|
+
}
|
|
422
|
+
change.close("suppressions", "space = ? AND entity = 'symbol' AND start_address = ?", space, request.address);
|
|
423
|
+
const current = symbolAt(db, request.address, space)!;
|
|
424
|
+
return existing !== undefined && existing.origin !== origin ? { current, previous: existing } : { current };
|
|
198
425
|
}
|
|
199
426
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
if (
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
427
|
+
function removeSymbolStep(db: DatabaseSync, change: Change, origin: Origin, request: AddressRequest): Step<null> {
|
|
428
|
+
const space = spaceOf(request);
|
|
429
|
+
const existing = symbolAt(db, request.address, space);
|
|
430
|
+
if (existing === undefined) throw new KnowledgeError("not-found", `There is no symbol at ${hex(request.address)}.`);
|
|
431
|
+
const users = operandsNaming(db, space, existing.name);
|
|
432
|
+
if (users.length > 0) {
|
|
433
|
+
throw new KnowledgeError(
|
|
434
|
+
"conflict",
|
|
435
|
+
`The operand expressions at ${users.map((row) => hex(row.address)).join(", ")} use ${existing.name}. Change or remove them first (unoperand), or rename the symbol.`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
const uses = usesNaming(db, space, existing.name);
|
|
439
|
+
if (uses.length > 0) {
|
|
440
|
+
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.`);
|
|
208
441
|
}
|
|
442
|
+
// No suppression: an analyzer name is a second name and never comes back as the name (D74).
|
|
443
|
+
change.close("symbols", "space = ? AND address = ?", space, request.address);
|
|
444
|
+
return { current: null };
|
|
209
445
|
}
|
|
210
446
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
db: DatabaseSync,
|
|
214
|
-
context: WriteContext,
|
|
215
|
-
request: { start: number; end: number; type: RegionType },
|
|
216
|
-
): WriteResult<RegionRow[], RegionRow[]> {
|
|
447
|
+
function checkRange(request: RangeRequest): void {
|
|
448
|
+
checkSpace(request);
|
|
217
449
|
checkAddress(request.start, "start");
|
|
218
450
|
checkAddress(request.end, "end");
|
|
219
451
|
if (request.end < request.start) throw new KnowledgeError("invalid-input", "end must not be before start.");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function checkRegion(request: RegionRequest): void {
|
|
455
|
+
checkRange(request);
|
|
220
456
|
checkOneOf(REGION_TYPES, request.type, "type");
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
clearRange(db, change, request.start, request.end);
|
|
227
|
-
change.insertRegion({ start: request.start, end: request.end, type: request.type, origin: context.origin });
|
|
228
|
-
previous = overlapping.filter((row) => row.origin !== context.origin);
|
|
229
|
-
}
|
|
230
|
-
return regionsOverlapping(db, request.start, request.end);
|
|
231
|
-
});
|
|
232
|
-
return previous.length === 0 ? result : { ...result, previous };
|
|
457
|
+
if (request.element === undefined) return;
|
|
458
|
+
if (NO_ELEMENT_TYPES.includes(request.type)) throw new KnowledgeError("invalid-input", `A ${request.type} region has no element size.`);
|
|
459
|
+
if (!Number.isInteger(request.element) || request.element < 1 || request.element > MAX_ELEMENT) throw new KnowledgeError("invalid-input", `element must be from 1 to ${MAX_ELEMENT}.`);
|
|
460
|
+
const length = request.end - request.start + 1;
|
|
461
|
+
if (length % request.element !== 0) throw new KnowledgeError("invalid-input", `The region has ${length} bytes, which is not a multiple of the element size ${request.element}.`);
|
|
233
462
|
}
|
|
234
463
|
|
|
235
|
-
/**
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
checkAddress(request.end, "end");
|
|
239
|
-
if (request.end < request.start) throw new KnowledgeError("invalid-input", "end must not be before start.");
|
|
240
|
-
return write(db, context, "unclassify-region", (change) => {
|
|
241
|
-
if (regionsOverlapping(db, request.start, request.end).length === 0) {
|
|
242
|
-
throw new KnowledgeError("not-found", `No region covers ${hex(request.start)}-${hex(request.end)}.`);
|
|
243
|
-
}
|
|
244
|
-
clearRange(db, change, request.start, request.end);
|
|
245
|
-
return null;
|
|
246
|
-
});
|
|
464
|
+
/** The element size of a piece of a region: kept when the piece still holds whole elements. */
|
|
465
|
+
function elementOf(piece: AddressRange, element: number | undefined): { element?: number } {
|
|
466
|
+
return element !== undefined && (piece.end - piece.start + 1) % element === 0 ? { element } : {};
|
|
247
467
|
}
|
|
248
468
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
469
|
+
/** Closes the current regions over [start, end] and keeps their parts outside it. */
|
|
470
|
+
export function clearRange(db: DatabaseSync, change: Change, start: number, end: number, space: Space = DEFAULT_SPACE): void {
|
|
471
|
+
const overlapping = regionsOverlapping(db, start, end, space);
|
|
472
|
+
if (overlapping.length === 0) return;
|
|
473
|
+
change.close("regions", "space = ? AND start_address <= ? AND end_address >= ?", space, end, start);
|
|
474
|
+
for (const region of overlapping) {
|
|
475
|
+
const left = { start: region.start, end: start - 1 };
|
|
476
|
+
const right = { start: end + 1, end: region.end };
|
|
477
|
+
if (region.start < start) change.insertRegion({ space, ...left, type: region.type, ...elementOf(left, region.element), origin: region.origin });
|
|
478
|
+
if (region.end > end) change.insertRegion({ space, ...right, type: region.type, ...elementOf(right, region.element), origin: region.origin });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function classifyStep(db: DatabaseSync, change: Change, origin: Origin, request: RegionRequest): Step<RegionRow[], RegionRow[]> {
|
|
483
|
+
const space = spaceOf(request);
|
|
484
|
+
const overlapping = regionsOverlapping(db, request.start, request.end, space);
|
|
485
|
+
const [only] = overlapping;
|
|
486
|
+
const same = overlapping.length === 1 && only!.start === request.start && only!.end === request.end && only!.type === request.type && only!.element === request.element;
|
|
487
|
+
if (same) return { current: overlapping };
|
|
488
|
+
clearRange(db, change, request.start, request.end, space);
|
|
489
|
+
change.insertRegion({ space, start: request.start, end: request.end, type: request.type, ...(request.element === undefined ? {} : { element: request.element }), origin });
|
|
490
|
+
liftRegionSuppressions(db, change, request.start, request.end, space);
|
|
491
|
+
const previous = overlapping.filter((row) => row.origin !== origin);
|
|
492
|
+
const current = regionsOverlapping(db, request.start, request.end, space);
|
|
493
|
+
return previous.length === 0 ? { current } : { current, previous };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function unclassifyStep(db: DatabaseSync, change: Change, origin: Origin, request: RangeRequest): Step<null> {
|
|
497
|
+
const space = spaceOf(request);
|
|
498
|
+
if (regionsOverlapping(db, request.start, request.end, space).length === 0) {
|
|
499
|
+
throw new KnowledgeError("not-found", `No region covers ${hex(request.start)}-${hex(request.end)}.`);
|
|
500
|
+
}
|
|
501
|
+
clearRange(db, change, request.start, request.end, space);
|
|
502
|
+
// Suppress only the parts that no region suppression holds yet, so suppressions never overlap.
|
|
503
|
+
for (const piece of subtract({ start: request.start, end: request.end }, suppressionsOverlapping(db, "region", request.start, request.end, space))) {
|
|
504
|
+
change.insertSuppression({ space, entity: "region", ...piece, origin: origin as SuppressionRow["origin"] });
|
|
505
|
+
}
|
|
506
|
+
return { current: null };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function checkComment(request: CommentRequest): string {
|
|
510
|
+
checkSpace(request);
|
|
254
511
|
checkAddress(request.address);
|
|
255
512
|
checkOneOf(COMMENT_PLACEMENTS, request.placement, "placement");
|
|
256
513
|
const text = request.text.trim();
|
|
257
514
|
if (text.length === 0 || text.length > MAX_COMMENT) throw new KnowledgeError("invalid-input", `A comment has 1 to ${MAX_COMMENT} characters.`);
|
|
258
|
-
return
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
515
|
+
return text;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function commentStep(db: DatabaseSync, change: Change, origin: Origin, request: CommentRequest): Step<CommentRow> {
|
|
519
|
+
const space = spaceOf(request);
|
|
520
|
+
const text = request.text.trim();
|
|
521
|
+
const existing = commentsAt(db, request.address, space).find((comment) => comment.placement === request.placement);
|
|
522
|
+
if (existing?.text === text) return { current: existing };
|
|
523
|
+
if (existing !== undefined) change.close("comments", "space = ? AND address = ? AND placement = ?", space, request.address, request.placement);
|
|
524
|
+
change.insertComment({ space, address: request.address, placement: request.placement, text, origin });
|
|
525
|
+
return { current: commentsAt(db, request.address, space).find((comment) => comment.placement === request.placement)! };
|
|
265
526
|
}
|
|
266
527
|
|
|
267
|
-
|
|
528
|
+
function checkPlacement(request: PlacementRequest): void {
|
|
529
|
+
checkSpace(request);
|
|
268
530
|
checkAddress(request.address);
|
|
269
531
|
checkOneOf(COMMENT_PLACEMENTS, request.placement, "placement");
|
|
270
|
-
return write(db, context, "remove-comment", (change) => {
|
|
271
|
-
if (!commentsAt(db, request.address).some((comment) => comment.placement === request.placement)) {
|
|
272
|
-
throw new KnowledgeError("not-found", `There is no ${request.placement} comment at ${hex(request.address)}.`);
|
|
273
|
-
}
|
|
274
|
-
change.close("comments", "address = ? AND placement = ?", request.address, request.placement);
|
|
275
|
-
return null;
|
|
276
|
-
});
|
|
277
532
|
}
|
|
278
533
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
checkOneOf(REFERENCE_KINDS, request.kind, "kind");
|
|
287
|
-
return write(db, context, "add-reference", (change) => {
|
|
288
|
-
const find = () => referencesFrom(db, request.from).find((reference) => reference.to === request.to && reference.kind === request.kind);
|
|
289
|
-
const existing = find();
|
|
290
|
-
if (existing !== undefined) return existing;
|
|
291
|
-
change.insertReference({ ...request, origin: context.origin });
|
|
292
|
-
return find()!;
|
|
293
|
-
});
|
|
534
|
+
function uncommentStep(db: DatabaseSync, change: Change, _origin: Origin, request: PlacementRequest): Step<null> {
|
|
535
|
+
const space = spaceOf(request);
|
|
536
|
+
if (!commentsAt(db, request.address, space).some((comment) => comment.placement === request.placement)) {
|
|
537
|
+
throw new KnowledgeError("not-found", `There is no ${request.placement} comment at ${hex(request.address)}.`);
|
|
538
|
+
}
|
|
539
|
+
change.close("comments", "space = ? AND address = ? AND placement = ?", space, request.address, request.placement);
|
|
540
|
+
return { current: null };
|
|
294
541
|
}
|
|
295
542
|
|
|
296
|
-
|
|
543
|
+
function checkReference(request: ReferenceRequest): void {
|
|
544
|
+
checkSpace(request);
|
|
297
545
|
checkAddress(request.from, "from");
|
|
298
546
|
checkAddress(request.to, "to");
|
|
299
547
|
checkOneOf(REFERENCE_KINDS, request.kind, "kind");
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
548
|
+
if (request.kind === POINTER_KIND) throw new KnowledgeError("invalid-input", "Only an operand expression writes a pointer reference (D65): use operand and unoperand.");
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function referenceStep(db: DatabaseSync, change: Change, origin: Origin, request: ReferenceRequest): Step<ReferenceRow> {
|
|
552
|
+
const space = spaceOf(request);
|
|
553
|
+
const find = () => referencesFrom(db, request.from, space).find((reference) => reference.to === request.to && reference.kind === request.kind);
|
|
554
|
+
const existing = find();
|
|
555
|
+
if (existing !== undefined) return { current: existing };
|
|
556
|
+
change.insertReference({ space, from: request.from, to: request.to, kind: request.kind, origin });
|
|
557
|
+
change.close("suppressions", "space = ? AND entity = 'reference' AND start_address = ? AND to_address = ? AND kind = ?", space, request.from, request.to, request.kind);
|
|
558
|
+
return { current: find()! };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function unreferenceStep(db: DatabaseSync, change: Change, origin: Origin, request: ReferenceRequest): Step<null> {
|
|
562
|
+
const space = spaceOf(request);
|
|
563
|
+
if (!referencesFrom(db, request.from, space).some((reference) => reference.to === request.to && reference.kind === request.kind)) {
|
|
564
|
+
throw new KnowledgeError("not-found", `There is no ${request.kind} reference from ${hex(request.from)} to ${hex(request.to)}.`);
|
|
565
|
+
}
|
|
566
|
+
change.close('"references"', "space = ? AND from_address = ? AND to_address = ? AND kind = ?", space, request.from, request.to, request.kind);
|
|
567
|
+
change.insertSuppression({ space, entity: "reference", start: request.from, end: request.from, to: request.to, kind: request.kind, origin: origin as SuppressionRow["origin"] });
|
|
568
|
+
return { current: null };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
type OperandRequest = InSpace & { address: number; select: OperandSelect; symbol?: string; offset?: number; expression?: string };
|
|
572
|
+
|
|
573
|
+
/** Current operand expressions that use a name, as a symbol or inside expression text. */
|
|
574
|
+
function operandsNaming(db: DatabaseSync, space: Space, name: string): OperandRow[] {
|
|
575
|
+
return listOperands(db, undefined, undefined, space).filter((row) => row.symbol === name || (row.expression !== undefined && identifiers(row.expression).includes(name)));
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** A rename of a symbol renames it in every operand expression that uses it; the rows keep their origin. */
|
|
579
|
+
function renameInOperands(db: DatabaseSync, change: Change, space: Space, from: string, to: string): void {
|
|
580
|
+
for (const row of operandsNaming(db, space, from)) {
|
|
581
|
+
change.close("operands", "space = ? AND address = ?", space, row.address);
|
|
582
|
+
const renamed: Omit<OperandRow, "revision"> = { ...row };
|
|
583
|
+
if (row.symbol === from) renamed.symbol = to;
|
|
584
|
+
if (row.expression !== undefined) renamed.expression = replaceIdentifiers(row.expression, (name) => (name === from ? to : name));
|
|
585
|
+
change.insertOperand(renamed);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function checkOperand(request: OperandRequest): void {
|
|
590
|
+
checkSpace(request);
|
|
591
|
+
checkAddress(request.address);
|
|
592
|
+
checkOneOf(OPERAND_SELECTS, request.select, "select");
|
|
593
|
+
if (request.select === "expr") {
|
|
594
|
+
if (request.symbol !== undefined || request.offset !== undefined) throw new KnowledgeError("invalid-input", "An expr operand has expression text, and no symbol or offset.");
|
|
595
|
+
if (typeof request.expression !== "string") throw new KnowledgeError("invalid-input", "An expr operand has expression text.");
|
|
596
|
+
try {
|
|
597
|
+
checkExpression(request.expression);
|
|
598
|
+
} catch (error) {
|
|
599
|
+
if (error instanceof ExpressionError) throw new KnowledgeError("invalid-input", error.message);
|
|
600
|
+
throw error;
|
|
601
|
+
}
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (request.expression !== undefined) throw new KnowledgeError("invalid-input", `A ${request.select} operand has a symbol and an optional offset, and no expression text.`);
|
|
605
|
+
if (request.symbol === undefined || !SYMBOL_NAME.test(request.symbol)) throw new KnowledgeError("invalid-input", `A ${request.select} operand names a symbol: a letter or underscore, then letters, digits or underscores.`);
|
|
606
|
+
if (request.offset !== undefined && (!Number.isInteger(request.offset) || Math.abs(request.offset) > 0xffff)) throw new KnowledgeError("invalid-input", "offset must be a whole number from -$ffff to $ffff.");
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const shown = (value: number, size: number) => (value < 0 ? String(value) : `$${value.toString(16).padStart(size === 0x100 ? 2 : 4, "0")}`);
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Refuses an expression whose value is not the operand in the image (D65), and
|
|
613
|
+
* returns the addresses of its names. Symbols of the knowledge come first, then
|
|
614
|
+
* the platform names of the C64 space.
|
|
615
|
+
*/
|
|
616
|
+
function checkOperandValue(db: DatabaseSync, space: Space, image: OperandImage, request: OperandRequest, text: string): number[] {
|
|
617
|
+
const where = hex(request.address);
|
|
618
|
+
const opcode = image.byteAt(request.address);
|
|
619
|
+
if (opcode === undefined) throw new KnowledgeError("invalid-input", `${where} is outside the image.`);
|
|
620
|
+
const length = instructionLength(opcode);
|
|
621
|
+
if (length === 1) throw new KnowledgeError("invalid-input", `The instruction at ${where} (opcode ${shown(opcode, 0x100)}) has no operand.`);
|
|
622
|
+
if ((opcode & 0x1f) === 0x10) throw new KnowledgeError("invalid-input", `The instruction at ${where} is a branch: its operand is a distance, not a value.`);
|
|
623
|
+
if ((request.select === "lo" || request.select === "hi") && length === 3) {
|
|
624
|
+
throw new KnowledgeError("invalid-input", `lo and hi give one byte, but the instruction at ${where} has a 2-byte operand. Use value.`);
|
|
625
|
+
}
|
|
626
|
+
const bytes = [image.byteAt(request.address + 1), length === 3 ? image.byteAt(request.address + 2) : 0];
|
|
627
|
+
if (bytes.some((byte) => byte === undefined)) throw new KnowledgeError("invalid-input", `The instruction at ${where} runs past the end of the image.`);
|
|
628
|
+
const operand = bytes[0]! | (bytes[1]! << 8);
|
|
629
|
+
const resolve = (name: string) => symbolNamed(db, name, space)?.address ?? (space === DEFAULT_SPACE ? image.platform.get(name) : undefined);
|
|
630
|
+
let value: number;
|
|
631
|
+
try {
|
|
632
|
+
value = evaluate(text, resolve);
|
|
633
|
+
} catch (error) {
|
|
634
|
+
if (error instanceof ExpressionError) throw new KnowledgeError("invalid-input", `${where}: ${error.message}`);
|
|
635
|
+
throw error;
|
|
636
|
+
}
|
|
637
|
+
const size = length === 2 ? 0x100 : 0x10000;
|
|
638
|
+
if (value < -size / 2 || value >= size || ((value % size) + size) % size !== operand) {
|
|
639
|
+
throw new KnowledgeError("invalid-input", `At ${where}, ${text} gives ${shown(value, size)}, but the operand in the image is ${shown(operand, size)}.`);
|
|
640
|
+
}
|
|
641
|
+
return identifiers(text).map((name) => resolve(name)!);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function operandStep(db: DatabaseSync, change: Change, context: ChangeContext & { image?: OperandImage }, request: OperandRequest): Step<OperandRow, OperandRow> {
|
|
645
|
+
const space = spaceOf(request);
|
|
646
|
+
if (context.image === undefined) throw new KnowledgeError("invalid-input", "An operand expression is checked against the image, and no image was given.");
|
|
647
|
+
const wanted: Omit<OperandRow, "space" | "address" | "origin" | "revision"> = { select: request.select, offset: request.offset ?? 0 };
|
|
648
|
+
if (request.symbol !== undefined) wanted.symbol = request.symbol;
|
|
649
|
+
if (request.expression !== undefined) wanted.expression = request.expression.trim();
|
|
650
|
+
const targets = new Set(checkOperandValue(db, space, context.image, request, operandText(wanted)));
|
|
651
|
+
const existing = operandAt(db, request.address, space);
|
|
652
|
+
if (existing !== undefined && existing.select === wanted.select && existing.symbol === wanted.symbol && existing.offset === wanted.offset && existing.expression === wanted.expression) {
|
|
653
|
+
return { current: existing };
|
|
654
|
+
}
|
|
655
|
+
const origin = context.origin as OperandRow["origin"];
|
|
656
|
+
if (existing !== undefined) change.close("operands", "space = ? AND address = ?", space, request.address);
|
|
657
|
+
change.insertOperand({ space, address: request.address, ...wanted, origin });
|
|
658
|
+
const pointers = referencesFrom(db, request.address, space).filter((reference) => reference.kind === POINTER_KIND);
|
|
659
|
+
for (const reference of pointers) {
|
|
660
|
+
if (!targets.has(reference.to)) change.close('"references"', "space = ? AND from_address = ? AND to_address = ? AND kind = ?", space, request.address, reference.to, POINTER_KIND);
|
|
661
|
+
}
|
|
662
|
+
for (const to of targets) {
|
|
663
|
+
if (!pointers.some((reference) => reference.to === to)) change.insertReference({ space, from: request.address, to, kind: POINTER_KIND, origin });
|
|
664
|
+
}
|
|
665
|
+
const current = operandAt(db, request.address, space)!;
|
|
666
|
+
return existing !== undefined && existing.origin !== origin ? { current, previous: existing } : { current };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function unoperandStep(db: DatabaseSync, change: Change, _origin: Origin, request: AddressRequest): Step<null> {
|
|
670
|
+
const space = spaceOf(request);
|
|
671
|
+
if (operandAt(db, request.address, space) === undefined) throw new KnowledgeError("not-found", `There is no operand expression at ${hex(request.address)}.`);
|
|
672
|
+
change.close("operands", "space = ? AND address = ?", space, request.address);
|
|
673
|
+
change.close('"references"', "space = ? AND from_address = ? AND kind = ?", space, request.address, POINTER_KIND);
|
|
674
|
+
return { current: null };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
type ModuleRequest = InSpace & { start: number; file: string; title?: string };
|
|
678
|
+
|
|
679
|
+
function checkModule(request: ModuleRequest): void {
|
|
680
|
+
checkSpace(request);
|
|
681
|
+
checkAddress(request.start, "start");
|
|
682
|
+
if (!MODULE_FILE.test(request.file)) throw new KnowledgeError("invalid-input", "A module file is a name of letters, digits, underscores or hyphens (at most 60) that ends in .asm, without a directory.");
|
|
683
|
+
if (isGeneratedFile(request.file)) {
|
|
684
|
+
throw new KnowledgeError("invalid-input", `c64-source writes ${GENERATED_FILES.join(", ")} itself, in any letter case, so a module cannot have the file ${request.file}. Give the module another file.`);
|
|
685
|
+
}
|
|
686
|
+
if (request.title !== undefined && (request.title.trim().length === 0 || request.title.length > MAX_COMMENT)) throw new KnowledgeError("invalid-input", `A module title has 1 to ${MAX_COMMENT} characters.`);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function moduleStep(db: DatabaseSync, change: Change, origin: Origin, request: ModuleRequest): Step<ModuleRow> {
|
|
690
|
+
const space = spaceOf(request);
|
|
691
|
+
const existing = moduleAt(db, request.start, space);
|
|
692
|
+
// A module write without a title keeps the title it has (F-26).
|
|
693
|
+
const title = request.title === undefined ? existing?.title : request.title.trim();
|
|
694
|
+
if (existing !== undefined && existing.file === request.file && existing.title === title) return { current: existing };
|
|
695
|
+
const owner = moduleWithFile(db, request.file, space);
|
|
696
|
+
if (owner !== undefined && owner.start !== request.start) {
|
|
697
|
+
throw new KnowledgeError("conflict", `The file ${request.file} already belongs to the module at ${hex(owner.start)}. Give this module another file, or remove that module first.`);
|
|
698
|
+
}
|
|
699
|
+
if (existing !== undefined) change.close("modules", "space = ? AND start_address = ?", space, request.start);
|
|
700
|
+
change.insertModule({ space, start: request.start, file: request.file, ...(title === undefined ? {} : { title }), origin: origin as ModuleRow["origin"] });
|
|
701
|
+
return { current: moduleAt(db, request.start, space)! };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function unmoduleStep(db: DatabaseSync, change: Change, _origin: Origin, request: InSpace & { start: number }): Step<null> {
|
|
705
|
+
const space = spaceOf(request);
|
|
706
|
+
if (moduleAt(db, request.start, space) === undefined) throw new KnowledgeError("not-found", `No module starts at ${hex(request.start)}.`);
|
|
707
|
+
change.close("modules", "space = ? AND start_address = ?", space, request.start);
|
|
708
|
+
return { current: null };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Refuses ACME text whose braces do not pair outside comments and strings:
|
|
713
|
+
* c64-source writes patch code and a macro body inside { }.
|
|
714
|
+
*/
|
|
715
|
+
function checkBraces(text: string, what: string): void {
|
|
716
|
+
let depth = 0;
|
|
717
|
+
for (const line of text.split("\n")) {
|
|
718
|
+
let quote: string | undefined;
|
|
719
|
+
for (let index = 0; index < line.length; index++) {
|
|
720
|
+
const character = line[index]!;
|
|
721
|
+
if (quote !== undefined) {
|
|
722
|
+
if (character === "\\") index++;
|
|
723
|
+
else if (character === quote) quote = undefined;
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
if (character === ";") break;
|
|
727
|
+
if (character === '"' || character === "'") quote = character;
|
|
728
|
+
else if (character === "{") depth++;
|
|
729
|
+
else if (character === "}" && --depth < 0) throw new KnowledgeError("invalid-input", `${what} has a } with no { before it.`);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
if (depth !== 0) throw new KnowledgeError("invalid-input", `${what} has a { with no }.`);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function checkCommentText(comment: string | undefined, what: string): void {
|
|
736
|
+
if (comment !== undefined && (comment.trim().length === 0 || comment.length > MAX_COMMENT)) throw new KnowledgeError("invalid-input", `${what} has 1 to ${MAX_COMMENT} characters.`);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** Refuses a range with a byte outside the image. */
|
|
740
|
+
function checkInImage(image: OperandImage, start: number, end: number, what: string): void {
|
|
741
|
+
for (let address = start; address <= end; address++) {
|
|
742
|
+
if (image.byteAt(address) === undefined) throw new KnowledgeError("invalid-input", `${hex(address)} is outside the image: ${what} is a range of the image.`);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
type PatchRequest = InSpace & { start: number; end: number; switch: string; code: string; comment?: string };
|
|
747
|
+
|
|
748
|
+
function checkPatch(request: PatchRequest): void {
|
|
749
|
+
checkRange(request);
|
|
750
|
+
if (typeof request.switch !== "string" || !SWITCH_NAME.test(request.switch)) {
|
|
751
|
+
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.");
|
|
752
|
+
}
|
|
753
|
+
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.`);
|
|
754
|
+
checkBraces(request.code, "The patch code");
|
|
755
|
+
checkCommentText(request.comment, "A patch comment");
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function patchStep(db: DatabaseSync, change: Change, context: ChangeContext & { image?: OperandImage }, request: PatchRequest): Step<PatchRow, PatchRow> {
|
|
759
|
+
const space = spaceOf(request);
|
|
760
|
+
const range = `${hex(request.start)}-${hex(request.end)}`;
|
|
761
|
+
if (context.image === undefined) throw new KnowledgeError("invalid-input", "A patch is checked against the image, and no image was given.");
|
|
762
|
+
checkInImage(context.image, request.start, request.end, "a patch");
|
|
763
|
+
const symbol = symbolNamed(db, request.switch, space);
|
|
764
|
+
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.`);
|
|
765
|
+
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.`);
|
|
766
|
+
const other = patchesOverlapping(db, request.start, request.end, space).find((row) => row.start !== request.start);
|
|
767
|
+
if (other !== undefined) {
|
|
768
|
+
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).`);
|
|
769
|
+
}
|
|
770
|
+
const [use] = macroUsesOverlapping(db, request.start, request.end, space);
|
|
771
|
+
if (use !== undefined) {
|
|
772
|
+
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).`);
|
|
773
|
+
}
|
|
774
|
+
const existing = patchesOverlapping(db, request.start, request.start, space).find((row) => row.start === request.start);
|
|
775
|
+
const code = request.code.trimEnd();
|
|
776
|
+
// A patch write without a comment keeps the comment it has, as a module keeps its title.
|
|
777
|
+
const comment = request.comment === undefined ? existing?.comment : request.comment.trim();
|
|
778
|
+
if (existing !== undefined && existing.end === request.end && existing.switch === request.switch && existing.code === code && existing.comment === comment) return { current: existing };
|
|
779
|
+
const origin = context.origin as PatchRow["origin"];
|
|
780
|
+
if (existing !== undefined) change.close("patches", "space = ? AND start_address = ?", space, request.start);
|
|
781
|
+
change.insertPatch({ space, start: request.start, end: request.end, switch: request.switch, code, ...(comment === undefined ? {} : { comment }), origin });
|
|
782
|
+
const current = patchesOverlapping(db, request.start, request.start, space).find((row) => row.start === request.start)!;
|
|
783
|
+
return existing !== undefined && existing.origin !== origin ? { current, previous: existing } : { current };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function unpatchStep(db: DatabaseSync, change: Change, _origin: Origin, request: InSpace & { start: number }): Step<null> {
|
|
787
|
+
const space = spaceOf(request);
|
|
788
|
+
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)}.`);
|
|
789
|
+
change.close("patches", "space = ? AND start_address = ?", space, request.start);
|
|
790
|
+
return { current: null };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
type MacroRequest = InSpace & { name: string; params?: string[]; body: string; comment?: string };
|
|
794
|
+
|
|
795
|
+
function checkMacroName(name: unknown, what = "A macro name"): void {
|
|
796
|
+
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.`);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function checkMacro(request: MacroRequest): void {
|
|
800
|
+
checkSpace(request);
|
|
801
|
+
checkMacroName(request.name);
|
|
802
|
+
const params = request.params ?? [];
|
|
803
|
+
if (!Array.isArray(params) || params.length > MAX_PARAMS) throw new KnowledgeError("invalid-input", `A macro has 0 to ${MAX_PARAMS} params.`);
|
|
804
|
+
for (const param of params) {
|
|
805
|
+
if (typeof param !== "string" || !MACRO_PARAM.test(param)) {
|
|
806
|
+
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.");
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
if (new Set(params).size !== params.length) throw new KnowledgeError("invalid-input", "The params of a macro have different names.");
|
|
810
|
+
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.`);
|
|
811
|
+
checkBraces(request.body, "The macro body");
|
|
812
|
+
checkCommentText(request.comment, "A macro comment");
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function macroStep(db: DatabaseSync, change: Change, origin: Origin, request: MacroRequest): Step<MacroRow, MacroRow> {
|
|
816
|
+
const space = spaceOf(request);
|
|
817
|
+
const existing = macroNamed(db, request.name, space);
|
|
818
|
+
const params = request.params ?? [];
|
|
819
|
+
const body = request.body.trimEnd();
|
|
820
|
+
const comment = request.comment === undefined ? existing?.comment : request.comment.trim();
|
|
821
|
+
if (existing !== undefined && listText(existing.params) === listText(params) && existing.body === body && existing.comment === comment) return { current: existing };
|
|
822
|
+
if (existing !== undefined && existing.params.length !== params.length) {
|
|
823
|
+
const uses = listMacroUses(db, undefined, request.name, undefined, space);
|
|
824
|
+
if (uses.length > 0) {
|
|
825
|
+
throw new KnowledgeError(
|
|
826
|
+
"conflict",
|
|
827
|
+
`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.`,
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
if (existing !== undefined) change.close("macros", "space = ? AND name = ?", space, request.name);
|
|
832
|
+
change.insertMacro({ space, name: request.name, params, body, ...(comment === undefined ? {} : { comment }), origin: origin as MacroRow["origin"] });
|
|
833
|
+
const current = macroNamed(db, request.name, space)!;
|
|
834
|
+
return existing !== undefined && existing.origin !== origin ? { current, previous: existing } : { current };
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function unmacroStep(db: DatabaseSync, change: Change, _origin: Origin, request: InSpace & { name: string }): Step<null> {
|
|
838
|
+
const space = spaceOf(request);
|
|
839
|
+
if (macroNamed(db, request.name, space) === undefined) throw new KnowledgeError("not-found", `There is no macro ${request.name}.`);
|
|
840
|
+
const uses = listMacroUses(db, undefined, request.name, undefined, space);
|
|
841
|
+
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).`);
|
|
842
|
+
change.close("macros", "space = ? AND name = ?", space, request.name);
|
|
843
|
+
return { current: null };
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
type MacroUseRequest = InSpace & { start: number; end: number; macro: string; args?: string[] };
|
|
847
|
+
|
|
848
|
+
function checkMacroUse(request: MacroUseRequest): void {
|
|
849
|
+
checkRange(request);
|
|
850
|
+
checkMacroName(request.macro, "The macro of a use");
|
|
851
|
+
const args = request.args ?? [];
|
|
852
|
+
if (!Array.isArray(args) || args.length > MAX_PARAMS) throw new KnowledgeError("invalid-input", `A macro use has 0 to ${MAX_PARAMS} args.`);
|
|
853
|
+
for (const arg of args) {
|
|
854
|
+
if (typeof arg !== "string") throw new KnowledgeError("invalid-input", "Each arg of a macro use is expression text.");
|
|
855
|
+
try {
|
|
856
|
+
checkExpression(arg);
|
|
857
|
+
} catch (error) {
|
|
858
|
+
if (error instanceof ExpressionError) throw new KnowledgeError("invalid-input", error.message);
|
|
859
|
+
throw error;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** Current macro uses that name a symbol in an arg. */
|
|
865
|
+
function usesNaming(db: DatabaseSync, space: Space, name: string): MacroUseRow[] {
|
|
866
|
+
return listMacroUses(db, undefined, undefined, undefined, space).filter((row) => row.args.some((arg) => identifiers(arg).includes(name)));
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** A rename of a symbol renames it in the args of every macro use; the rows keep their origin. */
|
|
870
|
+
function renameInUses(db: DatabaseSync, change: Change, space: Space, from: string, to: string): void {
|
|
871
|
+
for (const row of usesNaming(db, space, from)) {
|
|
872
|
+
change.close("macro_uses", "space = ? AND start_address = ?", space, row.start);
|
|
873
|
+
change.insertMacroUse({ ...row, args: row.args.map((arg) => replaceIdentifiers(arg, (name) => (name === from ? to : name))) });
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function useStep(db: DatabaseSync, change: Change, context: ChangeContext & { image?: OperandImage }, request: MacroUseRequest): Step<MacroUseRow, MacroUseRow> {
|
|
878
|
+
const space = spaceOf(request);
|
|
879
|
+
const image = context.image;
|
|
880
|
+
const range = `${hex(request.start)}-${hex(request.end)}`;
|
|
881
|
+
if (image === undefined) throw new KnowledgeError("invalid-input", "A macro use is checked against the image, and no image was given.");
|
|
882
|
+
const macro = macroNamed(db, request.macro, space);
|
|
883
|
+
if (macro === undefined) throw new KnowledgeError("not-found", `There is no macro ${request.macro}. Write it first (macro).`);
|
|
884
|
+
const args = (request.args ?? []).map((arg) => arg.trim());
|
|
885
|
+
if (args.length !== macro.params.length) {
|
|
886
|
+
throw new KnowledgeError("invalid-input", `The macro ${macro.name} has ${macro.params.length} params (${listText(macro.params)}), and the use gives ${args.length} args.`);
|
|
887
|
+
}
|
|
888
|
+
const resolve = (name: string) => symbolNamed(db, name, space)?.address ?? (space === DEFAULT_SPACE ? image.platform.get(name) : undefined);
|
|
889
|
+
for (const arg of args) {
|
|
890
|
+
try {
|
|
891
|
+
evaluate(arg, resolve);
|
|
892
|
+
} catch (error) {
|
|
893
|
+
if (error instanceof ExpressionError) throw new KnowledgeError("invalid-input", `${range}: ${error.message}`);
|
|
894
|
+
throw error;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
checkInImage(image, request.start, request.end, "a macro use");
|
|
898
|
+
let next = request.start;
|
|
899
|
+
while (next <= request.end) next += instructionLength(image.byteAt(next)!);
|
|
900
|
+
if (next !== request.end + 1) {
|
|
901
|
+
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)}.`);
|
|
902
|
+
}
|
|
903
|
+
const [patch] = patchesOverlapping(db, request.start, request.end, space);
|
|
904
|
+
if (patch !== undefined) {
|
|
905
|
+
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).`);
|
|
906
|
+
}
|
|
907
|
+
const other = macroUsesOverlapping(db, request.start, request.end, space).find((row) => row.start !== request.start);
|
|
908
|
+
if (other !== undefined) {
|
|
909
|
+
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).`);
|
|
910
|
+
}
|
|
911
|
+
const existing = macroUsesOverlapping(db, request.start, request.start, space).find((row) => row.start === request.start);
|
|
912
|
+
if (existing !== undefined && existing.end === request.end && existing.macro === request.macro && listText(existing.args) === listText(args)) return { current: existing };
|
|
913
|
+
const origin = context.origin as MacroUseRow["origin"];
|
|
914
|
+
if (existing !== undefined) change.close("macro_uses", "space = ? AND start_address = ?", space, request.start);
|
|
915
|
+
change.insertMacroUse({ space, start: request.start, end: request.end, macro: request.macro, args, origin });
|
|
916
|
+
const current = macroUsesOverlapping(db, request.start, request.start, space).find((row) => row.start === request.start)!;
|
|
917
|
+
return existing !== undefined && existing.origin !== origin ? { current, previous: existing } : { current };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function unuseStep(db: DatabaseSync, change: Change, _origin: Origin, request: InSpace & { start: number }): Step<null> {
|
|
921
|
+
const space = spaceOf(request);
|
|
922
|
+
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)}.`);
|
|
923
|
+
change.close("macro_uses", "space = ? AND start_address = ?", space, request.start);
|
|
924
|
+
return { current: null };
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** Names the symbol at an address (one current symbol per address, unique names). */
|
|
928
|
+
export function renameSymbol(db: DatabaseSync, context: WriteContext, request: SymbolRequest): WriteResult<SymbolRow, SymbolRow> {
|
|
929
|
+
checkSymbol(request);
|
|
930
|
+
return result(write(db, context, "rename-symbol", (change) => renameStep(db, change, context.origin, request)));
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
export function removeSymbol(db: DatabaseSync, context: WriteContext, request: AddressRequest): WriteResult<null> {
|
|
934
|
+
checkSpace(request);
|
|
935
|
+
checkAddress(request.address);
|
|
936
|
+
return result(write(db, context, "remove-symbol", (change) => removeSymbolStep(db, change, context.origin, request)));
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** Classifies [start, end]; overlapping regions are split so current regions never overlap. */
|
|
940
|
+
export function classifyRegion(db: DatabaseSync, context: WriteContext, request: RegionRequest): WriteResult<RegionRow[], RegionRow[]> {
|
|
941
|
+
checkRegion(request);
|
|
942
|
+
return result(write(db, context, "classify-region", (change) => classifyStep(db, change, context.origin, request)));
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** Makes [start, end] unclassified again; parts of regions outside it stay. */
|
|
946
|
+
export function unclassifyRegion(db: DatabaseSync, context: WriteContext, request: RangeRequest): WriteResult<null> {
|
|
947
|
+
checkRange(request);
|
|
948
|
+
return result(write(db, context, "unclassify-region", (change) => unclassifyStep(db, change, context.origin, request)));
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
export function setComment(db: DatabaseSync, context: WriteContext, request: CommentRequest): WriteResult<CommentRow> {
|
|
952
|
+
checkComment(request);
|
|
953
|
+
return result(write(db, context, "set-comment", (change) => commentStep(db, change, context.origin, request)));
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
export function removeComment(db: DatabaseSync, context: WriteContext, request: PlacementRequest): WriteResult<null> {
|
|
957
|
+
checkPlacement(request);
|
|
958
|
+
return result(write(db, context, "remove-comment", (change) => uncommentStep(db, change, context.origin, request)));
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
export function addReference(db: DatabaseSync, context: WriteContext, request: ReferenceRequest): WriteResult<ReferenceRow> {
|
|
962
|
+
checkReference(request);
|
|
963
|
+
return result(write(db, context, "add-reference", (change) => referenceStep(db, change, context.origin, request)));
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
export function removeReference(db: DatabaseSync, context: WriteContext, request: ReferenceRequest): WriteResult<null> {
|
|
967
|
+
checkReference(request);
|
|
968
|
+
return result(write(db, context, "remove-reference", (change) => unreferenceStep(db, change, context.origin, request)));
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/** Starts a file of the generated source at an address (D48); one module per address and per file. */
|
|
972
|
+
export function setModule(db: DatabaseSync, context: WriteContext, request: ModuleRequest): WriteResult<ModuleRow> {
|
|
973
|
+
checkModule(request);
|
|
974
|
+
return result(write(db, context, "set-module", (change) => moduleStep(db, change, context.origin, request)));
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
export function removeModule(db: DatabaseSync, context: WriteContext, request: InSpace & { start: number }): WriteResult<null> {
|
|
978
|
+
checkSpace(request);
|
|
979
|
+
checkAddress(request.start, "start");
|
|
980
|
+
return result(write(db, context, "remove-module", (change) => unmoduleStep(db, change, context.origin, request)));
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Writes the operand expression of the instruction at an address (D65), after
|
|
985
|
+
* a check against the image, and a pointer reference to each name it uses.
|
|
986
|
+
*/
|
|
987
|
+
export function setOperand(db: DatabaseSync, context: WriteContext, request: OperandRequest): WriteResult<OperandRow, OperandRow> {
|
|
988
|
+
checkOperand(request);
|
|
989
|
+
return result(write(db, context, "set-operand", (change) => operandStep(db, change, context, request)));
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
export function removeOperand(db: DatabaseSync, context: WriteContext, request: AddressRequest): WriteResult<null> {
|
|
993
|
+
checkSpace(request);
|
|
994
|
+
checkAddress(request.address);
|
|
995
|
+
return result(write(db, context, "remove-operand", (change) => unoperandStep(db, change, context.origin, request)));
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Writes code that replaces [start, end] when the build defines the switch
|
|
1000
|
+
* (D72), after a check that the range is in the image and overlaps no other
|
|
1001
|
+
* patch or macro use.
|
|
1002
|
+
*/
|
|
1003
|
+
export function setPatch(db: DatabaseSync, context: WriteContext, request: PatchRequest): WriteResult<PatchRow, PatchRow> {
|
|
1004
|
+
checkPatch(request);
|
|
1005
|
+
return result(write(db, context, "set-patch", (change) => patchStep(db, change, context, request)));
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
export function removePatch(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-patch", (change) => unpatchStep(db, change, context.origin, request)));
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/** Writes a macro of the generated source (D73). */
|
|
1015
|
+
export function setMacro(db: DatabaseSync, context: WriteContext, request: MacroRequest): WriteResult<MacroRow, MacroRow> {
|
|
1016
|
+
checkMacro(request);
|
|
1017
|
+
return result(write(db, context, "set-macro", (change) => macroStep(db, change, context.origin, request)));
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
export function removeMacro(db: DatabaseSync, context: WriteContext, request: InSpace & { name: string }): WriteResult<null> {
|
|
1021
|
+
checkSpace(request);
|
|
1022
|
+
checkMacroName(request.name);
|
|
1023
|
+
return result(write(db, context, "remove-macro", (change) => unmacroStep(db, change, context.origin, request)));
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** Writes the instructions in [start, end] as one use of a macro (D73), after a check against the image. */
|
|
1027
|
+
export function setMacroUse(db: DatabaseSync, context: WriteContext, request: MacroUseRequest): WriteResult<MacroUseRow, MacroUseRow> {
|
|
1028
|
+
checkMacroUse(request);
|
|
1029
|
+
return result(write(db, context, "set-macro-use", (change) => useStep(db, change, context, request)));
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
export function removeMacroUse(db: DatabaseSync, context: WriteContext, request: InSpace & { start: number }): WriteResult<null> {
|
|
1033
|
+
checkSpace(request);
|
|
1034
|
+
checkAddress(request.start, "start");
|
|
1035
|
+
return result(write(db, context, "remove-macro-use", (change) => unuseStep(db, change, context.origin, request)));
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/** One write of a batch (D42), named as the command that does it alone. */
|
|
1039
|
+
export type BatchOperation =
|
|
1040
|
+
| ({ op: "rename" } & SymbolRequest)
|
|
1041
|
+
| ({ op: "remove-symbol" } & AddressRequest)
|
|
1042
|
+
| ({ op: "classify" } & RegionRequest)
|
|
1043
|
+
| ({ op: "unclassify" } & RangeRequest)
|
|
1044
|
+
| ({ op: "comment" } & CommentRequest)
|
|
1045
|
+
| ({ op: "uncomment" } & PlacementRequest)
|
|
1046
|
+
| ({ op: "reference" } & ReferenceRequest)
|
|
1047
|
+
| ({ op: "unreference" } & ReferenceRequest)
|
|
1048
|
+
| ({ op: "module" } & ModuleRequest)
|
|
1049
|
+
| ({ op: "unmodule" } & InSpace & { start: number })
|
|
1050
|
+
| ({ op: "operand" } & OperandRequest)
|
|
1051
|
+
| ({ op: "unoperand" } & AddressRequest)
|
|
1052
|
+
| ({ op: "patch" } & PatchRequest)
|
|
1053
|
+
| ({ op: "unpatch" } & InSpace & { start: number })
|
|
1054
|
+
| ({ op: "macro" } & MacroRequest)
|
|
1055
|
+
| ({ op: "unmacro" } & InSpace & { name: string })
|
|
1056
|
+
| ({ op: "use" } & MacroUseRequest)
|
|
1057
|
+
| ({ op: "unuse" } & InSpace & { start: number });
|
|
1058
|
+
|
|
1059
|
+
export const MAX_BATCH = 10_000;
|
|
1060
|
+
|
|
1061
|
+
/** The result of each write of a batch, in order. */
|
|
1062
|
+
export type BatchStep = Step<
|
|
1063
|
+
SymbolRow | RegionRow[] | CommentRow | ReferenceRow | ModuleRow | OperandRow | PatchRow | MacroRow | MacroUseRow | null,
|
|
1064
|
+
SymbolRow | RegionRow[] | OperandRow | PatchRow | MacroRow | MacroUseRow
|
|
1065
|
+
>;
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Applies many writes as one revision in one transaction (D42): all of them,
|
|
1069
|
+
* or none when one is refused. Every write is checked before anything opens.
|
|
1070
|
+
*/
|
|
1071
|
+
export function applyBatch(db: DatabaseSync, context: WriteContext, operations: BatchOperation[]): { revision: string | null; steps: BatchStep[] } {
|
|
1072
|
+
if (operations.length === 0 || operations.length > MAX_BATCH) throw new KnowledgeError("invalid-input", `A batch has 1 to ${MAX_BATCH} writes.`);
|
|
1073
|
+
operations.forEach((operation, index) => {
|
|
1074
|
+
try {
|
|
1075
|
+
switch (operation.op) {
|
|
1076
|
+
case "rename":
|
|
1077
|
+
return checkSymbol(operation);
|
|
1078
|
+
case "remove-symbol":
|
|
1079
|
+
checkSpace(operation);
|
|
1080
|
+
return checkAddress(operation.address);
|
|
1081
|
+
case "classify":
|
|
1082
|
+
return checkRegion(operation);
|
|
1083
|
+
case "unclassify":
|
|
1084
|
+
return checkRange(operation);
|
|
1085
|
+
case "comment":
|
|
1086
|
+
return void checkComment(operation);
|
|
1087
|
+
case "uncomment":
|
|
1088
|
+
return checkPlacement(operation);
|
|
1089
|
+
case "reference":
|
|
1090
|
+
case "unreference":
|
|
1091
|
+
return checkReference(operation);
|
|
1092
|
+
case "module":
|
|
1093
|
+
return checkModule(operation);
|
|
1094
|
+
case "unmodule":
|
|
1095
|
+
checkSpace(operation);
|
|
1096
|
+
return checkAddress(operation.start, "start");
|
|
1097
|
+
case "operand":
|
|
1098
|
+
return checkOperand(operation);
|
|
1099
|
+
case "unoperand":
|
|
1100
|
+
checkSpace(operation);
|
|
1101
|
+
return checkAddress(operation.address);
|
|
1102
|
+
case "patch":
|
|
1103
|
+
return checkPatch(operation);
|
|
1104
|
+
case "unpatch":
|
|
1105
|
+
case "unuse":
|
|
1106
|
+
checkSpace(operation);
|
|
1107
|
+
return checkAddress(operation.start, "start");
|
|
1108
|
+
case "macro":
|
|
1109
|
+
return checkMacro(operation);
|
|
1110
|
+
case "unmacro":
|
|
1111
|
+
checkSpace(operation);
|
|
1112
|
+
return checkMacroName(operation.name);
|
|
1113
|
+
case "use":
|
|
1114
|
+
return checkMacroUse(operation);
|
|
1115
|
+
default:
|
|
1116
|
+
throw new KnowledgeError(
|
|
1117
|
+
"invalid-input",
|
|
1118
|
+
`op must be one of rename, remove-symbol, classify, unclassify, comment, uncomment, reference, unreference, module, unmodule, operand, unoperand, patch, unpatch, macro, unmacro, use, unuse.`,
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
if (error instanceof KnowledgeError) throw new KnowledgeError(error.code, `Write ${index + 1}: ${error.message} Nothing was written.`);
|
|
1123
|
+
throw error;
|
|
303
1124
|
}
|
|
304
|
-
change.close('"references"', "from_address = ? AND to_address = ? AND kind = ?", request.from, request.to, request.kind);
|
|
305
|
-
return null;
|
|
306
1125
|
});
|
|
1126
|
+
const written = write(db, context, "apply", (change) =>
|
|
1127
|
+
operations.map((operation, index): BatchStep => {
|
|
1128
|
+
try {
|
|
1129
|
+
switch (operation.op) {
|
|
1130
|
+
case "rename":
|
|
1131
|
+
return renameStep(db, change, context.origin, operation);
|
|
1132
|
+
case "remove-symbol":
|
|
1133
|
+
return removeSymbolStep(db, change, context.origin, operation);
|
|
1134
|
+
case "classify":
|
|
1135
|
+
return classifyStep(db, change, context.origin, operation);
|
|
1136
|
+
case "unclassify":
|
|
1137
|
+
return unclassifyStep(db, change, context.origin, operation);
|
|
1138
|
+
case "comment":
|
|
1139
|
+
return commentStep(db, change, context.origin, operation);
|
|
1140
|
+
case "uncomment":
|
|
1141
|
+
return uncommentStep(db, change, context.origin, operation);
|
|
1142
|
+
case "reference":
|
|
1143
|
+
return referenceStep(db, change, context.origin, operation);
|
|
1144
|
+
case "unreference":
|
|
1145
|
+
return unreferenceStep(db, change, context.origin, operation);
|
|
1146
|
+
case "module":
|
|
1147
|
+
return moduleStep(db, change, context.origin, operation);
|
|
1148
|
+
case "unmodule":
|
|
1149
|
+
return unmoduleStep(db, change, context.origin, operation);
|
|
1150
|
+
case "operand":
|
|
1151
|
+
return operandStep(db, change, context, operation);
|
|
1152
|
+
case "unoperand":
|
|
1153
|
+
return unoperandStep(db, change, context.origin, operation);
|
|
1154
|
+
case "patch":
|
|
1155
|
+
return patchStep(db, change, context, operation);
|
|
1156
|
+
case "unpatch":
|
|
1157
|
+
return unpatchStep(db, change, context.origin, operation);
|
|
1158
|
+
case "macro":
|
|
1159
|
+
return macroStep(db, change, context.origin, operation);
|
|
1160
|
+
case "unmacro":
|
|
1161
|
+
return unmacroStep(db, change, context.origin, operation);
|
|
1162
|
+
case "use":
|
|
1163
|
+
return useStep(db, change, context, operation);
|
|
1164
|
+
case "unuse":
|
|
1165
|
+
return unuseStep(db, change, context.origin, operation);
|
|
1166
|
+
}
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
if (error instanceof KnowledgeError) throw new KnowledgeError(error.code, `Write ${index + 1}: ${error.message} Nothing was written.`);
|
|
1169
|
+
throw error;
|
|
1170
|
+
}
|
|
1171
|
+
}),
|
|
1172
|
+
);
|
|
1173
|
+
return { revision: written.revision, steps: written.current };
|
|
307
1174
|
}
|
|
308
1175
|
|
|
309
1176
|
/** Row content without its revision, as a comparable key. */
|
|
1177
|
+
/** The tables of the program (D83) and the columns that make a row. */
|
|
1178
|
+
const PROGRAM_TABLES = [
|
|
1179
|
+
["memory", "space, address, bytes, origin"],
|
|
1180
|
+
["entries", "space, address, state, stages, origin"],
|
|
1181
|
+
["coverage", "space, kind, start_address, end_address, origin"],
|
|
1182
|
+
] as const;
|
|
1183
|
+
|
|
310
1184
|
const KEYS = {
|
|
311
|
-
symbols: { columns: SYMBOL_COLUMNS, map: toSymbol, key: (row: SymbolRow) => `${row.address}|${row.name}|${row.kind}|${row.origin}` },
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
1185
|
+
symbols: { columns: SYMBOL_COLUMNS, map: toSymbol, key: (row: SymbolRow) => `${row.space}|${row.address}|${row.name}|${row.kind}|${row.origin}` },
|
|
1186
|
+
analyzer_names: { columns: SYMBOL_COLUMNS, map: toSymbol, key: (row: SymbolRow) => `${row.space}|${row.address}|${row.name}|${row.kind}|${row.origin}` },
|
|
1187
|
+
regions: { columns: REGION_COLUMNS, map: toRegion, key: (row: RegionRow) => `${row.space}|${row.start}|${row.end}|${row.type}|${row.element ?? ""}|${row.origin}` },
|
|
1188
|
+
comments: { columns: COMMENT_COLUMNS, map: toComment, key: (row: CommentRow) => `${row.space}|${row.address}|${row.placement}|${row.text}|${row.origin}` },
|
|
1189
|
+
references: { columns: REFERENCE_COLUMNS, map: toReference, key: (row: ReferenceRow) => `${row.space}|${row.from}|${row.to}|${row.kind}|${row.origin}` },
|
|
1190
|
+
modules: { columns: MODULE_COLUMNS, map: toModule, key: (row: ModuleRow) => `${row.space}|${row.start}|${row.file}|${row.title ?? ""}|${row.origin}` },
|
|
1191
|
+
operands: {
|
|
1192
|
+
columns: OPERAND_COLUMNS,
|
|
1193
|
+
map: toOperand,
|
|
1194
|
+
key: (row: OperandRow) => `${row.space}|${row.address}|${row.select}|${row.symbol ?? ""}|${row.offset}|${row.expression ?? ""}|${row.origin}`,
|
|
1195
|
+
},
|
|
1196
|
+
patches: {
|
|
1197
|
+
columns: PATCH_COLUMNS,
|
|
1198
|
+
map: toPatch,
|
|
1199
|
+
key: (row: PatchRow) => `${row.space}|${row.start}|${row.end}|${row.switch}|${row.code}|${row.comment ?? ""}|${row.origin}`,
|
|
1200
|
+
},
|
|
1201
|
+
macros: { columns: MACRO_COLUMNS, map: toMacro, key: (row: MacroRow) => `${row.space}|${row.name}|${listText(row.params)}|${row.body}|${row.comment ?? ""}|${row.origin}` },
|
|
1202
|
+
macro_uses: { columns: MACRO_USE_COLUMNS, map: toMacroUse, key: (row: MacroUseRow) => `${row.space}|${row.start}|${row.end}|${row.macro}|${listText(row.args)}|${row.origin}` },
|
|
1203
|
+
suppressions: {
|
|
1204
|
+
columns: SUPPRESSION_COLUMNS,
|
|
1205
|
+
map: toSuppression,
|
|
1206
|
+
key: (row: SuppressionRow) => `${row.space}|${row.entity}|${row.start}|${row.end}|${row.to ?? ""}|${row.kind ?? ""}|${row.origin}`,
|
|
1207
|
+
},
|
|
315
1208
|
} as const;
|
|
316
1209
|
|
|
317
1210
|
/**
|
|
318
1211
|
* Makes the current knowledge equal to what it was at `revision`, as a new
|
|
319
1212
|
* revision. History is never erased; restored rows keep their origin.
|
|
320
1213
|
*/
|
|
321
|
-
export function revert(db: DatabaseSync, context: WriteContext, request: { revision:
|
|
1214
|
+
export function revert(db: DatabaseSync, context: WriteContext, request: { revision: string }): WriteResult<{ restoredRevision: string }> {
|
|
322
1215
|
return write(db, context, "revert", (change) => {
|
|
323
|
-
const
|
|
324
|
-
if (!Number.isInteger(request.revision) || request.revision < 0 || request.revision > current) {
|
|
325
|
-
throw new KnowledgeError("not-found", `There is no revision ${request.revision}; knowledge is at revision ${current}.`);
|
|
326
|
-
}
|
|
1216
|
+
const restored = resolveRevision(db, request.revision);
|
|
327
1217
|
const at = "valid_from_revision <= ? AND (valid_to_revision IS NULL OR valid_to_revision > ?)";
|
|
328
1218
|
for (const [table, spec] of Object.entries(KEYS)) {
|
|
329
1219
|
const name = table === "references" ? '"references"' : table;
|
|
330
|
-
const then = db.prepare(`SELECT ${spec.columns}, id FROM ${name} WHERE ${at}`).all(
|
|
1220
|
+
const then = db.prepare(`SELECT ${spec.columns}, id FROM ${name} WHERE ${at}`).all(restored, restored) as Array<Record<string, unknown>>;
|
|
331
1221
|
const now = db.prepare(`SELECT ${spec.columns}, id FROM ${name} WHERE valid_to_revision IS NULL`).all() as Array<Record<string, unknown>>;
|
|
332
1222
|
const key = (row: Record<string, unknown>) => (spec.key as (row: unknown) => string)(spec.map(row as never));
|
|
333
1223
|
const thenKeys = new Set(then.map(key));
|
|
334
1224
|
const nowKeys = new Set(now.map(key));
|
|
335
1225
|
// Close first, so restored rows never collide with the current-row rules.
|
|
336
|
-
for (const row of now) if (!thenKeys.has(key(row))) change.close(name, "id = ?", row.id as
|
|
1226
|
+
for (const row of now) if (!thenKeys.has(key(row))) change.close(name, "id = ?", row.id as string);
|
|
337
1227
|
for (const row of then) {
|
|
338
1228
|
if (nowKeys.has(key(row))) continue;
|
|
1229
|
+
if (table === "suppressions") {
|
|
1230
|
+
change.insertSuppression(spec.map(row as never) as SuppressionRow);
|
|
1231
|
+
continue;
|
|
1232
|
+
}
|
|
1233
|
+
if (table === "modules") {
|
|
1234
|
+
change.insertModule(spec.map(row as never) as ModuleRow);
|
|
1235
|
+
continue;
|
|
1236
|
+
}
|
|
1237
|
+
if (table === "operands") {
|
|
1238
|
+
change.insertOperand(spec.map(row as never) as OperandRow);
|
|
1239
|
+
continue;
|
|
1240
|
+
}
|
|
1241
|
+
if (table === "patches") {
|
|
1242
|
+
change.insertPatch(spec.map(row as never) as PatchRow);
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
1245
|
+
if (table === "macros") {
|
|
1246
|
+
change.insertMacro(spec.map(row as never) as MacroRow);
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
if (table === "macro_uses") {
|
|
1250
|
+
change.insertMacroUse(spec.map(row as never) as MacroUseRow);
|
|
1251
|
+
continue;
|
|
1252
|
+
}
|
|
339
1253
|
const mapped = spec.map(row as never) as SymbolRow & RegionRow & CommentRow & ReferenceRow;
|
|
340
1254
|
if (table === "symbols") change.insertSymbol(mapped);
|
|
1255
|
+
else if (table === "analyzer_names") change.insertAnalyzerName(mapped);
|
|
341
1256
|
else if (table === "regions") change.insertRegion(mapped);
|
|
342
1257
|
else if (table === "comments") change.insertComment(mapped);
|
|
343
1258
|
else change.insertReference(mapped);
|
|
344
1259
|
}
|
|
345
1260
|
}
|
|
346
|
-
|
|
1261
|
+
// The program (D83): a row is the same when all its values are the same.
|
|
1262
|
+
for (const [table, columns] of PROGRAM_TABLES) {
|
|
1263
|
+
const then = db.prepare(`SELECT ${columns}, id FROM ${table} WHERE ${at}`).all(restored, restored) as Array<Record<string, unknown>>;
|
|
1264
|
+
const now = db.prepare(`SELECT ${columns}, id FROM ${table} WHERE valid_to_revision IS NULL`).all() as Array<Record<string, unknown>>;
|
|
1265
|
+
const key = (row: Record<string, unknown>) => JSON.stringify(columns.split(", ").map((column) => row[column]));
|
|
1266
|
+
const thenKeys = new Set(then.map(key));
|
|
1267
|
+
const nowKeys = new Set(now.map(key));
|
|
1268
|
+
for (const row of now) if (!thenKeys.has(key(row))) change.close(table, "id = ?", row.id as string);
|
|
1269
|
+
for (const row of then) {
|
|
1270
|
+
if (nowKeys.has(key(row))) continue;
|
|
1271
|
+
const values = row as never;
|
|
1272
|
+
if (table === "memory") change.insertMemory(values);
|
|
1273
|
+
else if (table === "entries") change.insertEntry(values);
|
|
1274
|
+
else change.insertCoverage({ ...(values as { space: Space; kind: string; origin: Origin }), start: row.start_address as number, end: row.end_address as number });
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
return { restoredRevision: restored };
|
|
347
1278
|
});
|
|
348
1279
|
}
|
|
1280
|
+
|
|
1281
|
+
/** What a merge decided for one conflict: the newest row stays and the older rows close. */
|
|
1282
|
+
export interface MergeChoice {
|
|
1283
|
+
rule: Conflict["rule"];
|
|
1284
|
+
space: Space;
|
|
1285
|
+
kept: ConflictRow;
|
|
1286
|
+
closed: ConflictRow[];
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/** The most choices one merge makes; more means the knowledge text is broken. */
|
|
1290
|
+
const MAX_CHOICES = 100_000;
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* Decides every conflict of a git merge in one merge revision (D69). Of the
|
|
1294
|
+
* rows of one conflict, the row of the newest revision stays and the others
|
|
1295
|
+
* close; an older region keeps its parts outside the newer regions. Where an
|
|
1296
|
+
* operand expression closes, the pointer references of its address follow the
|
|
1297
|
+
* expression that stays. `platform` gives the C64 platform names.
|
|
1298
|
+
*/
|
|
1299
|
+
export function mergeConflicts(db: DatabaseSync, context: WriteContext, platform: ReadonlyMap<string, number>): WriteResult<MergeChoice[]> {
|
|
1300
|
+
return write(db, context, "merge", (change) => {
|
|
1301
|
+
const choices: MergeChoice[] = [];
|
|
1302
|
+
for (let found = conflicts(db); found.length > 0; found = conflicts(db)) {
|
|
1303
|
+
if (choices.length >= MAX_CHOICES) throw new KnowledgeError("invalid-database", `The merge made ${MAX_CHOICES} choices and still finds conflicts. Nothing was written.`);
|
|
1304
|
+
const conflict = found[0]!;
|
|
1305
|
+
const newest = conflict.rows.at(-1)!;
|
|
1306
|
+
const older = conflict.rows.slice(0, -1);
|
|
1307
|
+
const table = sqlName(conflictTable(conflict.rule));
|
|
1308
|
+
for (const entry of older) change.close(table, "id = ?", entry.id);
|
|
1309
|
+
if (conflict.rule === "region" || conflict.rule === "suppression-region") keepOutside(change, conflict, conflict.rule === "region");
|
|
1310
|
+
if (conflict.rule === "operand") followOperand(db, change, newest.row as OperandRow, platform);
|
|
1311
|
+
choices.push({ rule: conflict.rule, space: conflict.space, kept: newest, closed: older });
|
|
1312
|
+
}
|
|
1313
|
+
return choices;
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/** Puts back the parts of the older ranges of a conflict that no newer range covers. */
|
|
1318
|
+
function keepOutside(change: Change, conflict: Conflict, regions: boolean): void {
|
|
1319
|
+
const newer: AddressRange[] = [];
|
|
1320
|
+
for (const entry of [...conflict.rows].reverse()) {
|
|
1321
|
+
const row = entry.row as RegionRow & SuppressionRow;
|
|
1322
|
+
if (newer.length > 0) {
|
|
1323
|
+
for (const piece of subtract({ start: row.start, end: row.end }, newer)) {
|
|
1324
|
+
if (regions) change.insertRegion({ space: row.space, ...piece, type: row.type, ...elementOf(piece, row.element), origin: row.origin });
|
|
1325
|
+
else change.insertSuppression({ space: row.space, entity: "region", ...piece, origin: row.origin });
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
newer.push({ start: row.start, end: row.end });
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/** Makes the pointer references of an address the ones of the operand expression that stays there. */
|
|
1333
|
+
function followOperand(db: DatabaseSync, change: Change, kept: OperandRow, platform: ReadonlyMap<string, number>): void {
|
|
1334
|
+
const resolve = (name: string) => symbolNamed(db, name, kept.space)?.address ?? (kept.space === DEFAULT_SPACE ? platform.get(name) : undefined);
|
|
1335
|
+
const targets = new Set(identifiers(operandText(kept)).flatMap((name) => resolve(name) ?? []));
|
|
1336
|
+
const pointers = referencesFrom(db, kept.address, kept.space).filter((reference) => reference.kind === POINTER_KIND);
|
|
1337
|
+
for (const reference of pointers) {
|
|
1338
|
+
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);
|
|
1339
|
+
}
|
|
1340
|
+
for (const to of targets) {
|
|
1341
|
+
if (!pointers.some((reference) => reference.to === to)) change.insertReference({ space: kept.space, from: kept.address, to, kind: POINTER_KIND, origin: kept.origin });
|
|
1342
|
+
}
|
|
1343
|
+
}
|