@henols/vice-mcp 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/THIRD-PARTY-NOTICES.md +113 -0
- package/backend-detect.mts +595 -0
- package/build.ts +1 -0
- package/disasm-decoder.ts +248 -0
- package/disasm-opcodes.ts +464 -0
- package/disasm-renderer.ts +306 -0
- package/package.json +27 -2
- package/refresh-manifest.ts +23 -2
- package/resources/backend-detect.mjs +396 -0
- package/resources/broker-control.mjs +110 -8
- package/resources/broker-kill.mjs +114 -103
- package/resources/broker-launch.mjs +334 -19
- package/resources/broker-state.mjs +11 -0
- package/resources/vice-broker.mjs +185 -14
- package/stock-address.ts +219 -0
- package/stock-checkpoints.ts +794 -0
- package/stock-condition.ts +636 -0
- package/stock-connect.ts +427 -0
- package/stock-derived.ts +122 -0
- package/stock-disassemble.ts +252 -0
- package/stock-dispatch.ts +640 -0
- package/stock-execution.ts +327 -0
- package/stock-handler.ts +175 -0
- package/stock-input.ts +274 -0
- package/stock-machine.ts +357 -0
- package/stock-memory.ts +323 -0
- package/stock-paths.ts +191 -0
- package/stock-petscii.ts +143 -0
- package/stock-protocol.ts +2057 -0
- package/stock-registers.ts +324 -0
- package/stock-runstate.ts +104 -0
- package/tools-manifest.json +1 -9
- package/tools-manifest.stock.json +841 -0
- package/vice-broker-client.ts +233 -7
- package/vice-proxy.ts +202 -17
package/stock-memory.ts
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// stock-memory.ts
|
|
3
|
+
//
|
|
4
|
+
// THE memory half of Family A on the stock backend (D-03/DIRECT-01/
|
|
5
|
+
// DIRECT-09): vice_memory_read, vice_memory_write, and vice_memory_banks,
|
|
6
|
+
// as StockSessionHandler-shaped exports, plus the per-session bank catalog
|
|
7
|
+
// all three share.
|
|
8
|
+
//
|
|
9
|
+
// WHY THIS FILE EXISTS: this is the first half of phase success criterion
|
|
10
|
+
// 1 -- reads must be side-effect-free by default and must not force a
|
|
11
|
+
// pause/resume round trip. On stock there is no round trip at all: the
|
|
12
|
+
// command halts the machine (D-05) and the answer reports `runState`
|
|
13
|
+
// (D-06) via stock-handler.ts's stockAnswer(). A named `bank` argument
|
|
14
|
+
// (the fork's own "e.g. 'ram' to read RAM under ROM" convention) must
|
|
15
|
+
// resolve to a wire bank id through the emulator's own BANKS_AVAILABLE
|
|
16
|
+
// enumeration -- RESEARCH.md's "reading them beats hardcoding a guess" --
|
|
17
|
+
// never a hardcoded table, since bank ids are build- and machine-specific.
|
|
18
|
+
//
|
|
19
|
+
// WHAT NOT TO DO:
|
|
20
|
+
// - Never build a MEM_GET/MEM_SET body by hand -- stock-protocol.ts's
|
|
21
|
+
// memGetBody()/memSetBody() are the only encoders used here.
|
|
22
|
+
// - Never re-derive address parsing locally (D-04) -- stock-address.ts's
|
|
23
|
+
// parseAddress()/parseByteCount() are the only seam.
|
|
24
|
+
// - Never construct an ok-answer outside stockAnswer() (D-06) -- that is
|
|
25
|
+
// exactly how an answer ships without `runState`.
|
|
26
|
+
// - Never send an EXIT to "restore" the machine after a read (D-05) -- a
|
|
27
|
+
// read leaves the machine halted, and the answer says so via runState.
|
|
28
|
+
import { CommandType, memGetBody, memSetBody } from "./stock-protocol.ts";
|
|
29
|
+
import { parseAddress, parseByteCount } from "./stock-address.ts";
|
|
30
|
+
import { convertWireError, isErrorText, stockAnswer, type StockSessionHandler, type StockToolResult } from "./stock-handler.ts";
|
|
31
|
+
import type { StockConnectSession } from "./stock-connect.ts";
|
|
32
|
+
|
|
33
|
+
/** True iff `value` is a well-formed, generic JSON object -- not null, not
|
|
34
|
+
* an array. Matches vice.ts's own isPlainObject() predicate exactly -- the
|
|
35
|
+
* same narrowing discipline this module tree uses everywhere a parsed JSON
|
|
36
|
+
* value's fields are touched (03-PATTERNS.md). */
|
|
37
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
38
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// The bank catalog (Task 2) -- a per-session, lazily-fetched map between a
|
|
43
|
+
// bank's wire id and its name, backed by ONE module-level cache keyed on
|
|
44
|
+
// the session object (see freshCatalogCache() below for the single
|
|
45
|
+
// definition of that cache's storage). A stockReconnect() builds a fresh
|
|
46
|
+
// session and therefore naturally gets a fresh catalog, with no manual
|
|
47
|
+
// invalidation. Never a module-level singleton, and never a persisted
|
|
48
|
+
// record: this is in-memory, per-connection state, unlike
|
|
49
|
+
// stock-connect.ts's resolveCapabilities() (settle-once, cache-on-disk).
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
export interface BankCatalog {
|
|
53
|
+
byName: Map<string, number>;
|
|
54
|
+
byId: Map<number, string>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The one place this file's per-session cache storage is defined -- an
|
|
58
|
+
* object-keyed cache that never prevents a dropped session from being
|
|
59
|
+
* garbage-collected. Both the module-level holder below and
|
|
60
|
+
* resetBankCatalogsForTest() call this rather than repeating the
|
|
61
|
+
* constructor inline. */
|
|
62
|
+
function freshCatalogCache(): WeakMap<object, BankCatalog> { return new WeakMap(); }
|
|
63
|
+
|
|
64
|
+
let bankCatalogs = freshCatalogCache();
|
|
65
|
+
|
|
66
|
+
/** Test-only: replaces the module-level cache with a fresh one, matching
|
|
67
|
+
* clearHeldStockSession()'s / resetRunStateTrackersForTest()'s role in this
|
|
68
|
+
* module tree's beforeEach() convention. */
|
|
69
|
+
export function resetBankCatalogsForTest(): void {
|
|
70
|
+
bankCatalogs = freshCatalogCache();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolves (and caches, per session) the emulator's own bank enumeration.
|
|
75
|
+
* On a cache miss, sends BANKS_AVAILABLE (0x82) with no body -- the opcode
|
|
76
|
+
* takes an empty body, and client.send() already defaults to
|
|
77
|
+
* Buffer.alloc(0), so there is no dedicated wire-body encoder to invent for
|
|
78
|
+
* this command. Bank names are matched case-insensitively on lookup (the
|
|
79
|
+
* lowercased name is the map key; the wire's own spelling is kept in
|
|
80
|
+
* `byId` for reporting), because the fork's own tool description uses
|
|
81
|
+
* lowercase 'ram'.
|
|
82
|
+
*/
|
|
83
|
+
export async function bankCatalogFor(session: StockConnectSession): Promise<BankCatalog> {
|
|
84
|
+
const existing = bankCatalogs.get(session);
|
|
85
|
+
if (existing) {
|
|
86
|
+
return existing;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const response = await session.client.send(CommandType.BanksAvailable);
|
|
90
|
+
if (response.type !== "banks_available") {
|
|
91
|
+
throw new Error(`bankCatalogFor: expected a "banks_available" reply, got "${response.type}"`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const byName = new Map<string, number>();
|
|
95
|
+
const byId = new Map<number, string>();
|
|
96
|
+
for (const bank of response.banks) {
|
|
97
|
+
byName.set(bank.name.toLowerCase(), bank.id);
|
|
98
|
+
byId.set(bank.id, bank.name);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const catalog: BankCatalog = { byName, byId };
|
|
102
|
+
bankCatalogs.set(session, catalog);
|
|
103
|
+
return catalog;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Shared bank-argument resolution for both memory handlers below: omitted
|
|
107
|
+
* `bank` resolves to wire id 0x0000, a non-string `bank` refuses, and an
|
|
108
|
+
* unknown name refuses listing the names the catalog actually returned --
|
|
109
|
+
* never a hardcoded table. Factored once so handleMemoryRead/Write do not
|
|
110
|
+
* each re-derive the same three branches. */
|
|
111
|
+
async function resolveBank(
|
|
112
|
+
toolName: string,
|
|
113
|
+
bankArg: unknown,
|
|
114
|
+
session: StockConnectSession,
|
|
115
|
+
): Promise<{ ok: true; id: number; name?: string } | { ok: false; result: StockToolResult }> {
|
|
116
|
+
if (bankArg === undefined) {
|
|
117
|
+
return { ok: true, id: 0x0000 };
|
|
118
|
+
}
|
|
119
|
+
if (typeof bankArg !== "string") {
|
|
120
|
+
return { ok: false, result: isErrorText(`${toolName}: bank must be a string, got ${typeof bankArg}`) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let catalog: BankCatalog;
|
|
124
|
+
try {
|
|
125
|
+
catalog = await bankCatalogFor(session);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
return { ok: false, result: convertWireError(toolName, err) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const resolved = catalog.byName.get(bankArg.toLowerCase());
|
|
131
|
+
if (resolved === undefined) {
|
|
132
|
+
const names = [...catalog.byId.values()].join(", ") || "(none reported)";
|
|
133
|
+
return { ok: false, result: isErrorText(`${toolName}: unknown bank "${bankArg}" -- available banks: ${names}`) };
|
|
134
|
+
}
|
|
135
|
+
return { ok: true, id: resolved, name: bankArg };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// vice_memory_banks (Task 2)
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
export const handleMemoryBanks: StockSessionHandler = async (args, session, _deps) => {
|
|
143
|
+
if (!isPlainObject(args)) {
|
|
144
|
+
return isErrorText("vice_memory_banks: arguments must be an object");
|
|
145
|
+
}
|
|
146
|
+
const unexpected = Object.keys(args);
|
|
147
|
+
if (unexpected.length > 0) {
|
|
148
|
+
return isErrorText(`vice_memory_banks: unexpected argument(s): ${unexpected.join(", ")} -- this tool takes no arguments`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let catalog: BankCatalog;
|
|
152
|
+
try {
|
|
153
|
+
catalog = await bankCatalogFor(session);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
return convertWireError("vice_memory_banks", err);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const banks = [...catalog.byId.entries()].map(([id, name]) => ({ id, name }));
|
|
159
|
+
return stockAnswer(session.client, { banks, count: banks.length });
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// vice_memory_read (Task 1)
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
export const handleMemoryRead: StockSessionHandler = async (args, session, _deps) => {
|
|
167
|
+
if (!isPlainObject(args)) {
|
|
168
|
+
return isErrorText("vice_memory_read: arguments must be an object");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let address: number;
|
|
172
|
+
try {
|
|
173
|
+
address = parseAddress(args.address, { what: "address" });
|
|
174
|
+
} catch (err) {
|
|
175
|
+
return isErrorText(`vice_memory_read: ${err instanceof Error ? err.message : String(err)}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let size: number;
|
|
179
|
+
try {
|
|
180
|
+
size = parseByteCount(args.size, { max: 0xffff, what: "size" });
|
|
181
|
+
} catch (err) {
|
|
182
|
+
return isErrorText(`vice_memory_read: ${err instanceof Error ? err.message : String(err)}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const end = address + size - 1;
|
|
186
|
+
if (end > 0xffff) {
|
|
187
|
+
return isErrorText(
|
|
188
|
+
`vice_memory_read: address 0x${address.toString(16)} + size ${size} exceeds the 16-bit address space (end would be 0x${end.toString(16)})`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let encoding: "hex" | "array" = "hex";
|
|
193
|
+
if (args.encoding !== undefined) {
|
|
194
|
+
if (args.encoding !== "hex" && args.encoding !== "array") {
|
|
195
|
+
return isErrorText(`vice_memory_read: encoding must be "hex" or "array", got ${JSON.stringify(args.encoding)}`);
|
|
196
|
+
}
|
|
197
|
+
encoding = args.encoding;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let sideEffects = false;
|
|
201
|
+
if (args.sideEffects !== undefined) {
|
|
202
|
+
if (typeof args.sideEffects !== "boolean") {
|
|
203
|
+
return isErrorText(`vice_memory_read: sideEffects must be a boolean, got ${typeof args.sideEffects}`);
|
|
204
|
+
}
|
|
205
|
+
sideEffects = args.sideEffects;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const bankResolution = await resolveBank("vice_memory_read", args.bank, session);
|
|
209
|
+
if (!bankResolution.ok) {
|
|
210
|
+
return bankResolution.result;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Memspace is fixed to 0x00 (main) in Phase 3 -- drive memspace is Phase
|
|
214
|
+
// 6's GAIN-03; there is deliberately no argument for it here.
|
|
215
|
+
const body = memGetBody({ sidefx: sideEffects, start: address, end, memspace: 0x00, bank: bankResolution.id });
|
|
216
|
+
|
|
217
|
+
let response;
|
|
218
|
+
try {
|
|
219
|
+
response = await session.client.send(CommandType.MemoryGet, body);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
return convertWireError("vice_memory_read", err);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (response.type !== "memory_get") {
|
|
225
|
+
return isErrorText(
|
|
226
|
+
`vice_memory_read: the binary monitor replied with an unexpected response type ("${response.type}"), expected "memory_get"`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (response.bytes.length !== size) {
|
|
231
|
+
return isErrorText(`vice_memory_read: expected ${size} byte(s), got ${response.bytes.length} -- a short read is a wrong answer, not a partial success`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const payload: Record<string, unknown> = {
|
|
235
|
+
address,
|
|
236
|
+
size,
|
|
237
|
+
encoding,
|
|
238
|
+
sideEffects,
|
|
239
|
+
bank: bankResolution.name !== undefined ? { id: bankResolution.id, name: bankResolution.name } : bankResolution.id,
|
|
240
|
+
memspace: "main",
|
|
241
|
+
};
|
|
242
|
+
if (encoding === "hex") {
|
|
243
|
+
payload.hex = Buffer.from(response.bytes).toString("hex");
|
|
244
|
+
} else {
|
|
245
|
+
payload.bytes = Array.from(response.bytes);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return stockAnswer(session.client, payload);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// vice_memory_write (Task 1)
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
export const handleMemoryWrite: StockSessionHandler = async (args, session, _deps) => {
|
|
256
|
+
if (!isPlainObject(args)) {
|
|
257
|
+
return isErrorText("vice_memory_write: arguments must be an object");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let address: number;
|
|
261
|
+
try {
|
|
262
|
+
address = parseAddress(args.address, { what: "address" });
|
|
263
|
+
} catch (err) {
|
|
264
|
+
return isErrorText(`vice_memory_write: ${err instanceof Error ? err.message : String(err)}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!Array.isArray(args.data)) {
|
|
268
|
+
return isErrorText(`vice_memory_write: data must be an array of integers 0..255, got ${typeof args.data}`);
|
|
269
|
+
}
|
|
270
|
+
if (args.data.length === 0) {
|
|
271
|
+
return isErrorText("vice_memory_write: data must not be empty");
|
|
272
|
+
}
|
|
273
|
+
const data: number[] = [];
|
|
274
|
+
for (let index = 0; index < args.data.length; index += 1) {
|
|
275
|
+
const value: unknown = args.data[index];
|
|
276
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 0xff) {
|
|
277
|
+
return isErrorText(`vice_memory_write: data[${index}] must be an integer 0..255, got ${JSON.stringify(value)}`);
|
|
278
|
+
}
|
|
279
|
+
data.push(value);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const end = address + data.length - 1;
|
|
283
|
+
if (end > 0xffff) {
|
|
284
|
+
return isErrorText(
|
|
285
|
+
`vice_memory_write: address 0x${address.toString(16)} + data.length ${data.length} exceeds the 16-bit address space (end would be 0x${end.toString(16)})`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const bankResolution = await resolveBank("vice_memory_write", args.bank, session);
|
|
290
|
+
if (!bankResolution.ok) {
|
|
291
|
+
return bankResolution.result;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// memSetBody() forces sidefx to 0x00 on the wire -- a write is not a read
|
|
295
|
+
// and takes no sideEffects argument.
|
|
296
|
+
const body = memSetBody({ start: address, end, memspace: 0x00, bank: bankResolution.id, data: Buffer.from(data) });
|
|
297
|
+
|
|
298
|
+
let response;
|
|
299
|
+
try {
|
|
300
|
+
response = await session.client.send(CommandType.MemorySet, body);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
return convertWireError("vice_memory_write", err);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// MEM_SET's acknowledgement carries no useful body -- stock-protocol.ts
|
|
306
|
+
// has no named parsed shape for it (matching several other ack-only
|
|
307
|
+
// commands in that file's switch), so the "unknown" fallback IS the
|
|
308
|
+
// expected reply shape here, not an error. Anything else would mean a
|
|
309
|
+
// future parser change gave MEM_SET a real shape without this handler
|
|
310
|
+
// noticing.
|
|
311
|
+
if (response.type !== "unknown") {
|
|
312
|
+
return isErrorText(
|
|
313
|
+
`vice_memory_write: the binary monitor replied with an unexpected response type ("${response.type}"), expected an acknowledgement`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return stockAnswer(session.client, {
|
|
318
|
+
address,
|
|
319
|
+
bytesWritten: data.length,
|
|
320
|
+
bank: bankResolution.name !== undefined ? { id: bankResolution.id, name: bankResolution.name } : bankResolution.id,
|
|
321
|
+
memspace: "main",
|
|
322
|
+
});
|
|
323
|
+
};
|
package/stock-paths.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// stock-paths.ts
|
|
3
|
+
//
|
|
4
|
+
// D-17's ONE declared table and the ONE translation wrapper: this file is the
|
|
5
|
+
// single place a stock handler turns a container-side path into the
|
|
6
|
+
// host-side path stock VICE's binary monitor itself opens. Four tools --
|
|
7
|
+
// `vice_autostart` (AUTOSTART's filename), `vice_disk_attach` (AUTOSTART's
|
|
8
|
+
// filename, the D-14 approximation), `vice_snapshot_save` (DUMP's filename,
|
|
9
|
+
// client-constructed from `name`), `vice_snapshot_load` (UNDUMP's filename,
|
|
10
|
+
// also client-constructed) -- carry a filename VICE opens ON THE HOST, so
|
|
11
|
+
// those four, and only those four, translate through here.
|
|
12
|
+
//
|
|
13
|
+
// WHY THIS FILE EXISTS: this is the MIRROR IMAGE of Phase 4's DERIV-07
|
|
14
|
+
// hazard -- there, translating a client-side-derived path is the bug; here,
|
|
15
|
+
// NOT translating an emulator-side path is the bug. D-17 puts both
|
|
16
|
+
// directions in one legible place so a future implementer working either
|
|
17
|
+
// side finds this comment.
|
|
18
|
+
//
|
|
19
|
+
// WHAT NOT TO DO:
|
|
20
|
+
// - Never call rewriteArguments() from a stock handler. It lives INSIDE
|
|
21
|
+
// forwardToVice() (vice-proxy.ts, around line 2773) -- the one function
|
|
22
|
+
// Phase 2's D-09 says the stock path must never touch -- and its own
|
|
23
|
+
// comment inverts on stock: what is correct for the fork's derived tools
|
|
24
|
+
// is exactly wrong here.
|
|
25
|
+
// - Never build a host path with a local heuristic (a hand-rolled prefix
|
|
26
|
+
// swap, a hardcoded mount guess, anything not routed through
|
|
27
|
+
// hostpath.ts's own hostPathCandidates()/tryHostPaths()). hostpath.ts is
|
|
28
|
+
// the one seam that owns bind-mount discovery.
|
|
29
|
+
// - Never add a CLIENT-SIDE derivation to STOCK_EMULATOR_SIDE_PATH_TOOLS.
|
|
30
|
+
// Phase 5's screenshots are decoded client-side (the INDEXED8 framebuffer
|
|
31
|
+
// arrives over the wire and is encoded to PNG in this process) and must
|
|
32
|
+
// NEVER be translated -- adding a client-side-derived tool to this table
|
|
33
|
+
// would be the exact mirror-image bug this file's header exists to name.
|
|
34
|
+
// A future Phase 5 implementer who is tempted to route a screenshot path
|
|
35
|
+
// through withEmulatorSidePath() should stop and re-read this paragraph.
|
|
36
|
+
import { dirname, join } from "node:path";
|
|
37
|
+
|
|
38
|
+
import { ViceError, type ViceErrorOptions } from "./vice.ts";
|
|
39
|
+
import { repoRoot } from "./repo-root.ts";
|
|
40
|
+
import { isInsideContainer } from "./container-guard.mts";
|
|
41
|
+
import { tryHostPaths } from "./hostpath.ts";
|
|
42
|
+
import { ErrorCode, StockProtocolError } from "./stock-protocol.ts";
|
|
43
|
+
|
|
44
|
+
/** The one error type this module ever throws -- never a bare Error,
|
|
45
|
+
* matching vice.ts's established ViceError hierarchy (stock-address.ts's
|
|
46
|
+
* StockAddressError is the sibling precedent). */
|
|
47
|
+
export class StockPathError extends ViceError {
|
|
48
|
+
constructor(message: string, options: ViceErrorOptions = {}) {
|
|
49
|
+
super(message, options);
|
|
50
|
+
this.name = "StockPathError";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// D-17's declared table -- the complete Phase 3 set. Exactly four entries;
|
|
56
|
+
// a test asserts the size and membership so a future addition (or removal)
|
|
57
|
+
// is a deliberate, reviewed edit to this literal, not a silent drift.
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
export const STOCK_EMULATOR_SIDE_PATH_TOOLS: ReadonlySet<string> = new Set([
|
|
61
|
+
"vice_autostart", // AUTOSTART (0xdd) request body's filename field
|
|
62
|
+
"vice_disk_attach", // AUTOSTART (0xdd) again -- the D-14 approximation
|
|
63
|
+
"vice_snapshot_save", // DUMP (0x41) request body's filename field
|
|
64
|
+
"vice_snapshot_load", // UNDUMP (0x42) request body's filename field
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// withEmulatorSidePath() -- the one translation wrapper.
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Translates `containerPath` to a host path and calls `send(hostPath)`,
|
|
73
|
+
* returning both the callee's result and the path actually put on the wire.
|
|
74
|
+
*
|
|
75
|
+
* Refuses any `toolName` not in STOCK_EMULATOR_SIDE_PATH_TOOLS -- a handler
|
|
76
|
+
* cannot opt itself into translation without being declared in the table
|
|
77
|
+
* above, so the declared set and the actual behaviour can never drift apart.
|
|
78
|
+
*
|
|
79
|
+
* On a bare host (`isInsideContainer()` false), `containerPath` already IS
|
|
80
|
+
* the host path -- calling hostPathCandidates()'s mountinfo guesser there
|
|
81
|
+
* would fabricate a wrong path (with only a stderr warning to show for it),
|
|
82
|
+
* so this branch calls `send(containerPath)` directly and reports
|
|
83
|
+
* `sentPath: containerPath` unchanged.
|
|
84
|
+
*
|
|
85
|
+
* Inside a container, translates via `tryHostPaths()` with
|
|
86
|
+
* `workspaceRoot: repoRoot()` and a `fatal` predicate that returns `false`
|
|
87
|
+
* ONLY for a StockProtocolError whose errorCode is ErrorCode.CmdFailure
|
|
88
|
+
* (0x8f) -- "the monitor could not open that file", the one genuine
|
|
89
|
+
* wrong-path signal that licenses retrying the next candidate host path.
|
|
90
|
+
* Every other rejection (a framing error, a connection failure, a timeout)
|
|
91
|
+
* returns `true` (fatal), stopping probing immediately rather than retrying
|
|
92
|
+
* five more candidates against a connection that is not coming back.
|
|
93
|
+
*/
|
|
94
|
+
export async function withEmulatorSidePath<T>(
|
|
95
|
+
toolName: string,
|
|
96
|
+
containerPath: string,
|
|
97
|
+
send: (path: string) => Promise<T>,
|
|
98
|
+
): Promise<{ result: T; sentPath: string }> {
|
|
99
|
+
if (!STOCK_EMULATOR_SIDE_PATH_TOOLS.has(toolName)) {
|
|
100
|
+
throw new StockPathError(
|
|
101
|
+
`withEmulatorSidePath: ${toolName} is not declared in STOCK_EMULATOR_SIDE_PATH_TOOLS -- only vice_autostart, ` +
|
|
102
|
+
`vice_disk_attach, vice_snapshot_save and vice_snapshot_load carry an emulator-side path argument (D-17).`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!isInsideContainerFn()) {
|
|
107
|
+
// On a bare host, containerPath already IS the host path -- see this
|
|
108
|
+
// function's own header comment above for why the mountinfo guesser
|
|
109
|
+
// must not run here.
|
|
110
|
+
const result = await send(containerPath);
|
|
111
|
+
return { result, sentPath: containerPath };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const fatal = (err: unknown): boolean => {
|
|
115
|
+
if (err instanceof StockProtocolError && err.errorCode === ErrorCode.CmdFailure) {
|
|
116
|
+
return false; // the one genuine wrong-path signal -- keep probing
|
|
117
|
+
}
|
|
118
|
+
return true; // anything else stops probing immediately
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const { result, hostPath } = await tryHostPaths(containerPath, send, { workspaceRoot: repoRoot(), fatal });
|
|
122
|
+
return { result, sentPath: hostPath };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Test-only injection point for isInsideContainer(), following
|
|
127
|
+
// stock-address.ts's setSymbolResolver() / stock-runstate.ts's
|
|
128
|
+
// resetRunStateTrackersForTest() precedent: a module-level setter rather than
|
|
129
|
+
// widening withEmulatorSidePath()'s own public signature (which the plan
|
|
130
|
+
// fixes at exactly three parameters). Production code never calls this;
|
|
131
|
+
// the default below is the real isInsideContainer() from container-guard.mts.
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
let isInsideContainerFn: () => boolean = () => isInsideContainer();
|
|
135
|
+
|
|
136
|
+
/** Test-only: overrides the isInsideContainer() check withEmulatorSidePath()
|
|
137
|
+
* consults, without touching container-guard.mts's own memoised verdict or
|
|
138
|
+
* widening withEmulatorSidePath()'s public signature. Pass `null` to restore
|
|
139
|
+
* the real check. */
|
|
140
|
+
export function setIsInsideContainerForTest(fn: (() => boolean) | null): void {
|
|
141
|
+
isInsideContainerFn = fn ?? (() => isInsideContainer());
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Snapshot name sanitisation and path construction -- T-3-05's mitigation.
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
const SNAPSHOT_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Refuses anything not matching /^[A-Za-z0-9_-]{1,64}$/ -- the name is used
|
|
152
|
+
* to build a filename, so path separators, `..` and absolute paths are
|
|
153
|
+
* rejected outright rather than sanitised. Matches the fork's own documented
|
|
154
|
+
* constraint ("alphanumeric, underscore, hyphen only"). This is the T-3-05
|
|
155
|
+
* control: a snapshot `name` is never treated as a path fragment.
|
|
156
|
+
*/
|
|
157
|
+
export function sanitizeSnapshotName(name: unknown): string {
|
|
158
|
+
if (typeof name !== "string" || !SNAPSHOT_NAME_RE.test(name)) {
|
|
159
|
+
throw new StockPathError(
|
|
160
|
+
`sanitizeSnapshotName: name must be 1-64 characters of alphanumeric, underscore or hyphen only ` +
|
|
161
|
+
`(matching ${SNAPSHOT_NAME_RE}) -- it is used to build a filename, so path separators, ".." and absolute ` +
|
|
162
|
+
`paths are rejected outright. Got ${JSON.stringify(name)}.`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return name;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The container path a snapshot named `name` lives at:
|
|
170
|
+
* `<repoRoot>/.vice-snapshots/<name>.vsf`. The directory is inside the
|
|
171
|
+
* workspace rather than under `~/.config/vice/` (the fork's own location)
|
|
172
|
+
* because only a workspace path is inside hostpath.ts's bind-mount mapping
|
|
173
|
+
* -- anything outside it cannot be translated for the host at all -- and
|
|
174
|
+
* keeping it inside the workspace makes workspace escape structurally
|
|
175
|
+
* impossible rather than merely checked (T-3-05).
|
|
176
|
+
*/
|
|
177
|
+
export function snapshotPathFor(name: string): string {
|
|
178
|
+
return join(repoRoot(), ".vice-snapshots", `${sanitizeSnapshotName(name)}.vsf`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The sidecar metadata path for the same snapshot: same directory, `.json`
|
|
182
|
+
* extension, same sanitisation. */
|
|
183
|
+
export function snapshotMetaPathFor(name: string): string {
|
|
184
|
+
return join(repoRoot(), ".vice-snapshots", `${sanitizeSnapshotName(name)}.json`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Re-exported so a caller building a directory before translating (Task 3's
|
|
188
|
+
// handleSnapshotSave, matching vice-sync.ts's screenshot()'s own
|
|
189
|
+
// mkdirSync(dirname(containerPath), { recursive: true })-before-translate
|
|
190
|
+
// ordering) never needs a second import specifier for dirname().
|
|
191
|
+
export { dirname };
|
package/stock-petscii.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// stock-petscii.ts
|
|
3
|
+
//
|
|
4
|
+
// THE one ASCII<->PETSCII conversion in this tree. No such table exists
|
|
5
|
+
// anywhere else in this codebase or its skills -- the custom fork did this
|
|
6
|
+
// conversion server-side in C (docs/03-RESEARCH.md's own grep confirmed
|
|
7
|
+
// zero hits). stock-input.ts's handleKeyboardType() is the only production
|
|
8
|
+
// call site; nothing else may hand-roll a second version.
|
|
9
|
+
//
|
|
10
|
+
// WHY THIS FILE EXISTS: KEYBOARD_FEED (0x72) only accepts PETSCII bytes on
|
|
11
|
+
// the wire, but the `vice_keyboard_type` tool's `text` argument is an
|
|
12
|
+
// ordinary ASCII/JS string. Something has to sit between the two, and it
|
|
13
|
+
// has to get the case-swap and control-code boundaries exactly right --
|
|
14
|
+
// PETSCII's unshifted/shifted case-swap region is a frequent source of
|
|
15
|
+
// off-by-one and reversed-case bugs when hand-transcribed (03-RESEARCH.md
|
|
16
|
+
// Pitfall 3), because the letter ranges "look like" a uniform 0x20 XOR but
|
|
17
|
+
// the boundary and control regions do not follow that rule. The mapping
|
|
18
|
+
// below matches VICE's own charset_p_topetscii() case-swap behaviour.
|
|
19
|
+
//
|
|
20
|
+
// WHAT NOT TO DO:
|
|
21
|
+
// - Never write a second inline ASCII->PETSCII conversion at a call site.
|
|
22
|
+
// Five inconsistent hand-rolled versions scattered across handlers is
|
|
23
|
+
// exactly the failure mode this module exists to prevent -- import
|
|
24
|
+
// asciiToPetscii() instead.
|
|
25
|
+
// - Never pass an unmapped byte through silently. A raw PETSCII control
|
|
26
|
+
// code such as 0x93 (clear screen) landing in an LLM-supplied string
|
|
27
|
+
// would silently corrupt the debugged program's display the moment it
|
|
28
|
+
// reaches the keyboard buffer. Every byte this table does not
|
|
29
|
+
// explicitly map is refused, naming the offending index and hex code --
|
|
30
|
+
// never truncated, never passed through, never silently dropped.
|
|
31
|
+
// - Never assume the letter ranges are a uniform 0x20 XOR. The boundary
|
|
32
|
+
// bytes (0x40/0x41, 0x5a/0x5b, 0x60/0x61, 0x7a/0x7b) and the control-code
|
|
33
|
+
// regions do not follow that rule uniformly; each range below is
|
|
34
|
+
// checked explicitly, not derived from a single arithmetic shortcut.
|
|
35
|
+
import { ViceError } from "./vice.ts";
|
|
36
|
+
|
|
37
|
+
/** PETSCII's Return code. Both ASCII LF (`\n`) and CR (`\r`) map here -- this
|
|
38
|
+
* is what the fork's own "Use \n for Return" tool description promises. */
|
|
39
|
+
export const PETSCII_RETURN = 0x0d;
|
|
40
|
+
|
|
41
|
+
export interface StockPetsciiErrorOptions {
|
|
42
|
+
index?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Raised by asciiToPetscii() for any input that cannot be safely converted:
|
|
47
|
+
* a non-string, an empty string, a converted length over 255 bytes, a code
|
|
48
|
+
* unit above 0xff, or a byte this table does not map. Always thrown before
|
|
49
|
+
* any bytes are written to the caller-visible Buffer.
|
|
50
|
+
*/
|
|
51
|
+
export class StockPetsciiError extends ViceError {
|
|
52
|
+
index?: number;
|
|
53
|
+
|
|
54
|
+
constructor(message: string, { index }: StockPetsciiErrorOptions = {}) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = "StockPetsciiError";
|
|
57
|
+
this.index = index;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface AsciiToPetsciiOptions {
|
|
62
|
+
/** Default true: uppercase ASCII (`A`-`Z`) displays as uppercase on the
|
|
63
|
+
* C64 -- the fork's own petscii_upper default-true semantic. Setting this
|
|
64
|
+
* false is a deliberate pass-through of the raw ASCII byte for both case
|
|
65
|
+
* ranges, mirroring the fork's "raw PETSCII (uppercase ASCII maps to
|
|
66
|
+
* graphics)" documented behaviour. The case-swap this option performs
|
|
67
|
+
* when true is exactly what makes uppercase ASCII display as uppercase in
|
|
68
|
+
* both the unshifted and mixed-case C64 charsets. */
|
|
69
|
+
upper?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Converts one input byte (already narrowed to 0x00-0xff by the caller) to
|
|
74
|
+
* its PETSCII equivalent, or throws a StockPetsciiError naming `index` if
|
|
75
|
+
* the byte has no mapping. Matches VICE's own charset_p_topetscii() case-
|
|
76
|
+
* swap rule, byte range by byte range -- never a single 0x20 XOR shortcut.
|
|
77
|
+
*/
|
|
78
|
+
function convertByte(byte: number, index: number, upper: boolean): number {
|
|
79
|
+
if (byte === 0x0a || byte === 0x0d) {
|
|
80
|
+
return PETSCII_RETURN;
|
|
81
|
+
}
|
|
82
|
+
if (byte >= 0x20 && byte <= 0x40) {
|
|
83
|
+
return byte;
|
|
84
|
+
}
|
|
85
|
+
if (byte >= 0x41 && byte <= 0x5a) {
|
|
86
|
+
return upper ? (byte | 0x80) : byte;
|
|
87
|
+
}
|
|
88
|
+
if (byte >= 0x5b && byte <= 0x60) {
|
|
89
|
+
return byte;
|
|
90
|
+
}
|
|
91
|
+
if (byte >= 0x61 && byte <= 0x7a) {
|
|
92
|
+
return upper ? byte - 0x20 : byte;
|
|
93
|
+
}
|
|
94
|
+
if (byte >= 0x7b && byte <= 0x7e) {
|
|
95
|
+
return byte;
|
|
96
|
+
}
|
|
97
|
+
throw new StockPetsciiError(
|
|
98
|
+
`asciiToPetscii: character at index ${index} (0x${byte.toString(16).padStart(2, "0")}) has no PETSCII mapping -- ` +
|
|
99
|
+
`PETSCII control codes (e.g. 0x93 clear-screen) and other unmapped bytes must be sent explicitly via ` +
|
|
100
|
+
`vice_keyboard_petscii, never through vice_keyboard_type`,
|
|
101
|
+
{ index },
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Converts an ASCII/Latin-1 JS string to PETSCII bytes for KEYBOARD_FEED
|
|
107
|
+
* (0x72). Refuses (never silently truncates or passes through):
|
|
108
|
+
* - a non-string input
|
|
109
|
+
* - an empty string
|
|
110
|
+
* - a converted length over 255 bytes (KEYBOARD_FEED's textLen field is a
|
|
111
|
+
* uint8) -- since this mapping is 1:1, the converted length always
|
|
112
|
+
* equals `text.length`, so this is checked up front
|
|
113
|
+
* - any code unit above 0xff (a non-Latin-1 character) -- never a lossy
|
|
114
|
+
* `charCodeAt() & 0xff`
|
|
115
|
+
* - any byte convertByte() does not map (PETSCII control codes, the
|
|
116
|
+
* 0x00-0x1f/0x7f gaps, and every byte >= 0x80 not otherwise handled)
|
|
117
|
+
*/
|
|
118
|
+
export function asciiToPetscii(text: string, { upper = true }: AsciiToPetsciiOptions = {}): Buffer {
|
|
119
|
+
if (typeof text !== "string") {
|
|
120
|
+
throw new StockPetsciiError(`asciiToPetscii: text must be a string, got ${typeof text}`);
|
|
121
|
+
}
|
|
122
|
+
if (text.length === 0) {
|
|
123
|
+
throw new StockPetsciiError("asciiToPetscii: text must not be empty");
|
|
124
|
+
}
|
|
125
|
+
if (text.length > 255) {
|
|
126
|
+
throw new StockPetsciiError(
|
|
127
|
+
`asciiToPetscii: converted text exceeds 255 bytes (${text.length}) -- KEYBOARD_FEED's textLen field is a uint8`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
const out = Buffer.alloc(text.length);
|
|
131
|
+
for (let index = 0; index < text.length; index++) {
|
|
132
|
+
const codeUnit = text.charCodeAt(index);
|
|
133
|
+
if (codeUnit > 0xff) {
|
|
134
|
+
throw new StockPetsciiError(
|
|
135
|
+
`asciiToPetscii: character at index ${index} (code point 0x${codeUnit.toString(16)}) is not a Latin-1 byte -- ` +
|
|
136
|
+
`PETSCII conversion only accepts code points 0x00-0xff`,
|
|
137
|
+
{ index },
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
out[index] = convertByte(codeUnit, index, upper);
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|