LuaScript: untrusted scripts
LuaScript runs Lua 5.4 or 5.5 source text in a sandbox, with memory,
instruction and time limits. It is for code that the game does not fully
control: mods, levels, rules or configuration downloaded at runtime, scripts
written by players or by an AI. It has no bridge to C#: a script reaches only
the functions you register. LuaEnv, the rest of xLua, is not a sandbox
(Security and sandboxing).
Status: preview. What this page describes is implemented and tested
today: loading and calling (milestone 6 of
core-luascript.md), C# functions
called from Lua (milestone 7), table and function handles (LuaTableRef,
LuaFunctionRef, Globals, NewEnvironment(), Compile; milestone 8),
coroutines driven from C# (LuaCoroutine with IEnumerator, Task and
Awaitable drivers; milestone 9) and the buffer library (milestone 10).
The API can still change before the first release.
Setting up
Section titled “Setting up”LuaScript is in the XLua.Core assembly (Assets/XLua/Core) and compiles
only when the Player Settings define one of two symbols. It needs a native
library with LuaScript’s sandbox (Lua 5.4 or 5.5; LuaJIT and Lua 5.3 have
none).
| Project | Scripting define | Native library |
|---|---|---|
LuaScript only (core-only) |
XLUA_CORE_ONLY |
the core library: XLUA_PRESET=core, for example XLUA_VARIANT=core XLUA_PRESET=core build/make_android_lua55.sh |
LuaScript next to LuaEnv |
XLUA_LUASCRIPT |
the bridge library built with XLUA_LUASCRIPT=ON (and XLUA_BUFFER=ON for the buffer library) |
- Core-only needs xLua’s asmdefs (the default in the Assets folder since
2026-10-18, and the UPM package of
Tools/make_upm_package.py). ThereXLUA_CORE_ONLYleaves theXLuaandXLua.Editorassemblies out of the build. After XLua > Project Layout > Use Assembly-CSharp everything compiles intoAssembly-CSharpand the define leaves nothing out; deleteAssets/XLua/Srcinstead. - The core library can be the package’s plugin
(
make_upm_package.py --plugin Android/libs/arm64-v8a/libxlua.so=PATH), or the plugin variantPlugins/<platform>/xlua-variants/core/next to the default plugin, which the plugin selector picks whenXLUA_CORE_ONLYis defined (build/README.md, “Plugin variants”). The first is what the Android players below were built with. - Without either define
LuaScriptdoes not exist, and an IL2CPP player carries none of its code.
A first script
Section titled “A first script”using System;using UnityEngine;using XLua;
public class RewardRules : MonoBehaviour{ LuaScript lua;
void Start() { lua = new LuaScript(new LuaOptions { MemoryLimit = 4 << 20, // bytes the state may hold InstructionLimit = 10000000, // VM instructions per call TimeLimit = TimeSpan.FromMilliseconds(50), // wall clock per call Libraries = LuaLibs.String | LuaLibs.Table | LuaLibs.Math, }); // A C# function the script may call; nothing else of C# is reachable. lua.Register<long, long>("playerLevel", id => 12); lua.Load("function reward(id, kind) return playerLevel(id) * (kind == 'gold' and 10 or 1) end", "rules"); try { long gold = lua.Call<long>("reward", 7, "gold"); // 120 Debug.Log("reward " + gold); } catch (LuaLimitException e) { Debug.LogWarning("the script hit its " + e.Limit + " limit: " + e.Message); } catch (LuaException e) { Debug.LogError(e.Message + "\n" + e.LuaStackTrace); } }
void OnDestroy() { lua?.Dispose(); }}new LuaScript() without options starts with LuaOptions.ScriptDefaults: a
64 MiB memory limit and a 1 s time limit. new LuaOptions() sets no limit at
all; set the limits you want explicitly.
Loading and calling
Section titled “Loading and calling”-
Load(code, chunkName)compiles and runs a chunk;Load<T>returns its first result, andLoad(code, name, results)fills aSpan<LuaValue>. Only text is accepted: a precompiled chunk ("\x1bLua") throwsLuaSyntaxException, and a script’s ownloadrefuses binary chunks too. Error messages and tracebacks name the chunk aschunkName:line. -
Call(name, ...)calls a global function, also a dotted one ("rules.reward"), with 0 to 4 arguments or aReadOnlySpan<LuaValue>.Call<T>converts the first result tolong,int,double,float,bool,string,byte[],LuaValue,LuaTableRef,LuaFunctionReforLuaBufferRef; nil reads as null, false or the default handle, and fails for the number types.Call(name, args, results)returns the number of results, andCall<T1, T2>andCall<T1, T2, T3>return tuples. -
LuaValuecarries nil, booleans, 64-bit integers, floats, strings and handles to tables, functions and buffers; every C# integer type andfloat,double,stringand the handle types convert to it implicitly. Integers stay 64-bit both ways. -
Calls with primitive arguments and results allocate nothing on the managed heap; string results allocate the string.
-
requireexists only when you setModuleResolver, a delegate that returns a module’s source text and chunk name. Each module runs once, in an environment of its own, and its result is cached:lua.ModuleResolver = (string name, out string code, out string chunkName) =>{chunkName = "mods/" + name + ".lua";code = LoadModText(name); // null or false: "module not found"return code != null;};
Tables and functions
Section titled “Tables and functions”Tables and functions reach C# as handles: LuaTableRef and LuaFunctionRef,
structs that allocate nothing. Table access is raw (no metamethod runs), so it
never runs script code.
using (LuaTableRef cfg = lua.Globals.GetTable("config")) // an owning handle: dispose it{ double rate = cfg.GetDouble("dropRate"); // KeyNotFoundException if missing long gold = cfg.GetLong("gold", 0); // or a fallback LuaKey hot = lua.Key("hp"); // anchored once: the fast path for hot fields cfg.Set(hot, cfg.GetLong(hot) - 1); foreach (LuaPair p in cfg.Pairs()) // allocates nothing { if (p.KeyEquals("items")) Debug.Log(p.Value.AsTable().Length); // a table borrowed until the next step }}using (LuaFunctionRef f = lua.Compile("return ... * 2", "double")) // loaded, not run{ long four = f.Call<long>(2);}- Handles you get from
GetTable,GetFunction,NewTable,NewEnvironment,Compileor a call’s result are owning: dispose them. A forgotten one keeps its table until the script is disposed, which invalidates every handle;LiveHandlescounts the open ones. A copy of a disposed handle throwsObjectDisposedException. - Borrowed handles end by themselves: a table argument of a C# function
(
call.GetTable(i), valid until it returns) and a table value duringPairs(until the next step).Retain()makes an owning copy. - Environments:
Globalsis the default one.NewEnvironment()makes another over the same frozen base;Load(code, name, env),Compile(code, name, env),env.Call("f")andRegister(env, name, f)use it, and scripts in one environment do not see the globals of another. ToDictionary()copies a table intoDictionary<string, object>for debugging; it allocates.
C# functions called from Lua
Section titled “C# functions called from Lua”Register(name, callback) makes a C# function visible to every environment of
the script, as a global ("name") or in a library table ("game.log").
Registering a name again replaces the function; Unregister(name) removes it,
and a script that kept the old function gets an error when it calls it.
- Typed shorthands:
Register<A1, ..., R>(name, Func<...>)andRegister(name, Action<...>)with up to four arguments of the types ofCall<T>(notLuaBufferRefarguments). They do not box. LuaCallback(delegate void LuaCallback(ref LuaCall call)) reads the arguments by 0-based index (ArgCount,ArgType(i),GetLong(i),GetDouble(i),GetBool(i),GetString(i),GetUtf8(i),GetBufferSpan(i),ArgEquals(i, text),Get(i)and theOpt...getters) and returns any number of values withReturn(...)orReturnNil(). A wrong argument type gives Lua’s own message, such asbad argument #1 to 'reward' (number expected, got string).GetUtf8andArgEqualsallocate nothing.- Exceptions become Lua errors that a script can catch with
pcall; uncaught, the call throwsLuaRuntimeExceptionwith the host exception asInnerException. - A C# function is never interrupted by a limit; the call stops right after it returns. What it does with its arguments, how long it runs and how much it allocates is your code’s responsibility.
call.Yield()suspends the coroutine that called the function: one thatStartruns, or Lua’s owncoroutine.resumeandcoroutine.wrap(CanYieldtells whether that is possible). The function’s results are the yielded values, and the values of the next resume become its results in Lua (Coroutines).- A C# function may call back into the same script (
Call,Load); the nested call runs within the outer call’s limits.
Coroutines
Section titled “Coroutines”A script yields a request, and C# serves it and resumes the script:
function quest(level) local ok = coroutine.yield({op = "wait", seconds = 1.5}) local data = coroutine.yield({op = "fetch", key = "rewards"}) return data.gold * levelendusing (LuaCoroutine co = lua.Start("quest", 3)) // runs to the first yield{ while (co.IsYielded) { using (LuaTableRef reward = lua.NewTable()) { string op = co[0].AsTable().GetString("op"); // the yielded values: co[0], co[1] ... reward.Set("gold", 100); if (op == "wait") co.Resume(true); else co.Resume(reward); // the results of coroutine.yield } } long gold = co[0].AsLong(); // Finished: co[i] are the return values}Start(name, args)(orStart(functionRef, args)) runs a function as a new coroutine to its first yield or its end.Resume(args)runs it on to the next one.StatusisYielded,Finished,Faulted,ClosedorRunning(while it runs, for a C# function it called);ValueCountandco[i]are the values of the last yield or return (at mostLuaCoroutine.MaxValues, 64). Resuming one that is notYieldedthrowsInvalidOperationException.- Each step is a call of its own:
Startand everyResumeget the fullInstructionLimitandTimeLimit, andLastInstructionCountcounts one step.MemoryLimitcovers the whole script. Coroutines a step runs inside Lua share its budget. - Errors and limits throw from the step, as from
Call, with the coroutine’s own traceback inLuaStackTrace, and leave itFaulted. An error inStartthrows and leaves no coroutine. - Handles: a
LuaCoroutineis a handle likeLuaTableRef: dispose it (LiveHandlescounts it; disposing the script invalidates it). Tables, functions and buffers amongco[i]are borrowed until the next step;Retain()keeps one. Primitive values in and out of a step allocate nothing. Close()runs the coroutine’s pending to-be-closed variables (local x <close>) and makes itClosed, ascoroutine.closedoes.Dispose()closes a coroutine ofStartand releases it.- Coroutines a script made (
coroutine.create) reach C# asLuaCoroutinetoo:Call<LuaCoroutine>("f"),table.Get<LuaCoroutine>(key),co[i].AsCoroutine(), orcall.GetCoroutine(i)in a C# function (borrowed:Retain()keeps it). C# can resume them, and pass them back (LuaValueconverts fromLuaCoroutine). Disposing one only releases the handle: the script may still use it.
Drivers loop for you: the handler serves one request (co[0] …) and
fills the LuaReply, whose values the driver passes to the next Resume.
// Unity coroutine: the handler's enumerator runs inside it, so it may wait.StartCoroutine(co.Run((c, reply) => Serve(c, reply)));
IEnumerator Serve(LuaCoroutine co, LuaReply reply){ yield return new WaitForSeconds((float)co[0].AsTable().GetDouble("seconds")); reply.Set(true);}
// async: continues on the caller's SynchronizationContext (Unity's main thread)await co.RunAsync(async (c, reply, ct) => { reply.Set(await LoadRewardAsync(ct)); }, ct);
#if UNITY_2023_1_OR_NEWERawait co.RunAwaitable(async (c, reply, ct) => { await Awaitable.WaitForSecondsAsync(1.5f, ct); reply.Set(true); }, ct);#endifRunAsyncresumes on theSynchronizationContextit was called on, also when the handler usesConfigureAwait(false). Without one (a plain .NET thread) it resumes on whatever thread finishes the handler, which needsLuaOptions.CheckThread = false.RunAwaitable(Unity 2023.1 and later) returns to the main thread if a handler left it.- Cancelling
ctcloses the coroutine (its__closehandlers run) and ends the task withOperationCanceledException. A script error faults it. - The drivers allocate their state once per run (an
IEnumeratorrun of ten yields about 300 bytes,RunAsyncwith handlers that finish at once about 400; an awaiting handler adds what its ownasynccode costs). The scripting defineXLUA_NO_COROUTINE_DRIVERSleaves them out: about 24 KB of an Android APK in an otherwise empty project, less in a game that uses coroutines andasynccode of its own (the TPL andIEnumeratorcode is then there anyway).
Smaller players
Section titled “Smaller players”A core-only Android player (ARM64, Unity 6000.3, Lua 5.5.1) adds about 268 KB to its APK with Unity’s default Minimal managed stripping and about 188 KB with High stripping; the native library is 123 KB of either. Two scripting defines leave out parts that few scripts need:
| Define | Leaves out | APK bytes (Minimal) |
|---|---|---|
XLUA_NO_COROUTINE_DRIVERS |
LuaCoroutine.Run, RunAsync, RunAwaitable, LuaReply |
about 24,000 |
XLUA_NO_LUASCRIPT_EXTRAS |
LuaTableRef.ToDictionary, IPairs, the tuple calls Call<T1, T2> and Call<T1, T2, T3>, Register with three or four arguments |
about 14,000 |
With High stripping only what the project calls is compiled, so the defines matter little there. Measurements: SIZE.md.
Limits
Section titled “Limits”Each limit applies to one call from C# (Load, Call, a coroutine’s Start
or Resume) with everything it runs, nested calls and coroutines included. They can be changed between
calls through the properties of the same names.
| Limit | Over the limit | Can the script catch it? |
|---|---|---|
MemoryLimit (bytes) |
Lua’s “not enough memory” error; uncaught, LuaMemoryLimitException with MemoryUsed and MemoryLimit |
yes, with pcall |
InstructionLimit (VM instructions; string patterns, table.move and large buffer operations are charged too) |
LuaInstructionLimitException |
no: the call ends |
TimeLimit (wall clock) |
LuaTimeLimitException, a few milliseconds after the deadline (a watchdog thread; on WebGL the clock is read every 10,000 instructions) |
no |
Interrupt() from any thread |
LuaInterruptedException |
no |
Interrupt()stops the running call with or without an instruction or time limit, from any thread. Lua code stops within a fraction of a millisecond (measured on a macOS arm64 host); a C# function is not interrupted, and the call stops when it returns. Without a limit this costs about 6 ns per call from C# and 1 ns per C# function called from Lua;LuaOptions.AllowInterrupt = falsesaves that, andInterrupt()then stops only calls with a limit.Interrupt()before the call has begun does nothing. A timer started just before a call can fire before the call is running, for example on a busy device. Start the timer from inside the call (from a C# function that the script calls first), or callInterrupt()again until the call has returned. Once a call has accepted anInterrupt(), it never returns normally, even if its last instruction had already run.- The time limit stops a call a few milliseconds after its deadline:
within the watchdog’s period, a quarter of the limit (1 to 20 ms). With an
InstructionLimitas well, the instruction hook also reads the clock about every million instructions. MemoryUsed,MemoryPeakandLastInstructionCount(counted while an instruction limit is set) report what a call used;CollectGarbage()runs a full collection.- A runaway loop without any limit runs forever; there is no termination
guarantee without
InstructionLimitorTimeLimit.
The sandbox
Section titled “The sandbox”A script sees the base functions (assert, error, ipairs, pairs,
pcall, select, tonumber, tostring, type, xpcall and the raw
functions), and the libraries LuaOptions.Libraries names: coroutine,
string, table, math, utf8 (LuaLibs.Safe, the default) and the
opt-in buffer (LuaLibs.Buffer).
- Absent:
io,os,debug,package,dofile,loadfile,string.dump,printandwarn. Register your ownprintif scripts should log. - Wrapped:
load(text only; the caller’s environment by default),setmetatable(refuses a metatable with__gc) andcollectgarbage("count"only). - Each environment has its own copies of the library tables, so a script
that writes
string.format = nilchanges only its own. The string metatable is locked (getmetatable("")isfalse). - Asking for
io,os,debug,packageor LuaJIT’s libraries inLibrariesthrowsArgumentException.
Buffers
Section titled “Buffers”With LuaLibs.Buffer, scripts get Luau’s buffer library: fixed-size byte
arrays with create, fromstring, tostring, len, the little-endian
read/write functions for 8-, 16- and 32-bit integers and 32- and 64-bit
floats, readstring, writestring, copy and fill, plus two that Luau
does not have: readi64/writei64 (a whole 64-bit Lua integer) and
isbuffer(v). Every access is bounds-checked, and buffers count against
MemoryLimit.
C# shares them without copying: lua.NewBuffer(length) or a buffer result
(Call<LuaBufferRef>) is a LuaBufferRef with AsSpan(), CopyFrom,
CopyTo and ReadInt32-style accessors that cost no native call. Dispose a
handle when you are done; disposing the script releases all of them
(LiveHandles counts the open ones). Differences from Luau: integer writes
refuse fractions as Lua does (writei64 too), type(b) is "userdata"
(buffer.isbuffer(b) tells buffers apart), and readbits and writebits
are not there.
Errors
Section titled “Errors”| Exception | When |
|---|---|
LuaSyntaxException (ChunkName) |
the chunk does not compile, or is binary |
LuaRuntimeException (LuaStackTrace, InnerException for a C# exception, ErrorValue for error({code = 5})) |
an error the script did not catch |
LuaTypeException (Expected, Actual) |
Call<T> got a result it cannot convert |
LuaMemoryLimitException, LuaInstructionLimitException, LuaTimeLimitException, LuaInterruptedException |
the limits above (all LuaLimitException, with Limit) |
NotSupportedException |
the native library has no sandbox, or no buffer library for LuaLibs.Buffer |
All of them derive from LuaException.
Threads
Section titled “Threads”A LuaScript belongs to the thread that created it: with
LuaOptions.CheckThread (the default) every other member throws
InvalidOperationException on another thread, except Interrupt(),
IsDisposed and the statistics. Two scripts on two threads run in parallel.
Dispose inside a call of the same script throws; the finalizer closes a
script that was not disposed.
Measured on 2026-10-18 with Unity 6000.3.21f1, release ARM64 IL2CPP APKs of a small probe (a script with limits and the buffer library, one registered C# function, a chunk, two calls and a buffer), Lua 5.5.1, against an empty project (build/SIZE.md):
| Minimal stripping | High stripping | |
|---|---|---|
LuaScript alone (core-only) |
+223,530 bytes | +193,802 bytes |
LuaEnv alone (legacy, for comparison) |
+1,447,742 bytes | +1,304,870 bytes |
LuaScript added to a LuaEnv player |
+87,580 bytes | +63,348 bytes |
The core library is 121,686 of the core-only figure, IL2CPP code and metadata most of the rest.
Security
Section titled “Security”What counts as a security bug in LuaScript, and what is out of scope, is in
Security and sandboxing.
