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.
- 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.
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);
}
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:
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.
// 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");
}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
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!()
}
}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!()
}What you learned
- An opcode enum is fieldless and
#[repr(u8)]. Variant order fixes the numbers, which match the TypeScriptOPtable, andop as u8encodes for free. u8 as OpCodedoesn’t compile. drip-rs decodes with a table,ALL.get(b as usize).copied(), returningOption<OpCode>, which is still 1 byte. Nounsafeneeded.- Names like
OP_GET_LOCALcome from theDebugname. ChunkholdsVec<u8>,Vec<usize>andVec<Value>.- The disassembler takes
&Heapto print string constants, uses format specs for padding, and subtracts for loops becauseusizecan’t be negative. Its output matched the TypeScript one on every corpus program that compiles.
Check your understanding
0/6- 1.
In
#[repr(u8)] enum OpCode { Constant, Nil, True, ... }, what isOpCode::True as u8? - 2.
Why does drip-rs decode bytes with
OpCode::ALL.get(b as usize).copied()instead ofb as OpCode? - 3.
What is
size_of::<Option<OpCode>>()? - 4.
Someone adds a new opcode in the middle of the enum but forgets
ALL. What catches it? - 5.
Why does drip-rs’s disassembler need the heap?
- 6.
In drip-rs’s disassembler,
{name:<16}does what the TypeScript code did with…
References
- [1] The Rust Reference contributors. Enumerations. The Rust Reference. doc.rust-lang.org/reference/items/enumerations.html (checked 2026-10-31)
- [2] The Rust Reference contributors. Type layout. The Rust Reference. doc.rust-lang.org/reference/type-layout.html (checked 2026-10-31)