Skip to content
Lesson 11.11NPC Policy, Lua, and Script Execution

Call Frames, Closures, and Upvalues

Understand VM call frames, multiple returns, captured locals, open and closed upvalues, and the host boundary where native functions enter Lua.

Lesson 11.11
Read or listenPrint chapter or book
About 40 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 11.11Key ideas, reference and questions

A one-page summary of “Call Frames, Closures, and Upvalues” you can keep beside your notes. Plain Markdown.

Lesson 11.11 — Call Frames, Closures, and Upvalues: cheatsheet

  • Remember: Closures that capture the same local need one shared upvalue cell, so changes remain visible through every captured reference.
  • Also useful: A frame’s base is its starting shelf position: local index zero means that frame’s first slot, not the first slot of the entire VM stack.

Key terms

  • call frame: That record is a call frame.
  • count: count is an upvalue: a captured variable from an enclosing scope.
  • upvalue: count is an upvalue: a captured variable from an enclosing scope.

Review

  • Q: What must happen to an upvalue that still refers to a local when the local's call frame returns? A: It moves into storage owned by the closure
  • Q: What does a call frame record? A: Where the caller resumes, plus locals and function
Download cheatsheet (.md)

When one Lua function calls another, the interpreter must remember where the caller continues, where its local values live, which function is running, and where results should go. That record is a call frame.

This is the virtual machine’s version of the call-stack idea from the assembly chapter. The CPU still has a real native stack underneath, but Lua call frames describe Lua execution.

A VM can keep values in one vector and let each frame own a range:

struct CallFrame {
function: FunctionId,
base: usize,
return_instruction: usize,
expected_results: ResultCount,
}
enum ResultCount {
Exact(usize),
All,
}
Two call frames share one value stackThe caller keeps a = 10 and b = 3 at base 0, then calls a function that returns x − y.
  1. 1 Call: the new frame starts at base 2

    Value stack
    caller: base 0callee: base 2
    10slot 0 · a
    3slot 1 · b
    10slot 2 · x
    3slot 3 · y

    The arguments are copied to slots 2 and 3, so the callee’s local 1 is slot 2 + 1 = 3. The frame records: resume the caller at instruction 5, one result wanted.

  2. 2 Return: the result lands in slot 2

    Value stack
    caller: base 0
    10slot 0 · a
    3slot 1 · b
    7slot 2 · result
    freeslot 3

    The callee computes 10 − 3 = 7. The VM stores it in slot 2, removes the callee’s frame and continues the caller at instruction 5.

The caller keeps a = 10 and b = 3 at base 0, then calls a function that returns x − y.

base says where this function’s local area begins. Bytecode operands can refer to base + local_index rather than carrying raw pointers into the vector.

Lua allows multiple return values, so a caller may request an exact number or accept all produced results. Missing results become nil; unwanted extras can be discarded.

A closure is code plus captured surroundings

Section titled “A closure is code plus captured surroundings”

Consider:

local function make_counter()
local count = 0
return function()
count = count + 1
return count
end
end

The returned function keeps using count after make_counter has returned. count is an upvalue: a captured variable from an enclosing scope.

While the outer frame is active, an upvalue may refer to its live stack slot. Before that frame disappears, the interpreter must move or “close” the value into storage owned by the closure. Otherwise the closure would keep an invalid stack location.

An implementation can model the two states explicitly:

enum UpvalueLocation {
Open { stack_index: usize },
Closed(Value),
}

Several closures may capture the same local, so they must share one upvalue cell. If each closure copies the number independently, updates will disagree and the Lua behavior will be wrong.

Follow make_counter through with slot numbers and it stops being abstract. Say its frame gets base = 3, so its single local count lives in stack slot 3:

call make_counter()
stack slot 3 = 0 <- count, inside the live frame
inner function created
upvalue = Open { stack_index: 3 }
make_counter returns
slot 3 is about to belong to whatever runs next,
so the interpreter closes the upvalue first:
upvalue = Closed(0) <- the 0 now lives in a cell the closure owns
counter() reads the cell -> 0, writes 1, returns 1
counter() reads the cell -> 1, writes 2, returns 2

“Open” and “closed” are just those two rows: while the frame lives the upvalue is a slot number, and afterwards it is a value in a cell. The identity never changes — it is the same count throughout — only where it is stored.

That distinction predicts a specific bug. Hand the same count to two closures, then close it by copying the value into each one rather than sharing one cell. Call the first closure twice and the second once, and you get 1, 2, and then 1 again, because the second closure incremented its own private copy. Sharing the cell is what makes the third call return 3, which is the behaviour Lua actually promises.

The captured value changes location without changing its identity:

make_counter() frame, base = 3
stack slot 3 = 0 (count)
upvalue = Open { stack_index: 3 }
make_counter() returns
upvalue = Closed(0)
(count now lives in its own cell)
Closure A reads/writes the cell
Closure B reads/writes the same cell

