starlit

How scripts run

One Lua state for every script, the order of a frame, how requests from the scripts page and tuning calls are timed, the time and memory budgets, and what happens when a script goes over them.

One state for each script

Every loaded script runs in its own Lua 5.4 state with its own memory. Nothing is shared between scripts: not globals, not tables, not functions, not controls. A script cannot see that another script exists. You can have 16 scripts loaded at once.

The interpreter is Lua 5.4.8 with changes of ours. The changes remove things a script has no use for and put limits on things that could stall a frame. They are listed at the end of this page.

A script starts with a fresh table of globals. Your global assignments stay in that table. Three kinds of names are in it when your code starts:

  • The standard functions we allow, for example pairs, string.format and math.floor. See vec3, color, bit and math.
  • The module tables log, time, screen, events, draw, ui, game, entity, cvar, resolver, rage, settings, movement and visuals. They are read-only. Assigning to a member raises table is read-only, and rawset refuses them as well. Reading a member that does not exist gives nil, so if events.on_shot then is a safe way to test for a function.
  • The helpers vec3, color, bit and the extra math functions. They are written in Lua and run inside your own state, so they are your copies. If you replace math.clamp, only your script sees the change.

When scripts run

All script code runs once for every rendered frame, from the client's draw routine. A slow script lowers the frame rate, which is why every call has a budget.

Script code runs only while at least one script is loaded. When no script is running we build no game snapshot and the game-event listeners push nothing.

What happens in one frame

  1. Requests from the scripts page run first. Pressing load, confirm load, unload, reload, reload all or refresh does not start script code on the spot: the press is queued, and the request runs at the start of the next frame, before the menu is drawn. Up to 16 requests wait. A request names its file by the hash of the file name, so a refresh in between cannot make it hit another file.
  2. We build the game snapshot: the players, the local player, the server time and tick. Entity handles made in earlier frames are stale from now on.
  3. For each keybind control we read the key the user bound to it and update its state.
  4. The on_draw function of each surface the menu showed in the last frame runs. See menu.
  5. Button presses from the last frame run, one call for each press.
  6. on_change functions run for the controls the user edited, at most 16 per script.
  7. Finished ragebot shots reach events.on_shot, at most 16 per script.
  8. Game events reach their handlers, at most 32 per script.
  9. events.on_paint runs.
  10. If on_paint returned without an error, we draw the commands it recorded.

Steps 3 to 10 happen for one script after another, not for all scripts at once.

Scripts run before the ESP draws. What a script sets with visuals, settings or draw shows in the same frame, and the shapes of a script are drawn with the ESP layer, below the menu.

Scripts run one after another. The first script of the frame changes every frame so that no script is always last. All scripts together get 3 ms of script time per frame. A script that finds the time used up skips its on_paint for that frame. That is not a strike.

Values that live on

Calls that tune Starlit set a live value. The value is valid for a short time after the last call, and the script keeps it alive by repeating the call every frame.

modulevalid for
rage500 ms after the last call
settings500 ms after the last call
movementthe ttl of the intent, 1 to 6 ticks of 16 ms, so 16 to 96 ms
visualsthe frame the call was made in and the next one
resolver2 seconds after the last call
ui.stylewhile the script runs and asks again

A value goes back to the user's own when the script stops calling. It also goes back when the script is unloaded, stops with an error or is stopped, and when the level changes. A script that wants to keep a value calls the function again every frame, usually at the top of events.on_paint. The calls are cheap when the value does not change.

Time budgets

Every call into a script has two limits, a count of Lua instructions and a time on the wall clock. The call is stopped when the first one is reached. The instruction count is checked every 1000 instructions, and the clock is read at the same moment. Calls to API functions add a cost of their own. The costs are in Limits, files and saved values.

callinstructionstime
main chunk, when the script loads5,000,000100 ms
events.on_paint100,0001.5 ms
a ui.button callback100,0001.5 ms
events.on_unload20,0000.5 ms
one game event handler50,0000.5 ms
one events.on_shot call50,0000.5 ms
one on_change function100,0001.5 ms
one surface on_draw50,0000.5 ms

