Threading
The main thread renders. Anything you do in OnFrameReceived or OnFrame is paid for out of the frame budget — about 16 ms at 60fps — and spending it is what makes the picture late, which is the one thing this app exists not to do. Two ways out: a worker, which is a real OS thread with its own Lua state, and LateUnreliableThink, which is a coroutine on the main thread that spreads one long job over several frames.
Pick the worker when the work is heavy and its inputs can be copied. Pick LateUnreliableThink when the work is heavy but needs to see mod state directly. Reading pixels is neither: the Frame is only alive for the duration of the call it was handed to, so sample on the main thread and send the samples.
functionworker.spawn(path) → Worker | nil
Start a background thread running a script from your own addon folder. Only while your mod is loading — a worker is long-lived, and load time is when the engine still knows whose folder to read from. Returns nil (and logs) if the file isn't there.
| Param | Type | Notes |
| path | string | Relative to addons/<MOD_NAME>/, e.g. "workers/reader.lua". Absolute paths and .. are rejected. |
local w = worker.spawn("workers/reader.lua")
methodw:post(job) → boolean
Hand over one job. False means it is still busy — and that is the whole scheduling model: ask every frame, and a frame where the answer is no is a frame you simply skip. Nothing queues, so a slow pass can never build a backlog of work about frames that have already gone.
| Param | Type | Notes |
| job | any | Copied, not shared — see what can cross below. Usually a table. |
methodw:collect() → any[]
Everything the worker has finished since you last asked, as an array. Never blocks and returns an empty table when there is nothing — call it every frame. Each entry is whatever that job's onJob returned.
methodw:share(name, value) → nil
Give the worker a value once, as a global on its side. For the things that never change and are too big to send every frame — a font atlas, a lookup table. It is a snapshot: changing your copy afterwards changes nothing over there until you share it again.
w:share("FONT", PKMN.font) -- a few hundred KB, sent once
w:post({ rows = sampled }) -- a few KB, sent every frame
methodw:busy() → boolean
Whether a pass is running. You rarely need it — post already answers the same question by refusing.
methodw:alive() → boolean
False if the script failed to load or defines no onJob. A worker that throws inside a job stays alive: one bad frame shouldn't stop the mod being processed for the rest of the session.
fieldonJob(job) → any
In the worker script, not in your mod: the one function the engine calls, once per posted job. What you return comes back through collect. A worker script that doesn't define it is refused at spawn.
The worker gets a smaller sandbox than a mod: string, table, math, utf8 and the base library, plus log(msg) and now() (milliseconds, for timing a pass). No os, no data, and none of the engine APIs — no overlay, no hook, no game. They all reach shared state, and reaching is the thing this design exists to avoid.
-- workers/reader.lua
function onJob(job)
local out = {}
for _, w in ipairs(job.windows) do out[#out+1] = classify(w, FONT) end
return { seq = job.seq, windows = out }
end
noteWhat can cross a thread
nil, booleans, numbers, strings and tables of those, nested. Everything else — functions, userdata, metatables, a Frame — is dropped, and the engine logs how many values it left behind rather than failing quietly. Cycles are not followed.
This is a copy, in both directions. The two Lua states share no memory at all, which is what makes it safe to run one while the other is drawing; it is also why there are no globals to reach for over there. Package what the worker needs into the job, and expect the answer back the same way.
functiongame.slice() → nil
Inside LateUnreliableThink: yields if this frame's share of time is spent, and resumes here on the next one. Call it wherever stopping is safe. Without a slice call the pass runs to completion in one frame and you have gained nothing.
Why a coroutine rather than a thread: there is one Lua state shared by every mod (that is what makes cross-mod globals work), and a second OS thread touching it would corrupt it. So the slicing is cooperative, and the budget is 2 ms of a ~16 ms frame. "Unreliable" is the promise: passes never overlap and never queue, but nothing says how often one runs or how long it takes.
function g:LateUnreliableThink()
for i = 1, 100000 do
crunch(i)
if i % 500 == 0 then game.slice() end
end
end