luau-rs is a wasm → Luau compiler for Roblox. Write real Rust, compile
it to wasm32, and luau-rs emits strict --!strict Luau that runs inside Roblox
Studio.
Rust source ──cargo build --target wasm32-unknown-unknown──▶ module.wasm
module.wasm ──luau-rs decode──▶ DecodedModule (validated subset)
DecodedModule ──luau-rs translate──▶ strict Luau module (instantiate factory)
For a multi-module project, luau-rs reads luau-rs.toml, discovers wasm
inputs recursively, validates the complete project in memory, and publishes a
fresh Roblox layout only after every module succeeds.
Because the frontend is wasm, you get 100% real Rust semantics (borrow
checker, std library, crates.io) from rustc for free — the compiler only
translates a stable, well-defined instruction set into Luau. The Luau backend
models wasm's linear memory as a buffer, globals and indirect-call tables as
Luau tables, and wasm's stack machine as an explicit per-function stack.
Roblox bindings arrive through the import seam plus the bundled
runtime/roblox.luau binding layer: wasm imports become Luau callbacks
resolved from the imports table, and Rust receives Roblox objects as
integer handles (0 is null) that the runtime resolves to real Instances.
# Try the Roblox binding layer with a mock environment
cargo nextest run --test roblox_bindings --profile release-check # after python scripts/build_pinned_luau.py# Build the fixture Rust crate to wasm and feed it through the compiler
cargo build --release --target wasm32-unknown-unknown \
--manifest-path fixtures/rust-hello/Cargo.toml \
--target-dir fixtures/rust-hello/target
cargo run -- build fixtures/rust-hello/target/wasm32-unknown-unknown/release/rust_hello.wasm \
--out build --entrypoint
# → writes build/ServerScriptService/main.server.luauThe integration suite treats the official luau / luau-analyze binaries as
required oracles (pinned revision, bit32-era, no native bitwise ops):
python scripts/build_pinned_luau.py # one-time bootstrap
cargo +stable fmt --check
cargo +stable clippy --all-targets --all-features --locked -- -D warnings
cargo +stable nextest run --all-targets --all-features --locked --profile release-checkThe complete integration suite is written as Rust-native behavior scenarios
using rstest. Each case has an explicit Given/When/Then structure, so the
behavior and its evidence live together without a second feature-file DSL.
Generated Luau is also parsed by Full Moon and compiled, analyzed, and run by
the pinned official Luau tools; missing oracle binaries fail the suite rather
than silently skipping validation.
Scenario names follow given_<context>_when_<action>_then_<outcome> so the
test list itself explains the contract:
cargo nextest run --all-targets --profile release-checkThe scenarios use the existing tempfile, assert_cmd, predicates, and
insta crates for project setup, CLI execution, assertions, and snapshots.
cargo run -- build <module.wasm> --out <dir> [--entrypoint] [--side server|client] [--module-path <path>]
luau-rs check --manifest-path <path>
luau-rs compile --manifest-path <path>
The manifest defaults to luau-rs.toml when --manifest-path is omitted. Its
paths are relative to the manifest file:
[project]
source_root = "wasm"
output_root = "build"Under source_root, project files use the Roblox DataModel layout directly:
<source_root>/<RobloxService>/<module-path>.wasm
For example, wasm/ServerScriptService/game/main.wasm becomes
ServerScriptService/game/main.server.luau, while
wasm/ReplicatedStorage/math/core.wasm becomes
ReplicatedStorage/math/core.luau. ServerScriptService, Workspace,
ReplicatedFirst, StarterGui, StarterPlayer/StarterPlayerScripts, and
StarterPlayer/StarterCharacterScripts own entrypoint scripts. ServerStorage
and ReplicatedStorage own libraries. Existing .luau, .rbxm, .rbxmx,
.rbxl, .rbxlx, and *.model.json files are retained at their DataModel
paths. check performs discovery, decoding, and translation without writing
output. compile stages every generated file and retained asset, then
atomically replaces the previous output_root only after the complete tree is
ready. Unknown services, illegal StarterPlayer placement, duplicate output
paths, and overlapping source/output roots are rejected before generation.
Supported: the currently translated core wasm subset — numeric ops (i32/i64/f32/f64),
comparisons, conversions, locals, globals, memory load/store, bulk memory
(copy/fill/init + passive data segments), call / call_indirect,
block / loop / if / br / br_table, select, memory.grow, data
segments, element segments, start function, exports, function imports.
The bundled runtime/roblox.luau provides a handle-based Roblox binding layer
(get service, instance creation, number + Vector3 properties, print, destroy),
tested against a mock Roblox environment.
Rejected loudly (typed reasons): SIMD (v128), atomics,
exception-handling tags, shared or 64-bit memories, multiple memories,
non-function imports, global/table/tag exports, and passive/declarative element
segments.
i64 values are represented exactly as two unsigned 32-bit Luau number
values. The pair representation is used across locals, globals, calls,
results, arithmetic, comparisons, bitwise operations, conversions, and
memory access. f32 rounding is not modeled, and division-by-zero does not
trap.
The emitter models the wasm operand stack as a runtime stack table indexed by
a moving sp, and lowers wasm branches into nested while true do loops
guarded by exit_N boolean flags. Both are correct and both are slow. In the
7,511-line fixture snapshot that is 2,619 stack[sp] accesses, 1,214 sp += 1
updates, 82 while true do loops, and 417 exit_N flag references. A single
i32.sub currently costs six table operations and four sp updates:
sp += 1; stack[sp] = l1
sp += 1; stack[sp] = l2
local t21 = stack[sp]; sp -= 1
local t22 = stack[sp]; sp -= 1
sp += 1; stack[sp] = wasm_i32_wrap((t22 - t21))
local t23 = stack[sp]; sp -= 1Every value here has a static stack slot, so the table is avoidable: the same operation is one local binding once slots are resolved to named locals at translation time. Luau's 200-register-per-function limit is the reason this is a real design question rather than a mechanical substitution — deep functions need spilling, which is why the table exists today. This is the largest known throughput gap against the prior art below.
Researched 2026-08-27; every claim links to its source.
- Wasynth — the reference wasm-to-Luau translator, now archived. It did not stop over a wasm or performance wall; the author's note cites "accumulating technical debt and the rigid design that makes work on it difficult", and names Spider (RVSDG-based, wasm to Luau and LuaJIT) as its successor. Spider is actively developed.
- wasm2lua — stalled since
December 2021, before Luau gained the
buffertype that makes linear memory cheap. - reasm — compiles RISC-V rather
than wasm to Luau. Its author reports moving from roughly half native Luau
speed to a large speedup after switching to
bufferoperations, which is the clearest evidence that raw throughput is no longer the blocker. - Rust-side efforts are dormant rather than defeated: roblox-rs (v0.1.0) and LoganDark/luau-rs (v0.0.1, last touched 2023).
Luau numbers are IEEE doubles, so wasm's i64 has no native carrier. Wasynth
stores each one as a Vector3 packing two 32-bit halves, making every 64-bit
add, shift, multiply and divide a Luau function call that unpacks and repacks
its operands. Luau's
long-integer RFC names this
directly: high-low pairs are "cumbersome, requiring 16-24 bytes per number",
vector storage "lacks type system integration", and "the restriction to doubles
will be an increasing pain point". That RFC has not shipped, and reasm defers
64-bit support until it does.
This project instead carries i64 as an exact pair of unsigned 32-bit values
through every operation, which is why the pair representation is stated as
scope above rather than left as an implementation detail.
Two distinct costs get conflated as "it is slow", and they bite in different phases:
- Startup and size. Generated modules are large — Roblox developers describe Wasynth output as "huge and i cant understand any of it". That is load time, script size limits, and memory, not throughput. A DOOM port needed 4MB of buffer allocated inside Luau.
- Steady-state throughput. The
"horrid mass of
while true doloops" is not only a readability complaint. Luau has nogoto, so every wasm branch out of a nested block sets one flag per enclosing level and each level re-tests them on the way out. That is real work in the hot path, and it is the same pattern this emitter produces.
See CLAUDE.md for the full architecture and agent guidance.
WasmDecodeRejection::diagnostics() and TranslationRejection::diagnostics()
expose a stable serializable report. Each diagnostic contains code, message,
and a location with optional file, line, column, function, and wasm_offset
fields. source_available distinguishes a DWARF-backed Rust location from the
fallback, and hint explains how to recover source lines when debug metadata is
missing. Decode diagnostics use DWARF when available; translation-stage
diagnostics retain the function/offset contract for callers that add their own
source metadata.