Event, shot and change handlers also share a per-frame allowance: 32 event calls and 1.5 ms for events, 16 shot calls and 1.5 ms for shots, 16 change calls and 1.5 ms for on_change functions. What does not fit waits for the next frame and is dropped once it is five seconds old.

When a budget is exceeded

We raise the error script budget exceeded inside your code, at the place where the budget ran out. After the first overrun the check runs on every single instruction and raises every time. A pcall around the loop catches the first error and the next instruction raises again, so a script cannot swallow the error and carry on.

What follows depends on the call:

  • In the main chunk, the load fails. The script does not start and the entry shows error.
  • In any other call, the overrun is a strike.

Memory has a cap too: 8 MiB for one script and 32 MiB for all scripts together. An allocation over the cap fails with a memory error inside the script. If the error leaves the callback it is a strike with the text script memory limit reached.

Strikes

A callback that raises an error, runs out of time or memory is a strike. The count belongs to the loaded script. It is not per frame and it does not reset. The second strike stops the script: the state becomes error, the memory is freed, the controls disappear, and everything the script had set in Starlit is removed: resolver hints, rage, settings and movement values, visuals, its tabs, panels and menu style. The page shows the message of the error that stopped it. reload starts a new count.

A hardware fault inside the host while it serves your call stops the script at once. We do not expect this to happen. It is the reason every call into a script is guarded.

The first strike is counted and not displayed. If you want to see it, catch errors in your callbacks with pcall and send the message to log.info, as shown in Getting started.

Drawing

Drawing functions do not draw. They record commands into a buffer of 2000 commands, and the client draws the buffer after on_paint returns. If on_paint raises an error, the commands of that frame are thrown away, and nothing from the script is drawn that frame. The commands are drawn on the layer of the ESP, under the menu and its dialogs.

Draw functions only work inside events.on_paint. In any other callback they raise draw is only available inside on_paint. draw.measure is the exception and works everywhere.

Handles and the snapshot

Game data is copied once per frame. Everything a function returns is a fresh Lua value: you can change a table from game.players() and nothing else sees the change. Entity handles are different. A handle is valid only in the frame that created it. Keep slots between frames, not handles. See entity.

Callbacks that register callbacks

events.on_paint and the other registration functions can be called from any callback, also from inside a handler. Calling one again replaces the earlier function, and nil removes it. Controls can be created in any callback, but they count against the same caps as the ones made in the main chunk.

Differences from standard Lua

The interpreter is Lua 5.4.8. These are the differences you can notice.

areadifference
Missing librariesio, os, debug, package, coroutine and require do not exist.
Missing base functionsprint, load, loadfile, dofile, collectgarbage, warn and _VERSION do not exist. Use log.info instead of print.
Missing string functionsstring.dump, string.pack, string.unpack and string.packsize do not exist, and string.format has no %p.
ChunksOnly text is loaded. Precompiled chunks are refused.
getmetatableReturns nil for everything that is not a table, strings included.
tostringA table, function or userdata converts to its type name only.
Finalizers and weak tables__gc never runs and __mode is ignored. Objects are freed when the script unloads.
ClosingTo-be-closed variables (local x <close>) and __close are refused.
xpcallThe message handler runs after the error has unwound, inside its own protected call and under the same budget.
math.randomSeeded from a source of ours at load. math.randomseed() reseeds from the same source.
Pattern matchingA pattern is at most 256 bytes and a subject at most 256 KiB. A match call may take 200,000 steps. Going over the step limit raises pattern too complex.
Size capsstring.rep and table.concat results are at most 64 KiB, table.move at most 2^18 elements, table.sort at most 65,536 elements.
RecursionThe Lua stack holds 60,000 slots and C calls nest 200 deep. Deeper recursion is a stack overflow error.

The standard names that remain are listed in vec3, color, bit and math.

previous: Getting started · next: Limits and files