The Lua VM runtime
START WITH THE BIG PICTURE
What does this part of Epok do?
The VM guide explains what each Lua mode links, how much target memory it reserves, and how host-cooked bytecode is proven compatible with the 32-bit runtime.
- 01Select one project mode
- 02Build the matching archive and script payload
- 03Measure the fixed arena and target result
What is happening under the hood?
- Bytecode defaults to a 96 KiB arena and source to 128 KiB.
- The ABI verifier compares host and target chunk headers byte-for-byte.
- Lua 5.2/psxlua MIT notices apply only when a VM archive is linked.
Field note: Choose by measured game needs: source mode is convenient but carries the parser and the largest reserved arena.
Two of the three Lua execution modes run scripts through an interpreter on the console. This file describes what those builds actually contain, what has been measured, and what has not.
The product contract and the mode selection live in
knowledge/initiatives/lua-scripting/contract.md; this is the runtime side of
§7, §8 and §10.4.
What is linked, per mode
| Mode | EPOK_LUA_MODE |
Interpreter archive | Chunk payload |
|---|---|---|---|
| Native C++ | 0 | none | none — bodies are compiled MIPS |
| Lua VM — bytecode | 1 | lua/liblua-epok-noparser.a |
cooked bytecode |
| Lua VM — source | 2 | lua/liblua-epok-parser.a |
normalized Lua text |
src/settings.rs writes those lines into the generated sources.mk; the
runtime Makefile includes lua.mk only when EPOK_LUA_VM is set. The archive
is built into the build directory, from
$(NUGGET_DIR)/third_party/psxlua/src, with per-variant object directories —
upstream's own Makefile writes both archives from one set of objects, so the two
variants could otherwise contaminate each other across a mode change.
The archive carries psxlua's core plus lauxlib. The standard libraries
(lbaselib, lbitlib, lcorolib, ldblib, lstrlib, ltablib, linit) are
deliberately absent, and so is libpsyqo-lua.a: its constructor loads a
source bootstrap chunk and opens every library, which a cooked build neither
needs nor wants. The only globals that exist in a shipped VM are the registered
__epok_* helpers — there is no load, require, dofile or print.
runtime/lua_runtime.hpp therefore supplies the three libc hooks the fork
expects on this target itself (luaI_sprintf, luaI_realloc, luaI_free).
There are no --defsym bindings in the link.
Numeric parity
Every arithmetic operation in a normalized chunk is a call to a registered C
function that forwards to the same epok::bp::* function the native mode
compiles to. The fork's own long arithmetic never sees a user value. That is
what makes the three modes interchangeable rather than approximately equal:
saturation, division by zero, truncation toward zero and ineg(INT32_MIN) are
one implementation, not three.
Unsigned ordering is explicit (__epok_ult / __epok_ule) because a UInt32
travels as its bit pattern inside a signed 32-bit lua_Number. Signed ordering
uses Lua's own <, which already matches C++ for Int32, Fixed and Enum.
Arena budget
The runtime has no heap, so the VM's whole world — state, chunks, strings, call frames — lives in one static buffer. The size is per mode, chosen by the editor, not by the project:
| Mode | EPOK_LUA_ARENA_BYTES |
Why |
|---|---|---|
| Native C++ | (none — no arena is linked) | There is no VM. |
| Lua VM — bytecode | 98 304 (96 KiB) | luaU_undump allocates the chunk's structures and little else. |
| Lua VM — source | 131 072 (128 KiB) | Parsing on target allocates transiently during initialization, far above steady state. |
src/settings.rs (LuaExecution::arena_bytes) picks the value and writes it
into the generated lua-config.hh in the build directory, wrapped in
#ifndef EPOK_LUA_ARENA_BYTES, which runtime/lua_runtime.hpp includes first.
The header's own #ifndef fallback (96 KiB) is reached only when no generated
config exists, i.e. in the standalone harnesses.
Because the generated define is guarded, a project can raise the budget for one
build without touching the editor by appending
CPPFLAGS += -DEPOK_LUA_ARENA_BYTES=262144 to the export's or build
directory's sources.mk (the editor regenerates sources.mk, so in an editor
build the durable route is LuaExecution::arena_bytes). Exhaustion aborts with
Lua arena exhausted: ... Raise EPOK_LUA_ARENA_BYTES.; it never degrades
silently.
Sizing this is measured, not guessed — peak live bytes on the three-class
conformance fixture (tests/integration/lua_conformance/: Guard.lua,
Patrol.lua, Sentinel.lua, read out of guest RAM by verify_lua_modes.py):
| Mode | Peak live | Retained after init | Allocations | Headroom against the budget |
|---|---|---|---|---|
| Lua VM — bytecode | 18 368 B | 17 592 B | 301 | 5.4× |
| Lua VM — source | 21 376 B | 17 024 B | 478 | 6.1× |
The peak occurs while a single chunk is parsed and run; initialize() performs a
full collection after each chunk, so the peak follows the largest chunk rather
than the sum of every class, and the two modes retain almost the same memory
afterwards. The source mode reserves more because its transient parse footprint
grows with chunk text size, which a project cannot see from bytecode sizes.
An earlier revision reported a source peak near the whole budget; that was a
bookkeeping error in the arena's grow-in-place path, which counted the free
space it scanned as live before returning it, and it has been corrected.
These are one fixture's numbers. Scale with the number and size of chunks, not with instance count; a project with many classes must re-measure rather than multiply.
The allocator is a first-fit free list with forward coalescing and in-place
realloc growth. epok::lua::stats tracks live bytes, peak bytes and allocation counts.
Exhaustion calls psyqo::Kernel::abort with the request size and the live
total. It is never silent and never degrades: a build that cannot fit its
scripts says so on the first frame that needs the memory.
Related fixed budgets, all overridable or asserted at startup:
EPOK_LUA_MAX_CLASSES (64 binding entries), 64 method slots per class (the
bound_mask is a uint64_t), 8 arguments per call, and a call depth of 16 —
Lua may call back into C++ which re-enters Lua, and that nesting is bounded.
Bytecode ABI, and how it is verified
The bytecode mode cooks chunks on the host. build.rs compiles the pinned
psxlua parser for this machine and links it against
native/lua/epok_ldump32.c, a dumper that writes the two quantities whose width
differs between host and console at the console's width: size_t lengths and
psxlua's lua_Number, which is long — 64-bit here, 32-bit on the
PlayStation. A numeric constant outside int32 is an error, never a truncation.
Without the submodule, cargo build still succeeds and the bytecode mode
reports the missing sources as its real cause; it never falls back to shipping
source.
tests/integration/verify_lua_vm_abi.py is the proof, not the assumption:
- It cooks one normalized chunk with the shipping host cooker.
- It builds a PSX executable that compiles the same chunk on target and
dumps it with the fork's own
luaU_dump, runs it under PCSX-Redux, and reads the bytes back out of guest RAM. - It compares the two byte for byte, and checks the 18-byte header the target
build reports through
luaU_header()againstlua_bytecode::HEADER.
Measured on macOS arm64 with mipsel-none-elf-gcc 16.2.0, Nugget
6186b131, psxlua abed030e: 1805 host bytes, 1805 target bytes, identical;
header 1b4c7561520001040404040119930d0a1a0a on both sides.
Debug information
lua_bytecode::KEEP_DEBUG_INFO is true: chunks are cooked unstripped, so a
Lua error at runtime names the authored .lua file and the line. Stripping is a
choice the contract permits, not a requirement.
The cost, measured on the verifier's chunk (seven small methods, 1154 source bytes): 1805 bytes unstripped versus 992 stripped — 813 bytes, about 45%. That ratio is a property of this chunk, which is nearly all control flow and short bodies; a chunk with larger bodies carries proportionally less debug information.
Conformance smoke
The same verifier links lua_runtime.hpp into a minimal PsyQo program twice —
once with the chunk as source against the parser archive, once with the
host-cooked bytecode against the no-parser archive — and requires identical
results from both for: iadd(INT32_MAX, 1) saturating, idiv(x, 0) == 0,
fmul truncating toward zero on a negative product, ineg(INT32_MIN) == INT32_MAX, unsigned 0x80000000 > 1, a Bool round trip through the generated
field accessors, a nested Lua → __epok_call → C++ virtual → Lua dispatch, and
an absent slot whose Frame reports bound() == false and never enters the VM.
Last run: all probes matched in both modes. Arena peak 9088 bytes (source) and 6296 bytes (bytecode) for that one class, from 238 and 142 allocations.
Linked section sizes of that smoke program — not of a game:
| Build | .text |
.rodata |
.data |
.bss |
resident |
|---|---|---|---|---|---|
| smoke, source (parser archive) | 84 256 | 9 448 | 2 496 | 103 196 | 199 396 |
| smoke, bytecode (no-parser archive) | 56 672 | 8 184 | 2 672 | 103 196 | 170 724 |
.bss is dominated by the arena and is identical in both: this smoke program
includes lua_runtime.hpp without a generated lua-config.hh, so both builds
take the header's own 96 KiB #ifndef fallback rather than the per-mode budgets
above. The 27 584-byte
.text difference is the parser, lexer and code generator that the bytecode
mode does not link.
Standalone exports
An exported project in a VM mode needs both the pinned Nugget checkout and
its nested third_party/psxlua submodule; lua.mk builds the archive from the
latter during make. The three setup scripts initialize and verify psxlua
alongside Nugget. make needs nothing else: no host Lua, no luac, and no
network access — the chunk payloads are already inside the generated
scripts/generated/lua/lua_chunks.cpp.
What is not validated
- Nothing here ran on physical PlayStation hardware. PCSX-Redux is used for reproducible development comparison only.
- The sizes above are the smoke program's. They are not a game's, not RAM consumption during play, and not a per-class cost that can be multiplied out.
- The arena figures cover Lua allocations inside
EPOK_LUA_ARENA_BYTESonly — not the native side's stack, static data, or anything the rest of the runtime allocates. - The smoke exercises one class with one instance. Many classes, scene cycling and arena exhaustion under load are not covered by it.
- Steady-state cost per call is not measured here. The separate feasibility
harness (
tests/integration/verify_lua_feasibility.py) measured guest cycles for the technology comparison that chose this design.
Where the whole-project numbers live
This file covers the runtime in isolation. The cross-mode acceptance run —
per-mode linked sizes and ps-exe sizes for one real two-class project, tick
cycles in all three modes, the arena peaks quoted above and the 21 equivalence
checks behind them — is recorded in
knowledge/initiatives/lua-scripting/validation-2026-09-14.md,
produced by tests/integration/verify_lua_modes.py.