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