How scripts run
Files, lifecycle, errors and time budgets, threads, the sandbox, and how this differs from stock Lua.
This page is the model behind everything else: where scripts live, what happens when they load and unload, what stops them, which thread a handler runs on, and what a script may and may not touch. None of it is difficult, but knowing it saves you from the usual surprises.
#Files and folders
| Path | What it is |
|---|---|
C:\elysium\scripts\*.lua | The scripts. Loaded in alphabetical order. The file name without .lua is the script's name. |
C:\elysium\scripts\<name>\ | The script's own folder: its images, fonts, sounds and whatever files it writes. script.folder() returns it, script.path( "x.png" ) builds a path inside it. It is created when something is first written there. |
C:\elysium\scripts\lib\ | Modules shared by all scripts, for require. |
C:\elysium\scripts\.data\state.json | Which scripts are switched on or off, and which are trusted. |
C:\elysium\scripts\.data\<name>.values.json | The values of the script's menu controls. |
C:\elysium\scripts\.data\<name>.store.json | What the script saved with store. |
The name is the identity. Renaming a script file makes it a new script: its row, its saved values and its folder stay behind under the old name.
#Lifecycle
- Start. On the first frame after the cheat is injected every
.luafile is loaded, one after the other, in alphabetical order. Each gets its own Lua state: its own globals, its own handlers, nothing shared with any other script. - Load. Loading means the file runs once from top to bottom, with a budget of two seconds. Whatever it registers - handlers, controls, timers, windows - is live from that moment. A script that fails here is stopped before it did anything else.
- Run. From now on the script only runs when something calls one of its handlers.
- Unload. When the script is reloaded, switched off, or the cheat closes, its
unloadhandlers run first (half a second at most), then the state is closed and everything the script owned goes with it: handlers, timers, controls, windows, fonts, textures, shaders and particles.
In the Scripts tab:
- reload unloads the selected script and loads it again from disk. A script that had stopped on an error is started again, and one that was switched off is switched on.
- The on / off chip unloads or loads one script and keeps its row. The error chip restarts a stopped script.
- reload all restarts everything. rescan only looks for files that appeared or disappeared and leaves running scripts alone.
#Errors and time budgets
An error stops the script. An error in any handler - or in the file itself while loading - is reported in the output panel with a traceback, the row turns red, and the script stays stopped until it is reloaded. Its controls disappear, its handlers are dead. The reason is practical: a handler that fails on every frame would otherwise write a hundred errors a second and bury the first one, which is the one that matters.
Time is limited. Every call into a script has a budget:
| Call | Budget |
|---|---|
| Loading the file | 2 s |
unload handlers | 0.5 s |
| Every other handler, timer and callback | 30 to 40 ms |
A call that runs over is interrupted with the error script ran past its time budget, and the script is stopped like for any other error. While a script runs, the thread that called it waits: a slow frame handler is a slow game, a slow move handler delays the command. In practice a handler should finish in well under a millisecond; most do it in microseconds.
print( ... ) writes a line to the output panel, warn( ... ) writes one marked as a warning. The panel keeps the last 256 lines.
An error inside an unload handler is reported but does not stop the unload - the state is closed either way.
#Threads
The cheat calls scripts from several threads, because that is where the things they react to happen. Which handler runs where:
Two rules make this easy to live with.
One callback at a time. However many scripts there are and whichever thread calls them, exactly one callback of one script runs at any moment; the others wait their turn. A script's own tables therefore need no locking, and two handlers of the same script - move filling a table, frame reading it - can share data freely. velocity.lua does exactly that.
A few things belong to one thread. The runtime enforces them and says so when you get it wrong:
- Engine calls -
game.command,game.chat,game.sound,game.js,game.set_angles- are handed to the game thread and run at the next frame stage when you call them from anywhere else. Nothing to do on your side. game.particlehas to run on the game thread itself: call it frommove,stageor agame:handler. Elsewhere it raises an error.- Creating a toggle has to happen on the render thread - at load time, or inside
frame,menu,espor a timer. A toggle registers its hotkey with the menu, and that registry belongs to the render thread. - The canvas only exists inside
frameand inside a custom control's draw function.font:measureworks on the render thread only.
#Sandboxed and trusted
A script is sandboxed until you decide otherwise. A sandboxed script can read the game, draw, add menu controls, change the cheat's settings, and read and write files inside its own folder. It cannot:
| Needs trusted | |
|---|---|
net.get, net.post | Talk to the network |
mem.* | Read or write memory, find signatures, call native code |
clipboard.get, clipboard.set | Touch the clipboard |
util.open_url | Open a web address |
ent:address() | See where an entity lives in memory |
cheat.save_config, cheat.load_config | Replace the whole state of the cheat |
| Files outside its folder | Absolute paths and drive letters |
Calling one of these without the flag raises an error that says which, and how to turn it on. The sandboxed / trusted chip in the script's row toggles it; the script reloads when you do, because code that already ran has already been told no. The flag is stored per script name in .data\state.json, and script.trusted tells a script which it is.
#How this differs from stock Lua
The language is Lua 5.4 and everything in the standard string, table, math, utf8, coroutine and os.time/os.clock/os.date works as documented. Differences:
- Not there:
io,package,dofile,loadfile,os.execute,os.exit,os.getenv,os.remove,os.rename,os.tmpname,os.setlocale. Files go throughfs, modules throughrequire. loadtakes source text only - no binary chunks, no reader functions. The optional fourth argument sets the chunk's environment as usual.debughas one function,debug.traceback.printandwarnwrite to the Scripts tab and the console instead of standard output.- Added to the standard tables:
string.split,string.trim,string.starts_with,string.ends_with,table.copy,table.deep_copy,table.contains,table.keys,table.values- see Utilities. - Names that Lua's keywords rule out: a keyword cannot follow a dot, so the API says
ents.local_player()where you might expectents.local(), andinput.vk.end_keyfor the End key.
Every callback also runs under a hook that counts instructions, which is how the time budget is enforced. You will not notice it, apart from the error it raises when a loop does not end.
#Modules and shared code
require( "name" ) loads name.lua from the first of these places that has it:
scripts\lib\name.luascripts\name.luascripts\<this script>\name.lua
A dotted name is a path: require( "gfx.rounded" ) loads gfx\rounded.lua. Names are plain identifiers, so a script cannot reach a file outside those folders by writing ... The result is cached per script, as usual.
Note what this does not give you: every script has its own state, so two scripts that require the same module get two separate copies of it. Scripts cannot call each other or see each other's globals, and a sandboxed script cannot read another script's files. A module is for sharing code, not state.
#Saving state
Two things are saved for you:
- Control values are written to
.data\<name>.values.jsonabout a second and a half after the last change, and again when the script unloads. They come back by the control's group title and label, so renaming a control or its group resets that one value. storeis a small key-value table you fill yourself. It is written to.data\<name>.store.jsonthe same way. Seestore.set.
To reset a script to its defaults, switch it off, delete its two .data files and switch it on again.