Modules#
A component crate knows how to expose itself to Luau but never owns the VM. It implements
module::LuauModule; the host owns the Runtime, decides which modules exist, and calls
Runtime::register_module::<M>(). The result is a frozen package table the host places
wherever its script environment wants it: a global, a require loader, a sandbox package list.
use std::cell::Cell;
use l3i::bind::{Call, StackResults};
use l3i::module::{LuauModule, ModuleBuilder};
use l3i::userdata::{Userdata, tagged};
use l3i::value::Table;
use l3i::{Result, Runtime};
struct Asset {
name: String,
loads: Cell<u32>,
}
unsafe impl Userdata for Asset {
const NAME: &'static str = "dreamweave.assets.Asset";
}
struct AssetsModule;
impl LuauModule for AssetsModule {
const NAME: &'static str = "dreamweave.assets";
fn register(_runtime: &Runtime, module: &mut ModuleBuilder<'_>) -> Result<()> {
module.userdata::<Asset>(Some(30), |ty| {
ty.property("name", |asset: &Asset| asset.name.clone())?;
ty.method("load", |asset: &Asset| {
asset.loads.set(asset.loads.get() + 1);
asset.loads.get()
})
})?;
module.function("open", |call: &Call, name: &str| -> Result<StackResults> {
tagged::push(call, Asset { name: name.to_owned(), loads: Cell::new(0) })?;
Ok(StackResults)
})?;
module.set("VERSION", &3i32)?;
module.metamethod("__tostring", |_: Table| "dreamweave.assets package")?;
Ok(())
}
}
fn main() -> Result<()> {
let runtime = Runtime::new()?;
let assets = runtime.register_module::<AssetsModule>()?;
runtime.set_global("assets", &assets)?;
runtime.exec("local a = assets.open('tree.nif') assert(a.name == 'tree.nif' and a:load() == 1)")?;
runtime.exec("assert(assets.VERSION == 3 and tostring(assets) == 'dreamweave.assets package')")?;
Ok(())
}
ModuleBuilder is the PackageBuilder of the C++ binder: every function it binds is named
<module>.<key> under the host’s debug roots, so assets.open(5) fails with
dreamweave.assets.open: bad argument #1 (expected string). NAME is a dot-separated package
path rooted at one of the runtime’s debug roots; Runtime::module(path) starts a builder by
hand and refuses a path outside them.
ModuleBuilder | Does |
|---|---|
function(key, callable) | Binds callable as <path>.<key> and stores it |
set(key, &value) | Stores any pushable value |
userdata::<T>(Some(tag) or None, configure) | Registers a userdata type, tagged or untagged, and configures its metatable |
metamethod(name, callable), set_metafield(name, &value) | The package’s own metatable, created on first use |
path(), table() | The package path and the table being built |
finish() | Freezes the package and its metatable |
After finish, nothing can be added from scripts (attempt to modify a readonly table) or from
Rust except through readonly::set_read_only_field. Runtime::set_global(name, &value) and
Runtime::global(name) place and fetch globals.
Read-only tables#
readonly provides OpenMW’s two flavours:
| Function | Effect |
|---|---|
make_read_only(&runtime, &table) | Freezes the table in place (lua_setreadonly) |
make_strict_read_only(&runtime, &table) | Freezes it with the shared strict metatable: a missing key raises Key not found: <key>. The table must not already be frozen or have a metatable |
make_read_only_view(&runtime, &table) | A frozen proxy whose __index is the backing table, with __pairs, __iter, __ipairs and __len that iterate and measure the backing table without exposing it, and __metatable = false. A view of a view is the same view |
make_strict_read_only_view(&runtime, &table) | The same, raising Key not found for a missing key |
set_read_only_field(&runtime, &table, key, &value) | Sets a key on a frozen table, or on the backing table of a view, and restores the frozen state; for tables the host owns |
make_frozen_package(&runtime, &package, &to_string) | A metatable with only __tostring, then frozen; what ModuleBuilder::finish does |
Writes to a backing table stay visible through its view. Stock Luau’s pairs and ipairs
ignore __pairs and __ipairs; the generic for honours __iter, and the sandbox prelude
below replaces pairs and ipairs with versions that honour them.
Sandboxes#
Runtime::sandbox(log, options) prepares a sandbox on the VM, as OpenMW’s Lua::State does.
Call it once, after every module the base environment should expose is registered. log
receives each print line, already tab-joined and prefixed with the instance’s name.
- The base environment copies every string-keyed global except
_Gand the environment escape hatches (getfenv,setfenv,newproxy), exposes tables as read-only views, and replacesgetmetatablewith a tables-only version.printandrequireare left out because every instance gets its own. The base environment is frozen and marked safe so Luau’s import fast paths apply. The real_Gis never frozen. - An instance is a writable table whose frozen metatable indexes the base environment, with
_G, a namedprint, aloadedtable of packages, and arequirereading from it. - A template is a chunk compiled once and loaded on a loader thread whose globals are the
base environment, then instantiated per script with
lua_clonefunctionpluslua_setfenv. Because the base environment is marked safe, Luau resolves the chunk’s builtin imports (math.sqrt, a module placed as a global, …) against it when the template loads, and every instance, whose environment is marked safe too, takes the fast import path from its first run. The trade is Luau’s own: a global the base environment holds cannot be shadowed by a write from another chunk into the instance; a chunk that assigns the name itself compiles its reads as lookups, andprintandrequire, which the base environment leaves out, resolve per instance. OpenMW loads templates on a thread with empty, unsafe globals and looks every import up at run time.
SandboxOptions holds the compile options for templates and three compatibility switches:
compat_iterators (on by default: pairs and ipairs honouring __pairs and __ipairs, which
read-only views and iterable userdata depend on), compat_string_format (off: string.format
applies tostring to any %s argument, as LuaJIT does), and neuter_randomseed (off: math.random
is seeded once from the clock and math.randomseed becomes a no-op).
use l3i::runtime::{CallContext, MemoryCategory};
use l3i::sandbox::{InstanceSpec, SandboxOptions};
use l3i::{Result, Runtime};
fn main() -> Result<()> {
let runtime = Runtime::new()?;
let sandbox = runtime.sandbox(|line| println!("[script] {line}"), SandboxOptions::default())?;
let loader = runtime.load_function("return function(name) error('module ' .. name .. ' not found') end")?;
let instance = sandbox.new_instance(
&runtime,
&InstanceSpec { name: "player", packages: &[], hidden_data: None, loader: &loader },
)?;
let template = sandbox.load_template(
&runtime,
"player.lua",
"counter = (counter or 0) + 1 print('run', counter) return counter",
)?;
let context = CallContext { id: 1, category: MemoryCategory(3) };
sandbox.run(&runtime, &template, &instance, context)?;
let results = sandbox.run(&runtime, &template, &instance, context)?;
let count = results[0].with_value(&runtime.stack(), |_, view| view.read::<i32>())?;
assert_eq!(count, 2);
Ok(())
}
It prints [script] player:\trun\t1 and then [script] player:\trun\t2. The instance kept its
own counter; the real globals never saw it, and a second instance would start at 1.
| Call | Does |
|---|---|
Sandbox::base_env() | The frozen base environment every instance indexes |
Sandbox::common_packages(), add_common_package(&runtime, name, value) | Packages every instance receives: the base environment’s tables and userdata plus those added. A table is frozen in place; a function is a factory called once per instance with the hidden data |
Sandbox::new_instance(&runtime, &spec) | One instance, built inside an initialization call scope |
Sandbox::load_template(&runtime, chunk_name, source) | Compiles once and loads on the loader thread, with its imports resolved against the base environment. Binary chunks are rejected; a syntax error is Err with Luau’s message |
Sandbox::instantiate(&runtime, &template, env) | A fresh closure sharing the template’s prototype, running in env, or in the real globals for None |
Sandbox::run(&runtime, &template, &instance, context) | Instantiates in the instance and runs it inside a script call scope, returning everything the chunk returned |
Template::chunk_name() | The name the template was loaded under |
InstanceSpec names the instance (print prefixes its output with name:), lists per-instance
packages that override the common ones by name, passes optional hidden data to package
factories, and names the loader behind require: called as loader(name, env) for a name
missing from loaded, it must return a function that, called with name, produces the package.
An Instance exposes its env and loaded tables.
Loaders written in Rust#
load_template and instantiate take the root stack, which is suspended while a script call
is running. A require loader bound with bind_function runs inside that call, so it uses
Sandbox::load_template_in(scope, chunk_name, source) and
Sandbox::instantiate_in(scope, &template, env) with the call as the scope. Neither needs the
Runtime: the initialization context and the native code generator live with the VM, so a
loader captures only the sandbox:
use std::rc::Rc;
use l3i::bind::{Call, StackResults};
use l3i::sandbox::{Sandbox, SandboxOptions};
use l3i::value::{Function, Table, Value};
use l3i::{Result, Runtime};
fn loader(runtime: &Rc<Runtime>, sandbox: &Rc<Sandbox>) -> Result<Function> {
let sandbox = Rc::clone(sandbox);
runtime.bind_function("dreamweave.loader", move |call: &Call, name: &str, env: Value| -> Result<StackResults> {
if name != "util" {
return Err(l3i::Error::runtime(format!("module '{name}' not found")));
}
let env = Table::from_value(env)?;
let source = "return function(name) return { twice = function(x) return x * 2 end } end";
let template = sandbox.load_template_in(call, "util.lua", source)?;
let factory = sandbox.instantiate_in(call, &template, Some(&env))?;
factory.invoke::<Function, _>(call, ())?.value().push_to_scope(call)?;
Ok(StackResults)
})
}
fn main() -> Result<()> {
let runtime = Rc::new(Runtime::new()?);
let sandbox = Rc::new(runtime.sandbox(|line| println!("{line}"), SandboxOptions::default())?);
let _loader = loader(&runtime, &sandbox)?;
Ok(())
}Luau’s own sandbox#
Runtime::sandbox_globals() (also sandbox_luau()) is luaL_sandbox: every library table and
the globals table become read-only, the globals table is marked safe, and the string metatable
gets read-only protection. After it, Thread::sandbox(&scope) gives a coroutine its own
writable globals table that proxies the frozen main globals, which is how a chunk loaded on that
thread writes without touching the real globals. Runtime::load_with_env loads one chunk with
its globals resolving through a table of the host’s choosing instead.
Loading on the running thread#
A host with one global environment has no use for templates: it wants each chunk loaded on the
thread that runs it, so Luau resolves the chunk’s builtin imports against that thread’s safe
globals as it loads, and compiled natively according to the runtime’s policy. source::LoadScope
is implemented for every scope, so a bound require does both from its Call without a handle
to the Runtime:
use std::collections::HashMap;
use std::rc::Rc;
use l3i::bind::{Call, StackResults};
use l3i::source::{CompileOptions, LoadScope, compile};
use l3i::{Result, Runtime};
fn install_require(runtime: &Runtime, options: CompileOptions) -> Result<()> {
let bytecode: Rc<std::cell::RefCell<HashMap<String, Rc<Vec<u8>>>>> = Rc::default();
let require = runtime.bind_function("dreamweave.require", move |call: &Call, name: &str| -> Result<StackResults> {
let cached = bytecode.borrow().get(name).cloned();
let compiled = match cached {
Some(compiled) => compiled,
None => {
let source = std::fs::read_to_string(name).map_err(|error| l3i::Error::runtime(error.to_string()))?;
let compiled = Rc::new(compile(&source, &options)?);
bytecode.borrow_mut().insert(name.to_owned(), Rc::clone(&compiled));
compiled
}
};
let module = call.load_bytecode(&format!("@{name}"), &compiled)?;
module.value().push_to_scope(call)?;
Ok(StackResults)
})?;
runtime.set_global("require", &require)
}
load_bytecode(chunk_name, &bytecode) takes bytecode from source::compile, or a cache of it,
and load_source(chunk_name, source, &options) compiles first; both pin the chunk as a
Function that runs in the thread’s globals until the host gives it another environment. A
chunk name beginning @ or = loses that prefix in debug records, as Luau’s load does. A
failed load is Error::Runtime with Luau’s message, chunk name included.
require over a host navigator#
Luau resolves require("./path") by walking a navigator the host provides: reset to the
requiring module, step to parents and children, ask whether a module is present, and load it.
The host implements require::RequireNavigator over whatever its module space is (files, a VFS,
an archive, generated code), and the binder mounts it through Luau’s own implementation, which
supplies caching, cyclic-require placeholders, .luaurc alias handling, and the error messages
scripts see.
| Method | Meaning |
|---|---|
reset(requirer_chunkname) | Point the position at the requiring module |
to_parent(), to_child(name) | One navigation step; each returns Navigate::Success, Ambiguous or NotFound |
is_module_present() | Whether the position names a loadable module |
chunkname(), loadname(), cache_key() | The chunk name the module runs under, the name passed to load, and the key Luau caches the result under |
load(scope, path, chunkname, loadname) | Run the module on the requiring thread and return Load::Results(n) or Load::Yield; an error raises into the requiring script |
is_require_allowed, jump_to_alias, to_alias_override, to_alias_fallback, config_status, config, luau_config_timeout_ms | Optional, with defaults: permission, @alias resolution, and .luaurc contents |
Runtime::install_require(navigator) registers Luau’s require as a global;
require_function(navigator) returns the closure pinned for a sandbox to hand to instances
itself. proxy_require_function() is a proxyrequire(path, chunkname) closure that resolves a
path as if required from a named module. register_require_module(path, &value) makes a value
the permanent result of requiring an alias, registered_require_modules() lists every path
registered so, the plan’s included, and clear_require_cache_entry(key) and
clear_require_cache() drop cached results. A module that is loaded twice in a cycle sees a
locked placeholder table; the require::RequirePlaceholders trait on any scope creates,
locks and populates one from inside load.