Library active
l3i
The Luau runtime for DreamWeave: a Rust binder that owns its Luau 0.742 build, plans every VM before it exists, and lowers hot script calls to native code.
cargo add l3i- Stable
- 2.0.2 2026-10-12
- Package
- Rust crate
- License
- MIT OR Apache-2.0
Overview
A game engine that scripts in Luau needs three things from its binder. Scripts must reach engine objects at the cost of a table lookup, not a registry walk. The engine must be able to run thousands of script instances in sandboxes it can measure, limit and profile. And the surface a script sees has to be declared once, typed, and impossible to drift from what the runtime does.
l3i is that binder. It started life as a C++ Luau binder for OpenMW, and this is the same design done again in Rust, with the parts a multi-crate engine needs on top: a planner that composes a VM from extensions before the VM exists, a packed integer vocabulary for values that should never allocate, the network bridge in every runtime, Luau’s native code generator with lowering hooks written in Rust, and a dialect of Luau whose comprehensions, slices and record patterns compile to the loops you would have written by hand.
From extensions to a running script
Extensions describe their Luau surface without a VM. The planner composes them, assigns every tag, atom and slot, and renders the type definitions. Each runtime built from the plan gets its own VM with exactly that shape, and scripts run in sandboxes the host can measure.
-
Extension::describe
Modules and their typed members, userdata types under stable keys with tag and compiler-slot policies, packed kinds, services, capabilities, memory categories, lowering hooks. No VM is touched.
-
RuntimePlan::finalize
Orders extensions by dependency, merges owners with augmenters, assigns tags, Luau's 32 compiler slots, atoms and direct slots, validates every identifier and path, and inserts the dream.udp bridge.
-
Runtime::from_plan
A VM with the planned metatables, generic direct-access callbacks, opened and frozen modules, every extension's install run in order, and compiler metadata for the compatibility globals. Any number per plan.
-
Sandbox
A sandbox prelude, one environment per script instance cloned from a compiled template, and the watchdog, memory categories and call scopes around every call.
-
the plan rendersType definitions
The composition's declare extern type grammar, served to Luau's analysis frontend and type checked against strict scripts in the crate's tests, so the declared API and the runtime cannot drift.
-
with the jit featureNative code
Lowering hooks written in Rust compile annotated call sites to IR: quaternions, colours and vertex writes with no C call, exiting to the interpreter only on malformed input.
- Its own Luau. Luau 0.742 is a git submodule compiled by
build.rswith clang, lld and cross-language thin LTO, so the Rust thunks and the Luau API inline into each other. Nomlua, no separate binding crate, nothing fetched at build time. - Arguments read from the value layout. The typed binder reads stack slots straight from
Luau’s 16-byte
TValue, and each runtime proves the mirror against the API once at creation. A bound(f64, f64) -> f64call retires 369 instructions against 277 for a barelua_CFunction. - Tags, atoms and slots are plan data. A
RuntimePlanassigns userdata tags, Luau’s 32 compiler type slots, atoms and direct-access slots per VM, so the same Rust type can be tag 8 in one runtime and untagged in another, and one handler serves both. - Typed by construction. Every module member carries a Luau signature or is marked untyped.
The plan renders the
.d.luau, and theanalysisfeature type checks strict scripts against it in the crate’s tests, so the declared API and the runtime cannot disagree. - Values that never allocate. Rotations, animation keys, colours and clip rectangles travel
as one Luau integer each, with a kind nibble checked on every read. A packed
rotateruns in 21 ns lowered to native code. - The network is not optional. Every plan carries the
dream.udpbridge; the policy’s capabilities decide what a script may do with it. Nothing calls into Luau from inside the transport. - Sandboxes the engine can account for. A sandbox prelude, per-script instances cloned from compiled templates, a watchdog on time and heap, memory categories, call scopes with self-time accounting, and a sampling profiler.
- Loops you did not have to write.
[for e in entities if e.active => e.id],sum[for x in samples[a:b, "f32"] => x]andlocal {x, y, z} = positionare source syntax. They lower to the same-contract handwritten loop, one setup instruction apart, and a reduction over a buffer slice becomes one native call when@dream/datais in the plan. - Lowering hooks in Rust. With
jit, hosts write Luau’s userdata and vector lowering hooks against anIrBuilderC ABI. The quaternion, colour and vertex-writer paths compile to IR with no C call and exit to the interpreter only on malformed input.
What a host writes#
use l3i::Runtime;
use l3i::userdata::{Owned, Userdata, tagged};
struct Vec3 { x: f32, y: f32, z: f32 }
unsafe impl Userdata for Vec3 {
const NAME: &'static str = "dreamweave.Vec3";
}
fn main() -> l3i::Result<()> {
let runtime = Runtime::new()?;
// The host picks the tag, per runtime, at registration.
tagged::register::<Vec3>(&runtime, 10, |ty| {
ty.property("x", |v: &Vec3| v.x)?;
ty.method("length", |v: &Vec3| (v.x * v.x + v.y * v.y + v.z * v.z).sqrt())
})?;
let make = runtime.bind_function("dreamweave.vec3", |x: f32, y: f32, z: f32| Owned(Vec3 { x, y, z }))?;
runtime.set_global("vec3", &make)?;
runtime.exec("local v = vec3(3, 4, 0) assert(v.x == 3 and v:length() == 5)")?;
Ok(())
}
That is the hand-assembled runtime. An engine composes one from extensions instead:
use l3i::Runtime;
use l3i::extension::{RuntimePlan, RuntimePolicy};
use l3i::quat::QuatExtension;
use l3i::raster::RasterExtension;
let plan = RuntimePlan::builder()
.policy(RuntimePolicy::new().compat_global("@dream/quat", "quat"))
.extension(QuatExtension)
.extension(RasterExtension)
.finalize()?;
// One immutable plan, any number of runtimes, each with its own tags and atoms.
let runtime = Runtime::from_plan(&plan)?;
std::fs::write("dream.d.luau", plan.type_definitions())?;What a script sees#
--!strict
--!native
local quat = require("@dream/quat")
local raster = require("@dream/raster")
-- A rotation is one integer: 18 bits per component, smallest three, exact identity.
local spin = quat.axisAngle(vector.create(0, 0, 1), math.pi / 2)
local key = quat.key(spin, 5) -- an AnimationKey: the rotation plus four flag bits
assert(quat.keyFlags(key) == 5)
-- Annotate the receiver and its methods lower to native code: no C call, no allocation.
local Q: dream_quat_Math = quat.math()
local forward = Q:rotate(spin, vector.create(1, 0, 0))
local C: dream_raster_Math = raster.math()
local tint = C:lerp(raster.rgb8(255, 240, 255), raster.rgba8(198, 160, 246, 255), 0.5)
Every name above is declared in the plan’s definitions, so a strict script that misspells one, or
passes a key where a rotation is expected, fails in the checker before it runs. At runtime the same
mistake is a type error naming the kind: Quaternion expected, got AnimationKey.
What it costs#
Retired instructions per call, read from the CPU’s counters on Luau 0.740, with the loop subtracted. The rest of a call is Luau’s own machinery.
| Per call | Instructions | Cycles |
|---|---|---|
hand-written lua_CFunction (f64, f64) | 277 | 69 |
bound (f64, f64) -> f64 | 369 | 89 |
| typed direct namecall, the VM’s leanest method path | 368 | 98 |
planned method () -> f64 | 431 | 107 |
| planned direct field | 78 | 12 |
Every scenario reports fewer than 0.005 cache misses, TLB misses and branch misses per call: the whole path stays in L1 and predicts. Compatibility and performance has the Criterion tables, the memory per runtime, and the toolchain measurements behind the build rule.
Documentation#
- Start here: the toolchain, the first runtime, a bound function and a userdata type.
- Guide: the stack model, userdata, sandboxes, runtime options, direct access, extensions and plans, packed primitives, the built-in extensions, the dialect, and native code.
- Rust API: every public module.
- Safety model: what unwinds, what aborts, what panics, and the deliberate divergences from the C++ binder.
Install
Rust crate · 2.0.2
Add it with Cargofrom crates.io, 2.0.2 today
From your project's directory:
cargo add l3i
Or by hand, in Cargo.toml:
[dependencies]
l3i = "2.0.2"
Cargo reads a bare version as "this or any compatible newer release" and records the exact one in Cargo.lock.
From the repositorythe default branch, ahead of the latest release
[dependencies]
l3i = { git = "https://github.com/DreamWeave-MP/l3i" }
Cargo pins the commit it fetched in Cargo.lock; cargo update -p l3i moves it forward.
Compatibility
Anything not listed here is untested.
Requires
- dream-net=1.1.0
Runtime infrastructure: the @dream/udp module is in every plan, and l3i owns the bridge.
Works better with
- dream-soft-render=1.0.0
The @dream/soft-render module, when the soft-render feature is on.
Releases
-
2.0.2 stable
A natively lowered
@dream/internlookup no longer reads a slot table the pool has freed, and the@dream/datadefinitions say what a span’skindis.Fixed
- Interning a key a pool already held, through the binder, while the pool sat at its load limit (12, 24, 48 … identities) grew the slot table before looking the key up, and did not tell native code the table had moved, because the pool’s length had not changed. Until the next new key, a lowered
pool:findorpool:internon an annotateddream_intern_Poolread the freed table: wrong tokens,nilfor a key the pool held. A pool now looks a key up first and grows only to add one, so a key it holds moves nothing. Unannotated pools were never affected. - The
@dream/datadefinitions gavegather,scatterandaddakind: dream_data_Kindparameter, the union of receiver classes, where the runtime takes the layout string every other span takes; and they typed a span’skindas the eight bare kinds, so the documented strided form ("f32@16+12") was a type error under--!strict. A span’skindis now adream_data_Layout,dream_data_ElementKind | string, in every signature, and the strict-script gate that checks the definitions against the runtime covers@dream/data, which it had left out. data.fill’s description said the value converts asbuffer.write<kind>converts. It is written as every data-plane write is: a number, truncated toward zero and wrapped to the width, saturating first beyond 2^63. The description says so now; the behaviour is unchanged.
Migration
Nothing to change. A pool annotated
dream_intern_Poolthat also receives keys through unannotated code was the exposed case, and is correct after an upgrade. A strict script that cast a strided layout to pass it to@dream/datacan drop the cast.l3i 2.0.2Verify · sha256 18ad03a9047a…dbe36a5e
- File
- l3i-2.0.2.crate
- Size
- 2394884 bytes
- SHA-256
- 18ad03a9047a3e169553e0540a553e8c7442f428b6b0bd7a272983cedbe36a5e
The same digest as the crates.io index's checksum, which Cargo checks on every download.
- Interning a key a pool already held, through the binder, while the pool sat at its load limit (12, 24, 48 … identities) grew the slot table before looking the key up, and did not tell native code the table had moved, because the pool’s length had not changed. Until the next new key, a lowered
-
2.0.1 stable
A sink or slice statement may open a function body, and the
@dream/luaudefinitions declare the record pattern nodes the tree has carried since 2.0.0.Fixed
- A statement that begins with a sink or a slice (
into(out)[for x in xs => x],grid[2:3][1][1] = 5) failed to compile as the first statement of a function body, withExpected identifier when parsing expression, got ';': the lowering separated it from a statement that was not there. A body’s first statement now gets no separator, whatever the header ends with, and where a return type ends is decided by Luau’s parser, not estimated. - The
@dream/luaudefinitions did not declareStatLocalPattern,PatternRecord,PatternFieldorPatternIdentifier, and typedExprFunction.argsandComprehensionGenerator.bindingsas lists ofdream_luau_Localalthough a record pattern can stand in either. They are declared now,dream_luau_StatLocalPatternis a member ofdream_luau_Stat, and a slot that may hold a pattern is adream_luau_Binding,dream_luau_Local | dream_luau_PatternRecord.
Migration
A strict script that reads
nameoff an element ofExprFunction.argsor of a generator’sbindingsrefines it bykindfirst,"Local"or"PatternRecord". Nothing changes at run time.l3i 2.0.1Verify · sha256 4867f43ba175…b6e5eb06
- File
- l3i-2.0.1.crate
- Size
- 2393217 bytes
- SHA-256
- 4867f43ba1752f1ecbbe48dc6be68e9dc9ac342b2743a5417dc34f5eb6e5eb06
The same digest as the crates.io index's checksum, which Cargo checks on every download.
- A statement that begins with a sink or a slice (
-
2.0.0 stable
A dialect of Luau whose comprehensions, reducers, sinks, slices and record patterns lower to the handwritten loop with tooling in original coordinates; a bulk-data plane over typed buffer spans with native loops; raw TCP, DNS and verified TLS; locales, plural rules and decimal formatting; Luau 0.742; and the renames and defaults that needed a major version to change.
Breaking changes
@dream/netis@dream/udp: the extension isdream.udp, the Rust modulel3i::udp, the typeUdpExtension, the C symbolsdream_udp_*. The capability is stillnetwork.transport.TcpExtensionandTlsExtensionare no longer unit structs..extension(TcpExtension)becomes.extension(TcpExtension::default());TlsExtension::new()or::default()likewise.noDelaydefaults totrueontcp.connectandtcp.listen: Nagle’s algorithm is off unless a script passes{ noDelay = false }.luau.parsedefaults to thel3idialect, so comprehensions, slices and record patterns come back as source nodes;{ dialect = "luau" }selects strict stock syntax.- Luau 0.742 (from 0.740).
ffi::lua_setbuffercageislua_setmemorycage;disable_native_execution_for_functionis gone with Luau’s own;LuauNoDuplicateBinaryPrefixandLuauCompileIifeInlinegraduated and left the flag list. - Coroutine finalizers are chosen on the builder:
RuntimeBuilder::coroutine_finalizers(bool)replacesRuntime::enable_coroutine_finalizers, andThread::finalizer_function(scope, close)replacesRuntime::finalizer_function(). The first runtime built in a process decides for the process. DnsExtension::newlets 8 of a runtime’s lookups occupy a shared resolver by default, not 2.- The growable public types new since 1.3.1 are
#[non_exhaustive]:tcp::StreamState,tls::TlsState,dns::LookupError,dns::ResolverConfig,data::Kind,data::Layout,data::ReductionOp,data::Reduction,intl::Grouping,intl::DecimalOptions,intl::FormatterError,intl::Operand,intl::NumberError,intl::LocaleError,intl::UnsupportedLocale. Matches need a wildcard arm; the structs are built withResolverConfig::new(workers, queue),Layout::new(kind, stride, offset),Reduction::new(op, filter)andDecimalOptions::new().grouping(..).fraction_digits(min, max).
Added
- The l3i dialect:
[for e in entities if e.active => e.id]comprehensions withrange,enumerate,zipShortestandzipStrictgenerators;#,sum,min,max,anyandallconsumers that allocate no result;into(destination)table sinks andinto(buffer, "kind"[, offset])buffer sinks; inclusive slicest[a:b]of tables and buffers and typed slicesbuf[1:n, "f32@16"]; and record patternslocal {x, y} = pin declarations, parameters and generator slots. One frontend built on Luau’s lexer lowers all of it to the same-contract handwritten loop, one setup instruction apart, and compilation is strict while Analysis and@dream/luaurecover unfinished constructs. - Original coordinates everywhere: Analysis diagnostics and autocomplete,
analysis::parse,@dream/luautrees, compile errors, runtime errors, tracebacks, breakpoints and coverage all report the source you wrote, through a source map the frontend records as it lowers. @dream/data(featuredata): reductions, comparisons into reusable selections, selection algebra,gather,scatter,fill,add,scale,clamp, stableargsortwith optional caller scratch, andpartitionover typed, optionally strided buffer spans, bound once per operation with no per-element callback. Typed receiversdata.u8()throughdata.f64()lower their thirty-nine reductions and selects to unrolled native loops underjit, and the dialect’s recognized reductions over buffer slices call the plane automatically.@dream/tcp(featuretcp): nonblocking listeners and byte streams over numeric addresses,readIntointo caller buffers with end of stream kept apart from would-block, partial writes with no hidden queue, half-close, and a bounded, level-triggered poller that never calls Luau and watches only what has something to report. Behindnetwork.tcp.connect,network.tcp.listenandnetwork.tcp.public.@dream/dns(featuredns): host name resolution through the OS resolver on a bounded worker pool: one-shot requests that never block the script, IDNA names, both address families in the OS’s order, deadlines and cancellation, watchable by the TCP poller. Behindnetwork.dns.resolve.@dream/tls(featuretls): verified TLS 1.3 and 1.2 client and server sessions over TCP streams with rustls, the platform trust store or explicit roots, name verification independent of the address, ALPN and SNI, bounded buffers, close_notify kept apart from truncation, nonblocking and watchable. There is no way to skip verification.@dream/intl(featureintl): BCP 47 locale identity, CLDR cardinal and ordinal plural categories that see visible fraction digits, and locale-aware decimal formatting from ICU4X’s compiled data; no catalog and no current locale.ExtensionDescriptor::widen_type_alias, so an extension can add its handle type to a union another extension declared without that extension knowing it;dream_tcp_Watchableis widened bydream.dnsanddream.tls.- Luau 0.741’s GC heap-shrink pacing fix and 0.742’s compiler and code generator fixes are on, each with its reason beside its name in
flags::LUAU_FLAGS.
Changed
- A tcp
Poller:waitscans only the watches with something to report, so idle sockets cost it nothing: one ready stream among 8192 idle watches costs 0.4 µs, as among 1024. The event buffer starts at 256 entries and doubles as waits fill it, up tomaxEvents, instead of being allocated atmaxEventsup front. - A tcp stream’s
localAddressis asked of the OS on first use and remembered. Deadline-based readiness is judged once perwait, not pernext(). tls.clientConfig{ roots }with the platform store caches the verifier per distinct root set (8 sets of at most 256 KiB each per runtime) instead of reloading the store per call; the platform store alone is still loaded once per runtime. A TLSwritewhose socket has just refused bytes does not try the socket again in the same call.- Pollers and TLS configurations are capped per runtime (
TcpExtension::max_pollers64,TlsExtension::max_configs32), and the documentation says which native memory the VM’s memory limit does not see. - A slice bound spelled as an integer literal skips its run-time check; a standalone slice of a 16-element table costs about 215 ns against 150 for a handwritten
table.createplustable.move. - Plain Luau that never uses the dialect is lexed once by the frontend, and only when it contains a
[: about 17 to 20 percent over stockluau_compileon 16,000 lines with indexing in loops or calls inside brackets, nothing without a bracket. - The fast-flag policy is documented as l3i’s own named list rather than as another project’s.
Fixed
- Standalone slices after a
.name,:method(), call or index chain, slices of slices, and slices or comprehensions that use the enclosing function’s...lowered to broken Luau; a slice that starts a statement was refused. zipStrictnever compared the first argument’s length.anyandallover a buffer slice past the end asked the data plane for a span at an out-of-range offset.- Data span arithmetic could overflow (an abort when bound, a wrap when native);
any/allconsumers now guard an empty span like the other reductions. - A handshaking TLS session reported a stale readable bit while rustls had unsent flight bytes, a latent busy loop.
Poller:closeleaked a descriptor. An accept refused for want of descriptors re-armed the listener on every wait. - SIGPIPE from a TLS
writevand fromprocess.run’s stdin feeder killed the host; stack overflows in the location remapper and tree builder on deep shadow chains; quadratic prescans in the frontend;intldecimal exponents past 32767 overflowed an i16 in the parser. - The comprehension nesting limit counted clause delimiters; parentheses and brackets inside a clause no longer count toward the 128 levels.
Migration
Downstream plans need one line per network extension:
.extension(TcpExtension::default())and.extension(TlsExtension::new()). Rename@dream/netrequires andnetcompat globals to@dream/udpandudp. Amatchover any of the types listed above needs a wildcard arm. Tools that read@dream/luautrees and expect generated wrappers pass{ dialect = "luau" }or, better, handle the dialect’s nodes. Scripts that relied on Nagle pass{ noDelay = false }. Recompile cached bytecode to pick up original debug lines.l3i 2.0.0Verify · sha256 e4cb6a3e1060…569f35d8
- File
- l3i-2.0.0.crate
- Size
- 2377858 bytes
- SHA-256
- e4cb6a3e1060b0bf2480b0970b5f0eefe363cf7c94fc1dad47342b3f569f35d8
The same digest as the crates.io index's checksum, which Cargo checks on every download.
Credits
- MaintainerS3kshun8
- Luau, the language and VM this crate builds and binds (MIT)Roblox
- netcode, reliable and serialize, beneath dream-netMás Bandwidth LLC
Discussion
GitHub Discussions · sign in with GitHub to join in