@henols/c64-re-tools 1.1.0 → 2.0.0-rc.10
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 +154 -37
- package/distribution/package.ts +21 -0
- package/distribution/plugin.ts +122 -0
- package/package.json +34 -40
- package/skills/c64-assembler/SKILL.md +76 -0
- package/skills/c64-assembler/scripts/assemble.ts +110 -0
- package/skills/c64-assembler/scripts/compare.ts +84 -0
- package/skills/c64-basic/SKILL.md +66 -0
- package/skills/c64-basic/scripts/basic.ts +46 -0
- package/skills/c64-disk/SKILL.md +93 -0
- package/skills/c64-disk/scripts/containers.ts +124 -0
- package/skills/c64-disk/scripts/disk.ts +120 -0
- package/skills/c64-emulator/SKILL.md +134 -0
- package/skills/c64-knowledge/SKILL.md +166 -0
- package/skills/c64-knowledge/scripts/knowledge.ts +492 -0
- package/skills/c64-memory-map/SKILL.md +79 -0
- package/skills/c64-memory-map/scripts/memmap.ts +92 -0
- package/skills/c64-provenance/SKILL.md +110 -0
- package/skills/c64-provenance/scripts/compare.ts +152 -0
- package/skills/c64-provenance/scripts/provenance.ts +334 -0
- package/skills/c64-provenance/scripts/runtime.ts +191 -0
- package/skills/c64-provenance/scripts/separate.ts +140 -0
- package/skills/c64-reverse-engineering/SKILL.md +144 -0
- package/skills/c64-source/SKILL.md +126 -0
- package/skills/c64-source/scripts/generate.ts +490 -0
- package/skills/c64-source/scripts/loader.ts +571 -0
- package/skills/c64-source/scripts/opcodes.ts +61 -0
- package/skills/c64-source/scripts/source.ts +595 -0
- package/skills/c64-static-analysis/SKILL.md +119 -0
- package/skills/c64-static-analysis/scripts/analyze.ts +275 -0
- package/skills/c64-static-analysis/scripts/coverage.ts +78 -0
- package/skills/c64-static-analysis/scripts/findings.ts +75 -0
- package/skills/c64-static-analysis/scripts/idioms.ts +149 -0
- package/skills/c64-static-analysis/scripts/instructions.ts +105 -0
- package/skills/c64-testing/SKILL.md +103 -0
- package/skills/c64-testing/references/scenario-format.md +116 -0
- package/skills/c64-testing/scripts/checklist.ts +51 -0
- package/skills/c64-testing/scripts/run.ts +189 -0
- package/skills/c64-testing/scripts/scenario.ts +493 -0
- package/skills/c64-testing/scripts/test.ts +65 -0
- package/skills/c64-unpacker/SKILL.md +135 -0
- package/skills/c64-unpacker/scripts/evidence.ts +207 -0
- package/skills/c64-unpacker/scripts/loads.ts +209 -0
- package/skills/c64-unpacker/scripts/machine.ts +80 -0
- package/skills/c64-unpacker/scripts/stages.ts +130 -0
- package/skills/c64-unpacker/scripts/unpack.ts +683 -0
- package/src/c64.ts +411 -0
- package/src/cli/ap-sdk.ts +44 -0
- package/src/cli/cleanup.ts +194 -0
- package/src/cli/main.ts +125 -0
- package/src/cli/status.ts +61 -0
- package/src/host/main.ts +134 -0
- package/src/host/server.ts +392 -0
- package/src/host/tools/c1541.ts +284 -0
- package/src/host/tools/index.ts +35 -0
- package/src/host/tools/petcat.ts +77 -0
- package/src/host/tools/status.ts +47 -0
- package/src/host/tools/version.ts +41 -0
- package/src/host/vice/adapter.ts +804 -0
- package/src/host/vice/binary-monitor.ts +389 -0
- package/src/host/vice/chips.ts +118 -0
- package/src/host/vice/connect.ts +34 -0
- package/src/host/vice/process.ts +413 -0
- package/src/host/vice/screen.ts +184 -0
- package/src/host/vice/session.ts +1426 -0
- package/src/host/vice/text-monitor.ts +223 -0
- package/src/host-client/autostart.ts +55 -0
- package/src/host-client/connect.ts +241 -0
- package/src/host-client/coverage.ts +63 -0
- package/src/host-client/steps.ts +170 -0
- package/src/host-client/tools.ts +89 -0
- package/src/host-client/transfer.ts +126 -0
- package/src/host-client/vice-session.ts +316 -0
- package/src/knowledge/database.ts +132 -0
- package/src/knowledge/history.ts +163 -0
- package/src/knowledge/import.ts +391 -0
- package/src/knowledge/read.ts +342 -0
- package/src/knowledge/schema.ts +229 -0
- package/src/knowledge/write.ts +663 -0
- package/src/mcp/main.ts +134 -0
- package/src/mcp/output.ts +32 -0
- package/src/mcp/server.ts +316 -0
- package/src/mcp/tools/debug.ts +327 -0
- package/src/mcp/tools/execution.ts +117 -0
- package/src/mcp/tools/input.ts +72 -0
- package/src/mcp/tools/machine.ts +100 -0
- package/src/mcp/tools/media.ts +129 -0
- package/src/mcp/tools/memory.ts +250 -0
- package/src/mcp/tools/video.ts +271 -0
- package/src/native/acme.ts +116 -0
- package/src/native/discover.ts +179 -0
- package/src/native/dxa.ts +166 -0
- package/src/native/ghidra/analyze.ts +86 -0
- package/src/native/ghidra/index.ts +173 -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 +274 -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 +113 -0
- package/src/native/types.ts +257 -0
- package/src/native/watchdog.ts +78 -0
- package/src/package.ts +21 -0
- package/src/platform.ts +441 -0
- package/src/project.ts +39 -0
- package/src/protocol/framing.ts +132 -0
- package/src/protocol/messages.ts +259 -0
- package/src/protocol/tools.ts +266 -0
- package/src/protocol/vice.ts +1348 -0
- package/src/script.ts +60 -0
- package/src/time.ts +15 -0
- package/src/trace.ts +194 -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,223 @@
|
|
|
1
|
+
// VICE remote text monitor client, used only for operations the binary monitor
|
|
2
|
+
// cannot do (warp, file load/attach, checkpoint ignore counts, device switch).
|
|
3
|
+
// Raw monitor syntax never leaves src/host/vice.
|
|
4
|
+
//
|
|
5
|
+
// Framing: every command ends with a prompt
|
|
6
|
+
// such as "(C:$e5cf) ", but a command that makes VICE enter the monitor gets
|
|
7
|
+
// an extra prompt first. So prompts cannot delimit answers. Each command is
|
|
8
|
+
// followed by a sentinel `~ $nnnn` whose distinctive four-line answer marks
|
|
9
|
+
// where the command's own output ends. The sentinel is sent only after a
|
|
10
|
+
// prompt shows the command was taken: VICE drops the rest of an input chunk
|
|
11
|
+
// when the command in it makes VICE enter the monitor. Only idempotent
|
|
12
|
+
// commands go through here (see resendAfterMs).
|
|
13
|
+
//
|
|
14
|
+
// VICE sometimes reads a line but runs it only when more input comes. So a
|
|
15
|
+
// late sentinel is sent again, and stray sentinel answers are removed from
|
|
16
|
+
// output. The second answer of a resent sentinel can arrive in parts, while
|
|
17
|
+
// the next command starts: that command waits until the answer is whole
|
|
18
|
+
// before it drops what is left over, so no part of it is read as output. VICE also prints its step message here when the binary monitor
|
|
19
|
+
// steps, sometimes after the next text command was sent; no command of this
|
|
20
|
+
// client steps, so that message is removed from output too.
|
|
21
|
+
|
|
22
|
+
import type { Socket } from "node:net";
|
|
23
|
+
|
|
24
|
+
import { connectWithTimeout } from "./connect.ts";
|
|
25
|
+
|
|
26
|
+
export class TextMonitorError extends Error {
|
|
27
|
+
override name = "TextMonitorError";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A command got no answer in time, but the connection is open; it may still run later. */
|
|
31
|
+
export class TextMonitorTimeoutError extends TextMonitorError {
|
|
32
|
+
override name = "TextMonitorTimeoutError";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const PROMPT = /\((?:C|\d+):\$[0-9a-f]{4}\) /g;
|
|
36
|
+
export const DEFAULT_COMMAND_TIMEOUT_MS = 10_000;
|
|
37
|
+
/** The default of TextMonitor.resendAfterMs. */
|
|
38
|
+
const RESEND_AFTER_MS = 3_000;
|
|
39
|
+
/** The default of TextMonitor.overdueTimeoutMs. */
|
|
40
|
+
const OVERDUE_COMMAND_TIMEOUT_MS = 1_000;
|
|
41
|
+
/** The default of TextMonitor.sentinelResendMs. */
|
|
42
|
+
const SENTINEL_RESEND_MS = 1_000;
|
|
43
|
+
/** The longest delay a Node timer takes. */
|
|
44
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
45
|
+
const ANY_SENTINEL_ANSWER = /\+\d+\n\$[0-9a-f]{4}\n[0-7]+\n%[01 ]+\n/g;
|
|
46
|
+
const STEP_MESSAGE = /^Stepping through the next \d+ instruction\(s\)\.\n/gm;
|
|
47
|
+
|
|
48
|
+
function sentinelPattern(nonce: number): RegExp {
|
|
49
|
+
const hex = nonce.toString(16).padStart(4, "0");
|
|
50
|
+
return new RegExp(`\\+${nonce}\\n\\$${hex}\\n[0-7]+\\n%[01 ]+\\n\\((?:C|\\d+):\\$[0-9a-f]{4}\\) `);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Strips prompts, stray sentinel answers, step messages and surrounding blank space from a command's raw output. */
|
|
54
|
+
export function cleanOutput(raw: string): string {
|
|
55
|
+
return raw.replace(/\r/g, "").replace(ANY_SENTINEL_ANSWER, "").replace(STEP_MESSAGE, "").replace(PROMPT, "").trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class TextMonitor {
|
|
59
|
+
readonly closed: Promise<Error | undefined>;
|
|
60
|
+
readonly #socket: Socket;
|
|
61
|
+
#buffer = "";
|
|
62
|
+
#wake: (() => void) | undefined;
|
|
63
|
+
#queue: Promise<unknown> = Promise.resolve();
|
|
64
|
+
#nonce = 0x1000 + Math.floor(Math.random() * 0x1000);
|
|
65
|
+
#closedByUs = false;
|
|
66
|
+
#failure: Error | undefined;
|
|
67
|
+
/** The time limit of a command that names none. */
|
|
68
|
+
defaultTimeoutMs = DEFAULT_COMMAND_TIMEOUT_MS;
|
|
69
|
+
/**
|
|
70
|
+
* How long to wait for any answer before sending the command once more.
|
|
71
|
+
* VICE sometimes holds a command line until more input comes; every
|
|
72
|
+
* command this client sends is idempotent, so a repeat is harmless. A
|
|
73
|
+
* command that has printed output but no prompt yet still runs, and is not
|
|
74
|
+
* sent again.
|
|
75
|
+
*/
|
|
76
|
+
resendAfterMs = RESEND_AFTER_MS;
|
|
77
|
+
/** How long to wait for a sentinel's answer before sending the sentinel again. */
|
|
78
|
+
sentinelResendMs = SENTINEL_RESEND_MS;
|
|
79
|
+
/** While an earlier command timed out, later ones are waited for only this long (see BinaryMonitor). */
|
|
80
|
+
overdueTimeoutMs = OVERDUE_COMMAND_TIMEOUT_MS;
|
|
81
|
+
/** Set when a command timed out; cleared by the next command that is answered. */
|
|
82
|
+
#overdue = false;
|
|
83
|
+
/** The answers that a resent sentinel of the last command can still give. */
|
|
84
|
+
#strays: { nonce: number; count: number } | undefined;
|
|
85
|
+
|
|
86
|
+
private constructor(socket: Socket) {
|
|
87
|
+
this.#socket = socket;
|
|
88
|
+
socket.setNoDelay(true);
|
|
89
|
+
socket.on("data", (chunk) => {
|
|
90
|
+
this.#buffer += chunk.toString("latin1");
|
|
91
|
+
this.#wake?.();
|
|
92
|
+
});
|
|
93
|
+
socket.on("error", (error) => (this.#failure ??= new TextMonitorError(`text monitor connection failed: ${error.message}`)));
|
|
94
|
+
this.closed = new Promise((resolve) => {
|
|
95
|
+
socket.on("close", () => {
|
|
96
|
+
this.#wake?.();
|
|
97
|
+
resolve(this.#closedByUs ? undefined : (this.#failure ?? new TextMonitorError("VICE closed the text monitor connection")));
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
static connect(port: number, host = "127.0.0.1", timeoutMs = 2_000): Promise<TextMonitor> {
|
|
103
|
+
return connectWithTimeout({
|
|
104
|
+
port,
|
|
105
|
+
host,
|
|
106
|
+
timeoutMs,
|
|
107
|
+
wrap: (socket) => new TextMonitor(socket),
|
|
108
|
+
failure: (message) => new TextMonitorError(`text monitor ${message}`),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Runs one idempotent command and returns its output without prompts.
|
|
114
|
+
* Commands run one at a time. A command makes VICE stop the machine if it
|
|
115
|
+
* was running. `timeoutMs` Infinity waits with no limit, also after a
|
|
116
|
+
* command that timed out.
|
|
117
|
+
*/
|
|
118
|
+
command(line: string, timeoutMs = this.defaultTimeoutMs): Promise<string> {
|
|
119
|
+
if (/[\r\n]/.test(line)) return Promise.reject(new TypeError("a monitor command must be one line"));
|
|
120
|
+
const run = this.#queue.then(async () => {
|
|
121
|
+
try {
|
|
122
|
+
const limit = this.#overdue && timeoutMs !== Infinity ? Math.min(timeoutMs, this.overdueTimeoutMs) : timeoutMs;
|
|
123
|
+
const output = await this.#exchange(line, limit);
|
|
124
|
+
this.#overdue = false;
|
|
125
|
+
return output;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (error instanceof TextMonitorTimeoutError) this.#overdue = true;
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
this.#queue = run.catch(() => {});
|
|
132
|
+
return run;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
close(): Promise<void> {
|
|
136
|
+
this.#closedByUs = true;
|
|
137
|
+
this.#socket.destroy();
|
|
138
|
+
return this.closed.then(() => {});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async #exchange(line: string, timeoutMs: number): Promise<string> {
|
|
142
|
+
if (this.#socket.destroyed) throw new TextMonitorError("text monitor connection is closed");
|
|
143
|
+
this.#nonce = this.#nonce >= 0xfffe ? 0x1000 : this.#nonce + 1;
|
|
144
|
+
const sentinel = sentinelPattern(this.#nonce);
|
|
145
|
+
const deadline = Date.now() + timeoutMs;
|
|
146
|
+
await this.#settleStrays(deadline, timeoutMs);
|
|
147
|
+
// Anything left over belongs to no command; drop it so it cannot be misread.
|
|
148
|
+
this.#buffer = "";
|
|
149
|
+
this.#socket.write(`${line}\n`);
|
|
150
|
+
const prompted = () => new RegExp(PROMPT.source).exec(this.#buffer);
|
|
151
|
+
const resendAt = Math.min(deadline, Date.now() + this.resendAfterMs);
|
|
152
|
+
try {
|
|
153
|
+
await this.#waitFor(prompted, resendAt, timeoutMs);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (this.#socket.destroyed || Date.now() >= deadline) throw error;
|
|
156
|
+
// Output without a prompt: VICE runs the command and still prints. A repeat would run it twice.
|
|
157
|
+
if (cleanOutput(this.#buffer) === "") this.#socket.write(`${line}\n`);
|
|
158
|
+
await this.#waitFor(prompted, deadline, timeoutMs);
|
|
159
|
+
}
|
|
160
|
+
const sentinelLine = `~ $${this.#nonce.toString(16)}\n`;
|
|
161
|
+
let sends = 0;
|
|
162
|
+
for (;;) {
|
|
163
|
+
this.#socket.write(sentinelLine);
|
|
164
|
+
sends++;
|
|
165
|
+
let found: RegExpExecArray;
|
|
166
|
+
try {
|
|
167
|
+
found = await this.#waitFor(() => sentinel.exec(this.#buffer), Math.min(deadline, Date.now() + this.sentinelResendMs), timeoutMs);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (this.#socket.destroyed || Date.now() >= deadline) throw error;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const output = this.#buffer.slice(0, found.index);
|
|
173
|
+
this.#buffer = this.#buffer.slice(found.index + found[0].length);
|
|
174
|
+
this.#strays = sends > 1 ? { nonce: this.#nonce, count: sends - 1 } : undefined;
|
|
175
|
+
return cleanOutput(output);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Waits a short time for the answers that a resent sentinel of the last
|
|
181
|
+
* command can still give, and until an answer that has begun is whole. An
|
|
182
|
+
* answer that VICE holds until more input comes arrives whole inside the
|
|
183
|
+
* next command's output, where cleanOutput removes it.
|
|
184
|
+
*/
|
|
185
|
+
async #settleStrays(deadline: number, timeoutMs: number): Promise<void> {
|
|
186
|
+
const strays = this.#strays;
|
|
187
|
+
this.#strays = undefined;
|
|
188
|
+
if (strays === undefined) return;
|
|
189
|
+
const whole = new RegExp(sentinelPattern(strays.nonce).source, "g");
|
|
190
|
+
const begun = new RegExp(`\\+${strays.nonce}\\b|\\+\\d*$`);
|
|
191
|
+
const complete = () => ((this.#buffer.match(whole)?.length ?? 0) >= strays.count ? true : null);
|
|
192
|
+
const partial = () => begun.test(this.#buffer.replace(whole, ""));
|
|
193
|
+
try {
|
|
194
|
+
await this.#waitFor(complete, Math.min(deadline, Date.now() + this.sentinelResendMs), timeoutMs);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (!(error instanceof TextMonitorTimeoutError)) throw error;
|
|
197
|
+
if (partial()) await this.#waitFor(() => (partial() ? null : true), deadline, timeoutMs);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Waits until `probe` finds something in the received text, and returns what it found. */
|
|
202
|
+
async #waitFor<T>(probe: () => T | null, deadline: number, timeoutMs: number): Promise<T> {
|
|
203
|
+
for (;;) {
|
|
204
|
+
const found = probe();
|
|
205
|
+
if (found !== null) return found;
|
|
206
|
+
if (this.#socket.destroyed) throw new TextMonitorError("text monitor connection closed during a command");
|
|
207
|
+
const remaining = deadline - Date.now();
|
|
208
|
+
if (remaining <= 0) {
|
|
209
|
+
// The raw tail goes to the host log only; it is never shown to the LLM.
|
|
210
|
+
throw new TextMonitorTimeoutError(`text monitor command got no answer in ${timeoutMs} ms (received: ${JSON.stringify(this.#buffer.slice(-200))})`);
|
|
211
|
+
}
|
|
212
|
+
await new Promise<void>((resolve) => {
|
|
213
|
+
// No timer for a wait with no limit; a timer longer than Node allows would fire at once.
|
|
214
|
+
const timer = remaining === Infinity ? undefined : setTimeout(resolve, Math.min(remaining, MAX_TIMER_MS));
|
|
215
|
+
this.#wake = () => {
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
resolve();
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
this.#wake = undefined;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Autostart without the autostart's own RUN: the caller decides when the
|
|
2
|
+
// program starts, so keys it types later never mix with VICE's typing.
|
|
3
|
+
|
|
4
|
+
import { textToPetscii } from "../c64.ts";
|
|
5
|
+
import type { ViceSessionClient } from "./vice-session.ts";
|
|
6
|
+
|
|
7
|
+
/** How often, and for how long, a load is checked. */
|
|
8
|
+
export const LOAD_POLL_FRAMES = 25;
|
|
9
|
+
export const LOAD_LIMIT_FRAMES = 1500;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Autostarts the PRG at `path` with run false and advances frames until its
|
|
13
|
+
* first bytes are at its load address. Returns false when they do not arrive
|
|
14
|
+
* within LOAD_LIMIT_FRAMES.
|
|
15
|
+
*/
|
|
16
|
+
export async function autostartLoaded(client: ViceSessionClient, path: string, prg: Uint8Array): Promise<boolean> {
|
|
17
|
+
const load = prg[0]! | (prg[1]! << 8);
|
|
18
|
+
const head = Buffer.from(prg.subarray(2, Math.min(prg.length, 2 + 64))).toString("hex");
|
|
19
|
+
await client.autostart({ path, index: 0, run: false });
|
|
20
|
+
for (let frames = 0; frames < LOAD_LIMIT_FRAMES; frames += LOAD_POLL_FRAMES) {
|
|
21
|
+
await client.execution({ action: "advance-frames", count: LOAD_POLL_FRAMES, space: "c64" });
|
|
22
|
+
if ((await client.memoryRead({ address: load, size: head.length / 2, space: "c64", view: "ram" })).data === head) return true;
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** CURLIN high byte: $ff while BASIC waits in direct mode, a line number's high byte while a program runs. */
|
|
28
|
+
const CURLIN_HIGH = 0x003a;
|
|
29
|
+
export const RUN_LIMIT_FRAMES = 300;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Autostart that returns only when the program runs: load without VICE's
|
|
33
|
+
* own RUN, type RUN, and wait until BASIC runs a program line. A key that a
|
|
34
|
+
* later step types then reaches the program, never the READY prompt. Returns
|
|
35
|
+
* why it did not start, or undefined when it runs.
|
|
36
|
+
*/
|
|
37
|
+
export async function runProgram(client: ViceSessionClient, path: string, prg: Uint8Array): Promise<"not-loaded" | "not-started" | undefined> {
|
|
38
|
+
if (!(await autostartLoaded(client, path, prg))) return "not-loaded";
|
|
39
|
+
return (await typeRun(client)) ? undefined : "not-started";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Types RUN at the READY prompt and stops where BASIC starts the first line,
|
|
44
|
+
* before its SYS. Every tool that starts a loaded program this way reaches
|
|
45
|
+
* its entry at the same raster line and timer values (D62). False when BASIC
|
|
46
|
+
* runs no line within RUN_LIMIT_FRAMES.
|
|
47
|
+
*/
|
|
48
|
+
export async function typeRun(client: ViceSessionClient): Promise<boolean> {
|
|
49
|
+
await client.keyboard(textToPetscii("RUN\n"));
|
|
50
|
+
const started = await client.runUntil({
|
|
51
|
+
target: { kind: "memory", address: CURLIN_HIGH, operator: "ne", value: 0xff, space: "c64", view: "ram" },
|
|
52
|
+
timeoutFrames: RUN_LIMIT_FRAMES,
|
|
53
|
+
});
|
|
54
|
+
return started.reached;
|
|
55
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Deterministic connection to the Host Runtime and the version handshake.
|
|
2
|
+
|
|
3
|
+
import { connect as connectTcp, type Socket } from "node:net";
|
|
4
|
+
|
|
5
|
+
import { encodeFrame, MessageReader } from "../protocol/framing.ts";
|
|
6
|
+
import { elapsedMs, trace } from "../trace.ts";
|
|
7
|
+
import { DEFAULT_HOST_PORT, HEARTBEAT_INTERVAL_MS, HEARTBEAT_TIMEOUT_MS, HOST_PROTOCOL_ID, HOST_PROTOCOL_VERSION, parseHostMessage, ProtocolError, WireFailure, withStopAndAsk, type Hello, type HostMessage, type Ping, type Request, type Role, type VideoStandard } from "../protocol/messages.ts";
|
|
8
|
+
import { PACKAGE } from "../package.ts";
|
|
9
|
+
|
|
10
|
+
export interface Endpoint {
|
|
11
|
+
host: string;
|
|
12
|
+
port: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** How long one endpoint may take to accept a TCP connection. */
|
|
16
|
+
const CONNECT_TIMEOUT_MS = 1_500;
|
|
17
|
+
/**
|
|
18
|
+
* How long the host may take to answer hello. A VICE session starts its
|
|
19
|
+
* emulator first, and the host tries the start up to three times.
|
|
20
|
+
*/
|
|
21
|
+
export const DEFAULT_READY_TIMEOUT_MS = 120_000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The endpoints to try, in order: C64RT_HOST=host:port alone when set,
|
|
25
|
+
* else loopback, then the Docker and Podman host bridges.
|
|
26
|
+
*/
|
|
27
|
+
export function hostEndpoints(env: NodeJS.ProcessEnv = process.env): Endpoint[] {
|
|
28
|
+
const override = env.C64RT_HOST;
|
|
29
|
+
if (override !== undefined && override !== "") {
|
|
30
|
+
const match = /^(?:\[([^\]]+)\]|([^:]+)):(\d{1,5})$/.exec(override);
|
|
31
|
+
const port = match === null ? NaN : Number(match[3]);
|
|
32
|
+
if (match === null || !(port > 0 && port <= 0xffff)) {
|
|
33
|
+
throw new WireFailure("installation-incomplete", "C64RT_HOST must have the form host:port, for example 127.0.0.1:6464.");
|
|
34
|
+
}
|
|
35
|
+
return [{ host: match[1] ?? match[2]!, port }];
|
|
36
|
+
}
|
|
37
|
+
return ["127.0.0.1", "host.docker.internal", "host.containers.internal"].map((host) => ({ host, port: DEFAULT_HOST_PORT }));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A host that does not run or cannot be reached is machine-unavailable for every role (D37). */
|
|
41
|
+
function unreachable(): WireFailure {
|
|
42
|
+
return new WireFailure(
|
|
43
|
+
"machine-unavailable",
|
|
44
|
+
withStopAndAsk(
|
|
45
|
+
`The c64-re-tools host runtime is not running or cannot be reached. Start it on the host (npx -y --package=${PACKAGE.name}@${PACKAGE.tag} c64-re-tools-host) and try again.`,
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function stopped(): WireFailure {
|
|
51
|
+
return new WireFailure(
|
|
52
|
+
"machine-unavailable",
|
|
53
|
+
"The connection to the c64-re-tools host runtime stopped before the host runtime was ready.",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Resolves undefined when the endpoint refuses, does not answer in time, or `signal` stops the attempt. */
|
|
58
|
+
function tcpConnect(endpoint: Endpoint, signal: AbortSignal | undefined): Promise<Socket | undefined> {
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
const socket = connectTcp({ host: endpoint.host, port: endpoint.port });
|
|
61
|
+
const fail = () => finish(undefined);
|
|
62
|
+
const finish = (result: Socket | undefined) => {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
signal?.removeEventListener("abort", fail);
|
|
65
|
+
if (result === undefined) socket.destroy();
|
|
66
|
+
resolve(result);
|
|
67
|
+
};
|
|
68
|
+
const timer = setTimeout(fail, CONNECT_TIMEOUT_MS);
|
|
69
|
+
signal?.addEventListener("abort", fail, { once: true });
|
|
70
|
+
socket.once("connect", () => {
|
|
71
|
+
socket.removeAllListeners("error");
|
|
72
|
+
finish(socket);
|
|
73
|
+
});
|
|
74
|
+
socket.once("error", fail);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A handshaken, framed connection to the Host Runtime. */
|
|
79
|
+
export class HostConnection {
|
|
80
|
+
/** Settles when the connection is gone; carries the reason unless we closed it. */
|
|
81
|
+
readonly closed: Promise<Error | undefined>;
|
|
82
|
+
readonly #socket: Socket;
|
|
83
|
+
readonly #reader = new MessageReader();
|
|
84
|
+
#listener: ((message: HostMessage, attachments: Buffer[]) => void) | undefined;
|
|
85
|
+
#failure: Error | undefined;
|
|
86
|
+
#closedByUs = false;
|
|
87
|
+
#pingTimer: NodeJS.Timeout | undefined;
|
|
88
|
+
#silenceTimer: NodeJS.Timeout | undefined;
|
|
89
|
+
#heartbeat = false;
|
|
90
|
+
|
|
91
|
+
private constructor(socket: Socket) {
|
|
92
|
+
this.#socket = socket;
|
|
93
|
+
socket.setNoDelay(true);
|
|
94
|
+
socket.setKeepAlive(true, 10_000);
|
|
95
|
+
this.closed = new Promise((resolve) => {
|
|
96
|
+
socket.on("close", () => {
|
|
97
|
+
const reason = this.#closedByUs ? undefined : (this.#failure ?? new Error("the host runtime closed the connection"));
|
|
98
|
+
trace().event("host.closed", { byUs: this.#closedByUs, reason });
|
|
99
|
+
resolve(reason);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
socket.on("error", (error) => (this.#failure ??= error));
|
|
103
|
+
socket.on("close", () => {
|
|
104
|
+
clearInterval(this.#pingTimer);
|
|
105
|
+
clearTimeout(this.#silenceTimer);
|
|
106
|
+
});
|
|
107
|
+
socket.on("data", (chunk) => {
|
|
108
|
+
this.#restartSilence();
|
|
109
|
+
try {
|
|
110
|
+
for (const { message, attachments } of this.#reader.push(chunk)) {
|
|
111
|
+
const parsed = parseHostMessage(message);
|
|
112
|
+
if (parsed.type !== "pong") this.#listener?.(parsed, attachments);
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
// A broken frame, or a listener that throws, ends the connection with a
|
|
116
|
+
// reason; nothing escapes the socket handler.
|
|
117
|
+
this.#failure =
|
|
118
|
+
error instanceof ProtocolError
|
|
119
|
+
? error
|
|
120
|
+
: new Error(`could not handle a message from the host runtime: ${error instanceof Error ? error.message : String(error)}`);
|
|
121
|
+
socket.destroy();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Connects to the first endpoint that accepts, then handshakes. Once an
|
|
128
|
+
* endpoint accepts, its answer is final: no later endpoint is tried.
|
|
129
|
+
*/
|
|
130
|
+
static async open(options: {
|
|
131
|
+
role: Role;
|
|
132
|
+
videoStandard?: VideoStandard;
|
|
133
|
+
env?: NodeJS.ProcessEnv;
|
|
134
|
+
/** Stops the connect and the handshake at once; the open then fails and its socket is closed. */
|
|
135
|
+
signal?: AbortSignal;
|
|
136
|
+
}): Promise<HostConnection> {
|
|
137
|
+
const signal = options.signal;
|
|
138
|
+
const started = performance.now();
|
|
139
|
+
let socket: Socket | undefined;
|
|
140
|
+
for (const endpoint of hostEndpoints(options.env)) {
|
|
141
|
+
if (signal?.aborted) break;
|
|
142
|
+
const attempt = performance.now();
|
|
143
|
+
socket = await tcpConnect(endpoint, signal);
|
|
144
|
+
trace().event("host.connect", { ...endpoint, role: options.role, accepted: socket !== undefined, ms: elapsedMs(attempt) });
|
|
145
|
+
if (socket !== undefined) break;
|
|
146
|
+
}
|
|
147
|
+
if (signal?.aborted) {
|
|
148
|
+
socket?.destroy();
|
|
149
|
+
throw stopped();
|
|
150
|
+
}
|
|
151
|
+
if (socket === undefined) throw unreachable();
|
|
152
|
+
|
|
153
|
+
const connection = new HostConnection(socket);
|
|
154
|
+
const hello: Hello = { type: "hello", protocol: HOST_PROTOCOL_ID, version: HOST_PROTOCOL_VERSION, role: options.role };
|
|
155
|
+
if (options.videoStandard !== undefined) hello.videoStandard = options.videoStandard;
|
|
156
|
+
|
|
157
|
+
const stop = () => void connection.close();
|
|
158
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
159
|
+
const answer = await new Promise<HostMessage | Error>((resolve) => {
|
|
160
|
+
const timer = setTimeout(() => resolve(new Error("no answer to hello in time")), DEFAULT_READY_TIMEOUT_MS);
|
|
161
|
+
connection.#listener = (message) => {
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
resolve(message);
|
|
164
|
+
};
|
|
165
|
+
void connection.closed.then((reason) => {
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
resolve(reason ?? new Error("closed"));
|
|
168
|
+
});
|
|
169
|
+
connection.#send(hello);
|
|
170
|
+
});
|
|
171
|
+
connection.#listener = undefined;
|
|
172
|
+
signal?.removeEventListener("abort", stop);
|
|
173
|
+
if (signal?.aborted) {
|
|
174
|
+
await connection.close();
|
|
175
|
+
throw stopped();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!(answer instanceof Error) && answer.type === "ready") {
|
|
179
|
+
trace().event("host.ready", { role: options.role, ms: elapsedMs(started) });
|
|
180
|
+
connection.#startHeartbeat();
|
|
181
|
+
return connection;
|
|
182
|
+
}
|
|
183
|
+
trace().event("host.handshake-failed", { role: options.role, ms: elapsedMs(started), ...(answer instanceof Error ? { error: answer } : answer) });
|
|
184
|
+
await connection.close();
|
|
185
|
+
if (answer instanceof Error) {
|
|
186
|
+
if (answer instanceof ProtocolError) {
|
|
187
|
+
throw new WireFailure(
|
|
188
|
+
"installation-incomplete",
|
|
189
|
+
"The c64-re-tools host runtime is from another release. Stop the host runtime and start it again with npx, then restart the agent session. npx then starts the newest release on both sides.",
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
throw new WireFailure(
|
|
193
|
+
"machine-unavailable",
|
|
194
|
+
"The c64-re-tools host runtime did not finish the handshake. Check that it is running and try again.",
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (answer.type === "error") throw new WireFailure(answer.error.code, answer.error.message);
|
|
198
|
+
throw new WireFailure("installation-incomplete", "The c64-re-tools host runtime answered out of turn. Install the same c64-re-tools release on both sides.");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Receives every message after the handshake, with any attachments. */
|
|
202
|
+
onMessage(listener: (message: HostMessage, attachments: Buffer[]) => void): void {
|
|
203
|
+
this.#listener = listener;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
send(request: Request, attachments: readonly Uint8Array[] = []): void {
|
|
207
|
+
if (!this.#socket.destroyed) this.#socket.write(encodeFrame(request, attachments));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
close(): Promise<void> {
|
|
211
|
+
this.#closedByUs = true;
|
|
212
|
+
this.#socket.destroy();
|
|
213
|
+
return this.closed.then(() => {});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Pings the host so it knows this client is alive, and closes the connection
|
|
218
|
+
* when the host has sent nothing for the timeout: a host that vanished
|
|
219
|
+
* without a close then fails like a closed one. The timers never keep
|
|
220
|
+
* the process alive.
|
|
221
|
+
*/
|
|
222
|
+
#startHeartbeat(): void {
|
|
223
|
+
this.#heartbeat = true;
|
|
224
|
+
const ping: Ping = { type: "ping" };
|
|
225
|
+
this.#pingTimer = setInterval(() => this.#send(ping), HEARTBEAT_INTERVAL_MS).unref();
|
|
226
|
+
this.#restartSilence();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
#restartSilence(): void {
|
|
230
|
+
if (!this.#heartbeat || this.#socket.destroyed) return;
|
|
231
|
+
clearTimeout(this.#silenceTimer);
|
|
232
|
+
this.#silenceTimer = setTimeout(() => {
|
|
233
|
+
this.#failure ??= new Error(`the host runtime sent nothing for ${HEARTBEAT_TIMEOUT_MS} ms`);
|
|
234
|
+
this.#socket.destroy();
|
|
235
|
+
}, HEARTBEAT_TIMEOUT_MS).unref();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
#send(message: Hello | Request | Ping): void {
|
|
239
|
+
if (!this.#socket.destroyed) this.#socket.write(encodeFrame(message));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Reads the emulator's memory access map as coverage bytes (D40): one byte
|
|
2
|
+
// per address, RAM, ROM and I/O apart. The MCP tool and the skill scripts share it.
|
|
3
|
+
|
|
4
|
+
import { COVERAGE, type CodeRange } from "../c64.ts";
|
|
5
|
+
import { MAX_MEMMAP_RANGES, type MemmapRange } from "../protocol/vice.ts";
|
|
6
|
+
import { readProjectFile } from "./transfer.ts";
|
|
7
|
+
import type { ViceSessionClient } from "./vice-session.ts";
|
|
8
|
+
|
|
9
|
+
/** A full coverage file: offset n is address n (D40). */
|
|
10
|
+
export const FULL_COVERAGE = 0x10000;
|
|
11
|
+
|
|
12
|
+
/** Reads full coverage files from the project, one map per file. `fail` refuses a file of another size. */
|
|
13
|
+
export function readCoverageMaps(paths: string[], fail: (message: string) => never): Uint8Array[] {
|
|
14
|
+
return paths.map((path) => {
|
|
15
|
+
const { bytes } = readProjectFile(path);
|
|
16
|
+
if (bytes.length !== FULL_COVERAGE) {
|
|
17
|
+
fail(`--coverage ${path} has ${bytes.length} bytes; a full coverage file has 65536, one for each address. Write it with c64_memmap action read and out, without start and end.`);
|
|
18
|
+
}
|
|
19
|
+
return bytes;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Joins coverage maps: an address has every flag that any run gave it. */
|
|
24
|
+
export function joinCoverage(maps: readonly Uint8Array[]): Uint8Array {
|
|
25
|
+
const joined = new Uint8Array(FULL_COVERAGE);
|
|
26
|
+
for (const map of maps) for (let address = 0; address < FULL_COVERAGE; address++) joined[address]! |= map[address]!;
|
|
27
|
+
return joined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Reads full coverage files from the project and joins them: an address has
|
|
32
|
+
* every flag that any run gave it. `fail` refuses a file of another size.
|
|
33
|
+
*/
|
|
34
|
+
export function readCoverageFiles(paths: string[], fail: (message: string) => never): Uint8Array | undefined {
|
|
35
|
+
return paths.length === 0 ? undefined : joinCoverage(readCoverageMaps(paths, fail));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The coverage bytes of start..end, read from the session in parts. */
|
|
39
|
+
export async function readCoverage(session: Pick<ViceSessionClient, "memmap">, start = 0x0000, end = 0xffff): Promise<Uint8Array> {
|
|
40
|
+
const map = new Uint8Array(end - start + 1);
|
|
41
|
+
for (let from: number | undefined = start; from !== undefined; ) {
|
|
42
|
+
const part: { ranges: MemmapRange[]; next?: number } = await session.memmap({ action: "read", start: from, end, maxRanges: MAX_MEMMAP_RANGES });
|
|
43
|
+
for (const range of part.ranges) map.fill(range.flags, range.start - start, range.end - start + 1);
|
|
44
|
+
from = part.next;
|
|
45
|
+
}
|
|
46
|
+
return map;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Runs of RAM that a program wrote, at least `minimum` bytes each, in a map from $0000. */
|
|
50
|
+
export function writtenBlocks(map: Uint8Array, minimum: number): CodeRange[] {
|
|
51
|
+
const blocks: CodeRange[] = [];
|
|
52
|
+
let start = -1;
|
|
53
|
+
for (let address = 0; address <= map.length; address++) {
|
|
54
|
+
const written = address < map.length && (map[address]! & COVERAGE.ramWrite) !== 0;
|
|
55
|
+
if (written && start < 0) start = address;
|
|
56
|
+
if (!written && start >= 0) {
|
|
57
|
+
if (address - start >= minimum) blocks.push({ start, end: address - 1 });
|
|
58
|
+
start = -1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return blocks;
|
|
62
|
+
}
|
|
63
|
+
|