Skip to main content

aver/vm/
opcode.rs

1// Aver VM bytecode opcodes.
2//
3// Stack-based: operands are pushed/popped from the operand stack.
4// Variable-width encoding: opcode (1 byte) + operands (0-3 bytes).
5
6// -- Stack / locals ----------------------------------------------------------
7
8/// No-op, used as padding after superinstruction fusion.
9pub const NOP: u8 = 0x00;
10
11/// Push `stack[bp + slot]` onto the operand stack.
12pub const LOAD_LOCAL: u8 = 0x01; // slot:u8
13
14/// Push `stack[bp + slot]` and clear the slot (move semantics).
15/// Used for last-use variables: caller releases sole ownership so
16/// callees and builtins see refcount=1 and can mutate in-place.
17pub const MOVE_LOCAL: u8 = 0x48; // slot:u8
18
19/// Pop top and store into `stack[bp + slot]`.
20pub const STORE_LOCAL: u8 = 0x02; // slot:u8
21
22/// Push `constants[idx]` onto the operand stack.
23pub const LOAD_CONST: u8 = 0x03; // idx:u16
24
25/// Push `globals[idx]` onto the operand stack.
26pub const LOAD_GLOBAL: u8 = 0x04; // idx:u16
27
28/// Pop top and store into `globals[idx]`.
29pub const STORE_GLOBAL: u8 = 0x0A; // idx:u16
30
31/// Discard the top value.
32pub const POP: u8 = 0x05;
33
34/// Duplicate the top value.
35pub const DUP: u8 = 0x06;
36
37/// Push `NanValue::UNIT`.
38pub const LOAD_UNIT: u8 = 0x07;
39
40/// Push `NanValue::TRUE`.
41pub const LOAD_TRUE: u8 = 0x08;
42
43/// Push `NanValue::FALSE`.
44pub const LOAD_FALSE: u8 = 0x09;
45
46// -- Arithmetic --------------------------------------------------------------
47
48/// Pop b, pop a, push a + b.
49pub const ADD: u8 = 0x10;
50
51/// Pop b, pop a, push a - b.
52pub const SUB: u8 = 0x11;
53
54/// Pop b, pop a, push a * b.
55pub const MUL: u8 = 0x12;
56
57/// Pop b, pop a, push a / b.
58pub const DIV: u8 = 0x13;
59
60/// Pop b, pop a, push a % b.
61pub const MOD: u8 = 0x14;
62
63/// Pop a, push -a.
64pub const NEG: u8 = 0x15;
65
66/// Pop a, push !a (boolean not).
67pub const NOT: u8 = 0x16;
68
69/// Typed `+` for two `Int` operands. Materializes both as `AverInt` and
70/// adds over ℤ (non-wrapping; i64 fast path inside `AverInt::add`, promoting
71/// to a bignum only on overflow), boxing back through `from_aver_int`. Skips
72/// the tag-dispatch chain in `arith_add`. Emitted when both operand types
73/// resolve to `Type::Int`.
74pub const ADD_INT: u8 = 0x17;
75/// Typed `-` for two `Int` operands; same shape as `ADD_INT`.
76pub const SUB_INT: u8 = 0x18;
77/// Typed `*` for two `Int` operands; same shape as `ADD_INT`.
78pub const MUL_INT: u8 = 0x19;
79
80/// Typed `+` for two `Float` operands. `as_float` decode (raw
81/// `f64::from_bits`), IEEE 754 add, push as `NanValue::new_float`.
82/// Skips `arith_add` tag-dispatch + cross-type promotion. Hot in
83/// numeric loops (Mandelbrot inner step is 3 muls + 1 add per iter).
84pub const ADD_FLOAT: u8 = 0x1A;
85/// Typed `-` for two `Float` operands; same shape as `ADD_FLOAT`.
86pub const SUB_FLOAT: u8 = 0x1B;
87/// Typed `*` for two `Float` operands; same shape as `ADD_FLOAT`.
88pub const MUL_FLOAT: u8 = 0x1C;
89/// Typed `/` for two `Float` operands; same shape as `ADD_FLOAT`.
90/// IEEE 754 division — `b == 0.0` produces `inf`/`-inf`/`NaN` per
91/// the spec, no runtime check.
92pub const DIV_FLOAT: u8 = 0x1D;
93
94/// Typed unary `-` on an `Int` operand. Pops one value and negates over ℤ
95/// via `AverInt::neg` (non-wrapping; `-i64::MIN` promotes to a bignum), then
96/// re-boxes through `from_aver_int`. Skips the `is_int / is_float` branch in
97/// `NEG`. Emitted when `inner.ty()` resolves to `Type::Int`.
98pub const NEG_INT: u8 = 0x1E;
99/// Typed unary `-` on a `Float` operand. Same shape as `NEG_INT`
100/// but on the float side: raw `f64::from_bits`, IEEE 754 negation
101/// (sign-bit flip), push as `NanValue::new_float`. Skips the
102/// `NEG` tag-dispatch chain and preserves `-0.0` because the
103/// negation runs on the float bit pattern, not on `0 - x` desugar.
104pub const NEG_FLOAT: u8 = 0x1F;
105
106// -- Comparison --------------------------------------------------------------
107
108/// Pop b, pop a, push a == b.
109pub const EQ: u8 = 0x20;
110
111/// Pop b, pop a, push a < b.
112pub const LT: u8 = 0x21;
113
114/// Pop b, pop a, push a > b.
115pub const GT: u8 = 0x22;
116
117/// Typed `==` for two `Int` operands. Skips `NanValue::eq_in`'s tag
118/// dispatch — `as_int_unboxed()` on both sides, raw i64 compare, push
119/// bool. Emitted by the VM compiler when both `left.ty()` and
120/// `right.ty()` resolve to `Type::Int`. Hot path in pattern-match-on-
121/// Int (`match n { 0 -> …; _ -> … }`) and arithmetic guards; profile
122/// shows `eq_in` at 10–12% self-time in newtype/match scenarios.
123pub const EQ_INT: u8 = 0x23;
124
125/// Typed `<` for two `Int` operands. `as_int` decode on both sides
126/// (folds the inline path inline), raw i64 compare, push bool. Skips
127/// `compare_lt`'s tag-dispatch (`is_int`/`is_float`/`is_string` chain
128/// plus cross-type promotion). Emitted when both operands'
129/// `Spanned::ty()` resolve to `Type::Int`.
130pub const LT_INT: u8 = 0x24;
131
132/// Typed `>` for two `Int` operands; same shape as `LT_INT`.
133pub const GT_INT: u8 = 0x25;
134
135/// Typed `<` for two `Float` operands. `as_float` (raw `f64::from_bits`)
136/// on both sides, IEEE 754 compare, push bool. Hot in numeric loops
137/// (Mandelbrot inner step `match curZ2 > 4.0 { … }` etc.); profile
138/// shows `compare_lt` at 2.6% self-time on fractal_seahorse.
139pub const LT_FLOAT: u8 = 0x26;
140
141/// Typed `>` for two `Float` operands; same shape as `LT_FLOAT`.
142pub const GT_FLOAT: u8 = 0x27;
143
144/// Fused `match n { LIT -> ...; _ -> ... }` arm test: peek the top of
145/// stack (subject left in place by the surrounding `compile_match`),
146/// if its `Int` value equals the inline `imm` literal — fall through
147/// to the arm body; otherwise skip via `fail_offset`. Replaces the
148/// `DUP + LOAD_CONST + EQ + JUMP_IF_FALSE` sequence the generic
149/// `compile_pattern` emits — one dispatch instead of four in the hot
150/// path of every `match n { 0 -> ... }` shape.
151///
152/// Encoding: `MATCH_INT_LITERAL imm:i64 fail_offset:i16`.
153pub const MATCH_INT_LITERAL: u8 = 0x7F;
154
155// -- String ------------------------------------------------------------------
156
157/// Pop b, pop a, push str(a) ++ str(b).
158pub const CONCAT: u8 = 0x28;
159
160// -- Control flow ------------------------------------------------------------
161
162/// Unconditional relative jump: ip += offset.
163pub const JUMP: u8 = 0x30; // offset:i16
164
165/// Pop top, if falsy: ip += offset.
166pub const JUMP_IF_FALSE: u8 = 0x31; // offset:i16
167
168// -- Calls -------------------------------------------------------------------
169
170/// Call a known function by id. Args already on stack.
171pub const CALL_KNOWN: u8 = 0x40; // fn_id:u16, argc:u8
172
173/// Call a function value on the stack (under args).
174pub const CALL_VALUE: u8 = 0x41; // argc:u8
175
176/// Call a builtin service function.
177pub const CALL_BUILTIN: u8 = 0x42; // symbol_id:u32, argc:u8
178
179/// Like CALL_BUILTIN but with owned-argument bitmask from reuse analysis.
180/// Builtins receiving owned args can mutate in-place instead of cloning.
181pub const CALL_BUILTIN_OWNED: u8 = 0x46; // symbol_id:u32, argc:u8, owned:u8
182
183/// Like CALL_KNOWN but with owned-argument bitmask from reuse analysis.
184pub const CALL_KNOWN_OWNED: u8 = 0x47; // fn_id:u16, argc:u8, owned:u8
185
186/// Self tail-call: reuse current frame with new args.
187pub const TAIL_CALL_SELF: u8 = 0x43; // argc:u8
188
189/// Mutual tail-call to a known function: reuse frame, switch target.
190pub const TAIL_CALL_KNOWN: u8 = 0x44; // fn_id:u16, argc:u8
191
192/// Return top of stack to caller.
193pub const RETURN: u8 = 0x50;
194
195// -- Structured values -------------------------------------------------------
196
197/// Push Nil (empty cons list).
198pub const LIST_NIL: u8 = 0x60;
199
200/// Pop tail, pop head, push Cons(head, tail).
201pub const LIST_CONS: u8 = 0x61;
202
203/// Pop `count` items, build cons list from them (first item = head), push list.
204pub const LIST_NEW: u8 = 0x62; // count:u8
205
206/// Pop `count` field values, push a new record with `type_id`.
207pub const RECORD_NEW: u8 = 0x63; // type_id:u16, count:u8
208
209/// Pop record, push `fields[field_idx]` (compile-time resolved index).
210pub const RECORD_GET: u8 = 0x64; // field_idx:u8
211
212/// Pop record, lookup field by interned field symbol, push value.
213pub const RECORD_GET_NAMED: u8 = 0x67; // field_symbol_id:u32
214
215/// Pop `count` field values, push a new variant.
216pub const VARIANT_NEW: u8 = 0x65; // type_id:u16, variant_id:u16, count:u8
217
218/// Pop value, push wrapped value. kind: 0=Ok, 1=Err, 2=Some.
219pub const WRAP: u8 = 0x66; // kind:u8
220
221/// Pop `count` items, build a tuple from them, push tuple.
222pub const TUPLE_NEW: u8 = 0x68; // count:u8
223
224/// Parallel function calls for independent products (?! / !).
225/// Pops N callable values plus their args from the stack, dispatches them via
226/// the same callable resolution rules as CALL_VALUE, then builds the result tuple.
227/// Enters/exits replay group around parallel dispatch.
228///
229/// Encoding: CALL_PAR count:u8 unwrap:u8 [argc:u8 × count]
230/// unwrap=1 (?!): unwrap each Result, propagate first Err.
231/// unwrap=0 (!): return raw tuple.
232pub const CALL_PAR: u8 = 0x86;
233
234/// Update selected fields on a record, preserving the rest from the base value.
235/// Stack: [..., base_record, update_0, ..., update_n-1] -> [..., updated_record]
236pub const RECORD_UPDATE: u8 = 0x69; // type_id:u16, count:u8, field_idx[count]:u8
237
238/// Propagate `Result.Err` to caller or unwrap `Result.Ok` in place.
239pub const PROPAGATE_ERR: u8 = 0x6A;
240
241/// Pop list, push its length as Int.
242pub const LIST_LEN: u8 = 0x6B;
243
244// 0x6C and 0x6D were LIST_GET and LIST_APPEND — removed.
245
246/// Pop list, pop value, push prepended list.
247pub const LIST_PREPEND: u8 = 0x6E;
248
249// 0x6F was LIST_GET_MATCH — removed.
250
251// -- Pattern matching --------------------------------------------------------
252
253/// Peek top: if NaN tag != expected, ip += fail_offset.
254pub const MATCH_TAG: u8 = 0x70; // expected_tag:u8, fail_offset:i16
255
256/// Peek top (must be variant): if variant_id != expected, ip += fail_offset.
257pub const MATCH_VARIANT: u8 = 0x71; // ctor_id:u16, fail_offset:i16
258
259/// Peek top: if not wrapper of `kind`, ip += fail_offset.
260/// If matches, replace top with inner value (unwrap in-place).
261/// kind: 0=Ok, 1=Err, 2=Some.
262pub const MATCH_UNWRAP: u8 = 0x72; // kind:u8, fail_offset:i16
263
264/// Peek top: if not Nil, ip += fail_offset.
265pub const MATCH_NIL: u8 = 0x73; // fail_offset:i16
266
267/// Peek top: if Nil (not a cons), ip += fail_offset.
268pub const MATCH_CONS: u8 = 0x74; // fail_offset:i16
269
270/// Pop cons cell, push tail then push head.
271pub const LIST_HEAD_TAIL: u8 = 0x75;
272
273/// Peek top (record/variant), push `fields[field_idx]` (non-destructive).
274pub const EXTRACT_FIELD: u8 = 0x76; // field_idx:u8
275
276/// Peek top: if not a tuple of `count` items, ip += fail_offset.
277pub const MATCH_TUPLE: u8 = 0x78; // count:u8, fail_offset:i16
278
279/// Peek top tuple, push `items[item_idx]` (non-destructive).
280pub const EXTRACT_TUPLE_ITEM: u8 = 0x79; // item_idx:u8
281
282/// Non-exhaustive match error at source line.
283pub const MATCH_FAIL: u8 = 0x77; // line:u16
284
285/// Unified prefix/exact dispatch on NanValue bits.
286///
287/// Encoding:
288///   MATCH_DISPATCH count:u8 default_offset:i16
289///     [(kind:u8, expected:u64, offset:i16) × count]
290///
291/// kind=0: exact match  — `val.bits() == expected`
292/// kind=1: tag match    — `(val.bits() & TAG_MASK_FULL) == expected`
293///   where TAG_MASK_FULL = 0xFFFF_C000_0000_0000 (QNAN 14 bits + tag 4 bits)
294///
295/// Pops subject. Scans entries in order; first match wins → ip += offset.
296/// No match → ip += default_offset.
297/// All offsets are relative to the end of the full instruction.
298pub const MATCH_DISPATCH: u8 = 0x7A;
299
300/// Like MATCH_DISPATCH but every entry carries an inline result instead
301/// of a jump offset.  When an entry matches, the result is pushed directly
302/// onto the stack and the match body is skipped entirely.
303///
304/// Encoding:
305///   MATCH_DISPATCH_CONST count:u8 default_offset:i16
306///     [(kind:u8, expected:u64, result:u64) × count]
307///
308/// Hit → pop subject, push result NanValue.
309/// Miss → pop subject, ip += default_offset (execute default arm body).
310///
311/// Emitted when ALL dispatchable arms have constant bodies (literals).
312pub const MATCH_DISPATCH_CONST: u8 = 0x7B;
313
314/// Tail-call self for thin frames: no arena finalization needed.
315/// The compiler emits this instead of TAIL_CALL_SELF when the function
316/// is known to be "thin" (no heap allocations within the frame).
317/// Skips finalize_frame_locals_for_tail_call entirely — just copies
318/// args in-place and resets ip.
319pub const TAIL_CALL_SELF_THIN: u8 = 0x45; // argc:u8
320
321/// Inline Option.withDefault: pop default, pop option → push inner or default.
322/// Stack: [option, default] → [result]
323/// If option is Some → push unwrapped inner value.
324/// If option is None → push default.
325pub const UNWRAP_OR: u8 = 0x7C;
326
327/// Inline Result.withDefault: pop default, pop result → push inner or default.
328/// Stack: [result, default] → [value]
329/// If result is Ok → push unwrapped inner value.
330/// If result is Err → push default.
331pub const UNWRAP_RESULT_OR: u8 = 0x7D;
332
333/// Frameless call to a leaf+thin+args-only function.
334/// No CallFrame is pushed — just saves (fn_id, ip) in the dispatch loop,
335/// sets bp to the args already on stack, and jumps to the target.
336/// On RETURN, restores the caller's state directly.
337/// Format: fn_id:u16, argc:u8 (same as CALL_KNOWN).
338pub const CALL_LEAF: u8 = 0x7E;
339
340// ─── Superinstructions ──────────────────────────────────────
341
342/// Push two locals in one dispatch. Format: slot_a:u8, slot_b:u8.
343pub const LOAD_LOCAL_2: u8 = 0x80;
344
345/// Push one local + one constant in one dispatch. Format: slot:u8, const_idx:u16.
346pub const LOAD_LOCAL_CONST: u8 = 0x81;
347
348/// Inline Vector.get: pop index, pop vector → push Option (Some/None).
349/// Stack: [vector, index] → [option]
350pub const VECTOR_GET: u8 = 0x82;
351
352/// Fused Vector.get + Option.withDefault: pop default, pop index, pop vector → push value.
353/// Stack: [vector, index, default] → [value]
354/// Combines CALL_BUILTIN(Vector.get) + LOAD_CONST + UNWRAP_OR into one opcode.
355pub const VECTOR_GET_OR: u8 = 0x83;
356
357/// Inline Vector.set: pop value, pop index, pop vector → push Option<Vector>.
358/// Stack: [vector, index, value] → [option_vector]
359pub const VECTOR_SET: u8 = 0x84;
360
361/// Fused Vector.set + Option.withDefault(vec): pop value, pop index, pop vector → push vector.
362/// Stack: [vector, index, value] → [vector]
363pub const VECTOR_SET_OR_KEEP: u8 = 0x85;
364
365// -- Deforestation buffer (0.15 Traversal) -----------------------------------
366//
367// Mutable byte-buffer scratch backing the synthesizer's `__buf_*` intrinsics.
368// Buffer values travel as opaque `Int(handle)` NanValues — handles are
369// indices into `vm.buffer_pool: Vec<Option<String>>`. This keeps Buffer
370// out of `ArenaEntry` (no exhaustiveness ripples) and out of GC marking
371// (handle is an inline value, the underlying String lives on the host
372// heap unaffected by frame compactions). Buffers are use-once: created
373// by `BUFFER_NEW`, mutated through `BUFFER_APPEND_*`, finalized to an
374// arena `String` (Rc<str>) by `BUFFER_FINALIZE` which also frees the slot.
375
376/// Allocate a fresh String buffer. Pop cap_hint:i64 → push handle:Int(buffer_idx).
377pub const BUFFER_NEW: u8 = 0x90;
378
379/// Append the bytes of a string to a buffer. Pop str, pop buf →
380/// push buf (same handle). The String pointed to by `buf.handle` is
381/// mutated in place via `String::push_str`.
382pub const BUFFER_APPEND_STR: u8 = 0x91;
383
384/// Append separator only when buffer is non-empty. Pop sep, pop buf →
385/// push buf. No-op for the first append, so the synthesized `__buffered`
386/// loop body can place the separator before each element uniformly.
387pub const BUFFER_APPEND_SEP_UNLESS_FIRST: u8 = 0x92;
388
389/// Drain a buffer into an arena `OBJ_STRING`. Pop buf → push string.
390/// Frees the underlying `vm.buffer_pool` slot; the handle becomes invalid.
391pub const BUFFER_FINALIZE: u8 = 0x93;
392
393// -- Const-divisor Euclidean div/mod (0.24 "Divide") -------------------------
394//
395// Emitted by the MIR `const_fold` pass for `Int.div(a, k)` / `Int.mod(a, k)`
396// with a literal divisor `k` whose value rules out the partial cases
397// (`k == 0` for both, plus `k == -1` for div, which the fold leaves as the
398// runtime Result). Both pop two `Int`s and push the unchecked Euclidean
399// result, mirroring `i64::div_euclid` / `i64::rem_euclid` — the same routines
400// `Int.div` / `Int.mod` use in `src/types/int.rs`.
401
402/// Pop b, pop a, push `a.div_euclid(b)` (Int). Caller guarantees `b ∉ {0, -1}`
403/// (the const-fold divisor check), so no trap / overflow is possible.
404pub const INT_DIV_EUCLID: u8 = 0x94;
405
406/// Pop b, pop a, push `a.rem_euclid(b)` (Int). Caller guarantees `b != 0`.
407pub const INT_MOD_EUCLID: u8 = 0x95;
408
409/// Opcode name for debug/disassembly.
410pub fn opcode_name(op: u8) -> &'static str {
411    match op {
412        LOAD_LOCAL => "LOAD_LOCAL",
413        MOVE_LOCAL => "MOVE_LOCAL",
414        STORE_LOCAL => "STORE_LOCAL",
415        LOAD_CONST => "LOAD_CONST",
416        LOAD_GLOBAL => "LOAD_GLOBAL",
417        POP => "POP",
418        DUP => "DUP",
419        LOAD_UNIT => "LOAD_UNIT",
420        LOAD_TRUE => "LOAD_TRUE",
421        LOAD_FALSE => "LOAD_FALSE",
422        ADD => "ADD",
423        ADD_INT => "ADD_INT",
424        SUB_INT => "SUB_INT",
425        MUL_INT => "MUL_INT",
426        ADD_FLOAT => "ADD_FLOAT",
427        SUB_FLOAT => "SUB_FLOAT",
428        MUL_FLOAT => "MUL_FLOAT",
429        DIV_FLOAT => "DIV_FLOAT",
430        SUB => "SUB",
431        MUL => "MUL",
432        DIV => "DIV",
433        MOD => "MOD",
434        NEG => "NEG",
435        NEG_INT => "NEG_INT",
436        NEG_FLOAT => "NEG_FLOAT",
437        NOT => "NOT",
438        EQ => "EQ",
439        EQ_INT => "EQ_INT",
440        LT_INT => "LT_INT",
441        GT_INT => "GT_INT",
442        LT_FLOAT => "LT_FLOAT",
443        GT_FLOAT => "GT_FLOAT",
444        LT => "LT",
445        GT => "GT",
446        CONCAT => "CONCAT",
447        JUMP => "JUMP",
448        JUMP_IF_FALSE => "JUMP_IF_FALSE",
449        CALL_KNOWN => "CALL_KNOWN",
450        CALL_VALUE => "CALL_VALUE",
451        CALL_BUILTIN => "CALL_BUILTIN",
452        CALL_BUILTIN_OWNED => "CALL_BUILTIN_OWNED",
453        CALL_KNOWN_OWNED => "CALL_KNOWN_OWNED",
454        TAIL_CALL_SELF => "TAIL_CALL_SELF",
455        TAIL_CALL_KNOWN => "TAIL_CALL_KNOWN",
456        RETURN => "RETURN",
457        LIST_NIL => "LIST_NIL",
458        LIST_CONS => "LIST_CONS",
459        LIST_NEW => "LIST_NEW",
460        RECORD_NEW => "RECORD_NEW",
461        STORE_GLOBAL => "STORE_GLOBAL",
462        RECORD_GET => "RECORD_GET",
463        RECORD_GET_NAMED => "RECORD_GET_NAMED",
464        VARIANT_NEW => "VARIANT_NEW",
465        WRAP => "WRAP",
466        TUPLE_NEW => "TUPLE_NEW",
467        RECORD_UPDATE => "RECORD_UPDATE",
468        PROPAGATE_ERR => "PROPAGATE_ERR",
469        LIST_LEN => "LIST_LEN",
470        LIST_PREPEND => "LIST_PREPEND",
471        MATCH_TAG => "MATCH_TAG",
472        MATCH_VARIANT => "MATCH_VARIANT",
473        MATCH_UNWRAP => "MATCH_UNWRAP",
474        MATCH_INT_LITERAL => "MATCH_INT_LITERAL",
475        MATCH_NIL => "MATCH_NIL",
476        MATCH_CONS => "MATCH_CONS",
477        LIST_HEAD_TAIL => "LIST_HEAD_TAIL",
478        EXTRACT_FIELD => "EXTRACT_FIELD",
479        MATCH_TUPLE => "MATCH_TUPLE",
480        EXTRACT_TUPLE_ITEM => "EXTRACT_TUPLE_ITEM",
481        MATCH_FAIL => "MATCH_FAIL",
482        MATCH_DISPATCH => "MATCH_DISPATCH",
483        MATCH_DISPATCH_CONST => "MATCH_DISPATCH_CONST",
484        TAIL_CALL_SELF_THIN => "TAIL_CALL_SELF_THIN",
485        UNWRAP_OR => "UNWRAP_OR",
486        UNWRAP_RESULT_OR => "UNWRAP_RESULT_OR",
487        CALL_LEAF => "CALL_LEAF",
488        LOAD_LOCAL_2 => "LOAD_LOCAL_2",
489        LOAD_LOCAL_CONST => "LOAD_LOCAL_CONST",
490        VECTOR_GET => "VECTOR_GET",
491        VECTOR_GET_OR => "VECTOR_GET_OR",
492        VECTOR_SET => "VECTOR_SET",
493        VECTOR_SET_OR_KEEP => "VECTOR_SET_OR_KEEP",
494        BUFFER_NEW => "BUFFER_NEW",
495        BUFFER_APPEND_STR => "BUFFER_APPEND_STR",
496        BUFFER_APPEND_SEP_UNLESS_FIRST => "BUFFER_APPEND_SEP_UNLESS_FIRST",
497        BUFFER_FINALIZE => "BUFFER_FINALIZE",
498        INT_DIV_EUCLID => "INT_DIV_EUCLID",
499        INT_MOD_EUCLID => "INT_MOD_EUCLID",
500        CALL_PAR => "CALL_PAR",
501        NOP => "NOP",
502        _ => "UNKNOWN",
503    }
504}
505
506/// Operand byte width after the opcode byte. Single source of truth —
507/// all bytecode traversal functions must use this.
508pub fn opcode_operand_width(op: u8, code: &[u8], ip: usize) -> usize {
509    match op {
510        // 0-byte (stack-only)
511        POP
512        | DUP
513        | LOAD_UNIT
514        | LOAD_TRUE
515        | LOAD_FALSE
516        | ADD
517        | ADD_INT
518        | SUB_INT
519        | MUL_INT
520        | ADD_FLOAT
521        | SUB_FLOAT
522        | MUL_FLOAT
523        | DIV_FLOAT
524        | SUB
525        | MUL
526        | DIV
527        | MOD
528        | NEG
529        | NEG_INT
530        | NEG_FLOAT
531        | NOT
532        | EQ
533        | EQ_INT
534        | LT
535        | LT_INT
536        | LT_FLOAT
537        | GT
538        | GT_INT
539        | GT_FLOAT
540        | RETURN
541        | PROPAGATE_ERR
542        | LIST_HEAD_TAIL
543        | LIST_NIL
544        | LIST_CONS
545        | LIST_LEN
546        | LIST_PREPEND
547        | UNWRAP_OR
548        | UNWRAP_RESULT_OR
549        | CONCAT
550        | VECTOR_GET
551        | VECTOR_SET
552        | BUFFER_NEW
553        | BUFFER_APPEND_STR
554        | BUFFER_APPEND_SEP_UNLESS_FIRST
555        | BUFFER_FINALIZE
556        | INT_DIV_EUCLID
557        | INT_MOD_EUCLID
558        | NOP => 0,
559
560        // 1-byte
561        LOAD_LOCAL | MOVE_LOCAL | STORE_LOCAL | CALL_VALUE | RECORD_GET | EXTRACT_FIELD
562        | EXTRACT_TUPLE_ITEM | LIST_NEW | WRAP | TUPLE_NEW | TAIL_CALL_SELF
563        | TAIL_CALL_SELF_THIN | VECTOR_SET_OR_KEEP => 1,
564
565        // 2-byte (u16 or u8+u8)
566        LOAD_CONST | LOAD_GLOBAL | STORE_GLOBAL | JUMP | JUMP_IF_FALSE | MATCH_FAIL | MATCH_NIL
567        | MATCH_CONS | LOAD_LOCAL_2 | VECTOR_GET_OR => 2,
568
569        // 3-byte
570        CALL_KNOWN | CALL_LEAF | MATCH_TAG | MATCH_UNWRAP | MATCH_TUPLE | RECORD_NEW
571        | LOAD_LOCAL_CONST => 3,
572
573        // 4-byte
574        CALL_KNOWN_OWNED => 4, // fn_id:u16 + argc:u8 + owned:u8
575
576        // 4-byte
577        MATCH_VARIANT | RECORD_GET_NAMED => 4,
578
579        // 5-byte
580        CALL_BUILTIN | VARIANT_NEW => 5,
581
582        // 6-byte
583        CALL_BUILTIN_OWNED => 6, // symbol_id:u32 + argc:u8 + owned:u8
584
585        // 10-byte
586        MATCH_INT_LITERAL => 10, // imm:i64 + fail_offset:i16
587
588        // Variable-length
589        MATCH_DISPATCH | MATCH_DISPATCH_CONST if ip < code.len() => {
590            let count = code[ip] as usize;
591            let entry_size = if op == MATCH_DISPATCH { 11 } else { 17 };
592            3 + count * entry_size
593        }
594        RECORD_UPDATE if ip + 2 < code.len() => 3 + code[ip + 2] as usize,
595        // CALL_PAR count:u8 unwrap:u8 [argc:u8 × count]
596        CALL_PAR if ip < code.len() => {
597            let count = code[ip] as usize;
598            2 + count
599        }
600        _ => 0,
601    }
602}