@henols/c64-re-tools 1.1.0 → 2.0.0-rc.2
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/LICENSE +21 -0
- package/README.md +190 -37
- package/distribution/package.ts +36 -0
- package/distribution/plugin.ts +120 -0
- package/package.json +34 -40
- package/skills/c64-assembler/SKILL.md +73 -0
- package/skills/c64-assembler/scripts/assemble.ts +103 -0
- package/skills/c64-basic/SKILL.md +67 -0
- package/skills/c64-basic/scripts/basic.ts +46 -0
- package/skills/c64-disk/SKILL.md +79 -0
- package/skills/c64-disk/scripts/disk.ts +65 -0
- package/skills/c64-emulator/SKILL.md +124 -0
- package/skills/c64-knowledge/SKILL.md +102 -0
- package/skills/c64-knowledge/scripts/knowledge.ts +257 -0
- package/skills/c64-memory-map/SKILL.md +76 -0
- package/skills/c64-memory-map/scripts/memmap.ts +81 -0
- package/skills/c64-memory-map/scripts/platform.ts +424 -0
- package/skills/c64-provenance/SKILL.md +76 -0
- package/skills/c64-provenance/scripts/compare.ts +92 -0
- package/skills/c64-provenance/scripts/provenance.ts +83 -0
- package/skills/c64-reverse-engineering/SKILL.md +90 -0
- package/skills/c64-static-analysis/SKILL.md +96 -0
- package/skills/c64-static-analysis/scripts/analyze.ts +175 -0
- package/skills/c64-static-analysis/scripts/findings.ts +73 -0
- package/skills/c64-testing/SKILL.md +79 -0
- package/skills/c64-testing/references/scenario-format.md +76 -0
- package/skills/c64-testing/scripts/checklist.ts +51 -0
- package/skills/c64-testing/scripts/scenario.ts +473 -0
- package/skills/c64-testing/scripts/test.ts +55 -0
- package/skills/c64-unpacker/SKILL.md +82 -0
- package/skills/c64-unpacker/scripts/evidence.ts +135 -0
- package/skills/c64-unpacker/scripts/unpack.ts +216 -0
- package/src/c64.ts +281 -0
- package/src/cli/ap-sdk.ts +44 -0
- package/src/cli/cleanup.ts +194 -0
- package/src/cli/main.ts +122 -0
- package/src/cli/status.ts +34 -0
- package/src/host/main.ts +121 -0
- package/src/host/server.ts +409 -0
- package/src/host/tools/c1541.ts +255 -0
- package/src/host/tools/index.ts +31 -0
- package/src/host/tools/petcat.ts +70 -0
- package/src/host/tools/status.ts +30 -0
- package/src/host/tools/version.ts +41 -0
- package/src/host/vice/adapter.ts +761 -0
- package/src/host/vice/binary-monitor.ts +389 -0
- package/src/host/vice/chips.ts +108 -0
- package/src/host/vice/connect.ts +34 -0
- package/src/host/vice/process.ts +402 -0
- package/src/host/vice/screen.ts +183 -0
- package/src/host/vice/session.ts +1316 -0
- package/src/host/vice/text-monitor.ts +223 -0
- package/src/host-client/connect.ts +241 -0
- package/src/host-client/tools.ts +89 -0
- package/src/host-client/transfer.ts +100 -0
- package/src/host-client/vice-session.ts +254 -0
- package/src/knowledge/database.ts +132 -0
- package/src/knowledge/history.ts +128 -0
- package/src/knowledge/import.ts +344 -0
- package/src/knowledge/read.ts +191 -0
- package/src/knowledge/schema.ts +140 -0
- package/src/knowledge/write.ts +348 -0
- package/src/mcp/main.ts +109 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/tools/debug.ts +237 -0
- package/src/mcp/tools/execution.ts +116 -0
- package/src/mcp/tools/input.ts +69 -0
- package/src/mcp/tools/machine.ts +97 -0
- package/src/mcp/tools/media.ts +106 -0
- package/src/mcp/tools/memory.ts +203 -0
- package/src/mcp/tools/video.ts +260 -0
- package/src/native/acme.ts +116 -0
- package/src/native/discover.ts +141 -0
- package/src/native/dxa.ts +166 -0
- package/src/native/ghidra/analyze.ts +86 -0
- package/src/native/ghidra/index.ts +157 -0
- package/src/native/ghidra/language/c64rt_6510.cspec +34 -0
- package/src/native/ghidra/language/c64rt_6510.ldefs +15 -0
- package/src/native/ghidra/language/c64rt_6510.pspec +15 -0
- package/src/native/ghidra/language/c64rt_6510.slaspec +596 -0
- package/src/native/ghidra/scripts/C64Export.java +242 -0
- package/src/native/ghidra/scripts/C64Prepare.java +60 -0
- package/src/native/local.ts +34 -0
- package/src/native/processes.ts +350 -0
- package/src/native/run.ts +172 -0
- package/src/native/staging.ts +83 -0
- package/src/native/status.ts +101 -0
- package/src/native/types.ts +228 -0
- package/src/native/watchdog.ts +78 -0
- package/src/project.ts +39 -0
- package/src/protocol/framing.ts +132 -0
- package/src/protocol/messages.ts +252 -0
- package/src/protocol/tools.ts +247 -0
- package/src/protocol/vice.ts +1235 -0
- package/src/script.ts +59 -0
- package/src/time.ts +15 -0
- package/src/trace.ts +162 -0
- package/THIRD-PARTY-NOTICES.md +0 -26
- package/bin/cli.mjs +0 -283
- package/skills/acme-build/SKILL.md +0 -260
- package/skills/acme-build/scripts/acme.mjs +0 -341
- package/skills/acme-build/template.a +0 -53
- package/skills/c64-disk-access/SKILL.md +0 -159
- package/skills/c64-disk-access/scripts/c1541.mjs +0 -569
- package/skills/c64-memory-mapping/SKILL.md +0 -631
- package/skills/c64-memory-mapping/memmap.json +0 -8800
- package/skills/c64-memory-mapping/scripts/driver.mjs +0 -553
- package/skills/c64-petcat/SKILL.md +0 -87
- package/skills/c64-petcat/scripts/petcat.mjs +0 -221
- package/skills/c64-program-recon/SKILL.md +0 -706
- package/skills/c64-program-recon/references/control-flow.md +0 -180
- package/skills/c64-program-recon/references/graphics.md +0 -79
- package/skills/c64-program-recon/references/observation-hazards.md +0 -143
- package/skills/c64-program-recon/references/reconstruction.md +0 -133
- package/skills/c64-program-recon/references/sound-and-input.md +0 -74
- package/skills/c64-program-recon/references/tool-selection.md +0 -78
- package/skills/c64-program-recon/scripts/derive.mjs +0 -364
- package/skills/c64-program-recon/scripts/packer-finding.mjs +0 -709
- package/skills/c64-program-recon/templates/memory-map.template.md +0 -101
- package/skills/c64-provenance-diff/SKILL.md +0 -292
- package/skills/c64-provenance-diff/scripts/diff-images.mjs +0 -984
- package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +0 -395
- package/skills/c64-ram-capture/RELEASES.json.example +0 -17
- package/skills/c64-ram-capture/SKILL.md +0 -474
- package/skills/c64-ram-capture/scripts/compare-cross-binary.mjs +0 -525
- package/skills/c64-ram-capture/scripts/compare.mjs +0 -258
- package/skills/c64-ram-capture/scripts/derive-transients.mjs +0 -575
- package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +0 -317
- package/skills/c64-ram-capture/scripts/mcp-module.mjs +0 -174
- package/skills/c64-ram-capture/scripts/project-paths.mjs +0 -81
- package/skills/c64-ram-capture/scripts/releases.mjs +0 -109
- package/skills/c64-ram-capture/scripts/vsf-slice.mjs +0 -147
- package/skills/c64-ram-capture/scripts/watch-loads.mjs +0 -581
- package/skills/c64-ram-capture/templates/capture-record.template.md +0 -99
- package/skills/c64-ram-capture/transients/README.md +0 -137
- package/skills/routine-queue-walker/SKILL.md +0 -394
- package/skills/routine-queue-walker/scripts/completeness-report.mjs +0 -465
- package/skills/vice-wedge-triage/SKILL.md +0 -269
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// The long-lived Host Runtime connection an MCP process owns: one connection,
|
|
2
|
+
// one VICE. Exposes C64-domain operations only.
|
|
3
|
+
|
|
4
|
+
import { ProtocolError, WireFailure, type VideoStandard } from "../protocol/messages.ts";
|
|
5
|
+
import { validateViceResult, type Breakpoint, type BreakpointParams, type Watchpoint, type WatchpointParams, type ExecutionParams, type ExecutionResult, type JoystickState, type MachineStatus, type MemmapRange, type MemoryWriteParams, type RegisterValues, type ResetMode, type RunState, type RunTarget, type RunUntilResult, type MemoryReadParams, type MemoryReadResult, type Registers, type Space, type ViceOperation, type ViceOperations } from "../protocol/vice.ts";
|
|
6
|
+
import { HostConnection } from "./connect.ts";
|
|
7
|
+
import { readProjectFile } from "./transfer.ts";
|
|
8
|
+
|
|
9
|
+
const HOST_LOST =
|
|
10
|
+
"The connection to the c64-re-tools host runtime was lost, and the emulator and its machine state went with it. " +
|
|
11
|
+
"Check that the Host Runtime (c64-re-tools-host) is running, then restart the c64-re-tools MCP server.";
|
|
12
|
+
const SESSION_CLOSED = "The C64 session is closed because the c64-re-tools MCP server stops.";
|
|
13
|
+
|
|
14
|
+
interface Pending {
|
|
15
|
+
op: ViceOperation;
|
|
16
|
+
resolve: (value: unknown) => void;
|
|
17
|
+
reject: (error: Error) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class ViceSessionClient {
|
|
21
|
+
/** Settles when the session is gone, whether we closed it or the host went away. */
|
|
22
|
+
readonly closed: Promise<void>;
|
|
23
|
+
readonly #connection: HostConnection;
|
|
24
|
+
readonly #pending = new Map<number, Pending>();
|
|
25
|
+
#nextId = 1;
|
|
26
|
+
#lost: WireFailure | undefined;
|
|
27
|
+
|
|
28
|
+
private constructor(connection: HostConnection) {
|
|
29
|
+
this.#connection = connection;
|
|
30
|
+
connection.onMessage((message) => {
|
|
31
|
+
// Anything but a reply after the handshake breaks the contract.
|
|
32
|
+
if (message.type !== "reply") return this.#fail();
|
|
33
|
+
const pending = this.#pending.get(message.id);
|
|
34
|
+
if (pending === undefined) return; // not ours; ignore
|
|
35
|
+
this.#pending.delete(message.id);
|
|
36
|
+
if ("error" in message) {
|
|
37
|
+
pending.reject(new WireFailure(message.error.code, message.error.message));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
pending.resolve(validateViceResult(pending.op, message.result));
|
|
42
|
+
} catch (error) {
|
|
43
|
+
pending.reject(
|
|
44
|
+
new WireFailure(
|
|
45
|
+
"operation-failed",
|
|
46
|
+
error instanceof ProtocolError ? "The c64-re-tools host runtime sent an invalid reply." : "The c64-re-tools MCP server could not read the reply of the host runtime.",
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
this.#fail();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
this.closed = connection.closed.then(() => {
|
|
53
|
+
this.#lost ??= new WireFailure("machine-state-lost", HOST_LOST);
|
|
54
|
+
for (const pending of this.#pending.values()) pending.reject(this.#lost);
|
|
55
|
+
this.#pending.clear();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Connects and waits until the host has started this session's emulator; `signal` stops the wait at once. */
|
|
60
|
+
static async open(options: { videoStandard: VideoStandard; env?: NodeJS.ProcessEnv; signal?: AbortSignal }): Promise<ViceSessionClient> {
|
|
61
|
+
const connection = await HostConnection.open({
|
|
62
|
+
role: "vice-session",
|
|
63
|
+
videoStandard: options.videoStandard,
|
|
64
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
65
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
66
|
+
});
|
|
67
|
+
return new ViceSessionClient(connection);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
status(): Promise<MachineStatus> {
|
|
71
|
+
return this.#request("status", {});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
memoryRead(params: MemoryReadParams): Promise<MemoryReadResult> {
|
|
75
|
+
return this.#request("memoryRead", params);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
registersGet(space: Space): Promise<Registers> {
|
|
79
|
+
return this.#request("registersGet", { space });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
memoryWrite(params: MemoryWriteParams): Promise<{ address: number; bytesWritten: number }> {
|
|
83
|
+
return this.#request("memoryWrite", params);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
memorySearch(params: ViceOperations["memorySearch"]["params"]): Promise<{ matches: number[] }> {
|
|
87
|
+
return this.#request("memorySearch", params);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
memoryCompare(params: ViceOperations["memoryCompare"]["params"]): Promise<ViceOperations["memoryCompare"]["result"]> {
|
|
91
|
+
return this.#request("memoryCompare", params);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
disassemble(params: ViceOperations["disassemble"]["params"]): Promise<ViceOperations["disassemble"]["result"]> {
|
|
95
|
+
return this.#request("disassemble", params);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
registersSet(space: Space, values: RegisterValues): Promise<Registers> {
|
|
99
|
+
return this.#request("registersSet", { space, values });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
execution(params: ExecutionParams): Promise<ExecutionResult> {
|
|
103
|
+
return this.#request("execution", params);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
runUntil(params: { target: RunTarget; timeoutFrames: number }): Promise<RunUntilResult> {
|
|
107
|
+
return this.#request("runUntil", params);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Loads a project PRG into memory without reset or start; finishes stopped. */
|
|
111
|
+
async programLoad(params: { path: string; address?: number }): Promise<{ state: RunState; loadAddress: number; size: number }> {
|
|
112
|
+
const file = readProjectFile(params.path);
|
|
113
|
+
return this.#request("programLoad", params.address === undefined ? {} : { address: params.address }, [file.bytes]);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Autostarts a project program or image, as VICE's autostart does. */
|
|
117
|
+
async autostart(params: { path: string; index: number; run: boolean }): Promise<{ state: RunState }> {
|
|
118
|
+
const file = readProjectFile(params.path);
|
|
119
|
+
return this.#request("autostart", { type: file.type, index: params.index, run: params.run }, [file.bytes]);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Attaches a project disk image to drive 8. */
|
|
123
|
+
async diskAttach(params: { path: string }): Promise<{ attached: boolean }> {
|
|
124
|
+
const file = readProjectFile(params.path);
|
|
125
|
+
return this.#request("diskAttach", { type: file.type }, [file.bytes]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
reset(params: { mode: ResetMode; run: boolean }): Promise<{ state: RunState; pc?: number }> {
|
|
129
|
+
return this.#request("reset", params);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Queues PETSCII bytes for the C64 keyboard. */
|
|
133
|
+
keyboard(petscii: Uint8Array): Promise<{ queuedBytes: number }> {
|
|
134
|
+
return this.#request("keyboard", { data: Buffer.from(petscii).toString("hex") });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
joystick(state: JoystickState): Promise<JoystickState> {
|
|
138
|
+
return this.#request("joystick", state);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Action list gives all breakpoints; every other action gives the one breakpoint it added or changed. */
|
|
142
|
+
breakpoint(params: Extract<BreakpointParams, { action: "list" }>): Promise<{ breakpoints: Breakpoint[] }>;
|
|
143
|
+
breakpoint(params: Exclude<BreakpointParams, { action: "list" }>): Promise<Breakpoint>;
|
|
144
|
+
breakpoint(params: BreakpointParams): Promise<Breakpoint | { breakpoints: Breakpoint[] }> {
|
|
145
|
+
return this.#request("breakpoint", params);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Action list gives all watchpoints; every other action gives the one watchpoint it added or changed. */
|
|
149
|
+
watchpoint(params: Extract<WatchpointParams, { action: "list" }>): Promise<{ watchpoints: Watchpoint[] }>;
|
|
150
|
+
watchpoint(params: Exclude<WatchpointParams, { action: "list" }>): Promise<Watchpoint>;
|
|
151
|
+
watchpoint(params: WatchpointParams): Promise<Watchpoint | { watchpoints: Watchpoint[] }> {
|
|
152
|
+
return this.#request("watchpoint", params);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
cpuHistory(params: ViceOperations["cpuHistory"]["params"]): Promise<ViceOperations["cpuHistory"]["result"]> {
|
|
156
|
+
return this.#request("cpuHistory", params);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
backtrace(params: ViceOperations["backtrace"]["params"]): Promise<ViceOperations["backtrace"]["result"]> {
|
|
160
|
+
return this.#request("backtrace", params);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
timing(action: ViceOperations["timing"]["params"]["action"]): Promise<ViceOperations["timing"]["result"]> {
|
|
164
|
+
return this.#request("timing", { action });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
profile(limit: number): Promise<ViceOperations["profile"]["result"]> {
|
|
168
|
+
return this.#request("profile", { limit });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Action read gives the ranges; action clear gives cleared. */
|
|
172
|
+
memmap(params: Extract<ViceOperations["memmap"]["params"], { action: "read" }>): Promise<{ ranges: MemmapRange[] }>;
|
|
173
|
+
memmap(params: { action: "clear" }): Promise<{ cleared: boolean }>;
|
|
174
|
+
memmap(params: ViceOperations["memmap"]["params"]): Promise<ViceOperations["memmap"]["result"]> {
|
|
175
|
+
return this.#request("memmap", params);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
vicii(): Promise<ViceOperations["vicii"]["result"]> {
|
|
179
|
+
return this.#request("vicii", {});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Empty `indexes` means all eight sprites. */
|
|
183
|
+
sprites(indexes: number[]): Promise<ViceOperations["sprites"]["result"]> {
|
|
184
|
+
return this.#request("sprites", { indexes });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
cia(which: ViceOperations["cia"]["params"]["which"]): Promise<ViceOperations["cia"]["result"]> {
|
|
188
|
+
return this.#request("cia", { which });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
sid(): Promise<ViceOperations["sid"]["result"]> {
|
|
192
|
+
return this.#request("sid", {});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
observe(params: ViceOperations["observe"]["params"]): Promise<ViceOperations["observe"]["result"]> {
|
|
196
|
+
return this.#request("observe", params);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The last frame the VIC-II drew, as a base64 PNG with its size. */
|
|
200
|
+
screenCapture(baseline?: string): Promise<ViceOperations["screenCapture"]["result"]> {
|
|
201
|
+
return this.#request("screenCapture", baseline === undefined ? {} : { baseline });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
screenCompare(params: ViceOperations["screenCompare"]["params"]): Promise<ViceOperations["screenCompare"]["result"]> {
|
|
205
|
+
return this.#request("screenCompare", params);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
snapshot(params: ViceOperations["snapshot"]["params"]): Promise<ViceOperations["snapshot"]["result"]> {
|
|
209
|
+
return this.#request("snapshot", params);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
screenBaselines(): Promise<{ baselines: string[] }> {
|
|
213
|
+
return this.#request("screenBaselines", {});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
screenDiscard(baseline: string): Promise<{ discarded: boolean }> {
|
|
217
|
+
return this.#request("screenDiscard", { baseline });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
warp(enabled: boolean): Promise<{ enabled: boolean }> {
|
|
221
|
+
return this.#request("warp", { enabled });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Moves the machine into a VICE with a window (open) or back into a headless one (close). */
|
|
225
|
+
window(action: ViceOperations["window"]["params"]["action"]): Promise<ViceOperations["window"]["result"]> {
|
|
226
|
+
return this.#request("window", { action });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Ends the session; the host stops its emulator. Open and later requests fail as closed. */
|
|
230
|
+
async close(): Promise<void> {
|
|
231
|
+
this.#lost ??= new WireFailure("machine-unavailable", SESSION_CLOSED);
|
|
232
|
+
await this.#connection.close();
|
|
233
|
+
await this.closed;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
#request<O extends ViceOperation>(
|
|
237
|
+
op: O,
|
|
238
|
+
params: ViceOperations[O]["params"],
|
|
239
|
+
attachments: readonly Uint8Array[] = [],
|
|
240
|
+
): Promise<ViceOperations[O]["result"]> {
|
|
241
|
+
if (this.#lost !== undefined) return Promise.reject(this.#lost);
|
|
242
|
+
const id = this.#nextId;
|
|
243
|
+
this.#nextId = this.#nextId >= 0xffff_ffff ? 1 : this.#nextId + 1;
|
|
244
|
+
return new Promise((resolve, reject) => {
|
|
245
|
+
this.#pending.set(id, { op, resolve: resolve as (value: unknown) => void, reject });
|
|
246
|
+
this.#connection.send({ type: "request", id, op, params }, attachments);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Drops a connection whose host broke the protocol; pending work fails as lost. */
|
|
251
|
+
#fail(): void {
|
|
252
|
+
void this.#connection.close();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The one place that opens .c64-re-tools/knowledge.db.
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
import { projectRoot } from "../project.ts";
|
|
8
|
+
import { checkVersion, ensureSchema, schemaVersion } from "./schema.ts";
|
|
9
|
+
|
|
10
|
+
export const KNOWLEDGE_DIRECTORY = ".c64-re-tools";
|
|
11
|
+
export const KNOWLEDGE_FILE = "knowledge.db";
|
|
12
|
+
|
|
13
|
+
export const KNOWLEDGE_ERROR_CODES = [
|
|
14
|
+
"invalid-input",
|
|
15
|
+
"not-found",
|
|
16
|
+
"conflict",
|
|
17
|
+
"stale-revision",
|
|
18
|
+
"invalid-database",
|
|
19
|
+
"unsupported-schema",
|
|
20
|
+
] as const;
|
|
21
|
+
export type KnowledgeErrorCode = (typeof KNOWLEDGE_ERROR_CODES)[number];
|
|
22
|
+
|
|
23
|
+
/** A refused knowledge operation, with a stable code and an actionable message. */
|
|
24
|
+
export class KnowledgeError extends Error {
|
|
25
|
+
override name = "KnowledgeError";
|
|
26
|
+
readonly code: KnowledgeErrorCode;
|
|
27
|
+
|
|
28
|
+
constructor(code: KnowledgeErrorCode, message: string) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.code = code;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function knowledgePath(root: string = projectRoot()): string {
|
|
35
|
+
return join(root, KNOWLEDGE_DIRECTORY, KNOWLEDGE_FILE);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function configure(db: DatabaseSync): void {
|
|
39
|
+
// A committed write must be complete in knowledge.db itself: rollback journal, never WAL.
|
|
40
|
+
db.exec("PRAGMA journal_mode = DELETE; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function open(path: string, readOnly: boolean): DatabaseSync {
|
|
44
|
+
let db: DatabaseSync;
|
|
45
|
+
try {
|
|
46
|
+
db = new DatabaseSync(path, { readOnly });
|
|
47
|
+
configure(db);
|
|
48
|
+
db.prepare("SELECT count(*) FROM sqlite_master").get();
|
|
49
|
+
} catch (error) {
|
|
50
|
+
throw new KnowledgeError("invalid-database", `${KNOWLEDGE_DIRECTORY}/${KNOWLEDGE_FILE} is not a readable SQLite database: ${(error as Error).message}`);
|
|
51
|
+
}
|
|
52
|
+
return db;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Opens the project's knowledge database read-only, as it is: a read never
|
|
57
|
+
* migrates and never writes. Returns undefined when the database does not
|
|
58
|
+
* exist or has no schema yet: that reads as empty knowledge, and a read
|
|
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);
|
|
65
|
+
try {
|
|
66
|
+
const version = schemaVersion(db);
|
|
67
|
+
if (version === 0) {
|
|
68
|
+
db.close();
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
checkVersion(version);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
db.close();
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
return db;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Opens the project's knowledge database for a write, creating
|
|
81
|
+
* .c64-re-tools/knowledge.db and its schema when needed. Refuses a schema
|
|
82
|
+
* version other than this build's.
|
|
83
|
+
*/
|
|
84
|
+
export function openForWrite(root: string = projectRoot()): DatabaseSync {
|
|
85
|
+
mkdirSync(join(root, KNOWLEDGE_DIRECTORY), { recursive: true });
|
|
86
|
+
const db = open(knowledgePath(root), false);
|
|
87
|
+
try {
|
|
88
|
+
ensureSchema(db);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
db.close();
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
return db;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Runs one write on the project's knowledge database and closes it. When the
|
|
98
|
+
* project has no knowledge.db yet, `work` first runs on an empty database in
|
|
99
|
+
* memory: a write that is refused there creates no .c64-re-tools/. `work`
|
|
100
|
+
* must change nothing outside the database it gets.
|
|
101
|
+
*/
|
|
102
|
+
export function withWrite<T>(work: (db: DatabaseSync) => T, root: string = projectRoot()): T {
|
|
103
|
+
if (!existsSync(knowledgePath(root))) {
|
|
104
|
+
const trial = new DatabaseSync(":memory:");
|
|
105
|
+
try {
|
|
106
|
+
configure(trial);
|
|
107
|
+
ensureSchema(trial);
|
|
108
|
+
work(trial);
|
|
109
|
+
} finally {
|
|
110
|
+
trial.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const db = openForWrite(root);
|
|
114
|
+
try {
|
|
115
|
+
return work(db);
|
|
116
|
+
} finally {
|
|
117
|
+
db.close();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Runs `work` in one immediate transaction: all of it commits, or none of it does. */
|
|
122
|
+
export function transaction<T>(db: DatabaseSync, work: () => T): T {
|
|
123
|
+
db.exec("BEGIN IMMEDIATE");
|
|
124
|
+
try {
|
|
125
|
+
const result = work();
|
|
126
|
+
db.exec("COMMIT");
|
|
127
|
+
return result;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
db.exec("ROLLBACK");
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Knowledge history: what used to be known, which revision changed
|
|
2
|
+
// it, who changed it and why. History rows are never deleted.
|
|
3
|
+
|
|
4
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
5
|
+
|
|
6
|
+
import { KnowledgeError } from "./database.ts";
|
|
7
|
+
import {
|
|
8
|
+
COMMENT_COLUMNS,
|
|
9
|
+
REFERENCE_COLUMNS,
|
|
10
|
+
REGION_COLUMNS,
|
|
11
|
+
SYMBOL_COLUMNS,
|
|
12
|
+
toComment,
|
|
13
|
+
toReference,
|
|
14
|
+
toRegion,
|
|
15
|
+
toSymbol,
|
|
16
|
+
type CommentRow,
|
|
17
|
+
type ReferenceRow,
|
|
18
|
+
type RegionRow,
|
|
19
|
+
type SymbolRow,
|
|
20
|
+
} from "./read.ts";
|
|
21
|
+
import type { Origin } from "./schema.ts";
|
|
22
|
+
|
|
23
|
+
export interface Revision {
|
|
24
|
+
id: number;
|
|
25
|
+
createdAt: string;
|
|
26
|
+
origin: Origin;
|
|
27
|
+
operation: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
inputHash?: string;
|
|
30
|
+
toolVersion?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One knowledge row with its validity: from its first revision until the revision that closed it. */
|
|
34
|
+
export type HistoryEntry =
|
|
35
|
+
| { entity: "symbol"; row: SymbolRow; from: number; to?: number }
|
|
36
|
+
| { entity: "region"; row: RegionRow; from: number; to?: number }
|
|
37
|
+
| { entity: "comment"; row: CommentRow; from: number; to?: number }
|
|
38
|
+
| { entity: "reference"; row: ReferenceRow; from: number; to?: number };
|
|
39
|
+
|
|
40
|
+
type Raw = Record<string, unknown>;
|
|
41
|
+
|
|
42
|
+
function toRevision(row: Raw): Revision {
|
|
43
|
+
const revision: Revision = {
|
|
44
|
+
id: row.id as number,
|
|
45
|
+
createdAt: row.created_at as string,
|
|
46
|
+
origin: row.origin as Origin,
|
|
47
|
+
operation: row.operation as string,
|
|
48
|
+
};
|
|
49
|
+
if (row.description !== null) revision.description = row.description as string;
|
|
50
|
+
if (row.input_hash !== null) revision.inputHash = row.input_hash as string;
|
|
51
|
+
if (row.tool_version !== null) revision.toolVersion = row.tool_version as string;
|
|
52
|
+
return revision;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function entry<E extends HistoryEntry["entity"]>(entity: E, row: unknown, raw: Raw): HistoryEntry {
|
|
56
|
+
const result = { entity, row, from: raw.valid_from_revision as number } as HistoryEntry;
|
|
57
|
+
if (raw.valid_to_revision !== null) result.to = raw.valid_to_revision as number;
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function bySequence(a: HistoryEntry, b: HistoryEntry): number {
|
|
62
|
+
return a.from - b.from || (a.to ?? Infinity) - (b.to ?? Infinity);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every row, current or closed, that concerns one address, oldest first. */
|
|
66
|
+
export function historyAt(db: DatabaseSync | undefined, address: number): HistoryEntry[] {
|
|
67
|
+
if (db === undefined) return [];
|
|
68
|
+
const validity = ", valid_to_revision";
|
|
69
|
+
const entries: HistoryEntry[] = [];
|
|
70
|
+
for (const raw of db.prepare(`SELECT ${SYMBOL_COLUMNS}${validity} FROM symbols WHERE address = ?`).all(address) as Raw[]) entries.push(entry("symbol", toSymbol(raw), raw));
|
|
71
|
+
for (const raw of db.prepare(`SELECT ${REGION_COLUMNS}${validity} FROM regions WHERE start_address <= ? AND end_address >= ?`).all(address, address) as Raw[]) {
|
|
72
|
+
entries.push(entry("region", toRegion(raw), raw));
|
|
73
|
+
}
|
|
74
|
+
for (const raw of db.prepare(`SELECT ${COMMENT_COLUMNS}${validity} FROM comments WHERE address = ?`).all(address) as Raw[]) entries.push(entry("comment", toComment(raw), raw));
|
|
75
|
+
for (const raw of db.prepare(`SELECT ${REFERENCE_COLUMNS}${validity} FROM "references" WHERE from_address = ? OR to_address = ?`).all(address, address) as Raw[]) {
|
|
76
|
+
entries.push(entry("reference", toReference(raw), raw));
|
|
77
|
+
}
|
|
78
|
+
return entries.sort(bySequence);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Every symbol row that ever had this name, oldest first: a name can move between addresses. */
|
|
82
|
+
export function historyNamed(db: DatabaseSync | undefined, name: string): HistoryEntry[] {
|
|
83
|
+
if (db === undefined) return [];
|
|
84
|
+
return (db.prepare(`SELECT ${SYMBOL_COLUMNS}, valid_to_revision FROM symbols WHERE name = ?`).all(name) as Raw[])
|
|
85
|
+
.map((raw) => entry("symbol", toSymbol(raw), raw))
|
|
86
|
+
.sort(bySequence);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Revisions, newest first; with `before`, only revisions older than it. */
|
|
90
|
+
export function revisions(db: DatabaseSync | undefined, options: { limit?: number; before?: number } = {}): Revision[] {
|
|
91
|
+
if (db === undefined) return [];
|
|
92
|
+
return (
|
|
93
|
+
db
|
|
94
|
+
.prepare("SELECT * FROM revisions WHERE id < ? ORDER BY id DESC LIMIT ?")
|
|
95
|
+
.all(options.before ?? Number.MAX_SAFE_INTEGER, options.limit ?? 50) as Raw[]
|
|
96
|
+
).map(toRevision);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface RevisionChanges {
|
|
100
|
+
revision: Revision;
|
|
101
|
+
/** Rows this revision made current. */
|
|
102
|
+
added: HistoryEntry[];
|
|
103
|
+
/** Rows this revision closed. */
|
|
104
|
+
closed: HistoryEntry[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** One revision and exactly what it changed. */
|
|
108
|
+
export function revision(db: DatabaseSync | undefined, id: number): RevisionChanges {
|
|
109
|
+
const raw = db?.prepare("SELECT * FROM revisions WHERE id = ?").get(id) as Raw | undefined;
|
|
110
|
+
if (db === undefined || raw === undefined) throw new KnowledgeError("not-found", `There is no revision ${id}.`);
|
|
111
|
+
const tables = [
|
|
112
|
+
["symbol", "symbols", SYMBOL_COLUMNS, toSymbol],
|
|
113
|
+
["region", "regions", REGION_COLUMNS, toRegion],
|
|
114
|
+
["comment", "comments", COMMENT_COLUMNS, toComment],
|
|
115
|
+
["reference", '"references"', REFERENCE_COLUMNS, toReference],
|
|
116
|
+
] as const;
|
|
117
|
+
const added: HistoryEntry[] = [];
|
|
118
|
+
const closed: HistoryEntry[] = [];
|
|
119
|
+
for (const [entity, table, columns, map] of tables) {
|
|
120
|
+
for (const row of db.prepare(`SELECT ${columns}, valid_to_revision FROM ${table} WHERE valid_from_revision = ?`).all(id) as Raw[]) {
|
|
121
|
+
added.push(entry(entity, map(row), row));
|
|
122
|
+
}
|
|
123
|
+
for (const row of db.prepare(`SELECT ${columns}, valid_to_revision FROM ${table} WHERE valid_to_revision = ?`).all(id) as Raw[]) {
|
|
124
|
+
closed.push(entry(entity, map(row), row));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { revision: toRevision(raw), added, closed };
|
|
128
|
+
}
|