Viscosity
Review
Part X · Drip in Rust · Day 37

148. Chunks and opcodes in Rust

Turning an opcode into a byte is free in Rust. Turning a byte back into an opcode is the interesting half, and drip-rs does it with a table, no unsafe code, and an Option that still fits in one byte.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Declare an opcode enum with
  • Decode a byte safely with a table of every opcode, and explain why b as OpCode doesn’t compile.
  • Derive the disassembler’s OP_ names from Debug, and port Chunk to Vec<u8>.
  • Read drip-rs’s disassembler and say what each difference from debug.ts is for.

Thirty-seven numbers

Chapter 45’s instruction set lives in companion/ts/src/vm/chunk.ts as an object of numbers:

export const OP = {
  CONSTANT: 0, NIL: 1, TRUE: 2,
  // ...
  INHERIT: 35, METHOD: 36,
} as const;

The compiler writes OP.ADD into code, an array of JavaScript numbers, and the VM’s switch compares bytes with OP.ADD. A second table, OP_NAMES, is built from the keys for the disassembler. Three things to keep in step by hand: the numbers, the names, and every place that tests them.

Intuition

A café menu with numbered dishes. The kitchen hears “number 18” and knows it means the toastie. Rust lets you print the menu once, as a type, and the numbers come from where each dish sits on it.

OpCode

drip-rs’s chunk.rs:

#[repr(u8)]
#[derive(Clone, Copy, Debug,
         PartialEq, Eq)]
pub enum OpCode {
    Constant,
    Nil,
    True,
    // ... 33 more
    Method,
}

Variants without an explicit value are numbered from 0 in the order they are written[1]. The variants are listed in the same order as the OP table, so Constant is 0, Add 18 and Method 36, and both VMs produce the same bytes for the same program. I checked the two name lists against each other: all 37 line up.

#[repr(u8)] pins the representation. A fieldless enum with a primitive representation has the size and alignment of that integer, and its discriminants are of that type[2]. So an OpCode is its byte, and encoding is a cast the compiler’s emit_op does in one line:

fn emit_op(&mut self, op: OpCode) {
    self.emit(op as u8);
}
Definition

An opcode enum is a fieldless enum with one variant per instruction, marked #[repr(u8)] so each value is stored as, and casts with as u8 to, the byte that encodes it. Variant order fixes the numbering. drip-rs’s OpCode has 37 variants numbered like the TypeScript OP table.

Bytes back into opcodes

The VM reads a u8 and needs an OpCode. The obvious cast doesn’t exist (1.97, trimmed):

error[E0605]: non-primitive cast:
  `u8` as `Op`
  an `as` expression can only be
  used to convert between
  primitive types

It can’t, because most bytes aren’t opcodes: 37 to 255 have no variant. There are three ways out. Reinterpret the byte with unsafe code, which #![forbid(unsafe_code)] rules out, and which would be undefined behaviour for a bad byte anyway. Write a 37-arm match. Or do what drip-rs does, and keep a table:

pub const ALL: [OpCode; 37] = {
    use OpCode::*;
    [Constant, Nil, True, False,
     // ...
     Inherit, Method]
};

pub fn from_byte(b: u8)
    -> Option<OpCode> {
    OpCode::ALL.get(b as usize).copied()
}

ALL[op as usize] == op for every opcode, and a unit test checks exactly that, so a variant added out of order fails cargo test. get returns None past the end, so a bad byte is an ordinary value, not a crash. The VM, which trusts its own compiler, calls from_byte(byte).expect("the compiler emits valid opcodes").

And the Option is free: size_of::<Option<OpCode>>() is 1. Bytes 37 to 255 are never a valid OpCode, and rustc uses one of those spare values to mean None.

Names for free

TypeScript built OP_NAMES from the keys of OP. drip-rs builds a name from the variant’s Debug text, putting _ before each capital:

pub fn name(self) -> String {
    let mut name = String::from("OP");
    for c in format!("{self:?}").chars() {
        if c.is_ascii_uppercase() {
            name.push('_');
        }
        name.push(c.to_ascii_uppercase());
    }
    name
}

JumpIfFalse becomes OP_JUMP_IF_FALSE. There is no second list to forget to update.

