@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
|
@@ -1,14 +1,23 @@
|
|
|
1
|
-
// The one place that opens .
|
|
1
|
+
// The one place that opens the knowledge. The knowledge is text in
|
|
2
|
+
// .c64-re-tools/knowledge/ (D69); .c64-re-tools/local/knowledge.db is an index
|
|
3
|
+
// that is made again from the text when the text changed.
|
|
2
4
|
|
|
3
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
6
|
import { DatabaseSync } from "node:sqlite";
|
|
5
7
|
import { join } from "node:path";
|
|
6
8
|
|
|
7
|
-
import { projectRoot } from "../project.ts";
|
|
8
|
-
import {
|
|
9
|
+
import { ensureLocal, LOCAL_DIRECTORY, PROJECT_DIRECTORY, projectRoot } from "../project.ts";
|
|
10
|
+
import { ensureSchema } from "./schema.ts";
|
|
11
|
+
import { compareText, loadText, readText, REVISIONS_FILE, revisionsText, sha256, sqlName, TABLES, tableText, writeAtomic } from "./text.ts";
|
|
9
12
|
|
|
10
|
-
export const KNOWLEDGE_DIRECTORY =
|
|
11
|
-
|
|
13
|
+
export const KNOWLEDGE_DIRECTORY = PROJECT_DIRECTORY;
|
|
14
|
+
/** The folder of the committed knowledge text. */
|
|
15
|
+
export const TEXT_DIRECTORY = "knowledge";
|
|
16
|
+
/** The index file in .c64-re-tools/local/. */
|
|
17
|
+
export const INDEX_FILE = "knowledge.db";
|
|
18
|
+
/** Git keeps the lines of both sides when two computers changed the same knowledge file. */
|
|
19
|
+
// eol=lf: with core.autocrlf (the Windows default) git would check the files out with CRLF, so two computers would hold other bytes.
|
|
20
|
+
export const GIT_ATTRIBUTES = "# The knowledge of c64-re-tools: git keeps the lines of both sides of a merge, with LF line ends on every system.\n*.jsonl merge=union text eol=lf\n.gitattributes text eol=lf\n";
|
|
12
21
|
|
|
13
22
|
export const KNOWLEDGE_ERROR_CODES = [
|
|
14
23
|
"invalid-input",
|
|
@@ -31,12 +40,27 @@ export class KnowledgeError extends Error {
|
|
|
31
40
|
}
|
|
32
41
|
}
|
|
33
42
|
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
/** The folder of the knowledge text: .c64-re-tools/knowledge/. */
|
|
44
|
+
export function textPath(root: string = projectRoot()): string {
|
|
45
|
+
return join(root, KNOWLEDGE_DIRECTORY, TEXT_DIRECTORY);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The index: .c64-re-tools/local/knowledge.db. */
|
|
49
|
+
export function indexPath(root: string = projectRoot()): string {
|
|
50
|
+
return join(root, KNOWLEDGE_DIRECTORY, LOCAL_DIRECTORY, INDEX_FILE);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Refuses a project that has only the knowledge.db of an older c64-re-tools. */
|
|
54
|
+
function refuseOldLayout(root: string): void {
|
|
55
|
+
if (!existsSync(textPath(root)) && existsSync(join(root, KNOWLEDGE_DIRECTORY, INDEX_FILE))) {
|
|
56
|
+
throw new KnowledgeError(
|
|
57
|
+
"unsupported-schema",
|
|
58
|
+
`${KNOWLEDGE_DIRECTORY}/${INDEX_FILE} is the knowledge of an older c64-re-tools. This c64-re-tools keeps the knowledge as text in ${KNOWLEDGE_DIRECTORY}/${TEXT_DIRECTORY}/. Convert it one time with distribution/convert-knowledge.ts of the c64-re-tools source repository.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
36
61
|
}
|
|
37
62
|
|
|
38
63
|
function configure(db: DatabaseSync): void {
|
|
39
|
-
// A committed write must be complete in knowledge.db itself: rollback journal, never WAL.
|
|
40
64
|
db.exec("PRAGMA journal_mode = DELETE; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;");
|
|
41
65
|
}
|
|
42
66
|
|
|
@@ -47,28 +71,23 @@ function open(path: string, readOnly: boolean): DatabaseSync {
|
|
|
47
71
|
configure(db);
|
|
48
72
|
db.prepare("SELECT count(*) FROM sqlite_master").get();
|
|
49
73
|
} catch (error) {
|
|
50
|
-
throw new KnowledgeError("invalid-database", `${KNOWLEDGE_DIRECTORY}/${
|
|
74
|
+
throw new KnowledgeError("invalid-database", `${KNOWLEDGE_DIRECTORY}/${LOCAL_DIRECTORY}/${INDEX_FILE} is not a readable SQLite database: ${(error as Error).message} Delete it: it is made again from the knowledge text.`);
|
|
51
75
|
}
|
|
52
76
|
return db;
|
|
53
77
|
}
|
|
54
78
|
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
* creates nothing. Refuses a schema version other than this build's.
|
|
60
|
-
*/
|
|
61
|
-
export function openForRead(root: string = projectRoot()): DatabaseSync | undefined {
|
|
62
|
-
const path = knowledgePath(root);
|
|
63
|
-
if (!existsSync(path)) return undefined;
|
|
64
|
-
const db = open(path, true);
|
|
79
|
+
/** Opens the index for a write and gives it the schema of this build. A file that is no database is made again. */
|
|
80
|
+
function openIndex(root: string): DatabaseSync {
|
|
81
|
+
ensureLocal(root);
|
|
82
|
+
let db: DatabaseSync;
|
|
65
83
|
try {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
84
|
+
db = open(indexPath(root), false);
|
|
85
|
+
} catch {
|
|
86
|
+
writeFileSync(indexPath(root), "");
|
|
87
|
+
db = open(indexPath(root), false);
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
ensureSchema(db);
|
|
72
91
|
} catch (error) {
|
|
73
92
|
db.close();
|
|
74
93
|
throw error;
|
|
@@ -76,16 +95,75 @@ export function openForRead(root: string = projectRoot()): DatabaseSync | undefi
|
|
|
76
95
|
return db;
|
|
77
96
|
}
|
|
78
97
|
|
|
98
|
+
function storedHashes(db: DatabaseSync): Record<string, string> {
|
|
99
|
+
const row = db.prepare("SELECT value FROM meta WHERE key = 'text_hashes'").get() as { value: string } | undefined;
|
|
100
|
+
return row === undefined ? {} : (JSON.parse(row.value) as Record<string, string>);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function storeHashes(db: DatabaseSync, hashes: Record<string, string>): void {
|
|
104
|
+
const sorted = Object.fromEntries(Object.entries(hashes).sort(([a], [b]) => compareText(a, b)));
|
|
105
|
+
db.prepare("INSERT INTO meta (key, value) VALUES ('text_hashes', ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value").run(JSON.stringify(sorted));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The SHA-256 of each knowledge file, by file name, in name order. */
|
|
109
|
+
function textHashes(files: ReadonlyMap<string, { sha256: string }>): Record<string, string> {
|
|
110
|
+
return Object.fromEntries([...files].map(([name, file]): [string, string] => [name, file.sha256]).sort(([a], [b]) => compareText(a, b)));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const sameHashes = (a: Record<string, string>, b: Record<string, string>) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort());
|
|
114
|
+
|
|
79
115
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
116
|
+
* Makes the index equal to the text when a knowledge file has another
|
|
117
|
+
* SHA-256 than the index holds, or a file came or went. Runs inside a write
|
|
118
|
+
* transaction, so two processes never make the index at the same time.
|
|
119
|
+
*/
|
|
120
|
+
function freshen(db: DatabaseSync, root: string): void {
|
|
121
|
+
const files = readText(textPath(root));
|
|
122
|
+
const hashes = textHashes(files);
|
|
123
|
+
if (sameHashes(hashes, storedHashes(db))) return;
|
|
124
|
+
db.exec("DELETE FROM evidence");
|
|
125
|
+
for (const spec of TABLES) db.exec(`DELETE FROM ${sqlName(spec.table)}`);
|
|
126
|
+
db.exec("DELETE FROM revisions");
|
|
127
|
+
loadText(db, files);
|
|
128
|
+
storeHashes(db, hashes);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The project of each index opened for a write: its writes go to the text too. */
|
|
132
|
+
const projects = new WeakMap<DatabaseSync, string>();
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Opens the project's knowledge read-only, made fresh from the text first. A
|
|
136
|
+
* read never changes the knowledge. Returns undefined when the project has no
|
|
137
|
+
* knowledge text: that reads as empty knowledge, and a read creates nothing.
|
|
138
|
+
*/
|
|
139
|
+
export function openForRead(root: string = projectRoot()): DatabaseSync | undefined {
|
|
140
|
+
refuseOldLayout(root);
|
|
141
|
+
if (!existsSync(textPath(root))) return undefined;
|
|
142
|
+
const index = openIndex(root);
|
|
143
|
+
try {
|
|
144
|
+
// The lock is taken only when the text changed: a read does not wait for a long write that keeps the index fresh.
|
|
145
|
+
if (!sameHashes(textHashes(readText(textPath(root))), storedHashes(index))) transaction(index, () => freshen(index, root));
|
|
146
|
+
} finally {
|
|
147
|
+
index.close();
|
|
148
|
+
}
|
|
149
|
+
return open(indexPath(root), true);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Opens the project's knowledge for a write: it creates
|
|
154
|
+
* .c64-re-tools/knowledge/ with its .gitattributes when needed, and the index.
|
|
155
|
+
* Every committed transaction on the database writes the changed knowledge
|
|
156
|
+
* files.
|
|
83
157
|
*/
|
|
84
158
|
export function openForWrite(root: string = projectRoot()): DatabaseSync {
|
|
85
|
-
|
|
86
|
-
|
|
159
|
+
refuseOldLayout(root);
|
|
160
|
+
mkdirSync(textPath(root), { recursive: true });
|
|
161
|
+
const attributes = join(textPath(root), ".gitattributes");
|
|
162
|
+
if (!existsSync(attributes)) writeFileSync(attributes, GIT_ATTRIBUTES);
|
|
163
|
+
const db = openIndex(root);
|
|
164
|
+
projects.set(db, root);
|
|
87
165
|
try {
|
|
88
|
-
|
|
166
|
+
transaction(db, () => undefined);
|
|
89
167
|
} catch (error) {
|
|
90
168
|
db.close();
|
|
91
169
|
throw error;
|
|
@@ -94,13 +172,14 @@ export function openForWrite(root: string = projectRoot()): DatabaseSync {
|
|
|
94
172
|
}
|
|
95
173
|
|
|
96
174
|
/**
|
|
97
|
-
* Runs one write on the project's knowledge
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
175
|
+
* Runs one write on the project's knowledge and closes it. When the project
|
|
176
|
+
* has no knowledge yet, `work` first runs on an empty database in memory: a
|
|
177
|
+
* write that is refused there creates no .c64-re-tools/. `work` must change
|
|
178
|
+
* nothing outside the database it gets.
|
|
101
179
|
*/
|
|
102
180
|
export function withWrite<T>(work: (db: DatabaseSync) => T, root: string = projectRoot()): T {
|
|
103
|
-
|
|
181
|
+
refuseOldLayout(root);
|
|
182
|
+
if (!existsSync(textPath(root))) {
|
|
104
183
|
const trial = new DatabaseSync(":memory:");
|
|
105
184
|
try {
|
|
106
185
|
configure(trial);
|
|
@@ -118,11 +197,44 @@ export function withWrite<T>(work: (db: DatabaseSync) => T, root: string = proje
|
|
|
118
197
|
}
|
|
119
198
|
}
|
|
120
199
|
|
|
121
|
-
/**
|
|
200
|
+
/** The newest revision id, or "0" when there is none. */
|
|
201
|
+
function newest(db: DatabaseSync): string {
|
|
202
|
+
return (db.prepare("SELECT max(id) AS id FROM revisions").get() as { id: string | null }).id ?? "0";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Writes the knowledge files that the revisions after `before` changed, then
|
|
207
|
+
* keeps their SHA-256 in the index. The text is written before the index
|
|
208
|
+
* commits: when the commit fails, the next open makes the index from the text.
|
|
209
|
+
*/
|
|
210
|
+
function writeText(db: DatabaseSync, root: string, before: string): void {
|
|
211
|
+
const directory = textPath(root);
|
|
212
|
+
const hashes = storedHashes(db);
|
|
213
|
+
const write = (file: string, text: string) => {
|
|
214
|
+
writeAtomic(join(directory, file), text);
|
|
215
|
+
hashes[file] = sha256(text);
|
|
216
|
+
};
|
|
217
|
+
write(REVISIONS_FILE, revisionsText(db));
|
|
218
|
+
for (const spec of TABLES) {
|
|
219
|
+
const changed = db.prepare(`SELECT 1 FROM ${sqlName(spec.table)} WHERE valid_from_revision > ? OR valid_to_revision > ? LIMIT 1`).get(before, before);
|
|
220
|
+
if (changed !== undefined) write(spec.file, tableText(db, spec));
|
|
221
|
+
}
|
|
222
|
+
storeHashes(db, hashes);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Runs `work` in one immediate transaction: all of it commits, or none of it
|
|
227
|
+
* does. On the knowledge of a project, the index is made fresh from the text
|
|
228
|
+
* first, and a work that adds a revision writes the changed knowledge files.
|
|
229
|
+
*/
|
|
122
230
|
export function transaction<T>(db: DatabaseSync, work: () => T): T {
|
|
231
|
+
const root = projects.get(db);
|
|
123
232
|
db.exec("BEGIN IMMEDIATE");
|
|
124
233
|
try {
|
|
234
|
+
if (root !== undefined) freshen(db, root);
|
|
235
|
+
const before = root === undefined ? "0" : newest(db);
|
|
125
236
|
const result = work();
|
|
237
|
+
if (root !== undefined && newest(db) !== before) writeText(db, root, before);
|
|
126
238
|
db.exec("COMMIT");
|
|
127
239
|
return result;
|
|
128
240
|
} catch (error) {
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Operand expressions (D65): the part of ACME's expression syntax that names
|
|
2
|
+
// an operand by symbols and numbers. Knowledge checks an expression against
|
|
3
|
+
// the image with it, and c64-source writes it with the names of its layout.
|
|
4
|
+
//
|
|
5
|
+
// Priorities are ACME's, from high to low: unary - and !, then * / %, + -,
|
|
6
|
+
// << >> >>>, unary < and >, &, XOR, |. A unary < or > takes everything of a
|
|
7
|
+
// higher priority after it, so <a+1 is the low byte of a+1.
|
|
8
|
+
|
|
9
|
+
/** An expression that does not parse, or that names a symbol with no value. */
|
|
10
|
+
export class ExpressionError extends Error {
|
|
11
|
+
override name = "ExpressionError";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The selectors of an operand expression: a byte of a symbol, its value, or free expression text. */
|
|
15
|
+
export const OPERAND_SELECTS = ["lo", "hi", "value", "expr"] as const;
|
|
16
|
+
export type OperandSelect = (typeof OPERAND_SELECTS)[number];
|
|
17
|
+
|
|
18
|
+
/** An operand expression as knowledge stores it: a symbol with an offset, or text for expr. */
|
|
19
|
+
export interface OperandExpression {
|
|
20
|
+
select: OperandSelect;
|
|
21
|
+
symbol?: string;
|
|
22
|
+
offset?: number;
|
|
23
|
+
expression?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type Node =
|
|
27
|
+
| { kind: "number"; value: number }
|
|
28
|
+
| { kind: "name"; name: string }
|
|
29
|
+
| { kind: "unary"; operator: string; operand: Node }
|
|
30
|
+
| { kind: "binary"; operator: string; left: Node; right: Node };
|
|
31
|
+
|
|
32
|
+
/** Binary operators by priority (higher binds tighter); unary < and > sit at LOW_HIGH. */
|
|
33
|
+
const BINARY: Record<string, number> = { "*": 10, "/": 10, "%": 10, "+": 9, "-": 9, "<<": 8, ">>": 8, ">>>": 8, "&": 4, XOR: 3, "|": 2 };
|
|
34
|
+
const LOW_HIGH = 7;
|
|
35
|
+
const NAME = /^[A-Za-z_.][A-Za-z0-9_]*/;
|
|
36
|
+
const MAX_TEXT = 200;
|
|
37
|
+
|
|
38
|
+
interface Token {
|
|
39
|
+
text: string;
|
|
40
|
+
at: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The expression as tokens with their offsets; context decides % (binary literal or remainder). */
|
|
44
|
+
function tokenize(text: string): Token[] {
|
|
45
|
+
const tokens: Token[] = [];
|
|
46
|
+
let at = 0;
|
|
47
|
+
let operandNext = true;
|
|
48
|
+
while (at < text.length) {
|
|
49
|
+
const rest = text.slice(at);
|
|
50
|
+
const space = /^\s+/.exec(rest);
|
|
51
|
+
if (space) {
|
|
52
|
+
at += space[0].length;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const number: RegExpExecArray | null = operandNext ? /^(\$[0-9A-Fa-f]+|%[01]+|[0-9]+)/.exec(rest) : null;
|
|
56
|
+
const match: RegExpExecArray | null = number ?? /^(>>>|<<|>>|XOR\b)/.exec(rest) ?? NAME.exec(rest) ?? /^[-+*/%&|!<>()]/.exec(rest);
|
|
57
|
+
if (!match) throw new ExpressionError(`"${text}" has an unknown character at position ${at + 1}: "${rest[0]}".`);
|
|
58
|
+
const token: string = match[0];
|
|
59
|
+
tokens.push({ text: token, at });
|
|
60
|
+
at += token.length;
|
|
61
|
+
// After an operand or a ), an operator comes next; after anything else, an operand.
|
|
62
|
+
operandNext = !(number !== null || (NAME.test(token) && token !== "XOR") || token === ")");
|
|
63
|
+
}
|
|
64
|
+
return tokens;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parse(text: string): Node {
|
|
68
|
+
if (text.trim().length === 0) throw new ExpressionError("An expression is empty.");
|
|
69
|
+
if (text.length > MAX_TEXT) throw new ExpressionError(`An expression has at most ${MAX_TEXT} characters.`);
|
|
70
|
+
const tokens = tokenize(text);
|
|
71
|
+
let index = 0;
|
|
72
|
+
const peek = () => tokens[index]?.text;
|
|
73
|
+
const fail = (what: string): never => {
|
|
74
|
+
throw new ExpressionError(`"${text}" ${what}.`);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const operand = (): Node => {
|
|
78
|
+
const token = tokens[index++]?.text ?? fail("ends where an operand is missing");
|
|
79
|
+
if (token === "(") {
|
|
80
|
+
const inner = binary(0);
|
|
81
|
+
if (tokens[index++]?.text !== ")") fail("has a ( with no )");
|
|
82
|
+
return inner;
|
|
83
|
+
}
|
|
84
|
+
if (token === "-" || token === "!") return { kind: "unary", operator: token, operand: operand() };
|
|
85
|
+
if (token === "<" || token === ">") return { kind: "unary", operator: token, operand: binary(LOW_HIGH + 1) };
|
|
86
|
+
if (token.startsWith("$")) return { kind: "number", value: Number.parseInt(token.slice(1), 16) };
|
|
87
|
+
if (token.startsWith("%")) return { kind: "number", value: Number.parseInt(token.slice(1), 2) };
|
|
88
|
+
if (/^[0-9]/.test(token)) return { kind: "number", value: Number.parseInt(token, 10) };
|
|
89
|
+
if (NAME.test(token) && token !== "XOR") return { kind: "name", name: token };
|
|
90
|
+
return fail(`has "${token}" where an operand must be`);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const binary = (minimum: number): Node => {
|
|
94
|
+
let left = operand();
|
|
95
|
+
for (;;) {
|
|
96
|
+
const operator = peek();
|
|
97
|
+
const priority = operator === undefined ? undefined : BINARY[operator];
|
|
98
|
+
if (priority === undefined || priority < minimum) return left;
|
|
99
|
+
index++;
|
|
100
|
+
left = { kind: "binary", operator: operator!, left, right: binary(priority + 1) };
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const tree = binary(0);
|
|
105
|
+
if (index < tokens.length) fail(`has "${tokens[index]!.text}" after the end of the expression`);
|
|
106
|
+
return tree;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function names(node: Node, into: string[]): string[] {
|
|
110
|
+
if (node.kind === "name" && !into.includes(node.name)) into.push(node.name);
|
|
111
|
+
if (node.kind === "unary") names(node.operand, into);
|
|
112
|
+
if (node.kind === "binary") {
|
|
113
|
+
names(node.left, into);
|
|
114
|
+
names(node.right, into);
|
|
115
|
+
}
|
|
116
|
+
return into;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function value(node: Node, resolve: (name: string) => number | undefined): number {
|
|
120
|
+
switch (node.kind) {
|
|
121
|
+
case "number":
|
|
122
|
+
return node.value;
|
|
123
|
+
case "name": {
|
|
124
|
+
const found = resolve(node.name);
|
|
125
|
+
if (found === undefined) throw new ExpressionError(`"${node.name}" is no symbol of the knowledge and no platform name.`);
|
|
126
|
+
return found;
|
|
127
|
+
}
|
|
128
|
+
case "unary": {
|
|
129
|
+
const inner = value(node.operand, resolve);
|
|
130
|
+
if (node.operator === "-") return -inner;
|
|
131
|
+
if (node.operator === "!") return ~inner;
|
|
132
|
+
return node.operator === "<" ? inner & 0xff : (inner >> 8) & 0xff;
|
|
133
|
+
}
|
|
134
|
+
case "binary": {
|
|
135
|
+
const left = value(node.left, resolve);
|
|
136
|
+
const right = value(node.right, resolve);
|
|
137
|
+
switch (node.operator) {
|
|
138
|
+
case "*":
|
|
139
|
+
return left * right;
|
|
140
|
+
case "/":
|
|
141
|
+
case "%":
|
|
142
|
+
if (right === 0) throw new ExpressionError("An expression divides by zero.");
|
|
143
|
+
return node.operator === "/" ? Math.trunc(left / right) : left % right;
|
|
144
|
+
case "+":
|
|
145
|
+
return left + right;
|
|
146
|
+
case "-":
|
|
147
|
+
return left - right;
|
|
148
|
+
case "<<":
|
|
149
|
+
return left << right;
|
|
150
|
+
case ">>":
|
|
151
|
+
return left >> right;
|
|
152
|
+
case ">>>":
|
|
153
|
+
return left >>> right;
|
|
154
|
+
case "&":
|
|
155
|
+
return left & right;
|
|
156
|
+
case "XOR":
|
|
157
|
+
return left ^ right;
|
|
158
|
+
default:
|
|
159
|
+
return left | right;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Throws ExpressionError when the text is no expression of this syntax. */
|
|
166
|
+
export function checkExpression(text: string): void {
|
|
167
|
+
parse(text);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The value of the expression; `resolve` gives the value of a name, or undefined. */
|
|
171
|
+
export function evaluate(text: string, resolve: (name: string) => number | undefined): number {
|
|
172
|
+
return value(parse(text), resolve);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The names the expression uses, each once, in their order. */
|
|
176
|
+
export function identifiers(text: string): string[] {
|
|
177
|
+
return names(parse(text), []);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The text with each name replaced by `replace(name)` (a name it returns
|
|
182
|
+
* unchanged stays). Spaces and other tokens stay as written.
|
|
183
|
+
*/
|
|
184
|
+
export function replaceIdentifiers(text: string, replace: (name: string) => string): string {
|
|
185
|
+
parse(text);
|
|
186
|
+
let out = "";
|
|
187
|
+
let from = 0;
|
|
188
|
+
for (const token of tokenize(text)) {
|
|
189
|
+
if (!NAME.test(token.text) || token.text === "XOR") continue;
|
|
190
|
+
out += text.slice(from, token.at) + replace(token.text);
|
|
191
|
+
from = token.at + token.text.length;
|
|
192
|
+
}
|
|
193
|
+
return out + text.slice(from);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function offsetText(offset: number): string {
|
|
197
|
+
const size = Math.abs(offset);
|
|
198
|
+
return `${offset < 0 ? "-" : "+"}${size < 10 ? String(size) : `$${size.toString(16)}`}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** ACME text of an operand expression, for example <irq_handler, >(sprite_bank+$1000) or bar_colours-1. */
|
|
202
|
+
export function operandText(operand: OperandExpression): string {
|
|
203
|
+
if (operand.select === "expr") return operand.expression!;
|
|
204
|
+
const offset = operand.offset ?? 0;
|
|
205
|
+
const sum = offset === 0 ? operand.symbol! : `${operand.symbol!}${offsetText(offset)}`;
|
|
206
|
+
if (operand.select === "value") return sum;
|
|
207
|
+
const selector = operand.select === "lo" ? "<" : ">";
|
|
208
|
+
return offset === 0 ? `${selector}${sum}` : `${selector}(${sum})`;
|
|
209
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The routine graph of the knowledge (D78, D80): which routine calls, jumps
|
|
2
|
+
// to or dispatches through a table to which other routine, and the same
|
|
3
|
+
// graph as a mermaid flowchart. The callers give the rows, so the graph needs
|
|
4
|
+
// no database and the documentation and the knowledge script draw one graph.
|
|
5
|
+
|
|
6
|
+
import { formatC64Address } from "../c64.ts";
|
|
7
|
+
import type { ReferenceKind, SymbolKind } from "./schema.ts";
|
|
8
|
+
|
|
9
|
+
/** How one routine reaches another: a call, a jump, or an entry of a table of addresses. */
|
|
10
|
+
export type EdgeKind = "call" | "jump" | "table";
|
|
11
|
+
|
|
12
|
+
export interface GraphReference {
|
|
13
|
+
from: number;
|
|
14
|
+
to: number;
|
|
15
|
+
kind: ReferenceKind | "table";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RoutineHead {
|
|
19
|
+
address: number;
|
|
20
|
+
name: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GraphEdge {
|
|
24
|
+
from: number;
|
|
25
|
+
to: number;
|
|
26
|
+
/** The strongest kind of the references that make the edge: a call, then a table entry, then a jump. */
|
|
27
|
+
kind: EdgeKind;
|
|
28
|
+
/** The references that make the edge. */
|
|
29
|
+
count: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const STRENGTH: Record<EdgeKind, number> = { call: 3, table: 2, jump: 1 };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The routine heads: every routine of the knowledge, and every call or table
|
|
36
|
+
* target, which another routine enters at its start. A head without a name of
|
|
37
|
+
* the knowledge gets the name that `nameOf` gives.
|
|
38
|
+
*/
|
|
39
|
+
export function routineHeads(symbols: ReadonlyArray<{ address: number; name: string; kind: SymbolKind }>, references: readonly GraphReference[], nameOf: (address: number) => string): RoutineHead[] {
|
|
40
|
+
const names = new Map(symbols.map((symbol) => [symbol.address, symbol.name]));
|
|
41
|
+
const heads = new Set(symbols.filter((symbol) => symbol.kind === "routine").map((symbol) => symbol.address));
|
|
42
|
+
for (const reference of references) if (reference.kind === "call" || reference.kind === "table") heads.add(reference.to);
|
|
43
|
+
return [...heads].sort((a, b) => a - b).map((address) => ({ address, name: names.get(address) ?? nameOf(address) }));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The head that holds an address: the nearest head at or below it, when no
|
|
48
|
+
* boundary (a module start) lies between the two.
|
|
49
|
+
*/
|
|
50
|
+
export function ownerOf(heads: readonly RoutineHead[], boundaries: readonly number[], address: number): RoutineHead | undefined {
|
|
51
|
+
let low = 0;
|
|
52
|
+
let high = heads.length - 1;
|
|
53
|
+
let found: RoutineHead | undefined;
|
|
54
|
+
while (low <= high) {
|
|
55
|
+
const middle = (low + high) >> 1;
|
|
56
|
+
if (heads[middle]!.address <= address) {
|
|
57
|
+
found = heads[middle];
|
|
58
|
+
low = middle + 1;
|
|
59
|
+
} else high = middle - 1;
|
|
60
|
+
}
|
|
61
|
+
if (found === undefined) return undefined;
|
|
62
|
+
return boundaries.some((boundary) => boundary > found.address && boundary <= address) ? undefined : found;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The edges between routines: each call, jump or table reference from one head's code to another head; a jump inside a routine is no edge. */
|
|
66
|
+
export function routineEdges(heads: readonly RoutineHead[], boundaries: readonly number[], references: readonly GraphReference[]): GraphEdge[] {
|
|
67
|
+
const edges = new Map<string, GraphEdge>();
|
|
68
|
+
for (const reference of references) {
|
|
69
|
+
if (reference.kind !== "call" && reference.kind !== "jump" && reference.kind !== "table") continue;
|
|
70
|
+
const from = ownerOf(heads, boundaries, reference.from);
|
|
71
|
+
const to = ownerOf(heads, boundaries, reference.to);
|
|
72
|
+
if (from === undefined || to === undefined || from.address === to.address) continue;
|
|
73
|
+
// A jump into the middle of another routine is an edge to that routine.
|
|
74
|
+
const key = `${from.address}:${to.address}`;
|
|
75
|
+
const edge = edges.get(key);
|
|
76
|
+
if (edge === undefined) edges.set(key, { from: from.address, to: to.address, kind: reference.kind, count: 1 });
|
|
77
|
+
else {
|
|
78
|
+
edge.count++;
|
|
79
|
+
if (STRENGTH[reference.kind] > STRENGTH[edge.kind]) edge.kind = reference.kind;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return [...edges.values()].sort((a, b) => a.from - b.from || a.to - b.to);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const ARROWS: Record<EdgeKind, string> = { call: "-->", table: "==>", jump: "-.->" };
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A mermaid flowchart of the edges: a node per head with its name and
|
|
89
|
+
* address, a solid arrow for a call, a thick one for a table entry and a
|
|
90
|
+
* dotted one for a jump. The heads in `own` are drawn as rounded nodes.
|
|
91
|
+
*/
|
|
92
|
+
export function mermaidGraph(heads: readonly RoutineHead[], edges: readonly GraphEdge[], own: ReadonlySet<number> = new Set()): string {
|
|
93
|
+
const used = new Set(edges.flatMap((edge) => [edge.from, edge.to]));
|
|
94
|
+
for (const address of own) used.add(address);
|
|
95
|
+
const id = (address: number) => `n${address.toString(16).padStart(4, "0")}`;
|
|
96
|
+
const lines = ["flowchart LR"];
|
|
97
|
+
for (const head of heads) {
|
|
98
|
+
if (!used.has(head.address)) continue;
|
|
99
|
+
const label = `${head.name}<br/>${formatC64Address(head.address).toUpperCase()}`;
|
|
100
|
+
lines.push(own.has(head.address) ? ` ${id(head.address)}("${label}")` : ` ${id(head.address)}["${label}"]`);
|
|
101
|
+
}
|
|
102
|
+
for (const edge of edges) lines.push(` ${id(edge.from)} ${ARROWS[edge.kind]} ${id(edge.to)}`);
|
|
103
|
+
return lines.join("\n");
|
|
104
|
+
}
|