Both closures keep referring to one shared cell after the original stack frame is gone.

This is a useful ownership lesson: the local starts inside one frame, escapes, becomes shared state, and outlives its original scope.

Two closures must share one captured counter. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

count = 0
make closures A and B capture count
A increments its captured value
A returns that value
B increments its captured value
B returns; compare captured storage

Result: A returned 1; B returned 2. Both closures refer to the same final cell.

Final values: storage = shared; cell_A = 2; cell_B = 2; returned_A = 1; returned_B = 2.

Read every step of the lesson’s example
  1. Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.

    storage = shared.

  2. Step 2, line 2. Both closures initially observe zero.

    storage = shared; cell_A = 0; cell_B = 0.

  3. Step 3, line 3. Closure A increments its cell.

    storage = shared; cell_A = 1; cell_B = 1.

  4. Step 4, line 4. Closure A returns 1.

    storage = shared; cell_A = 1; cell_B = 1; returned_A = 1.

  5. Step 5, line 5. Closure B sees the already updated shared 1 and increments it to 2.

    storage = shared; cell_A = 2; cell_B = 2; returned_A = 1.

  6. Step 6, line 6. Result: A returned 1; B returned 2. Both closures refer to the same final cell.

    storage = shared; cell_A = 2; cell_B = 2; returned_A = 1; returned_B = 2.

Host callbacks look like VM functions at the boundary

Section titled “Host callbacks look like VM functions at the boundary”

When mlua registers game.log, it builds a callable Lua value backed by a host closure. The boundary must:

  1. check the Lua argument count and types;
  2. convert them to host values;
  3. call the host function;
  4. convert success values back to Lua;
  5. turn host errors into Lua errors with context;
  6. avoid letting a panic cross the language boundary.

The callback should be short and bounded. A Lua instruction budget cannot interrupt arbitrary blocking work performed inside native host code.

The Lua C API makes this boundary concrete with an indexed value stack. A native callback reads arguments from stack slots, validates them, pushes return values, and reports how many it returned. mlua wraps much of that protocol, but stack balance and error conversion still exist underneath:

before call: [function][argument 1][argument 2]
native code: validate arguments, perform bounded work
after call: [return value 1]

Treat a host callback like a small parser. Specify accepted types and ranges, reject invalid input before side effects, and return an error in the form the runtime expects. A panic or native exception must not cross a C ABI boundary uncontrolled.

Re-entrancy adds nested host and VM call frames

Section titled “Re-entrancy adds nested host and VM call frames”

A host callback may call Lua again, which may call another host function. This is re-entrancy. The host needs a clear policy for locks and mutable game state.

Host callback BLua (frame 2)Host callback ALua (frame 1)Host callback BLua (frame 2)Host callback ALua (frame 1)calls a host functioncalls back into Luacalls another host functionreturnsreturnsreturns

Avoid holding a mutable engine lock while executing unknown script code. Give Lua a copied snapshot, collect typed requests, return to the host, and then validate and apply them. That architecture from earlier lessons also prevents complicated nested borrows.

When a script call behaves incorrectly, record:

  • bytecode instruction and VM instruction pointer;
  • active function and call-frame base;
  • argument slots and their value tags;
  • requested result count;
  • open upvalues and the stack slots they reference;
  • host callback name and converted arguments;
  • final Lua error chain.

Do not print secrets or entire game snapshots by default. A debugger can be useful without becoming a data leak.

The original mini_vm lab intentionally stops before functions. Extend a copy in this order:

  1. add named local slots;
  2. add Call and Return instructions;
  3. introduce a CallFrame stack;
  4. support an exact result count;
  5. add function constants;
  6. capture one read-only upvalue;
  7. close it when the outer frame returns;
  8. share one mutable upvalue between two closures;
  9. add recursion and enforce a maximum frame depth.

Each stage should have a failing test before it gets more features. A small verified interpreter teaches more than a large VM whose stack layout and call/return behavior are guesses.

The detailed implementation path in Build a Lua Interpreter in Rust is useful further reading for functions and closure escape. Keep your own code original, compare observable behavior with the official Lua manual, and treat the reference implementation as a guide to questions—not a source to paste from.

The VM explains how a script keeps state and reaches a host callback. It does not decide whether that callback’s requested game action is valid. A copied snapshot can grow stale while Lua runs; another script or input path may reach the same effect. Chapter 11 returns to the Windows process that hosts both the game and its scripts, identifying its build, modules, memory regions, and thread state before later chapters inspect deeper trust boundaries.

Knowledge check

Let a local escape

What must happen to an upvalue that still refers to a local when the local's call frame returns?

Finished Lesson 11.11?

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.

Chapter 11: 11 lessons

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.