The chunk

Chunk is chapter 46’s three arrays, typed:

#[derive(Default)]
pub struct Chunk {
    pub code: Vec<u8>,
    pub lines: Vec<usize>,
    pub constants: Vec<Value>,
}

write pushes a byte and its line; add_constant pushes a value and returns its index. Both are line-for-line the TypeScript methods. The difference is in the types: code holds real bytes, so chunk.write(300, 1) doesn’t compile (“literal out of range for u8”) instead of being a bug the disassembler finds later. Value is chapter 149’s subject. #[derive(Default)] writes the Chunk::default() that starts all three empty.

The disassembler

debug.rs prints the same text as debug.ts, column for column. Step through it on chapter 46’s eleven bytes:

Step throughDecoding a chunk, one instruction at a time
The chunk's code and lines arrays, the current instruction highlighted, and the disassembly so farcodeline01010111181241022224212332constants:0: 1.51: 22: "hi"from_byte → Constant · constant arm: 2 bytes== <script> ==0000    1 OP_CONSTANT         0 '1.5'
offset 0: OpCode::from_byte(0) is Some(Constant). Its arm reads the operand byte code[1] = 0, and prints constants[0] through the heap: '1.5'. Line 1 differs from the line before (there is none), so the number is printed. Next offset: 2.
1/6

Its signature has one extra parameter:

pub fn disassemble_instruction(
    heap: &Heap,
    chunk: &Chunk,
    offset: usize,
) -> (String, usize)

A string constant in drip-rs is a handle into the heap (chapter 145), so printing 'hi' means asking the heap for the text. The TypeScript version just reads the object. The rest is translation. padStart and padEnd become format specs, {offset:04}, {:>4} and {name:<16}. An unknown byte is handled up front with let ... else:

let Some(op) = OpCode::from_byte(b)
else {
    return (format!(
        "{prefix}Unknown opcode {b}"),
        offset + 1);
};

And jumps had to change. TypeScript computed offset + 3 + sign * j with sign = -1 for loops. A usize can’t be negative, so the Loop arm subtracts instead, offset + 3 - jump. If that ever went below zero, a debug build would panic on the overflow instead of printing a nonsense target.

Try itOpcodes and a tiny disassembler
// Opcodes and a tiny disassembler,
// adapted from drip-rs's chunk.rs and
// debug.rs.
#[repr(u8)]
#[derive(Clone, Copy, Debug, PartialEq)]
enum OpCode {
    Constant,
    Nil,
    Add,
    Print,
    Return,
}

use OpCode::*;

impl OpCode {
    const ALL: [OpCode; 5] = [
        Constant, Nil, Add,
        Print, Return,
    ];

    fn from_byte(b: u8)
        -> Option<OpCode> {
        let i = b as usize;
        Self::ALL.get(i).copied()
    }

    // GetLocal -> OP_GET_LOCAL
    fn name(self) -> String {
        let mut s = String::from("OP");
        let dbg = format!("{self:?}");
        for c in dbg.chars() {
            if c.is_ascii_uppercase() {
                s.push('_');
            }
            s.extend(c.to_uppercase());
        }
        s
    }
}

#[derive(Default)]
struct Chunk {
    code: Vec<u8>,
    lines: Vec<usize>,
    constants: Vec<f64>,
}

impl Chunk {
    fn write(&mut self, b: u8, l: usize)
    {
        self.code.push(b);
        self.lines.push(l);
    }
}

// One instruction: its text and the
// offset of the next one.
fn dis(c: &Chunk, at: usize)
    -> (String, usize) {
    let b = c.code[at];
    let p = format!("{at:04} {:>4} ",
        c.lines[at]);
    let Some(op) = OpCode::from_byte(b)
    else {
        let u = "Unknown opcode";
        let t = format!("{u} {b}");
        return (p + &t, at + 1);
    };
    let name = op.name();
    if op == Constant {
        let k = c.code[at + 1];
        let v = c.constants[k as usize];
        let t = format!(
            "{name:<16} {k:>4} '{v}'");
        return (p + &t, at + 2);
    }
    (p + &name, at + 1)
}

