Optimized for quick agent onboarding. Read top to bottom; stop when you have enough context for your task.
AegisVM is a complete Luau-in-Luau interpreter that runs inside Roblox Studio. It tokenizes, parses, and evaluates Luau source code at runtime without using loadstring, getfenv, setfenv, or any Roblox internals. The pipeline is:
source text -> Lexer.tokenize() -> Parser.parse() -> Runtime:execBlock()
All core modules are children of the Aegis ModuleScript. The public entry point is Aegis.luau. Current version: 4 (see Constants.luau).
Repo: AegisLua/AegisVM | Branch: main | Toolchain: Rojo 7.7.0-rc.1 via Aftman
All core language features are implemented and working:
- Full Luau grammar (closures, metatables, metamethods, coroutines, varargs, goto/break/continue via signals)
class/extendsdeclarations,<const>enforcement, interpolated strings,if-expressions,__itermetamethodlocal x <const> = v- enforced at runtime via Scope.consts; reassignment raises RuntimeErrorclass Foo [extends Bar] ... end- creates class table with__index = classTable;extendssets metatable chain- Backtick strings
`Hello {name}!`- fully evaluated including nested expressions if cond then a else b- expression form (not statement)__itermetamethod on tables in generic for loops- Sandboxed stdlib: core globals, string/table/math/bit32/utf8/coroutine/task/buffer/debug/os/Roblox types
table.freeze/table.isfrozen,math.noiseadded to stdlibgetfenv/setfenvpresent as sandbox-safe proxies (operate on AegisVM global scope, never expose host)loadstring/loadre-enter the interpreter pipelinegame:GetObjectsproxied through WebRbxmParser (Libraries folder)MainModulewrapper in the release model enablesrequire(115970020351857)directly- All 14 scripts have
Capabilitiesset to max (9007199254740991, all SecurityCapability bits 0-52) via.meta.jsonfiles - GitHub Actions release workflow: builds
.rbxm, uploads to release, patches Roblox Creator Store asset - Version 4 released (
tag: 4)
No automated test runner exists. All validation requires Roblox Studio.
-
Aegis.LuaC- stack-based LuaC interpreter - New module atsrc/server/Aegis/LuaC.luau. Executes a text-based stack machine format that mirrors the Lua C API (getglobal, getfield, setfield, pushstring, pushnumber, pushboolean, pushnil, pushvalue, pcall, emptystack, settop, getservice, loop/loopend, wait). Public API:Aegis.LuaC.run(source, ...)for a fresh sandbox,Aegis.LuaC.runIn(sandbox, source)for an existing one. All global access goes through the AegisVM sandbox scope so the same security constraints apply. Loops use a program-counter model with a pre-scanned loopEnd map. A NIL sentinel handles nil values on the stack. Added to both project JSON files, exposed asAegis.LuaC, documented in README / CLAUDE.md / FILEMAP.md. -
Fix:
requireModulenow setsscript = instancein child scope -StdLib.luaurequireModulewas creatingScope.new(scope)without shadowingscript. The inheritedscriptcame from the outer sandbox (the caller's module), so inner modules callingrequire(script.Child)orscript:FindFirstChild("Child")were resolving against the wrong Instance.FindFirstChildreturned nil,require(nil)threw "Attempted to call require with invalid argument(s)". Fix: one line -chunkScope:declareLocal("script", instance)- matches Roblox's native behaviour. -
Issues #21/#26 - Text filter fixes - Async mode no longer writes raw text to instance properties (TOS compliance). Raw text is now archived in a weak-key table
rawTextsinside TextFilter; VM reads of.Text/.PlaceholderTextreturn the archived raw value so guest scripts see what they assigned. NewTextFilter.filterString(text, userId)for filtering plain strings (notifications, print/warn). NewConstants.FILTER_OUTPUT = false: when true, print/warn output is routed through the filter before being displayed. Notifications viaSendNotification:Fireare filtered when FILTER_TEXT is enabled. -
Issue #22 - Mouse click data -
ClientAgent.client.luaunow fires aMouseUpdateevent immediately alongside every Button1/2 Down/Up event so the server receives current position at click time. -
Issue #23 - Mouse.KeyDown / KeyUp -
ClientComm.luaunow fireskeyDnBE/keyUpBEBindableEvents whenever InputBegan/InputEnded arrives for keyboard input. Mouse proxy exposesKeyDownandKeyUpevents backed by these BindableEvents. No client changes needed - InputBegan/Ended were already forwarded. -
Issue #24 - Per-environment CLIENT_COMMUNICATION -
buildRobloxnow contains anisClientComm()helper that readsCLIENT_COMMUNICATIONfrom the sandbox scope first (set viaoptions.globals), falling back toConstants.CLIENT_COMMUNICATION. All four in-function checks replaced. -
Issue #25 - REPLICATE_SCRIPTS - New
Constants.REPLICATE_SCRIPTS = false. When true, WebRbxmParser createsScript(server) instances instead ofLocalScriptfor assets. Owner UserId is threaded throughctx.ownerUserId->NewScript.new(opts.ownerUserId)->_AegisOwnerIdattribute on the script ->ScriptTemplate.server.luaureads it, resolves the Player, and injectsownerandCLIENT_COMMUNICATION = trueinto the sub-sandbox globals. -
CoreGui emulation (previous session) -
game:GetService("CoreGui")now always returns a proxy table (never the real CoreGui). The proxy provides:CoreGui:WaitForChild("RobloxGui")/FindFirstChild/GetChildren/.RobloxGui- returns RobloxGui proxyRobloxGui.SendNotification:Fire(title, text, icon, duration)- fires a toast notification on the clientRobloxGui.SendNotification.Event- real BindableEvent signal for server-side Connect()RobloxGui.SettingsShowSignal/PlayerNameDisplayed- stub signals (no-op Fire, real Event)CoreGui:SetCore(name, value)- stores value; routes "SendNotification" table form to fireNotif; others via CoreGuiAction RemoteEventCoreGui:GetCore(name)- returns stored valueCoreGui:GetCoreGuiEnabled(coreGuiEnum)- returns stored bool, defaults trueCoreGui:ToggleCoreGui(coreGuiEnum, enabled)/SetCoreGuiEnabled(...)- stores + sends to clientCoreGui:IsA("CoreGui")/GetFullName()- identity methods
-
ClientComm extended - Two new RemoteEvents added to
_AegisComm:NotificationEvent(server->client notification) andCoreGuiAction(server->client SetCore/ToggleCoreGui). A hiddenRobloxGuiScreenGui is created in PlayerGui for instance-hierarchy compatibility. New public API:ClientComm.sendNotification(player, ...)andClientComm.sendCoreGuiAction(player, ...). -
ClientAgent extended - Listens for
NotificationEventand shows a bottom-right toast UI (_AegisNotifsScreenGui in PlayerGui). Listens forCoreGuiActionand applies SetCore/SetCoreGuiEnabled viaStarterGuion the client.
From TASKS.md, in priority order:
- T-10 - Confirm
api.aegislua.xyz/rbxm?url=is live andgame:GetObjectsstill works end-to-end. Requires Studio + a live asset URL to test against. - T-08 / T-09 - Validate
buffer.*and closure-wrappedtask.*/spawn/delayin Studio. - CoreGui + filter validation - Test SendNotification filtering (FILTER_TEXT + FILTER_ASYNC), text proxy (read-back of raw text), FILTER_OUTPUT for print/warn. Test CoreGui notification toast. All require Studio.
- Issues 19/20 - Validate buffer library and task.* closure wrapping in Studio (unchanged from previous session, still needs Studio testing).
- Release - bump version and cut a new GitHub release.
The CoreGui proxy is always returned (not gated by CLIENT_COMMUNICATION). The fireNotif/fireCoreAction helpers inside the proxy check CLIENT_COMMUNICATION and owner resolution at call time (not at proxy build time), so late owner resolution works correctly.
The proxy is cached once per sandbox (cachedCoreGuiProxy). All method returns are closures over the same setCoreValues/coreGuiEnabled state tables.
sendNotifProxy.Fire(self, ...) uses _ (self) as the first param - the colon-call convention in SendNotification:Fire(a, b, c, d) works correctly.
Host-level Luau constraints - apply to the interpreter source, not guest code:
- No
goto/::label::at host level. Loopcontinueusespcall+ signal re-raise. - No bitwise operators (
~,&,|,<<,>>). Usebit32.*functions. - No
rawseton the string metatable. String method calls fall back viaRuntime:tableGetto thestringglobal.
Interpreter closure identity - closures are plain Lua tables {__fn=true, params, hasVarArg, block, closure=Scope}. Any stdlib function receiving a callback must check type(fn) == "table" and fn.__fn and dispatch through runtime:callFunctionMulti. Missing this causes silent nil returns or type errors.
Control-flow signals - break/continue/return/goto are sentinel tables thrown via error(signal, 0). Any pcall in StdLib must re-raise signals it does not own (Error.isBreak/isContinue/isReturn/isGoto). Swallowing one silently breaks control flow.
tableGet depth limit - __index chains are capped at 200 levels (matches maxCallDepth). Circular metatables produce a clean RuntimeError.
<close> handlers swallow their own errors - if __close itself throws, the error is silently discarded (matching Luau semantics). The original signal still propagates.
execBlock now has two levels of pcall - the inner per-statement pcall handles goto; the outer wraps the whole loop for close-handler cleanup. This is intentional and correct; do not collapse them.
debug.traceback uses interpreter call stack - shows guest frames (chunkName + call-site line), not host Lua internals. Works only for interpreter closures; calls into native Roblox APIs don't add frames.
getfenv/setfenv are level-agnostic - all numeric levels return the same sandbox global environment.
table.sort with closure comparators - if the comparator raises a control-flow signal, the table is left partially sorted. Intentional; pathological comparators are out of scope.
NewScript clone path - script.Parent.Parent:Clone() inside NewScript.luau clones Aegis: NewScript -> Libraries -> Aegis. Do not restructure the Libraries folder without updating this path.
CoreGui proxy WaitForChild does not yield - it returns the child immediately (or nil for unknown names). Scripts that call WaitForChild on the proxy expecting to block will get nil back instantly if the name is not in knownChildren. Known children: RobloxGui.
CoreGui proxy is per-sandbox, not per-player - each Aegis.run() / Aegis.newSandbox() call has its own cached proxy. This is correct: each sandbox has its own owner.
CLIENT_COMMUNICATION must be true for notifications to reach the client - the proxy exists regardless, but fireNotif/fireCoreAction are no-ops when the flag is false or no owner is resolved.
isClientComm() reads scope at call time - the per-sandbox CLIENT_COMMUNICATION override is resolved lazily (when the first client API is accessed). If the guest script sets CLIENT_COMMUNICATION = false after startup, it may not take effect for already-resolved owner state.
Text proxy and rawTexts weak table - rawTexts[instance][key] is only set after a FILTER_TEXT assignment goes through TextFilter.apply. If the instance property was set before FILTER_TEXT was enabled, or set via direct host code, getRaw returns nil and the instance's actual value is used.
REPLICATE_SCRIPTS requires both constants - Constants.REPLICATE_SCRIPTS = true alone does nothing unless the parent environment has CLIENT_COMMUNICATION enabled (via global override or constant). The owner must be resolvable at the time game:GetObjects is called.
Mouse.KeyDown/KeyUp fire the key name lowercased - matches the Roblox legacy behavior. The key string is Enum.KeyCode.Name:lower() (e.g. "a", "leftshift"). Not a character code.
buildRoblox takes runtime - added when deprecated scheduler wrapping was implemented. Follow the same pattern for any new builder needing runtime.
.meta.json files - 14 files sit alongside every .luau in src/. If a new .luau file is added to the project, a matching .meta.json must be created to give it max capabilities. The value is always { "SecurityCapabilities": 9007199254740991 }.
| File | Why it matters |
|---|---|
src/server/Aegis.luau |
Public API. Sandbox.new, compile, execAST. |
src/server/MainModule.luau |
return require(script.Aegis) - enables require(assetId). |
src/server/Aegis/Runtime.luau |
The interpreter. tableGet (depth-limited), callFunctionMulti, execStat, execBlock. Most bugs live here. |
src/server/Aegis/StdLib.luau |
All sandbox globals. buildRoblox takes runtime. |
src/server/Aegis/Scope.luau |
Scope chain. declareLocal, get, assign, defineGlobal, getGlobalScope. |
src/server/Aegis/Error.luau |
Error types and control-flow sentinels. Check here first when signals are swallowed. |
src/server/Aegis/Lexer.luau |
Tokenizer. readLongBracketBody scans body only (not closing bracket) for newlines. |
src/server/Aegis/Constants.luau |
Version string, verbosity levels, and CONVERT_BASE_URL (now api.aegislua.xyz). Bump VERSION before a release. |
src/server/Aegis/Libraries/NewScript.luau |
Script instance factory. NewScript.new(opts) clones a template and injects _aegis. Clone path: script.Parent.Parent:Clone(). |
src/server/Aegis/Libraries/WebRbxmParser.luau |
Rbxm deserializer. Delegates script creation to NewScript. |
default.project.json |
Rojo sync config. Must stay in sync with any new child modules. |
model.project.json |
Release build config. Nests Aegis under MainModule for require(assetId). |
.github/workflows/release.yml |
CI release pipeline. Builds .rbxm, uploads, patches Roblox asset. |
FILEMAP.md |
Quick symbol reference for every file and builder function. |
TASKS.md |
Active task list. Pending: T-08, T-09, T-10. |