From Lua Source to a Tiny Virtual Machine
Tokens, syntax, bytecode, and a bounded interpreter loop turn a short Lua-like expression into machine behavior.
Cheatsheet for Lesson 11.9
A one-page summary of “From Lua Source to a Tiny Virtual Machine” you can keep beside your notes. Plain Markdown.
Lesson 11.9 — From Lua Source to a Tiny Virtual Machine: cheatsheet
- Remember: The pool stores fixed constants. The stack stores temporary values. Adding two values removes both and leaves one result.
- Also useful: A register-based VM names temporary slots directly. This stack VM instead makes each instruction use the values at the top.
Reading map
- An interpreter is a pipeline
- Bytecode names small operations
- The VM has its own instruction pointer
- Model the VM as state plus one transition function
- The value stack carries temporary results
- Keep dynamic types explicit
- Separate four kinds of failure
- Budgets belong inside the execution loop
- Run the original lab
Review
- Q: What does the tiny Lua VM's instruction pointer identify? A: The next bytecode instruction
- Q: In what order do the stages of a typical interpreter process a line of source code? A: Lexer, then parser, then compiler to bytecode, then virtual machine
The earlier Lua lessons used scripts from the outside and defined what the game host would let them do. The computability lesson then showed how one program can execute another—and why no perfect termination checker can replace a work budget. Now look inside the interpreter. The same small expression has to become operations a virtual machine can execute, and those operations still need limits and useful errors.
An interpreter is a pipeline
Section titled “An interpreter is a pipeline”When Lua reads total = 5 + 2, it does not ask the CPU to execute the characters + and = directly. Several layers turn text into actions:
- a lexer groups characters into tokens such as name, equals, integer, plus, integer;
- a parser checks that the token order follows the language grammar;
- a compiler turns the meaning into compact bytecode instructions;
- a virtual machine executes those bytecodes using values, a stack, and an instruction pointer.
Real Lua is carefully optimized. Our tiny VM is intentionally smaller so every moving part fits on one page.
These stages are common, but they are not the definition of every interpreter. One interpreter may walk a syntax tree directly; another may compile to bytecode first; a just-in-time compiler may later translate hot bytecode into native machine code. The language’s meaning can stay the same while its implementation changes.
That gives us an important abstraction boundary:
language rule: `+` adds two numbers and produces a numberimplementation: pop two VM values, check their variants, add, push the resultmachine work: host code becomes native instructions executed by the CPUDo not explain Lua semantics by accident from one current bytecode layout. The source rule is the contract; tokens, syntax trees, bytecodes, stacks, and native instructions are replaceable representations used to carry it out.
The same pipeline can be viewed from the host’s side. Lua 5.4 typically recognizes names and numbers with a lexer, checks expressions and statements with a parser, compiles them to bytecode, and executes those instructions in its VM. The VM maintains Lua values, call frames, and its own program counter; the host supplies functions that may be called during execution:
Some hosts cache bytecode; others compile source each time it loads. LuaJIT may additionally translate hot paths into native machine code. Identify the runtime before assuming its internal representation matches this teaching VM.
Bytecode names small operations
Section titled “Bytecode names small operations”The lab uses an enum instead of raw bytes:
enum Instruction { Constant(usize), Add, GreaterThan, JumpIfFalse(usize), Jump(usize), Print, Halt,}Constant(2) means “copy constant number two onto the value stack.” Add pops two integers and pushes their sum. JumpIfFalse(9) changes the VM instruction pointer when the popped condition is false.
An enum makes this teaching VM easy to inspect. A full game-scripting interpreter may encode opcodes and operands more densely after profiling shows that bytecode size or dispatch speed matters.
That 9 is not decoration. Jump operands are positions in the bytecode vector,
so they only mean anything once you can see the whole program. Here is the
lab’s example, if (5 + 2) > 6 then print(1) else print(0), after compilation.
The constants are collected into a pool, and the instructions refer to them by
index:
if (5 + 2) > 6 then print(1) else print(0). Instructions refer to these by index, never by value directly.constant pool: 0:5 1:2 2:6 3:1 4:0
0 Constant(0) push 5 1 Constant(1) push 2 2 Add pop two, push 7 3 Constant(2) push 6 4 GreaterThan pop two, push true 5 JumpIfFalse(9) condition false -> continue at 9 6 Constant(3) push 1 <- then branch 7 Print 8 Jump(11) skip the else branch 9 Constant(4) push 0 <- else branch 10 Print 11 HaltNow the operands read as addresses within this listing. JumpIfFalse(9) sends
a false condition to index 9, which is exactly where the else branch begins.
Jump(11) carries the then branch past the else branch to the Halt. The
if/else structure you wrote in Lua has become two jumps and a layout — the
compiler kept the meaning and threw away the shape.
1 Index 0, Constant(0): push 5
Stack: older value, then top5topThe stack starts empty; the constant 5 goes on it.
2 Index 1, Constant(1): push 2
Stack: older value, then top52top2 goes on top of 5.
3 Index 2, Add: pop both, push 7
Stack: older value, then top7top · popped 2 and 5Add takes 2 and 5 off the stack and pushes 5 + 2 = 7.
Constants push values; Add pops two and pushes their sum. The stack is the working memory.
The VM has its own instruction pointer
Section titled “The VM has its own instruction pointer”The CPU has an instruction pointer such as RIP. The VM also needs one, but it points into the bytecode vector:
let instruction = *self.code .get(self.instruction_pointer) .ok_or_else(|| VmError("instruction pointer left the program".into()))?;self.instruction_pointer += 1; // 🧭 Default to the next bytecode.A jump replaces that default next location. Bounds checks prevent corrupt bytecode from jumping outside the program.
Model the VM as state plus one transition function
Section titled “Model the VM as state plus one transition function”At any instant, this tiny machine is completely described by a small state:
instruction pointerbytecode vectorconstant poolvalue stackremaining step budgetoutput produced so farOne interpreter-loop iteration reads the current instruction and changes that
state. Constant advances the pointer and grows the stack. Add removes two
values and adds one. Jump changes the pointer. Print moves a value to observable
output. Halt ends the transition loop.
This state-machine view is more reliable than imagining that the VM “somehow runs Lua.” It lets you state an invariant for every instruction: required stack inputs, resulting stack height, legal jump targets, possible error, and instruction-pointer change.
The value stack carries temporary results
Section titled “The value stack carries temporary results”Indices 0 to 4 of that listing compute (5 + 2) > 6. Watching the stack
through those five steps shows where the intermediate values live:
| Step | Bytecode | Stack after step |
|---|---|---|
| 1 | constant 5 | 5 |
| 2 | constant 2 | 5, 2 |
| 3 | add | 7 |
| 4 | constant 6 | 7, 6 |
| 5 | greater-than | true |
Constant(1) pushed 2 on top of the 5 pushed in step 1).Add pops the right side first because it was pushed last. This last-in, first-out order is the same basic idea as a call stack, although a real VM may use separate regions or registers for different jobs.
A stack operation consumes its operands. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
push a; push b
Add pops right and left, then pushes their sum
push threshold
GreaterThan pops threshold and sum
JumpIfFalse pops the comparison
print 1 on true, otherwise 0; haltResult: print 1, then halt. Each instruction has a specific stack effect.
Final values: a = 5; b = 2; threshold = 6; stack = []; sum = 7; condition = true; printed = 1.
Read every step of the lesson’s example
Step 1, line 1. Start with the selected inputs and follow how the next operation changes the state.
a = 5; b = 2; threshold = 6.
Step 2, line 1. The constant pushes fill the value stack.
a = 5; b = 2; threshold = 6; stack = [5, 2].
Step 3, line 2. Add replaces two operands with one result.
a = 5; b = 2; threshold = 6; stack = [7]; sum = 7.
Step 4, line 3. Push the right-hand comparison operand.
a = 5; b = 2; threshold = 6; stack = [7, 6]; sum = 7.
Step 5, line 4. GreaterThan replaces both numbers with one boolean.
a = 5; b = 2; threshold = 6; stack = [true]; sum = 7; condition = true.
Step 6, line 5. The jump consumes the boolean; the stack is empty again.
a = 5; b = 2; threshold = 6; stack = []; sum = 7; condition = true.
Step 7, line 6. Result: print 1, then halt. Each instruction has a specific stack effect.
a = 5; b = 2; threshold = 6; stack = []; sum = 7; condition = true; printed = 1.
A bytecode listing with an instruction pointer, a constant pool of 5, 2, 6, 1 and 0, and a value stack. Constants are pushed from the pool; Add pops 2 and 5 and pushes 7; 6 is pushed; GreaterThan pops 6 and 7 and pushes true; JumpIfFalse pops true and falls through to the then branch, which prints 1 and halts.
Read the steps
- The VM starts at bytecode 0 with an empty value stack. The constants live in the pool; instructions name them by index.
- Constant(0): copy pool entry 0, the number 5, onto the stack. The instruction pointer moves to 1.
- Constant(1): push pool entry 1, the number 2, on top of the 5. Now 2 is the value a pop would remove first.
- Add: pop the right operand, 2, then the left operand, 5. Both are integers, so push their sum, 7. Two temporary values became one.
- Constant(2): push pool entry 2, the number 6, above the 7.
- GreaterThan: pop 6, pop 7, and push the answer to 7 > 6. The stack now holds a condition, not numbers.
- JumpIfFalse(9) pops true. The condition is not false, so the jump is not taken and execution continues at 6, the then branch.
- Constant(3) pushes the number 1, Print pops it into the output, and Jump(11) skips the else branch to Halt.
The constant pool and the value stack have different jobs: the pool holds the program’s fixed values, and the stack holds the results in progress.
Keep dynamic types explicit
Section titled “Keep dynamic types explicit”Lua values can have several types. Our tiny subset has two:
enum Value { Integer(i64), Boolean(bool),}pop_integer checks the variant. Adding a Boolean produces a useful VM error rather than reinterpreting its bytes as a number. Dynamic typing means the check happens at run time; it does not mean “types do not exist.”
A larger Lua VM also needs an environment that maps names to values. Functions capture environments through closures, tables hold references to other values, and garbage collection decides when unreachable heap objects can be reclaimed. Those features enlarge the state, but they do not change the basic model: each operation checks its inputs and moves the machine from one valid state to the next.
Separate four kinds of failure
Section titled “Separate four kinds of failure”Keeping stages separate produces clearer errors:
| Stage | Example failure |
|---|---|
| Lexing | unfinished quoted string |
| Parsing | if without the required structure |
| Bytecode validation | jump target outside the program |
| Execution | adding a Boolean to an integer or exhausting the budget |
If all four become “script failed,” the host cannot tell whether the author wrote invalid Lua, the compiler produced invalid code, or a valid script exceeded the game tool’s resource policy.
Budgets belong inside the execution loop
Section titled “Budgets belong inside the execution loop”Every instruction consumes one step:
if self.steps_left == 0 { return Err(VmError("instruction budget exhausted".into()));}self.steps_left -= 1;The test program Jump(0) would loop forever without this guard. This miniature rule connects directly to the instruction hook used by the mlua host earlier in the chapter.
Run the original lab
Section titled “Run the original lab”The executable’s sample expression takes 5 + 2, compares it with 6, and
prints 1 for true. The test command then checks two failure paths: a jump
outside the program and a loop that exhausts its instruction budget.
cargo run --manifest-path lua-labs/Cargo.toml --bin mini_vmcargo test --manifest-path lua-labs/Cargo.toml --bin mini_vmFor a much larger interpreter-building journey, Build a Lua Interpreter in Rust develops tokens, bytecode, values, tables, control flow, functions, and closures over many stages. This book’s VM and wording are original and deliberately smaller; the linked book is useful further reading when you want to build a language implementation rather than embed an existing Lua runtime.
Follow the virtual machine
What does the tiny Lua VM's instruction pointer identify?
The processor has a native instruction pointer, while an interpreter keeps a separate index into its bytecode program.
Finished Lesson 11.9?
Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.
Plain Markdown, saved in this browser.
This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.