fn main() {
    let mut c = Chunk::default();
    c.constants.push(1.5);
    c.write(Constant as u8, 1);
    c.write(0, 1);
    c.write(Print as u8, 1);
    c.write(Nil as u8, 2);
    c.write(Return as u8, 2);
    c.write(99, 2); // not an opcode
    println!("bytes: {:?}", c.code);

    let mut at = 0;
    while at < c.code.len() {
        let (text, next) = dis(&c, at);
        println!("{text}");
        at = next;
    }
    use std::mem::size_of;
    let n = size_of::<Option<OpCode>>();
    println!("Option<OpCode>: {n} B");
}
Open in Rust Playground ↗
Under the hood

Does it really match? The TypeScript CLI has no disassembly flag, so I wrote a short script that compiles each program with the TypeScript compiler and calls disassembleChunk on every function, and compared its output with drip-rs --disassemble for all 265 .drip files in the corpus. 199 compile, and all 199 listings are identical. The other 66 fail to compile in both, with the same exit code. drip-rs also has a disassemble_function that prints nested functions after their parent, which my script did by hand for the TypeScript side. Two small Rust details make the closure arm work: let Value::Function(f) = ... else pulls the function handle out of the constant, and each upvalue is printed as a continuation line, local 1 or upvalue 0.

Your turn

ExerciseEncode, decode, name

Here are five opcodes, numbered by #[repr(u8)] in the order written. Add a constant ALL holding every opcode in encoding order, then write encode (the opcode’s byte), decode (the opcode for a byte, or None), and name, which turns GetLocal into OP_GET_LOCAL like the TypeScript disassembler.

#[repr(u8)]
#[derive(Clone, Copy, Debug, PartialEq)]
enum Op {
    Constant,
    Nil,
    GetLocal,
    JumpIfFalse,
    Return,
}

impl Op {
    fn encode(self) -> u8 {
        todo!()
    }

    fn decode(b: u8) -> Option<Op> {
        todo!()
    }

    fn name(self) -> String {
        todo!()
    }
}
Open in Rust Playground ↗
Extra practiceWhere does the jump land?

A jump instruction at offset at is three bytes: the opcode, then a 16-bit distance, high byte first. The distance counts from the end of the instruction (at + 3): forward for Jump and JumpIfFalse, backward for Loop. Write target, which the disassembler prints after ->.

#[derive(Clone, Copy, Debug, PartialEq)]
enum Op {
    Jump,
    JumpIfFalse,
    Loop,
}

fn target(
    op: Op,
    code: &[u8],
    at: usize,
) -> usize {
    todo!()
}
Open in Rust Playground ↗

What you learned

  • An opcode enum is fieldless and #[repr(u8)]. Variant order fixes the numbers, which match the TypeScript OP table, and op as u8 encodes for free.
  • u8 as OpCode doesn’t compile. drip-rs decodes with a table, ALL.get(b as usize).copied(), returning Option<OpCode>, which is still 1 byte. No unsafe needed.
  • Names like OP_GET_LOCAL come from the Debug name.
  • Chunk holds Vec<u8>, Vec<usize> and Vec<Value>.
  • The disassembler takes &Heap to print string constants, uses format specs for padding, and subtracts for loops because usize can’t be negative. Its output matched the TypeScript one on every corpus program that compiles.

Check your understanding

0/6
  1. 1.

    In #[repr(u8)] enum OpCode { Constant, Nil, True, ... }, what is OpCode::True as u8?

  2. 2.

    Why does drip-rs decode bytes with OpCode::ALL.get(b as usize).copied() instead of b as OpCode?

  3. 3.

    What is size_of::<Option<OpCode>>()?

  4. 4.

    Someone adds a new opcode in the middle of the enum but forgets ALL. What catches it?

  5. 5.

    Why does drip-rs’s disassembler need the heap?

  6. 6.

    In drip-rs’s disassembler, {name:<16} does what the TypeScript code did with…

References

  1. [1] The Rust Reference contributors. Enumerations. The Rust Reference. doc.rust-lang.org/reference/items/enumerations.html (checked 2026-10-31)
  2. [2] The Rust Reference contributors. Type layout. The Rust Reference. doc.rust-lang.org/reference/type-layout.html (checked 2026-10-31)