@henols/vice-mcp 0.1.10 → 0.1.12

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,272 @@
1
+ // disasm-decoder.ts
2
+ //
3
+ // The pure `decode(bytes, startAddress, opts) -> Instruction[]` function --
4
+ // D-05's standalone module. Phase 5's backtrace (DERIV-02) and Phase 6's
5
+ // CPU-history decode (GAIN-01) import THIS file directly, never a tool
6
+ // module, so a protocol import here would force those consumers to pull in
7
+ // transport code they do not need. This module has no emulator, no
8
+ // protocol, no network -- its only input is a byte array. Note: DERIV-02 and
9
+ // GAIN-01 were both cut from v0.2.0 scope on 2026-08-17 -- see the
10
+ // startAddress bound below, which is now defense-in-depth on a currently
11
+ // unreachable path rather than a guard against a live in-process caller.
12
+ //
13
+ // ---------------------------------------------------------------------------
14
+ // WHY THIS FILE EXISTS RATHER THAN LIVING INSIDE THE TOOL HANDLER
15
+ // ---------------------------------------------------------------------------
16
+ // `stock-disassemble.ts` (04-05) is the tool-facing consumer, but three other
17
+ // consumers need decoding without a socket in the picture at all: the
18
+ // renderer (04-04), Phase 5's backtrace and Phase 6's CPU-history decode.
19
+ // Keeping decode() import-free of `stock-*.ts`/`vice*.ts`/any `node:`
20
+ // builtin means all four can depend on this one file without dragging in
21
+ // transport code.
22
+ //
23
+ // ---------------------------------------------------------------------------
24
+ // WHAT NOT TO DO
25
+ // ---------------------------------------------------------------------------
26
+ // - Never import `stock-*.ts`, `vice*.ts` or any `node:` builtin -- the
27
+ // only import is `./disasm-opcodes.ts`.
28
+ // - Never throw on malformed input; return `notes: ["truncated"]` for a
29
+ // partial instruction or `[]` for a malformed top-level argument.
30
+ // - Never fabricate operand bytes that were not in `bytes` (DISASM-05 is
31
+ // precisely the requirement that a partial instruction is reported, not
32
+ // invented).
33
+ // - Never add recursion or an unbounded loop -- the bound is what makes an
34
+ // attacker-controlled memory image safe to decode (04-RESEARCH.md
35
+ // Security Domain, T-04-03-01).
36
+ // - Never fold the `<= 0xffff` upper bound into `isNonNegativeSafeInteger()`
37
+ // or apply it to `opts.count`/`opts.end` -- those two stay unbounded by
38
+ // design (04-REVIEW.md IN-03: the loop is always bounded by
39
+ // `bytes.length`, so an absurd value degrades to "no effective limit",
40
+ // never a crash or hang). Bounding them would be a behaviour change
41
+ // dressed up as a consistency fix. The upper bound belongs only in the
42
+ // separate `isValidStartAddress()` guard below.
43
+
44
+ import { OPCODES, type AddressingMode } from "./disasm-opcodes.ts";
45
+
46
+ /**
47
+ * D-10's structured note vocabulary. A closed union, not a free string, so
48
+ * the renderer (04-04) and Phase 5's backtrace can switch on it
49
+ * exhaustively.
50
+ */
51
+ export type DisasmNote = "nmos-page-wrap" | "truncated" | "acme-unassemblable" | "illegal-opcode";
52
+
53
+ /**
54
+ * A decoded instruction's operand. `role` and `width` together are what
55
+ * DISASM-06's substitution rule (D-11) reads to decide whether a symbol can
56
+ * safely replace a literal. `value` is the operand as encoded (for
57
+ * `relative`, the raw signed offset -- the resolved address lives in
58
+ * `Instruction.resolvedTarget`). `width` is the operand's byte width (1 or
59
+ * 2), always equal to `entry.length - 1`.
60
+ */
61
+ export interface DecodedOperand {
62
+ role: "immediate" | "zeropage" | "absolute" | "relative" | "indirect";
63
+ value: number;
64
+ width: 1 | 2;
65
+ }
66
+
67
+ /**
68
+ * One decoded 6502/6510 instruction. `notes` is always present (empty array
69
+ * when there is nothing to say), never `undefined`. `bytes` holds every
70
+ * byte the instruction consumed, including a partial instruction's bytes.
71
+ */
72
+ export interface Instruction {
73
+ address: number;
74
+ bytes: number[];
75
+ opcode: number;
76
+ mnemonic: string;
77
+ mode: AddressingMode;
78
+ illegal: boolean;
79
+ acmeExpressible: boolean;
80
+ operand?: DecodedOperand;
81
+ resolvedTarget?: number;
82
+ notes: DisasmNote[];
83
+ }
84
+
85
+ export interface DecodeOptions {
86
+ count?: number;
87
+ end?: number;
88
+ }
89
+
90
+ /** True iff `value` is a well-formed, generic JSON object -- not null, not
91
+ * an array. Matches this module tree's own isPlainObject() convention
92
+ * (stock-checkpoints.ts, stock-schema-check.ts et al. -- the one-line
93
+ * predicate is repeated per file, never centrally imported). */
94
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
95
+ return typeof value === "object" && value !== null && !Array.isArray(value);
96
+ }
97
+
98
+ /** True iff `value` is a representable, non-negative whole number -- the
99
+ * shared narrowing for `startAddress`, `opts.count` and `opts.end`. A
100
+ * non-integer, negative or non-safe-integer ("absurd") value is treated as
101
+ * absent rather than thrown on; argument-validation refusal text is the
102
+ * tool's job (04-05), not this module's. */
103
+ function isNonNegativeSafeInteger(value: unknown): value is number {
104
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
105
+ }
106
+
107
+ /** True iff `value` is a valid `startAddress` -- a safe, non-negative integer
108
+ * that additionally fits in the C64's 16-bit address space (`<= 0xffff`).
109
+ * Deliberately separate from `isNonNegativeSafeInteger()` rather than an
110
+ * upper bound folded into it: `opts.count`/`opts.end` must stay unbounded
111
+ * (see the `WHAT NOT TO DO` block above and 04-REVIEW.md IN-03), so only
112
+ * `startAddress` gets this stricter narrowing. Mirrors `stock-address.ts`'s
113
+ * `inAddressRange()` without importing it -- D-05 keeps this module's only
114
+ * import to `./disasm-opcodes.ts`. */
115
+ function isValidStartAddress(value: unknown): value is number {
116
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= 0xffff;
117
+ }
118
+
119
+ /** Interprets `b` as an 8-bit two's-complement signed byte, per DISASM-04's
120
+ * branch resolution rule: `signed8(b) = b < 0x80 ? b : b - 0x100`. */
121
+ function signed8(b: number): number {
122
+ return b < 0x80 ? b : b - 0x100;
123
+ }
124
+
125
+ /** Builds the `notes` array in the plan's mandated, deterministic order:
126
+ * `"truncated"` first (rule 3), then `"nmos-page-wrap"` (rule 7), then
127
+ * `"illegal-opcode"` and `"acme-unassemblable"` (rule 8) -- so the
128
+ * renderer's output and the round-trip test are stable. */
129
+ function buildNotes(flags: { truncated: boolean; pageWrap: boolean; illegal: boolean; acmeExpressible: boolean }): DisasmNote[] {
130
+ const notes: DisasmNote[] = [];
131
+ if (flags.truncated) notes.push("truncated");
132
+ if (flags.pageWrap) notes.push("nmos-page-wrap");
133
+ if (flags.illegal) notes.push("illegal-opcode");
134
+ if (!flags.acmeExpressible) notes.push("acme-unassemblable");
135
+ return notes;
136
+ }
137
+
138
+ /**
139
+ * Decodes `bytes` as a stream of 6502/6510 instructions starting at
140
+ * `startAddress`. Bounded by construction (T-04-03-01): a single `while`
141
+ * loop over a byte cursor, every iteration consumes at least one byte, no
142
+ * recursion anywhere in this file. Never throws -- malformed input (a
143
+ * non-`Uint8Array` `bytes`, a negative or non-integer `startAddress`, or a
144
+ * `startAddress` above `0xffff`, the top of the 16-bit address space)
145
+ * returns `[]` rather than wrapping the address into range and returning a
146
+ * plausible-looking but wrong listing.
147
+ */
148
+ export function decode(bytes: Uint8Array, startAddress: number, opts: DecodeOptions = {}): Instruction[] {
149
+ if (!(bytes instanceof Uint8Array)) return [];
150
+ if (!isValidStartAddress(startAddress)) return [];
151
+
152
+ const options = isPlainObject(opts) ? opts : {};
153
+ const count = isNonNegativeSafeInteger(options.count) ? options.count : undefined;
154
+ const end = isNonNegativeSafeInteger(options.end) ? options.end : undefined;
155
+
156
+ const instructions: Instruction[] = [];
157
+ let offset = 0;
158
+
159
+ while (offset < bytes.length) {
160
+ if (count !== undefined && instructions.length >= count) break;
161
+
162
+ // Rule 2: address arithmetic wraps at 16 bits.
163
+ const address = (startAddress + offset) & 0xffff;
164
+
165
+ // Rule 9: an instruction starting past `end` is dropped entirely, not
166
+ // emitted. Checked against the START address, before the opcode byte is
167
+ // even read -- an instruction starting at or before `end` is emitted in
168
+ // full even if its last byte lies past `end`.
169
+ if (end !== undefined && address > end) break;
170
+
171
+ const opcodeByte = bytes[offset]!;
172
+ const entry = OPCODES[opcodeByte]!;
173
+ const available = bytes.length - offset;
174
+
175
+ if (available < entry.length) {
176
+ // Rule 3 / DISASM-05: truncated instruction. Only the bytes that
177
+ // actually exist are recorded; operand/resolvedTarget are omitted;
178
+ // never fabricate the missing bytes. The loop stops here.
179
+ const rawBytes: number[] = [];
180
+ for (let i = 0; i < available; i++) rawBytes.push(bytes[offset + i]!);
181
+
182
+ instructions.push({
183
+ address,
184
+ bytes: rawBytes,
185
+ opcode: opcodeByte,
186
+ mnemonic: entry.mnemonic,
187
+ mode: entry.mode,
188
+ illegal: entry.illegal,
189
+ acmeExpressible: entry.acmeExpressible,
190
+ notes: buildNotes({ truncated: true, pageWrap: false, illegal: entry.illegal, acmeExpressible: entry.acmeExpressible }),
191
+ });
192
+ break;
193
+ }
194
+
195
+ const rawBytes: number[] = [];
196
+ for (let i = 0; i < entry.length; i++) rawBytes.push(bytes[offset + i]!);
197
+ const b1 = entry.length >= 2 ? rawBytes[1]! : undefined;
198
+ const b2 = entry.length >= 3 ? rawBytes[2]! : undefined;
199
+
200
+ let operand: DecodedOperand | undefined;
201
+ let resolvedTarget: number | undefined;
202
+ let pageWrap = false;
203
+
204
+ // Rule 4: operand extraction by mode.
205
+ switch (entry.mode) {
206
+ case "implicit":
207
+ case "accumulator":
208
+ break;
209
+
210
+ case "immediate":
211
+ operand = { role: "immediate", value: b1!, width: 1 };
212
+ break;
213
+
214
+ case "zeropage":
215
+ case "zeropage_x":
216
+ case "zeropage_y":
217
+ case "indirect_x":
218
+ case "indirect_y":
219
+ operand = { role: "zeropage", value: b1!, width: 1 };
220
+ break;
221
+
222
+ case "relative":
223
+ // Rule 5, DISASM-04: raw signed offset stays in `operand.value`;
224
+ // the resolved absolute target (wrapped at 16 bits) is separate.
225
+ operand = { role: "relative", value: b1!, width: 1 };
226
+ resolvedTarget = (address + 2 + signed8(b1!)) & 0xffff;
227
+ break;
228
+
229
+ case "absolute":
230
+ case "absolute_x":
231
+ case "absolute_y": {
232
+ const value = b1! | (b2! << 8);
233
+ operand = { role: "absolute", value, width: 2 };
234
+ // Rule 6: jmp absolute ($4C) and jsr absolute ($20) also resolve a
235
+ // control-flow target, so a consumer never has to special-case
236
+ // which operand is the target.
237
+ if (entry.mode === "absolute" && (opcodeByte === 0x4c || opcodeByte === 0x20)) {
238
+ resolvedTarget = value;
239
+ }
240
+ break;
241
+ }
242
+
243
+ case "indirect": {
244
+ const value = b1! | (b2! << 8);
245
+ operand = { role: "indirect", value, width: 2 };
246
+ // Rule 7, D-10: NMOS page-wrap bug, jmp ($xxFF) only ($6C is the
247
+ // only opcode using this mode).
248
+ if (opcodeByte === 0x6c && (value & 0x00ff) === 0x00ff) {
249
+ pageWrap = true;
250
+ }
251
+ break;
252
+ }
253
+ }
254
+
255
+ instructions.push({
256
+ address,
257
+ bytes: rawBytes,
258
+ opcode: opcodeByte,
259
+ mnemonic: entry.mnemonic,
260
+ mode: entry.mode,
261
+ illegal: entry.illegal,
262
+ acmeExpressible: entry.acmeExpressible,
263
+ ...(operand !== undefined ? { operand } : {}),
264
+ ...(resolvedTarget !== undefined ? { resolvedTarget } : {}),
265
+ notes: buildNotes({ truncated: false, pageWrap, illegal: entry.illegal, acmeExpressible: entry.acmeExpressible }),
266
+ });
267
+
268
+ offset += entry.length;
269
+ }
270
+
271
+ return instructions;
272
+ }