@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.
@@ -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 };
@@ -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
+ }