@henols/c64-re-tools 1.0.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,259 @@
|
|
|
1
|
+
// The message layer of the Host Runtime wire: protocol identity, error codes, the message shapes and
|
|
2
|
+
// their parsers, and the field checks that every validator uses.
|
|
3
|
+
|
|
4
|
+
import type { ToolOperation, ToolOperations } from "./tools.ts";
|
|
5
|
+
import type { ViceOperation, ViceOperations } from "./vice.ts";
|
|
6
|
+
|
|
7
|
+
/** Private Host Runtime protocol identifier. */
|
|
8
|
+
export const HOST_PROTOCOL_ID = "c64-re-tools-host" as const;
|
|
9
|
+
/** Bumped on any incompatible change; a mismatch fails the handshake. */
|
|
10
|
+
export const HOST_PROTOCOL_VERSION = 3;
|
|
11
|
+
/** After ready, the client pings this often, on every connection. */
|
|
12
|
+
export const HEARTBEAT_INTERVAL_MS = 10_000;
|
|
13
|
+
/** Either side closes a connection that has sent nothing for this long. */
|
|
14
|
+
export const HEARTBEAT_TIMEOUT_MS = 30_000;
|
|
15
|
+
/** The Host Runtime listens on 127.0.0.1 at this port by default. */
|
|
16
|
+
export const DEFAULT_HOST_PORT = 6464;
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Shared vocabulary
|
|
20
|
+
|
|
21
|
+
export const ERROR_CODES = [
|
|
22
|
+
"invalid-input",
|
|
23
|
+
"machine-running",
|
|
24
|
+
"machine-unavailable",
|
|
25
|
+
"machine-state-lost",
|
|
26
|
+
"not-found",
|
|
27
|
+
"media-error",
|
|
28
|
+
"limit-exceeded",
|
|
29
|
+
"unsupported-in-space",
|
|
30
|
+
"installation-incomplete",
|
|
31
|
+
"operation-failed",
|
|
32
|
+
] as const;
|
|
33
|
+
export type ErrorCode = (typeof ERROR_CODES)[number];
|
|
34
|
+
|
|
35
|
+
/** An actionable failure carried on the wire and shown to the LLM unchanged. */
|
|
36
|
+
export interface WireError {
|
|
37
|
+
code: ErrorCode;
|
|
38
|
+
message: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const ROLES = ["vice-session", "tool"] as const;
|
|
42
|
+
export type Role = (typeof ROLES)[number];
|
|
43
|
+
|
|
44
|
+
export const VIDEO_STANDARDS = ["pal", "ntsc"] as const;
|
|
45
|
+
export type VideoStandard = (typeof VIDEO_STANDARDS)[number];
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Messages
|
|
49
|
+
|
|
50
|
+
export interface Hello {
|
|
51
|
+
type: "hello";
|
|
52
|
+
protocol: string;
|
|
53
|
+
version: number;
|
|
54
|
+
role: Role;
|
|
55
|
+
/** vice-session only; fixed for the session. */
|
|
56
|
+
videoStandard?: VideoStandard;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Ready {
|
|
60
|
+
type: "ready";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Sent instead of `ready` when the handshake fails; the host then closes. */
|
|
64
|
+
export interface HandshakeError {
|
|
65
|
+
type: "error";
|
|
66
|
+
error: WireError;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface Request<O extends ViceOperation | ToolOperation = ViceOperation | ToolOperation> {
|
|
70
|
+
type: "request";
|
|
71
|
+
id: number;
|
|
72
|
+
op: O;
|
|
73
|
+
params: O extends ViceOperation ? ViceOperations[O]["params"] : O extends ToolOperation ? ToolOperations[O]["params"] : never;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type Reply =
|
|
77
|
+
| { type: "reply"; id: number; result: unknown }
|
|
78
|
+
| { type: "reply"; id: number; error: WireError };
|
|
79
|
+
|
|
80
|
+
/** Heartbeat: the client pings after ready, the host answers pong. Neither touches the session. */
|
|
81
|
+
export interface Ping {
|
|
82
|
+
type: "ping";
|
|
83
|
+
}
|
|
84
|
+
export interface Pong {
|
|
85
|
+
type: "pong";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type ClientMessage = Hello | Request | Ping;
|
|
89
|
+
|
|
90
|
+
/** A request as the host receives it: the operation is not checked yet, so the host answers an unknown one with invalid-input. */
|
|
91
|
+
export interface ReceivedRequest {
|
|
92
|
+
type: "request";
|
|
93
|
+
id: number;
|
|
94
|
+
op: string;
|
|
95
|
+
params: Fields;
|
|
96
|
+
}
|
|
97
|
+
export type ReceivedClientMessage = Hello | ReceivedRequest | Ping;
|
|
98
|
+
export type HostMessage = Ready | HandshakeError | Reply | Pong;
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Validation. A ProtocolError means the peer broke the contract; the receiver
|
|
102
|
+
// closes the connection. Bad operation parameters are a WireError instead.
|
|
103
|
+
|
|
104
|
+
export class ProtocolError extends Error {
|
|
105
|
+
override name = "ProtocolError";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Thrown by parameter/result validators; carries the wire code to answer with. */
|
|
109
|
+
/**
|
|
110
|
+
* The instruction at the end of a refusal that only the user can fix: a
|
|
111
|
+
* missing or broken tool, or a Host Runtime that does not run (D37).
|
|
112
|
+
*/
|
|
113
|
+
export const STOP_AND_ASK = "Stop the task. Give this message to the user and wait for the user. Do not make a replacement for the tool or the host runtime.";
|
|
114
|
+
|
|
115
|
+
/** The message with STOP_AND_ASK at its end, once. */
|
|
116
|
+
export function withStopAndAsk(message: string): string {
|
|
117
|
+
return message.endsWith(STOP_AND_ASK) ? message : `${message} ${STOP_AND_ASK}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The message that the agent sees for a refusal: an installation-incomplete one ends with STOP_AND_ASK. */
|
|
121
|
+
export function shownMessage(error: { code: string; message: string }): string {
|
|
122
|
+
return error.code === "installation-incomplete" ? withStopAndAsk(error.message) : error.message;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export class WireFailure extends Error implements WireError {
|
|
126
|
+
override name = "WireFailure";
|
|
127
|
+
readonly code: ErrorCode;
|
|
128
|
+
|
|
129
|
+
constructor(code: ErrorCode, message: string) {
|
|
130
|
+
super(message);
|
|
131
|
+
this.code = code;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
toWire(): WireError {
|
|
135
|
+
return { code: this.code, message: this.message };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The message of a caught value, which may not be an Error. */
|
|
140
|
+
export function describeError(error: unknown): string {
|
|
141
|
+
return error instanceof Error ? error.message : String(error);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export type Fields = Record<string, unknown>;
|
|
145
|
+
|
|
146
|
+
export function isObject(value: unknown): value is Fields {
|
|
147
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** True when `value` is one of `values`; narrows a string to the union. */
|
|
151
|
+
export function isOneOf<T extends string>(values: readonly T[], value: unknown): value is T {
|
|
152
|
+
return typeof value === "string" && (values as readonly string[]).includes(value);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function isInteger(value: unknown, min: number, max: number): value is number {
|
|
156
|
+
return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isRequestId(value: unknown): value is number {
|
|
160
|
+
return isInteger(value, 0, 0xffff_ffff);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isWireError(value: unknown): value is WireError {
|
|
164
|
+
return isObject(value) && isOneOf(ERROR_CODES, value.code) && typeof value.message === "string";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Validates a message the host received. Throws ProtocolError on any contract break. */
|
|
168
|
+
export function parseClientMessage(value: unknown): ReceivedClientMessage {
|
|
169
|
+
if (!isObject(value)) throw new ProtocolError("message is not an object");
|
|
170
|
+
if (value.type === "hello") {
|
|
171
|
+
// protocol/version are checked by the handshake, which answers a mismatch
|
|
172
|
+
// with installation-incomplete; here they only need their JSON types.
|
|
173
|
+
if (typeof value.protocol !== "string" || typeof value.version !== "number") {
|
|
174
|
+
throw new ProtocolError("hello lacks protocol or version");
|
|
175
|
+
}
|
|
176
|
+
if (!isOneOf(ROLES, value.role)) throw new ProtocolError("hello has an unknown role");
|
|
177
|
+
if (value.videoStandard !== undefined && !isOneOf(VIDEO_STANDARDS, value.videoStandard)) {
|
|
178
|
+
throw new ProtocolError("hello has an unknown video standard");
|
|
179
|
+
}
|
|
180
|
+
const hello: Hello = { type: "hello", protocol: value.protocol, version: value.version, role: value.role };
|
|
181
|
+
if (value.videoStandard !== undefined) hello.videoStandard = value.videoStandard;
|
|
182
|
+
return hello;
|
|
183
|
+
}
|
|
184
|
+
if (value.type === "request") {
|
|
185
|
+
if (!isRequestId(value.id)) throw new ProtocolError("request id is not a 32-bit unsigned integer");
|
|
186
|
+
if (typeof value.op !== "string") throw new ProtocolError("request op is not a string");
|
|
187
|
+
if (!isObject(value.params)) throw new ProtocolError("request params is not an object");
|
|
188
|
+
return { type: "request", id: value.id, op: value.op, params: value.params };
|
|
189
|
+
}
|
|
190
|
+
if (value.type === "ping") return { type: "ping" };
|
|
191
|
+
throw new ProtocolError("unknown message type");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Validates a message the client received. Throws ProtocolError on any contract break. */
|
|
195
|
+
export function parseHostMessage(value: unknown): HostMessage {
|
|
196
|
+
if (!isObject(value)) throw new ProtocolError("message is not an object");
|
|
197
|
+
if (value.type === "ready") return { type: "ready" };
|
|
198
|
+
if (value.type === "pong") return { type: "pong" };
|
|
199
|
+
if (value.type === "error") {
|
|
200
|
+
if (!isWireError(value.error)) throw new ProtocolError("handshake error is malformed");
|
|
201
|
+
return { type: "error", error: { code: value.error.code, message: value.error.message } };
|
|
202
|
+
}
|
|
203
|
+
if (value.type === "reply") {
|
|
204
|
+
if (!isRequestId(value.id)) throw new ProtocolError("reply id is not a 32-bit unsigned integer");
|
|
205
|
+
if ("error" in value) {
|
|
206
|
+
if (!isWireError(value.error)) throw new ProtocolError("reply error is malformed");
|
|
207
|
+
return { type: "reply", id: value.id, error: { code: value.error.code, message: value.error.message } };
|
|
208
|
+
}
|
|
209
|
+
if (!("result" in value)) throw new ProtocolError("reply has neither result nor error");
|
|
210
|
+
return { type: "reply", id: value.id, result: value.result };
|
|
211
|
+
}
|
|
212
|
+
throw new ProtocolError("unknown message type");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Checks a hello against this build's protocol. Returns the failure to send, or undefined. */
|
|
216
|
+
/**
|
|
217
|
+
* Refuses a client of another protocol version, and names the older side and
|
|
218
|
+
* how to update it. A release that changes the wire increments the protocol
|
|
219
|
+
* version, so the first client of a new release refuses an older host.
|
|
220
|
+
*/
|
|
221
|
+
export function checkHello(hello: Hello): WireError | undefined {
|
|
222
|
+
if (hello.protocol !== HOST_PROTOCOL_ID) {
|
|
223
|
+
return {
|
|
224
|
+
code: "installation-incomplete",
|
|
225
|
+
message:
|
|
226
|
+
"The c64-re-tools host runtime and this client come from different installations. " +
|
|
227
|
+
"Install the same c64-re-tools release for both and restart the host runtime.",
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
if (hello.version > HOST_PROTOCOL_VERSION) {
|
|
231
|
+
return {
|
|
232
|
+
code: "installation-incomplete",
|
|
233
|
+
message:
|
|
234
|
+
"The c64-re-tools host runtime is from an older release than this client. " +
|
|
235
|
+
"Stop the host runtime and start it again with the same npx command. npx then starts the newest release.",
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
if (hello.version < HOST_PROTOCOL_VERSION) {
|
|
239
|
+
return {
|
|
240
|
+
code: "installation-incomplete",
|
|
241
|
+
message:
|
|
242
|
+
"This client is from an older release of c64-re-tools than the host runtime. " +
|
|
243
|
+
"Restart the agent session, so that npx starts the newest MCP server. " +
|
|
244
|
+
"For the skill scripts, run c64-re-tools update in the project.",
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function invalid(message: string): never {
|
|
251
|
+
throw new WireFailure("invalid-input", message);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Refuses a field that is not in `allowed`, the same way for every operation. */
|
|
255
|
+
export function onlyFields(value: Fields, allowed: readonly string[], where?: string): void {
|
|
256
|
+
for (const key of Object.keys(value)) {
|
|
257
|
+
if (!allowed.includes(key)) invalid(where === undefined ? `unknown field: ${key}` : `unknown field in ${where}: ${key}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// The native-tool operations of the Host Runtime wire ("tool" connections): their shapes and validators.
|
|
2
|
+
|
|
3
|
+
import { textToPetsciiName, type BasicHandoff } from "../c64.ts";
|
|
4
|
+
import { type Fields, invalid, isInteger, isObject, isOneOf, onlyFields, ProtocolError } from "./messages.ts";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Native-tool operations. Short-lived requests on "tool" connections; they
|
|
8
|
+
// never touch an emulator or knowledge.db.
|
|
9
|
+
|
|
10
|
+
/** A transferred source tree: relative POSIX paths, one attachment per file in this order. */
|
|
11
|
+
export type SourceTree = Array<{ path: string; size: number }>;
|
|
12
|
+
export const MAX_TREE_FILES = 4096;
|
|
13
|
+
|
|
14
|
+
export const DISK_IMAGE_TYPES = ["d64", "d71", "d81", "g64"] as const;
|
|
15
|
+
export type DiskImageType = (typeof DISK_IMAGE_TYPES)[number];
|
|
16
|
+
export const DISK_ACTIONS = ["directory", "bam", "entry", "chain", "read"] as const;
|
|
17
|
+
export type DiskAction = (typeof DISK_ACTIONS)[number];
|
|
18
|
+
export const CBM_FILE_TYPES = ["del", "seq", "prg", "usr", "rel", "unknown"] as const;
|
|
19
|
+
export type CbmFileType = (typeof CBM_FILE_TYPES)[number];
|
|
20
|
+
|
|
21
|
+
/** c1541.inspect. The image bytes are the request's one attachment. */
|
|
22
|
+
export interface C1541Params {
|
|
23
|
+
action: DiskAction;
|
|
24
|
+
imageType: DiskImageType;
|
|
25
|
+
/** entry, chain and read only: the file name as petsciiNameToText shows it. */
|
|
26
|
+
name?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DiskFile {
|
|
30
|
+
name: string;
|
|
31
|
+
type: CbmFileType;
|
|
32
|
+
blocks: number;
|
|
33
|
+
closed: boolean;
|
|
34
|
+
locked: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface DiskSector {
|
|
38
|
+
track: number;
|
|
39
|
+
sector: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type C1541Result =
|
|
43
|
+
| { action: "directory"; diskName: string; diskId: string; dosType: string; freeBlocks: number; entries: DiskFile[] }
|
|
44
|
+
/** unreferenced: the used sectors that no file, the header, the directory or the BAM holds. */
|
|
45
|
+
| { action: "bam"; tracks: Array<{ track: number; freeSectors: number[]; usedSectors: number[] }>; unreferenced: DiskSector[] }
|
|
46
|
+
| { action: "entry"; found: false }
|
|
47
|
+
| { action: "entry"; found: true; entry: DiskFile & { startTrack: number; startSector: number } }
|
|
48
|
+
| { action: "chain"; found: false }
|
|
49
|
+
| { action: "chain"; found: true; sectors: DiskSector[] }
|
|
50
|
+
| { action: "read"; found: false }
|
|
51
|
+
/** The file's bytes are the reply's attachment. */
|
|
52
|
+
| { action: "read"; found: true; name: string; bytes: number };
|
|
53
|
+
|
|
54
|
+
/** petcat.decode takes no fields: the program bytes are the request's one attachment. */
|
|
55
|
+
export type PetcatParams = Record<string, never>;
|
|
56
|
+
|
|
57
|
+
/** A machine-code handoff: SYS with a constant address, or SYS/USR whose target is computed at run time. */
|
|
58
|
+
export type { BasicHandoff };
|
|
59
|
+
|
|
60
|
+
export type PetcatResult =
|
|
61
|
+
| { decoded: false; reason: string }
|
|
62
|
+
| {
|
|
63
|
+
decoded: true;
|
|
64
|
+
loadAddress: number;
|
|
65
|
+
/** The first address that holds no BASIC: after a 00 00 end marker, or at a link whose high byte only is zero. */
|
|
66
|
+
basicEnd: number;
|
|
67
|
+
listing: string;
|
|
68
|
+
lines: Array<{ number: number; text: string }>;
|
|
69
|
+
handoffs: BasicHandoff[];
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** One native tool on the host, as host.status found and ran it. */
|
|
73
|
+
export interface ToolStatus {
|
|
74
|
+
name: string;
|
|
75
|
+
found: boolean;
|
|
76
|
+
/** Where it is, when found. */
|
|
77
|
+
path?: string;
|
|
78
|
+
/** Its version text, when it ran. */
|
|
79
|
+
version?: string;
|
|
80
|
+
runs: boolean;
|
|
81
|
+
/** What is wrong and what to do, when it is missing or does not run. */
|
|
82
|
+
problem?: string;
|
|
83
|
+
/** Known flaws of this version, each with the version that fixes it (D56). The tool still runs. */
|
|
84
|
+
flaws?: string[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The Host Runtime itself: its package version and the system it runs on. */
|
|
88
|
+
export interface HostInfo {
|
|
89
|
+
version: string;
|
|
90
|
+
/** Node's name of the operating system: linux, darwin or win32. */
|
|
91
|
+
platform: string;
|
|
92
|
+
/** The release of the operating system. */
|
|
93
|
+
release: string;
|
|
94
|
+
arch: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ToolOperations {
|
|
98
|
+
"host.status": { params: Record<string, never>; result: { host: HostInfo; tools: ToolStatus[] } };
|
|
99
|
+
"c1541.inspect": { params: C1541Params; result: C1541Result };
|
|
100
|
+
"petcat.decode": { params: PetcatParams; result: PetcatResult };
|
|
101
|
+
}
|
|
102
|
+
export type ToolOperation = keyof ToolOperations;
|
|
103
|
+
export const TOOL_OPERATIONS = ["host.status", "c1541.inspect", "petcat.decode"] as const satisfies readonly ToolOperation[];
|
|
104
|
+
|
|
105
|
+
const RELATIVE_PATH = /^(?!\/)(?!.*(?:^|\/)\.\.?(?:\/|$))[^\0\\]+$/;
|
|
106
|
+
|
|
107
|
+
/** A relative POSIX path with no empty, "." or ".." segment and no backslash. */
|
|
108
|
+
export function isRelativePath(value: unknown): value is string {
|
|
109
|
+
return typeof value === "string" && value.length <= 1024 && RELATIVE_PATH.test(value) && !value.includes("//") && !value.endsWith("/");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Validates a transferred source tree against its attachments. Throws WireFailure(invalid-input). */
|
|
113
|
+
export function validateSourceTree(value: unknown, attachments: readonly Uint8Array[]): SourceTree {
|
|
114
|
+
if (!Array.isArray(value) || value.length > MAX_TREE_FILES) invalid(`files must list at most ${MAX_TREE_FILES} files`);
|
|
115
|
+
if (value.length !== attachments.length) invalid("each listed file needs exactly one attachment");
|
|
116
|
+
const seen = new Set<string>();
|
|
117
|
+
return value.map((entry, index) => {
|
|
118
|
+
if (!isObject(entry) || !isRelativePath(entry.path)) invalid("each file needs a relative path inside the source root");
|
|
119
|
+
if (seen.has(entry.path)) invalid(`the file ${entry.path} is listed twice`);
|
|
120
|
+
seen.add(entry.path);
|
|
121
|
+
if (entry.size !== attachments[index]!.length) invalid(`the size of ${entry.path} does not match its bytes`);
|
|
122
|
+
return { path: entry.path, size: entry.size as number };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Refuses anything but exactly one attachment with at least one byte, named by what it must hold. */
|
|
127
|
+
function oneFile(attachments: readonly Uint8Array[], what: string): void {
|
|
128
|
+
if (attachments.length !== 1 || attachments[0]!.length === 0) invalid(`${what} must be the one attachment, and it must not be empty`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Validates tool request parameters on the host. Throws WireFailure(invalid-input). */
|
|
132
|
+
export function validateToolParams<O extends ToolOperation>(op: O, params: unknown, attachments: readonly Uint8Array[]): ToolOperations[O]["params"] {
|
|
133
|
+
if (!isObject(params)) invalid("parameters must be an object");
|
|
134
|
+
switch (op) {
|
|
135
|
+
case "c1541.inspect": {
|
|
136
|
+
onlyFields(params, ["action", "imageType", "name"]);
|
|
137
|
+
if (!isOneOf(DISK_ACTIONS, params.action)) invalid(`action must be one of ${DISK_ACTIONS.join(", ")}`);
|
|
138
|
+
if (!isOneOf(DISK_IMAGE_TYPES, params.imageType)) invalid(`imageType must be one of ${DISK_IMAGE_TYPES.join(", ")}`);
|
|
139
|
+
oneFile(attachments, "the disk image");
|
|
140
|
+
const result: C1541Params = { action: params.action, imageType: params.imageType };
|
|
141
|
+
const named = params.action === "entry" || params.action === "chain" || params.action === "read";
|
|
142
|
+
if (!named) {
|
|
143
|
+
if (params.name !== undefined) invalid(`action ${params.action} takes no name`);
|
|
144
|
+
} else {
|
|
145
|
+
if (typeof params.name !== "string" || params.name.length === 0) invalid(`action ${params.action} needs a file name`);
|
|
146
|
+
let bytes: Uint8Array;
|
|
147
|
+
try {
|
|
148
|
+
bytes = textToPetsciiName(params.name);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
return invalid(`name: ${(error as Error).message}`);
|
|
151
|
+
}
|
|
152
|
+
if (bytes.length > 16) invalid("a CBM file name has at most 16 characters");
|
|
153
|
+
result.name = params.name;
|
|
154
|
+
}
|
|
155
|
+
return result as ToolOperations[O]["params"];
|
|
156
|
+
}
|
|
157
|
+
case "petcat.decode": {
|
|
158
|
+
onlyFields(params, []);
|
|
159
|
+
oneFile(attachments, "the program");
|
|
160
|
+
return {} as ToolOperations[O]["params"];
|
|
161
|
+
}
|
|
162
|
+
case "host.status":
|
|
163
|
+
onlyFields(params, []);
|
|
164
|
+
if (attachments.length !== 0) invalid("host.status takes no attachment");
|
|
165
|
+
return {} as ToolOperations[O]["params"];
|
|
166
|
+
}
|
|
167
|
+
return invalid(`unknown tool operation: ${String(op)}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Validates a tool result on the client. Throws ProtocolError. */
|
|
171
|
+
export function validateToolResult<O extends ToolOperation>(op: O, value: unknown, attachments: readonly Uint8Array[]): ToolOperations[O]["result"] {
|
|
172
|
+
if (!isObject(value)) throw new ProtocolError(`${op} result is not an object`);
|
|
173
|
+
switch (op) {
|
|
174
|
+
case "c1541.inspect": {
|
|
175
|
+
if (!isC1541Result(value, attachments)) throw new ProtocolError("c1541.inspect result is malformed");
|
|
176
|
+
return value as unknown as ToolOperations[O]["result"];
|
|
177
|
+
}
|
|
178
|
+
case "petcat.decode": {
|
|
179
|
+
if (!isPetcatResult(value) || attachments.length !== 0) throw new ProtocolError("petcat.decode result is malformed");
|
|
180
|
+
return value as unknown as ToolOperations[O]["result"];
|
|
181
|
+
}
|
|
182
|
+
case "host.status": {
|
|
183
|
+
const tools = value.tools;
|
|
184
|
+
const isStatus = (tool: unknown) =>
|
|
185
|
+
isObject(tool) &&
|
|
186
|
+
typeof tool.name === "string" &&
|
|
187
|
+
typeof tool.found === "boolean" &&
|
|
188
|
+
typeof tool.runs === "boolean" &&
|
|
189
|
+
[tool.path, tool.version, tool.problem].every((field) => field === undefined || typeof field === "string") &&
|
|
190
|
+
(tool.flaws === undefined || (Array.isArray(tool.flaws) && tool.flaws.every((flaw: unknown) => typeof flaw === "string")));
|
|
191
|
+
const host = value.host;
|
|
192
|
+
const isHost = isObject(host) && [host.version, host.platform, host.release, host.arch].every((field) => typeof field === "string");
|
|
193
|
+
if (!isHost || !Array.isArray(tools) || !tools.every(isStatus) || attachments.length !== 0) throw new ProtocolError("host.status result is malformed");
|
|
194
|
+
return value as unknown as ToolOperations[O]["result"];
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
throw new ProtocolError(`unknown tool operation: ${String(op)}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const isTrackSector = (value: unknown): boolean => isObject(value) && isInteger(value.track, 0, 255) && isInteger(value.sector, 0, 255);
|
|
201
|
+
const isSectorList = (value: unknown): boolean => Array.isArray(value) && value.every((item) => isInteger(item, 0, 255));
|
|
202
|
+
|
|
203
|
+
function isDiskFile(value: unknown): value is DiskFile {
|
|
204
|
+
return (
|
|
205
|
+
isObject(value) &&
|
|
206
|
+
typeof value.name === "string" &&
|
|
207
|
+
isOneOf(CBM_FILE_TYPES, value.type) &&
|
|
208
|
+
isInteger(value.blocks, 0, 0xffff) &&
|
|
209
|
+
typeof value.closed === "boolean" &&
|
|
210
|
+
typeof value.locked === "boolean"
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isC1541Result(value: Fields, attachments: readonly Uint8Array[]): boolean {
|
|
215
|
+
const carries = value.action === "read" && value.found === true ? 1 : 0;
|
|
216
|
+
if (attachments.length !== carries) return false;
|
|
217
|
+
switch (value.action) {
|
|
218
|
+
case "directory":
|
|
219
|
+
return (
|
|
220
|
+
typeof value.diskName === "string" &&
|
|
221
|
+
typeof value.diskId === "string" &&
|
|
222
|
+
typeof value.dosType === "string" &&
|
|
223
|
+
isInteger(value.freeBlocks, 0, 0xffff) &&
|
|
224
|
+
Array.isArray(value.entries) &&
|
|
225
|
+
value.entries.every(isDiskFile)
|
|
226
|
+
);
|
|
227
|
+
case "bam":
|
|
228
|
+
return (
|
|
229
|
+
Array.isArray(value.tracks) &&
|
|
230
|
+
value.tracks.every((track) => isObject(track) && isInteger(track.track, 1, 255) && isSectorList(track.freeSectors) && isSectorList(track.usedSectors)) &&
|
|
231
|
+
Array.isArray(value.unreferenced) &&
|
|
232
|
+
value.unreferenced.every(isTrackSector)
|
|
233
|
+
);
|
|
234
|
+
case "entry":
|
|
235
|
+
return (
|
|
236
|
+
value.found === false ||
|
|
237
|
+
(value.found === true && isObject(value.entry) && isDiskFile(value.entry) && isTrackSector({ track: value.entry.startTrack, sector: value.entry.startSector }))
|
|
238
|
+
);
|
|
239
|
+
case "chain":
|
|
240
|
+
return value.found === false || (value.found === true && Array.isArray(value.sectors) && value.sectors.every(isTrackSector));
|
|
241
|
+
case "read":
|
|
242
|
+
return value.found === false || (value.found === true && typeof value.name === "string" && value.bytes === attachments[0]!.length);
|
|
243
|
+
}
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function isPetcatResult(value: Fields): boolean {
|
|
248
|
+
if (value.decoded === false) return typeof value.reason === "string";
|
|
249
|
+
const isLine = (line: unknown) => isObject(line) && isInteger(line.number, 0, 0xffff) && typeof line.text === "string";
|
|
250
|
+
const isHandoff = (handoff: unknown) =>
|
|
251
|
+
isObject(handoff) &&
|
|
252
|
+
isInteger(handoff.line, 0, 0xffff) &&
|
|
253
|
+
((handoff.kind === "sys" && isInteger(handoff.address, 0, 0xffff) && handoff.computed === undefined) ||
|
|
254
|
+
(handoff.kind === "sys" && handoff.computed === true && handoff.address === undefined && typeof handoff.expression === "string") ||
|
|
255
|
+
(handoff.kind === "usr" && handoff.computed === true && handoff.address === undefined));
|
|
256
|
+
return (
|
|
257
|
+
value.decoded === true &&
|
|
258
|
+
isInteger(value.loadAddress, 0, 0xffff) &&
|
|
259
|
+
isInteger(value.basicEnd, 0, 0x10000) &&
|
|
260
|
+
typeof value.listing === "string" &&
|
|
261
|
+
Array.isArray(value.lines) &&
|
|
262
|
+
value.lines.every(isLine) &&
|
|
263
|
+
Array.isArray(value.handoffs) &&
|
|
264
|
+
value.handoffs.every(isHandoff)
|
|
265
|
+
);
|
|
266
|
+
}
|