No API entries match your search.

Getting started

An addon is a folder of Lua that lives under addons/. Every .lua file inside addons/<name>/lua/autorun/ is run once at startup, in alphabetical order. A downloaded mod arrives instead as a single addons/<name>.ccmod file — a plain ZIP, mounted in place and never unpacked, so it stays obvious which mods are yours (folders) and which came from the site (packages). Worker scripts load from inside the package too; a loose folder with the same name out-ranks a package of that name.

layoutaddons/<modname>/lua/autorun/*.lua

All addons run in one shared sandbox: a global you set in one addon is readable in every other addon, functions can be called across mods, and hook events fired by one mod reach listeners in another. Four things are per-addon: data (your private storage folder), MOD_NAME (your folder name), MOD_VERSION and MOD_SOURCE.

A minimal addon — a pinned FPS-style HUD plus a button:
-- addons/hello/lua/autorun/hello.lua
log("hello loaded as " .. MOD_NAME)

-- A free-form HUD element, always on screen (mode = "pinned")
overlay.add("hello.clock", {
    mode = "pinned",
    paint = function(d)
        d:rect(d:w() - 150, 16, 130, 34, 0, 0, 0, 150)
        d:text(d:w() - 138, 24, os.date("%H:%M:%S"), 20, 255, 255, 255)
    end,
})

-- A button in the overlay menu (Shift+Tab)
overlay.add("hello.hi", {
    mode = "menu",
    button = { label = "Say hi", onClick = function() log("hi!") end },
})

Globals

Functions and values available directly in every addon.

functionscreenshot(path?, x?, y?, w?, h?) → nil

Queues a PNG capture. The default — no overlay, no region — saves the raw capture frame, pixel for pixel at the frame's own resolution, straight off the card and after mods' OnFrame mutations: what the reader sees is what lands in the file, whatever size the window is or how the picture is scaled into it. Fulfilled on a background thread, so it never stalls rendering. A relative path is resolved against the app's screenshots/ folder; omit it for an auto-named file.

The other two shapes photograph the rendered window instead: overlay = true (options-table form, screenshot(path, { overlay = true })) captures after the overlay draws, panels and HUD included — the built-in screenshot addon binds this to O, with P on the raw frame. A region (all four of x, y, w, h, in window pixels) also reads from the window, because that is the space its coordinates live in. Window-based shots are scaled by the display fit; only the default form is fixture-grade.

ParamTypeNotes
pathstring (optional)Output path, e.g. "screenshots/shot.png". Omit or pass nil for an auto-named file.
x, ynumber (optional)Top-left corner of the region to capture, in pixels.
w, hnumber (optional)Region width & height, in pixels. Region is used only if all four of x, y, w, h are supplied; otherwise the whole frame is captured.
hook.Add("KeyPress", "mymod", function(key)
    if key == "F12" then
        screenshot("screenshots/grab_" .. os.time() .. ".png")   -- whole frame
    elseif key == "F11" then
        screenshot("screenshots/hud.png", 20, 20, 260, 90)       -- just a region
    end
end)
functionsound.play(path) → boolean

Plays a small WAV once — UI feedback, not a music system. The sound is mixed over the passthrough audio on its own stream and follows the app's output volume, so a muted instance stays silent. path is resolved against the app's data root exactly like image paths ("addons/<mod>/click.wav"); absolute paths and .. are refused. Returns false if the file could not be loaded or played. Keep files short — the whole file is queued at once.

ParamTypeNotes
pathstringA .wav under the data root, usually "addons/<mod>/…". Ship it with the mod like any other asset.
-- a shutter click alongside a screenshot (see the screenshot addon)
screenshot(name)
if sound and sound.play then                -- guard for older engines
    sound.play("addons/" .. MOD_NAME .. "/shutter.wav")
end
functionsound.capture(on) → nil

Start or stop keeping a copy of the console's sound as it passes through to the speakers — for a mod that wants to record it. Nothing is buffered until a mod asks, and switching it off drops whatever was held, so a recorder that is turned off costs nothing. sound.capturing() answers whether it is on.

Why a pull and not a hook. The samples arrive on SDL's audio thread and the Lua state belongs to the main one — dispatching into Lua from there would corrupt it. So the engine holds the bytes and you collect them from your own Think, where calling Lua is safe. The engine's buffer is capped at about two seconds; a mod that stops collecting loses the oldest rather than growing without limit.
functionsound.take() → bytes, channels, rate, bits, isFloat

Everything captured since the last call, plus the format it is in. The bytes are raw interleaved PCM exactly as the passthrough has them — no conversion, so writing them straight into a WAV or an AVI's audio stream is lossless. Returns an empty string when nothing has arrived. Takes no parameters.

ReturnsTypeNotes
bytesstringInterleaved samples. One frame of audio is channels * bits/8 bytes.
channels, rate, bitsnumberTypically 2, 48000, 32.
isFloatbooleanTrue when the samples are IEEE floats — WAVE format tag 3 rather than 1.
-- collected in Think, where calling into Lua is safe
sound.capture(true)
function g:Think()
    local pcm, ch, rate, bits, isFloat = sound.take()
    if #pcm > 0 then keep(pcm, perf.nowMs()) end
end
functionlog(...) → nil

Writes a line to the console/log stream, prefixed with [mod]. Takes any number of arguments and joins them with nothing in between, so log(msg) is still the whole of it for most uses.

Colour, GMod MsgC-style: a colour table colours everything after it, up to the next one. The shape is the overlay's own, {r, g, b} or {r=, g=, b=}, 0–255; a fourth component (alpha) is ignored. The colour shows in the developer console. log.txt gets the same line without it, because escapes in a file are noise in anything that is not a terminal.

Colour comes only from colour tables. Control characters in your strings — anything below a space except newline and tab, so ESC included — are removed before the line is written. Otherwise a mod could clear the console, move its cursor or retitle the window just by logging the right bytes.

ParamTypeNotes
...string / colour / anyStrings are written as they are. A table of three numbers is a colour. Anything else is written the way tostring writes it.
log("calibrated")                                  -- as it always was
log({255, 90, 90}, "error: ", {200, 200, 200}, "no save in data/")
log({r = 120, g = 220, b = 140}, "found ", count, " encounters")

Level: a LOG_LEVEL value as the first argument is the line's level, not text. A line without one is Verbose. It only counts as a level when it is exactly one of the levels and something follows it, so log(2) still prints 2. To start a line with a number that happens to be a level, put a string in front of it.

log(LOG_LEVEL.IMPORTANT, {255, 90, 90}, "the save is missing")
log(LOG_LEVEL.VERBOSE, "frame ", n)                -- same as log("frame ", n)

Which lines are shown is the user's choice, in Options → Developer: a source (everything, the engine, or one mod) and a level (Verbose, Important, None, or one of that mod's own levels). log.txt always keeps everything. While the choice narrows anything, log.filtered.txt beside it holds what passes. Your lines are always yours: the engine knows which mod wrote them, and that includes your print output and the errors your hooks raise.

print(...) lands in the same log as a Verbose line, prefixed [print]. It used to go to standard output, which a Release build does not have.

fieldLOG_LEVELtable of int

The levels a log() line can have, as an int enum: LOG_LEVEL.VERBOSE (1) and LOG_LEVEL.IMPORTANT (2). An error the engine catches from your mod (a hook that raises, a file that fails to load) is always Important.

LOG_LEVEL.add(name) makes a level of your mod's own and returns its number, to pass to log() like the two above. The user can then pick your mod and that one level, and see only those lines. Asking again for the same name returns the same number. Returns nil for an empty name, or once a mod has 32 levels. Against the plain Verbose / Important / None setting, your own levels count as Verbose.

ParamTypeNotes
namestringWhat the user sees in the level list. Keep it short, up to 32 characters.
local CAL = LOG_LEVEL.add("Calibration")
local RD  = LOG_LEVEL.add("Reader")

log(CAL, "bars at ", top, " / ", bottom)
log(RD, "read ", name, " at level ", lv)

Numbers are handed out per mod, from 16, in the order you add them. Pass them to a worker in a job if its log() should use them too. The user's choice is saved by name, so adding a level later does not break it.

functionclipboard.set(text) → ok, why

Puts text on the user's clipboard: a Copy button for a seed, a split time, a stat line. Returns true, or false and a sentence saying why not. Every write is logged under your mod's name.

Only in answer to the user. It works while the engine is handling a key press or a click: inside a KeyPress hook, a button's onClick, a combo's onSelect, an element's OnMouseDown. From Think, OnFrameReceived, a timer or a coroutine slice it is refused. Otherwise a mod could rewrite the clipboard every frame and swap whatever the user is about to paste somewhere else.

Write only. There is no clipboard.get. The clipboard holds whatever the user last copied anywhere, passwords included, and a mod does not need to read it to put its own text there.

ParamTypeNotes
textstringUp to 64 KB. Control characters other than newline and tab are removed.
{ type = "button", text = "Copy seed", onClick = function()
    local ok, why = clipboard.set(tostring(seed))
    if not ok then log(LOG_LEVEL.IMPORTANT, "copy failed: ", why) end
end }
fieldMOD_NAMEstring

The addon's folder name. Also the name of your private data folder (data/<MOD_NAME>/).

fieldMOD_VERSIONstring

What your mod.txt says this copy's version is, as "major.minor.patch" — "0.0.0" when there is no manifest. Handy for a "did the update land?" line in your own logging.

fieldMOD_SOURCEstring

Which copy of your mod is running: ".ccmod" for the package, "file" for a loose folder. A mod can exist twice on one machine and the versions decide which one wins, so after an update "did my edit take, or the download?" is otherwise only answerable from the startup log. The engine shows this beside the version in the mod's settings header; you get the same string if you want it yourself.

functionoverlay.setTheme(theme) → nil

Install a skin: one table of sections that every native widget falls back to. This is the whole redesign surface — a mod that styles nothing follows the installed skin, a mod that styled something keeps exactly what it asked for, and with no skin everything is the engine's built-in look. Resolution order is always widget style → theme section → built-in default.

Pass nil to take a skin back off. You rarely need to: unloading the mod that called this clears its skin automatically, so removing a skin mod restores the stock look with nothing to undo.

SectionKeys
overlayscrim — the dim drawn behind an open overlay — and bgImage, a full-screen backdrop over it.
panelbg, bgImage, border.
labelfg.
rectbg, bgImage, border. Skinning this restyles every list band at once.
rowbg, bgImage, border — the background of a layout row.
buttonbg, bgHover, bgImage, bgImageHover, border, fg, fgHover, and per role, with the role in the middle: bgActive, bgActiveHover, bgDanger, bgDangerHover, and the matching bgImageActive, fgDangerHover, …. A role key falls back to the role-less one, so bgHover set once covers every button and you only name bgActiveHover where that case should differ.
textboxbg, bgImage, border, borderFocus, fg, placeholder, selection, bgDisabled, borderDisabled, fgDisabled. A disabled field skips its bgImage: dim is the whole message there.
combobg, bgImage, border, fg.
sliderbg (track), fill.
menuThe overlay's own button column: dir ("column" | "row"), anchor ("center" | "top" | "bottom" | "left" | "right"), w, h, gap, margin, font, plus bg, bgHover, bgImage, bgImageHover, border, fg, fgHover. A strip that doesn't fit the window shrinks as a whole, gaps and font with it; a centred or side-anchored one also stays clear of the MCCP title.
gameslift — pixels to raise the cartridge bar off the bottom edge, so a bottom menu has room.
Colours are {r,g,b[,a]} in 0..255, the same shape a widget's style uses. Every key is optional; whatever you leave out keeps the built-in value. fgHover defaults to fg, so a skin only sets it where a strong hover fill would otherwise wreck the label's contrast.
Art (bgImage and friends) is a path, the same one d:image() takes — ship it with your mod and point at "addons/" .. MOD_NAME .. "/…", which resolves whether your mod is a loose folder or a downloaded .ccmod. It is stretched to the rect it fills and drawn over that rect's colour, so the colour is the ground beneath any transparency: to let art with rounded corners actually show them, set the fill's alpha to 0. A path that won't load draws nothing and leaves the colour alone.
overlay.setTheme({
    overlay = { scrim = {14,20,27,204} },
    panel   = { bg = {35,38,46,242}, border = {61,68,80,255} },
    button  = { bg = {61,68,80,255}, bgHover = {26,159,255,255},
                bgActive = {26,159,255,255}, fg = {220,222,223,255} },
    menu    = { dir = "row", anchor = "bottom", w = 170, h = 40, gap = 8,
                -- the fill is transparent so the art's corners survive
                bg = {0,0,0,0}, bgHover = {0,0,0,0},
                bgImage      = "addons/" .. MOD_NAME .. "/skin/toolbar.png",
                bgImageHover = "addons/" .. MOD_NAME .. "/skin/toolbar_hover.png" },
})
functionoverlay.getTheme() → table?

The installed skin, or nil. For reading colours out of a skin see overlay.themeColor below — it handles the "no skin" case for you.

functionoverlay.themeColor(section, key, r, g, b, a) → r, g, b, a

One colour out of the installed skin, falling back to the four values you pass. Returns them as four numbers, so it drops straight into d:text / d:rect. This is how something that draws its own pixels — a paint function — follows a skin instead of being the one part of the screen that ignores it. overlay.themeNum(section, key, def) and overlay.themeStr(section, key, def) are the same idea for numbers and strings.

function el.paint(d)
    d:rect(0, 0, 200, 40, overlay.themeColor("panel", "bg", 40, 42, 54, 235))
end
fieldrolestring?

On a button widget: what the button means, leaving what it looks like to the skin. "active" for a toggle that is on or the selected one of a set, "danger" for the ones that delete, "neutral" (the default) for the rest. The theme is consulted per role — bgActive, bgDanger — at DRAW time, which is what lets a skin loaded later restyle a button built earlier. A style.bg of your own still wins over both.

{ type = "button", text = "Sound: ON", role = "active", style = { font = 14 } }
fieldOVERLAY_OPENboolean

Whether the overlay menu is up, right now. Unlike MOD_NAME and friends this one changes as the user opens and closes it, and the engine keeps it current the moment the state changes — read it from anywhere: a Think, a paint, a "KeyPress" handler, a helper three files away. overlay.isOpen() is the same fact as a function call.

It is what a mod filters keybinds on, since the engine no longer decides that for you: a key that would be a nuisance while the menus are up returns early, and a key that is about what is on screen (a screenshot) does not.

hook.Add("KeyPress", "mymod", function(key)
    if OVERLAY_OPEN then return end        -- not while the user is in the menus
    if key == "R" then reset() end
end)

-- and anywhere else, not just in a callback that was handed it:
hook.Add("Think", "mymod_idle", function()
    if not OVERLAY_OPEN then pollTheGame() end
end)
filemod.txt

An optional file at the root of your addon (addons/<name>/mod.txt), read by the engine before any Lua runs. It exists to answer one question: when the same mod is present twice — as a folder you are editing and as a .ccmod you downloaded — which copy loads? The higher version wins, and a tie goes to the package — equal versions mean the folder has not been bumped since that release was cut, so the two are claiming to be the same thing and the published one is the copy that was actually built.

# addons/mymod/mod.txt
version = 1.2.3
No manifest means version 0.0.0, which loses to any stated version. That is what lets a downloaded release take over from a folder you left lying around without moving or renaming anything — and equally, bumping your folder past the release puts you back in charge. Both copies stay on disk either way; the log says which one ran and why. Unknown keys are ignored, so the file can grow later. Comments start with #, and a UTF-8 byte-order mark (what Notepad writes) is tolerated.

Two more keys, both advisory. dependencies lists mods this one needs beside it, optionally with a minimum version; incompatible lists mods it cannot live with. Names are folder names, matched without regard to case, comma separated.

# addons/mymod/mod.txt
version      = 1.2.3
dependencies = pkmn_stats >= 0.3.0, notes
incompatible = steam_skin
Nothing is refused. The engine checks these once, after every mod has loaded, and then says what it found — in the log, and under that mod in Options → Base Mods. It does not stop a mod loading, unload anything, or reorder anything. A mod host that refuses to run your mods because a third party's manifest has a typo in it, or names a dependency you deliberately replaced, is worse than the problem it is solving — and nothing here can tell a real conflict from a stale claim. Load order remains what it has always been: alphabetical by filename, within a mod.
Uploading needs one, and a new one every time. The version in mod.txt is sent with the package when you push a mod to the site, and it becomes the heading of the changelog entry that upload writes. The site refuses a version that mod has used before — two different downloads answering to one version is the thing a version exists to prevent — so bump it before every push. A folder with no version cannot be uploaded at all; the app says so before it packs anything, rather than after sending megabytes. It is also the number the app compares against the site's to decide whether the copy you have installed is the one being served.
functionperf.latencyMs() → number

Current frame-to-screen latency in milliseconds (a smoothed average) — from when the OS timestamps a received frame to when it's presented. This is the software path only; it does not include the capture card + USB latency. Handy for testing your mod's rendering cost. Takes no parameters.

functionperf.fps() → number

Frames presented in the last second. Takes no parameters.

functionperf.nowMs() → number

Milliseconds since the app started — the same clock the frame loop runs on. Use it for anything that has to fit inside a frame; os.clock measures CPU time and is too coarse. Takes no parameters.

hook

GMod-style event system. Register named callbacks for engine events; the name lets you replace or remove your own hook later.

functionhook.Add(event, name, fn)

Registers fn for event under a unique name. Adding again with the same event + name replaces the previous callback.

ParamTypeNotes
eventstringEvent name, e.g. "OverlayElement".
namestringUnique identifier for this callback.
fnfunctionCalled with the event's arguments.
functionhook.Remove(event, name) → nil

Unregisters the callback previously added under this event + name.

ParamTypeNotes
eventstringThe event name.
namestringThe unique identifier used in hook.Add.
functionhook.Run(event, ...) → nil · alias: hook.Call

Fires an event, calling every registered callback with the given arguments. The engine uses this to dispatch events, but you can also define and fire your own custom events between mods.

ParamTypeNotes
eventstringThe event name to fire.
...anyAny number of arguments, passed through to each callback.
functionhook.RunReturn(event, ...) → any | nil

Like Run, but stops at the first callback that returns a non-nil value and hands that value back. This is how a handler consumes an event instead of merely observing it — OnFrameReceived is dispatched with it, so a mod that has fully handled a frame can say so and the rest are not called. Callback order between mods is not defined, so do not build anything that depends on getting there first. Errors are caught and logged per callback, exactly as with Run.

ParamTypeNotes
eventstringThe event name to fire.
...anyAny number of arguments, passed through to each callback.

Engine events

Events the engine fires into hook. Subscribe with hook.Add.

event"OnVideoResized" → your callback receives (w, h)

The incoming video changed size — a different capture mode, a different console output setting, a stream restart. Fired before the first frame of the new size reaches OnFrameReceived, so anything you measured against the old frames can be thrown out in time. Note this is about the video, not the app window: resizing the window never moves a pixel of frame space, so there is deliberately no event for it.

event"KeyPress" → your callback receives (key)

Fired on key-down, whatever the overlay is doing. Whether an open menu should silence your keybind is your call, not the engine's: check OVERLAY_OPEN and return early if that is what you want.

The one thing the engine does decide: a focused textbox takes the keystroke first, and no KeyPress follows. Typing is never a keybind — a letter going into a pinned note must not also fire out of somebody's sentence.

Callback argTypeNotes
keystringThe key's display name, e.g. "P", "Space", "F1".

Return true to consume the key. A consumed key goes no further: later mods' "KeyPress" hooks do not fire for it, and neither do the app's own defaults (Escape closing the overlay — or, with the overlay closed, quitting the program). This is how a mod-drawn text field takes typing safely: its letters stop doubling as other mods' keybinds, and Escape lets go of the field instead of closing the app. Return nothing for keys that are not yours — consuming everything would eat every other mod's binds and the app's own controls.

The usual shape is to ignore keys while the menu is up — one line, and now it is a line you can see rather than a rule you cannot reach. The exceptions are the keys that are about what is on screen: a screenshot key that goes quiet with the overlay open cannot take the one picture it exists for.
hook.Add("KeyPress", "screenshotter", function(key)
    if OVERLAY_OPEN then return end         -- not while the menu is up
    if key == "P" then screenshot("screenshots/" .. os.time() .. ".png") end
end)
event"OverlayElement" → your callback receives (el)

Fired once per element, every frame, just before it is drawn — for every element, native ones included. Mutate the passed element table to restyle, rename, hide (el.hidden = true), or re-mode anything on the overlay.

Callback argTypeNotes
elElement (table)The live Element being drawn. Its el.id tells you which one; mutating it takes effect this frame.
hook.Add("OverlayElement", "myskin", function(el)
    if el.id == "options" then el.button.label = "Settings" end
    if el.id == "close_overlay" then el.hidden = true end
end)
event"OnFrameReceived" → your callback receives (frame)

Fired for every captured video frame, before it is displayed. Mutate the frame's pixels in place (through the Frame object) to alter what's shown — no return value needed. Handlers run in turn; if one returns a non-nil value, the remaining handlers are skipped for that frame.

Callback argTypeNotes
frameFrameA read/write view of the raw pixel buffer. See Frame.
Performance: this fires ~60×/second and byte access crosses into C each call, so heavy per-pixel loops in Lua are costly. Touch as few bytes as you can, and check frame:format() — capture frames are usually NV12 (planar YUV), not RGBA.
hook.Add("OnFrameReceived", "dim-top", function(frame)
    local pitch = frame:pitch()
    for row = 0, 40 do                 -- NV12: Y plane is the first rows
        for col = 0, frame:width() - 1 do
            frame:setByte(row * pitch + col, 16)   -- Y=16 -> black band
        end
    end
end)

Frame

A read/write view of a raw captured frame, passed to OnFrameReceived. Byte access is 0-based into the pixel buffer, whose layout depends on format() — capture frames are typically NV12 (a full-size Y/luma plane, then an interleaved half-size UV plane).

methodframe:width() → number

Frame width in pixels. Takes no parameters.

methodframe:height() → number

Frame height in pixels. Takes no parameters.

methodframe:pitch() → number

Bytes per row of the first (luma) plane. Index a pixel's row with row * pitch() + col. Takes no parameters.

methodframe:format() → string

The pixel format name, e.g. "SDL_PIXELFORMAT_NV12". Takes no parameters.

methodframe:size() → number

Total pixel-buffer size in bytes — the valid range for byte indices. Takes no parameters.

methodframe:getByte(i) → number

Reads one byte of the pixel buffer.

ParamTypeNotes
inumber0-based byte index (0 .. size()-1). Out of range returns 0.
methodframe:region(x, y, w, h) → string

Read a rectangle in one call. Returns w*h bytes, row-major — index it with string.byte. Anything outside the buffer comes back as 0 rather than being refused.

ParamTypeNotes
x, ynumberTop-left, in bytes across and rows down the pixel buffer.
wnumberWidth in bytes, capped at 4096.
hnumberRows, capped at 8192 — enough for the extra chroma rows below.
Rows run over the whole buffer, not just the picture. On a planar format the interleaved chroma plane sits immediately after the luma rows, so a 1920×1080 NV12 frame is 1620 readable rows, not 1080 — frame:size() is the byte count that covers, and this is the only way to read it in bulk. That is what makes a whole frame reachable: frame:region(0, 0, w, h + math.ceil(h/2)) hands back one packed NV12 frame, padding excluded, which is exactly what a recorder or an exporter needs.
Use this, not a loop of getByte. Every call from Lua into the engine costs about 1.6µs whatever it carries, so the price of reading an area is its pixel count: one 150×38 window was 5700 calls and about 9ms — most of a frame at 60fps. The same window read a row at a time is 38 calls, and the per-pixel work stays in Lua where it's cheap. Reading the Pokémon mod's windows this way took it from 51fps back to 60 while scanning four times as often.
-- one row of the luma plane, sampled every 6th pixel
local row = frame:region(x0, y, 600, 1)
for i = 1, 600, 6 do
    local luma = row:byte(i)
end
methodframe:setByte(i, v) → nil

Writes one byte of the pixel buffer — this is how you change what's displayed.

ParamTypeNotes
inumber0-based byte index. Out of range is ignored.
vnumberByte value, 0–255.
methodframe:into(buf, at, x, y, w, h [, step, unit]) → number

The same rectangle as region, written into a Buffer you already own instead of into a fresh Lua string. Returns how many bytes landed. Rows are written back to back at w bytes each, so what arrives is packed with any row padding left behind.

ParamTypeNotes
bufBufferDestination. Anything that would land past its end is dropped, not wrapped.
atnumber0-based offset in buf to start writing at.
x, y, w, hnumberAs region — except that with step, w and h describe what is written, not what is read.
stepnumberOptional, default 1. Take every step-th row and every step-th sample across: 2 is half size, 4 is quarter. Nearest sample, not an average.
unitnumberOptional, default 1. Bytes per sample. NV12's chroma plane is interleaved U,V pairs, so halving it across means keeping every other pair — pass unit = 2 for that plane, or the colour comes out sampled off by one and smears.
This is what makes keeping frames possible. A string is immutable, so a buffer of frames is one allocation and one dead object per frame — hundreds of megabytes a second at 1080p, given back whenever the collector decides. Written into a Buffer, a ring is a cursor over bytes overwritten in place: nothing allocated, nothing freed. The same goes for step: shrinking a frame in Lua means a pass over three megabytes, which measured 128ms a frame — eight frames a second — where this is a strided copy costing a couple of ms.
-- a whole NV12 frame, packed, into a slot of your own storage
local rows = h + math.ceil(h / 2)
frame:into(buf, slot, 0, 0, w, rows)

-- the same frame at half size: luma one byte a sample, chroma in U,V pairs
frame:into(buf, slot,            0, 0, w//2, h//2,          2, 1)
frame:into(buf, slot + w//2*h//2, 0, h, w//2, math.ceil(h//2/2), 2, 2)

buffer

A block of mutable bytes. Lua strings are immutable, so anything that holds a lot of data and changes it has to allocate a new copy every time — fine for a few kilobytes, ruinous for megabytes arriving sixty times a second. A Buffer is a fixed-size array you write into and overwrite in place. It is deliberately primitive: a size, a read, a write, a fill. No growth, no types, no structure — whatever a mod wants to keep in it is the mod's business.

Offsets are 0-based throughout, to match Frame. Every access is bounds-checked: a read past the end is short, a write past the end is dropped.

functionbuffer.new(n) → Buffer or nil

A buffer of n zeroed bytes. Returns nil if that much memory isn't available rather than raising — asking for more than the machine has is a normal thing for a mod sizing itself to a user's settings to do, and it needs to be able to back off instead of dying.

methodbuf:size() → number

How many bytes it holds. Fixed for the buffer's life. Takes no parameters.

methodbuf:sub(from, len) → string

Copies len bytes out as a string, from 0-based offset from. Clamped to the end of the buffer, so a request that runs off it returns what exists.

methodbuf:put(at, src) → number

Writes the string src at 0-based offset at. Returns how many bytes were written — less than #src if it hit the end.

methodbuf:fill(v) → nil

Sets every byte to v (0–255).

Writing part of one to disk: data.write and data.append take (name, buf, from, len) as well as a string, so a slice goes to the file without being copied through Lua first.

browser

Show a web page to the user. This is deliberately a command, not a mechanism: your mod says "show this", and the engine decides how. Today it hands the address to the system browser; if the overlay later grows an embedded view, every mod calling this follows automatically with no change. Don't try to open pages any other way.

functionbrowser.open(url) → boolean

Open a web page. Returns true if it was handed off successfully.

ParamTypeNotes
urlstringMust begin http:// or https://. Anything else — a file:// path, a program name, a custom scheme — is refused and returns false, so this can't become a way out of the sandbox. Percent-encode anything exotic in the address yourself.
browser.open("https://bulbapedia.bulbagarden.net/wiki/Pikachu_(Pok%C3%A9mon)")

net

Outgoing TCP, for talking to a program that listens on a port: LiveSplit's server, a stream tool, something on another PC. Nothing connects until the user says so. The first time your mod tries an address, the engine asks the user in a window of its own: Allow, Disallow, or Uninstall mod (after two Disallows, also Block all network for this mod). The answer is remembered for your mod, that address and that port. Another port, another address or another mod is asked about separately. The user can take any answer back in Options → Network.

Connect only. There is no listening and no UDP, so nothing on the network can reach your mod first. Workers have no net.

None of it runs on the frame loop. The engine's own network thread connects, sends, reads, and asks your questions on a timer (conn:watch), and your mod hears about it through onState and onReceive only when something actually happened. A program on the other end that is slow to answer slows down nothing.

functionnet.connect(host, port) → Connection | nil, why

Starts a connection and returns it at once. It never waits: the connection reads "asking" while the user decides and "connecting" until the other side answers. Set onState to be told when that changes. Returns nil and a sentence only when the arguments are not a host and a port. Every other outcome (refused, blocked, too many) is a connection whose state says so.

ParamTypeNotes
hoststringAn address ("127.0.0.1", "::1") or a name ("localhost"). A name is looked up once, off the main thread. The user is asked about the address it resolved to, and the connection goes to that address, so a name pointed somewhere else later cannot take the user's yes with it.
portinteger1 to 65535.

One question at a time. While the user is being asked about one address for your mod, a try at a different address is turned away on the spot (state "denied", nothing remembered). Try it again once the first is answered. Another try at the same address waits on the same answer.

Limits, per mod: 8 connections at once, 5 connects a second, 1 MB waiting to be sent and 1 MB waiting to be read. A connect that has not finished after 5 seconds fails.

local ls = net.connect("127.0.0.1", 16834)    -- LiveSplit Server's default

ls:onState(function(state, why)
    if state == "open" then
        -- Asked four times a second by the engine; you hear only changes.
        ls:watch("getcurrentsplitindex\r\n", 250)
    end
end)
ls:onReceive(function(data)
    print("LiveSplit is now on", data)
end)

-- Later, whenever it is time:
ls:send("split\r\n")
methodconn:state() → string

One of:

StateMeaning
"resolving"Looking up the name.
"asking"Waiting for the user's answer.
"connecting"Allowed, and waiting for the other side.
"open"Connected. Bytes go both ways.
"closed"Closed by you, by the other side, or by the user taking the permission back. What the other side sent last can still be read.
"failed"Could not connect, or broke. error() says why.
"denied"The user said no, blocked your mod, or was still being asked about another address.
methodconn:send(data) → ok, why

Hands the bytes to the engine's network thread, which sends them at once. You may send before the connection is open: the bytes wait and go out once it opens, or are dropped if it never does. Returns false and why once the connection is closed, failed or denied, or when more than 1 MB is already waiting.

ParamTypeNotes
datastringSent as-is. Add your own line ending if the other side expects one (LiveSplit wants \r\n).
methodconn:onReceive(fn)

Calls fn(data) with what arrived, when something arrives, and never otherwise. TCP is a stream, not messages: a reply can arrive in pieces or two at once, so collect until you see your delimiter. While it is set, what arrives goes to it and not to receive(). nil removes it.

methodconn:onState(fn)

Calls fn(state, why) when the state changes, and once straight away with the state it is in now, so a connection that was already refused is still reported. why is what error() would say, or nil. nil removes it.

Both callbacks run on the frame loop, between frames, like everything else in your mod. A function that uses its connection keeps it alive; close() lets go of both callbacks.

methodconn:watch(text, everyMs) → ok, why

For a program that has to be asked what changed, as LiveSplit does. The engine sends text every everyMs on its own clock, reads the answers itself, and passes a line on to onReceive only when it differs from the line before it. Your mod is not called at all while the answer stays the same. The first answer of a new watch always comes through. Call it again to change it, or with nil to stop.

ParamTypeNotes
textstring | nilSent as-is, at most 4096 bytes. nil or "" stops it.
everyMsnumberAt least 20. The first one goes at once, or the moment the connection opens.

Lines are split at \n and compared whole, so the program's answers have to be one per line. What you send yourself in between is answered on the same stream; a different line comes through, the same one does not.

methodconn:repeat(text, everyMs) → ok, why

Like watch, but every answer comes through: for a keep-alive, or when you need each reply. One per connection, shared with watch; the newest call wins.

methodconn:receive() → string

Everything that has arrived since the last call, or "", for a mod that would rather look than be told. Not needed with onReceive.

methodconn:error() → string | nil

Why the connection is closed, failed or denied, in a sentence. nil while nothing went wrong.

methodconn:close()

Closes it. A connection you drop all references to is closed when the collector takes it, and all of your mod's connections close when it is unloaded.

What the user sees: the question, drawn by the engine over everything. Your panels cannot cover it, nothing they click goes through to it, and no key press reaches any mod while it is up. Its buttons ignore clicks for the first moment it is shown, so a question that appears just as the user clicks something else cannot take that click. While any mod holds a connection, a Connected badge shows in the corner: always with the overlay open, and for a few seconds whenever a new connection opens with it closed.

game

Attach a mod to one or more games. Every registered game becomes a cartridge in the row along the bottom of the overlay; only the selected game's mods run, and everything else lies dormant. Clicking the selected cartridge pops it back out (no game selected, no mod running). Hovering a cartridge fans out the mods registered for it, and picking one opens that mod's settings.

functiongame.register(def) → GameMod

Register this mod against a game (or several) and get back the object you hang your callbacks and settings on. Pass a handle string, one definition table, or an array of them — an array means the same mod serves several games (e.g. FireRed and LeafGreen).

Field of a definitionTypeNotes
handlestringStable id for the game, e.g. "pkmn_firered". Two mods using the same handle share one cartridge. "base" is reserved — see below.
namestring?Shown under the cartridge. Defaults to the handle.
imagestring?Path to cartridge art, relative to the app folder (e.g. "addons/"..MOD_NAME.."/cart.png"). When it's missing or unloadable, the engine falls back to the site's own icon for that game (curated games carry one, fetched and cached automatically while any installed mod registers the game), and only then to the name as text.
local g = game.register({
    { handle="pkmn_firered",   name="Pokemon FireRed",   image="addons/"..MOD_NAME.."/fr.png" },
    { handle="pkmn_leafgreen", name="Pokemon LeafGreen", image="addons/"..MOD_NAME.."/lg.png" },
})
game.BASE ("base") is the mod that isn't about a game. Register it and you get no cartridge — there would be nothing to select, since it never stops — and your Think/OnFrame run whatever is or isn't selected. OnActivate fires once, on the first frame the mod exists (so a mod downloaded mid-session starts like one that was there at boot), and OnDeactivate only when the mod is unloaded. Screenshot keys, a clock, a chat overlay: things it would be absurd to switch off by picking a different cartridge. The site lists it as a game too ("Anything not game related. Always active."), so a mod that declares it is filed under it there.
local g = game.register(game.BASE, { name = "Screenshot keys" })
function g:Think() ... end      -- runs always
fieldGameMod.Thinkfunction(self)

Overridable: called once per frame, but only while one of your games is selected (or always, if you registered game.BASE). Dormant mods cost nothing.

Runs at the start of the frame, before the capture card is read — so the newest pixels it can see are the ones OnFrame was handed last frame. That is deliberate: Think sits outside the window the engine has to finish before the display's deadline, so a slow Think costs you nothing but a frame of its own freshness, instead of delaying the video for everyone. Read pixels in OnFrame, decide things in Think.

fieldGameMod.OnFramefunction(self, frame)

Overridable: called with each captured Frame while your game is selected — the gated version of OnFrameReceived. Use it to read or alter pixels only when your game is actually on.

fieldGameMod.OnActivate / OnDeactivatefunction(self)

Overridable: your game was just selected / deselected. Switching between two games that both belong to your mod does not fire these — you stay active.

fieldGameMod.LateUnreliableThinkfunction(self)

Overridable: for work too heavy to finish inside one frame. Offered every frame, but a call that hasn't returned yet keeps running instead of a new one starting — see Threading for what that costs you and how to yield.

methodg:addSetting(key, label, kind, options?, default?) → GameMod

Declare a setting. You only say what it's called, how it should be shown, and what kind of input it is — the engine owns everything else: it stores the value, remembers it between runs, validates it, and builds the settings menu. There is nothing to draw and nothing to save.

ParamTypeNotes
keystringHow you read it back with g:get(key).
labelstringShown in the settings menu.
kindstring"bool" (yes/no), "choice" (pick one), "multi" (pick any number), or "text".
optionsstring[]?The choices, for "choice" and "multi". For a choice, a value outside the list is rejected; for a multi, unknown names are dropped and the rest kept.
defaultany?Used until the user changes it. Defaults to true for bool, the first option for choice, nothing chosen for multi, empty for text.
g:addSetting("show_stats", "Show Pokemon stats", "bool", nil, true)
g:addSetting("route_info", "Show route information", "choice",
             { "on", "off", "overlay only" }, "on")
g:addSetting("badges", "Gym badges you hold", "multi",
             { "Boulder", "Cascade", "Thunder", "Rainbow" })

if g:get("show_stats") then ... end
for _, badge in ipairs(g:get("badges")) do ... end

Reach for "multi" when the answers are not exclusive. A count is not a set: "four gym badges" does not say which four, and in FireRed only four of the eight boost anything at all — so a number can be right about the total and wrong about every effect it is used for.

methodg:get(key) → boolean | string | string[] | nil

Current value of one of your settings — a boolean for "bool", an array for "multi", otherwise a string. nil if you never declared that key.

A "multi" always comes back as an array, in the order the options were declared rather than the order they were ticked — so two equal answers compare equal without sorting first. Nothing chosen is an empty array, never nil. Pass an array back to g:set.

methodg:set(key, value) → nil

Change a setting yourself. Saved immediately; invalid values for a choice are ignored.

functiongame.active() → string | nil

Handle of the selected game, or nil if none is. Takes no parameters.

functiongame.isActive(handle) → boolean

Whether that particular game is the selected one — handy when your mod serves several and needs to tell them apart.

functiongame.select(handle) → nil

Select a game as if its cartridge had been clicked. Fires the relevant OnDeactivate/OnActivate. The choice is remembered and restored next launch. Pass nil to deselect — no game selected is a normal state (clicking the selected cartridge does the same).

functiongame.list() → table[]

Every registered game in registration order, as { handle, name, image }. Takes no parameters.

functiongame.gamesOfMod(modName) → string[]

The handles of the games a given mod registered, e.g. game.gamesOfMod("pkmn_stats") → {"pkmn_firered", "pkmn_leafgreen"}. Used by the engine's upload form to fill itself in from the mod rather than asking you to retype what the mod already declared — handles rather than display names, because the site matches on internal names that look like handles.

ParamTypeNotes
modNamestringAn addon folder name — the same value as MOD_NAME.
functiongame.modsFor(handle) → table[]

The other direction from gamesOfMod: every mod registered against one game, as the objects those mods passed to game.register. This is what the overlay's per-game mod list is built from. The objects are the mods' own, so treat them as read-only unless one is yours.

ParamTypeNotes
handlestringA game handle, e.g. "pkmn_firered".
functiongame.modName(obj) → string | nil

Which addon a registered object belongs to — the folder name, the same value that mod sees as MOD_NAME. Useful for labelling something you got out of modsFor. nil for anything that was not registered.

ParamTypeNotes
objtableAn object from game.modsFor.
functiongame.modSettings(obj) → table[]

The settings a mod declared with addSetting, with their current values — this is what the overlay builds a mod's settings panel out of, and it is why a mod does not have to draw one. Empty for a mod that declared none.

ParamTypeNotes
objtableAn object from game.modsFor.
functiongame.setModSetting(obj, key, value) → nil

Change one of those settings, as if the user had. The value is stored and persisted by the engine and the owning mod reads it back through its own get — so this is the supported way for a settings UI to write, and not a way to reach into another mod's state generally.

ParamTypeNotes
objtableAn object from game.modsFor.
keystringThe setting's key, as declared.
valueanyThe new value, matching the setting's declared type.

camera

Read-only. Everything the capture card reports about itself — its name, the live frame format, and every format it advertises — plus one thing the user tells us: source(). A USB capture card exposes nothing about the game, the source console, or signal presence, so anything content-aware must come from the pixels (via OnFrameReceived); the source label is the one exception, and it's user-declared, not detected. The table is locked: assigning to it or replacing its metatable raises an error.

functioncamera.isOpen() → boolean

Whether a capture device is currently open and streaming. Takes no parameters.

functioncamera.name() → string | nil

The device's name, e.g. "UGREEN-25854" — the card's USB name, not the console or game. nil if no device is open. Takes no parameters.

functioncamera.source() → string | nil

The user-declared label for whatever HDMI device is plugged into this card, e.g. "Nintendo Switch". The card cannot detect this — the user types it in Options → Connected device, and it's remembered per card name. nil until they set it. Useful for source-specific mods: if camera.source() == "Nintendo Switch" then …. Takes no parameters.

functioncamera.width() → number

Width in pixels of the live frame format (0 if none). Takes no parameters.

functioncamera.height() → number

Height in pixels of the live frame format (0 if none). Takes no parameters.

functioncamera.format() → string | nil

Pixel format name of the live frame, e.g. "SDL_PIXELFORMAT_NV12". Matches frame:format(). nil if none. Takes no parameters.

functioncamera.fps() → number

Frame rate of the live format in frames per second (0 if unknown). Takes no parameters.

functioncamera.colorspace() → number

The frame colorspace as SDL's numeric SDL_Colorspace enum value (0 if none). Niche — most mods won't need it. Takes no parameters.

functioncamera.pictureRect() → x, y, w, h

Where the video actually is, in window pixels. The picture is letterboxed: it keeps the card's own shape and is centred, so it does not fill the window unless the two happen to match. Anything you draw on the game — a marker glued to a menu entry, a panel that has to sit beside a health bar — has to map through this rectangle, or it slides into the black bars the moment the window is resized. Anything you draw on the app (a settings panel, a note) wants window coordinates as before and should ignore this. Returns four zeroes until the first frame has been drawn, which means "no picture yet" rather than "the picture is at 0,0". Takes no parameters.

-- a native GBA point, in screen pixels
local px, py, pw, ph = camera.pictureRect()
if pw > 0 then
    local x = px + (gx / 240) * pw
    local y = py + (gy / 160) * ph
end
functioncamera.formats() → table[]

Every format/resolution/framerate the device advertises, as an array of tables. A fresh copy each call — safe to modify. Takes no parameters.

Field of each entryTypeNotes
width, heightnumberResolution in pixels.
fpsnumberFrame rate for this combination.
formatstringPixel format name, e.g. "SDL_PIXELFORMAT_MJPG".
if camera.isOpen() then
    log(camera.name() .. ": " .. camera.width() .. "x" .. camera.height()
        .. " " .. camera.format() .. " @ " .. camera.fps() .. " fps")
    log("advertises " .. #camera.formats() .. " formats")
end

account

Read-only. Who is signed in to their dasmaffin.com account, if anyone. Signing in is the engine's job — there is a Sign in button on the overlay — and a mod cannot start, end, or inspect a session beyond the two properties here. The access and refresh tokens are not reachable from Lua at all: the sandbox is shared between every mod, so exposing them to one would expose them to all. The table is locked: assigning to it or replacing its metatable raises an error.

It is one account across the whole thing: the same one signs in on the website and here, and a person may have several ways in to it (a password, Steam) that all lead to the same id.
functionaccount.signedIn() → boolean

Whether somebody is signed in right now. Everything else here is nil when this is false. Takes no parameters.

functionaccount.name() → string | nil

Their display name, for showing — e.g. greeting them in a HUD. Not an identity: it is user-chosen, changeable, and not unique. nil when signed out or unset. Takes no parameters.

functionaccount.id() → number | nil

The account id, and the only identity there is — not the email address, since one account can have several logins and the address is merely one of them. Key any per-user state a mod stores on this. nil when signed out. Takes no parameters.

-- remember something per signed-in user
local id = account.id()
if id then data.write("prefs_" .. id .. ".txt", "...") end

overlay

The native Steam-style overlay (toggled with Shift+Tab) and its element registry. Everything shown on the overlay — buttons, panels, HUDs — is an Element.

functionoverlay.add(id, spec) → Element

Registers an element under a unique string id and returns the element table (the same spec, now with .id set). Adding with an existing id replaces it.

ParamTypeNotes
idstringUnique; namespace it, e.g. "mymod.panel".
specElementSee Element.
functionoverlay.remove(id) → nil

Removes the element registered under id. Does nothing if no such element exists.

ParamTypeNotes
idstringThe element's unique id (the same string you passed to overlay.add).
functionoverlay.get(id) → Element | nil

Returns the registered element table for id. Mutating the returned table changes the element live.

ParamTypeNotes
idstringThe element's unique id.
returnsThe Element table, or nil if not found.
functionoverlay.toggle() → nil

Toggles the overlay open/closed (same as Shift+Tab). Takes no parameters.

functionoverlay.open() → nil

Opens the overlay. Takes no parameters.

functionoverlay.close() → nil

Closes the overlay (and clears any text-field focus). Takes no parameters.

functionoverlay.isOpen() → boolean

Whether the overlay is currently open. Takes no parameters. Returns a boolean.

functionoverlay.setUserMode(id, mode) → nil

Sets a user override for an element's visibility mode. Ignored for elements whose lockMode is true.

ParamTypeNotes
idstringThe element's unique id.
modestringOne of "menu", "pinned", "hud" (see Element).
functionoverlay.getUserMode(id) → string | nil

Returns the user override mode for an element.

ParamTypeNotes
idstringThe element's unique id.
returnsstring mode, or nil if none is set.
functionoverlay.focus(elemId, childIdx) → nil

Gives keyboard focus to a textbox widget. Useful right after creating an element so the user can type immediately.

ParamTypeNotes
elemIdstringThe id of the panel element that holds the textbox.
childIdxnumber1-based index of the textbox within that panel's children.
Internal: overlay._elements (id → element) and overlay._order (draw order) back the registry. Prefer add/get/remove over touching them directly.
functionoverlay.screenW() → number

Width of the window in pixels — use it to centre or right-align things. Takes no parameters.

functionoverlay.screenH() → number

Height of the window in pixels. Takes no parameters.

functionoverlay.textHeight(text, w, font) → number

How tall text will be once it is wrapped into a box w wide at size font, in pixels. This is the engine's own wrapper, not a rule that matches it — so a row laid out around the answer cannot drift from what gets drawn.

Use it to size a wrapping label, or to grow a row to fit one. A label given too little height loses its tail silently, which reads as the text simply being wrong.

local h = overlay.textHeight(s.label, 336, 14)
kids[#kids+1] = { type = "label", x = 18, y = y, w = 336, h = h, text = s.label }
y = y + math.max(46, h + 14)
functionoverlay.textLines(text, w, font) → number

The same measurement as a line count rather than a pixel height. Useful when the count itself is the decision — "does this still fit on one line?".

functionoverlay.bounds(id) → x, y, w, h

The element's position and size in pixels, with any units resolved for the window as it is now — nil if there is no such element. A paint element placed in units needs it: its self.x is the string it was written as ("5sw"), and this is the number to draw at. In the element's own pixels, the same ones paint draws in, even while it is shrunk to fit.

overlay.add("mymod.hud", { mode = "pinned", x = "2sw", y = "2sh", w = "20sw", h = "6sh",
    paint = function(d, self)
        local x, y, w, h = overlay.bounds(self.id)
        d:rect(x, y, w, h, 20, 24, 34, 220)
    end })
functionoverlay.mouseX() → number

Pointer X, or -1 until the mouse has moved once. Lets an element work out where inside itself the cursor is — which row of a list it is over, say — rather than only that it is somewhere inside. Still reported while the pointer is auto-hidden. Takes no parameters.

Asked from an element's own code — its paint, the "OverlayElement" hook, its mouse callbacks, a widget's onClick — it answers in that element's coordinates, the ones it draws in. An element shrunk to fit a small window (see Fitting the window) therefore compares the pointer against its own numbers and gets the right row. Anywhere else it is window pixels.

functionoverlay.mouseY() → number

Pointer Y, or -1 until the mouse has moved once — in the asking element's coordinates, exactly as mouseX. Takes no parameters.

Element

The table you pass to overlay.add. Every element has an id, a visibility mode, and exactly one visual form: a button, a paint callback, or a panel of widgets.

fieldmodestring = "menu"

Controls when the element is visible:

ModeOverlay openOverlay closed
"menu"✓ shown— hidden
"pinned"✓ shown✓ shown
"hud"— hidden✓ shown
fieldlockModeboolean = false

When true, the element's mode is forced and overlay.setUserMode is ignored for it. When false, the user may override the mode.

fieldx, y, w, hlength

Where the element is and how big. A length is a number of pixels, as it always was, or a string with a unit — on an element, and on every widget and row inside a panel, and for a style's font:

WriteMeans
120  /  "120px"Pixels.
"50%"Of the parent: for a widget, the panel (or row) it sits in; for an element that names a parent, that element; otherwise the window. Widths and x are a percent of the parent's width, heights, y and font of its height. An element's "%" position counts from its parent's corner.
"30sw"Percent of the window's width (like CSS vw): "100sw" is the whole width.
"5sh"Percent of the window's height (like CSS vh).

Units resolve every frame, so a panel written in them follows the window as it is resized. Anything that isn't a number or one of these counts as missing. The engine never rewrites them — except that a user dragging or resizing the panel turns its position or size into plain pixels from then on, because that is what the user chose.

overlay.add("mymod.bar", {
    mode = "pinned", x = "5sw", y = "80sh", w = "90sw", h = "12sh",
    children = {
        { type = "label",  x = "2%", y = "20%", text = "Splits", style = { font = "30%" } },
        { type = "button", x = "70%", y = "15%", w = "28%", h = "70%", text = "Reset" },
    },
})
fieldfitboolean = true

Fitting the window. An element bigger than the window is drawn shrunk, as a whole, until it fits — text, gaps and borders together, one scale for both directions, so nothing is squashed. Too narrow a window and it shrinks to the width with room left above and below; too short and it shrinks to the height with room left and right. It is never drawn bigger than you made it. It keeps its centre where it can and is then pulled back inside the window, which also brings back an element that is merely hanging off an edge.

Your code does not change for it: x, y, w, h and every widget keep the numbers you wrote, paint draws in those same numbers, and the clicks, the caret, the x, y handed to OnMouseDown / OnMouseUp / OnWheel and overlay.mouseX / mouseY all come back in them. An element with a parent is drawn with its parent's fit. Set fit = false to draw at your own numbers whatever the window — for something that is meant to hang off the edge.

fieldhiddenboolean

Reset to false each frame, then read after the OverlayElement hook — set it there to hide the element for that frame.

fieldpassThroughboolean = false

Mouse behaviour of the element's rectangle. false (default): the element consumes mouse events over its bounds — it receives the callbacks below, and elements beneath it get nothing. true: the element (including its widgets) ignores the mouse entirely and events fall through to the next element under the cursor. Elements are tested topmost-first (last drawn = on top), so the highest non-passThrough element under the cursor is always the one that receives events.

fieldmovableboolean

Whether the user can drag the element around by its background. A press only starts a drag if it missed every child widget and then travelled a few pixels, so buttons, textboxes and plain clicks all work normally. Menu-column buttons are never movable — the engine owns that column's layout.

The default depends on who draws the element. A panel (one with children) is drawn by the engine at its own x, y, so dragging it moves what you see: movable defaults to true, and false nails it down. A paint element draws wherever its own callback decides, so a drag would move its clickable area and nothing else — it defaults to false. Set movable = true to opt in, and draw from the self your paint is handed rather than from a position you compute, or the two will part company.

The drag is stored as an offset from whatever position your code asks for, and applied after the OverlayElement hook: a panel that recentres itself every frame stays centred-plus-the-user's-offset instead of snapping back, and one that sets x, y only once stays put. Either way the engine writes the final on-screen position back into x, y, so you can read it (and save it) after the fact.

fieldresizableboolean = false

Whether the user can resize the element by dragging its edges or corners (an 8 px grab zone just inside the border; the mouse cursor changes to show it). Off by default, because most panels are laid out for the size their mod chose. An edge grab beats a child widget that reaches into that zone, and dragging the left or top edge moves the panel as well as sizing it, so the opposite edge stays put. The new size is written into w, h; children are yours to re-place — see OnResize.

fieldminW, minHnumber = 120, 80

Smallest size a user resize may reach. Only consulted when resizable is true. The maximum is the render size.

fieldhoveredboolean

Maintained by the engine: true while this element is the current mouse-hover target, false otherwise. Read-only in spirit — useful inside paint to restyle on hover without writing callbacks.

fieldOnHoverEnterfunction(self)

Overridable callback (GMod-panel style; nil = no-op): called once when the mouse moves onto this element.

Callback argTypeNotes
selfElement (table)The element itself.
fieldOnHoverExitfunction(self)

Overridable callback: called once when the mouse leaves this element (or another element takes the hover).

Callback argTypeNotes
selfElement (table)The element itself.
fieldOnMouseDownfunction(self, x, y, button)

Overridable callback: a mouse button was pressed on this element (and no child widget took the click — widgets inside a panel get first crack, left button only).

Callback argTypeNotes
selfElement (table)The element itself.
x, ynumberPress position in pixels, relative to the element's top-left corner — in its own pixels, even while it is drawn shrunk to fit the window.
buttonnumber1 = left, 2 = middle, 3 = right.
fieldOnWheelfunction(self, x, y, dy)

Overridable callback: the mouse wheel turned over this element — how a long list scrolls. Delivered topmost-first to the first element under the pointer that defines OnWheel, so a plain panel drawn on top cannot swallow the scroll of one underneath it. Unlike clicks this fires with the overlay closed as well, because a pinned list is on screen during play. dy is positive when the wheel goes up (away from you), matching "scroll towards the top of the list".

Callback argTypeNotes
selfElement (table)The element itself.
x, ynumberPointer position in pixels, relative to the element's top-left corner.
dynumberWheel movement; > 0 is up/away, < 0 is down/towards you.
local top = 1
overlay.add("mymod.list", {
    mode = "pinned",
    OnWheel = function(self, x, y, dy)
        top = math.max(1, top - (dy > 0 and 3 or -3))
    end,
    paint = function(d) --[[ draw rows starting at `top` ]] end,
})
fieldOnMouseUpfunction(self, x, y, button)

Overridable callback: the button was released. Sent to the element that received the OnMouseDown (capture semantics — a drag that ends outside the element still notifies it).

Callback argTypeNotes
selfElement (table)The element itself.
x, ynumberRelease position in pixels, relative to the element's top-left corner — in its own pixels, the ones its w, h are in, even while it is drawn shrunk to fit.
buttonnumber1 = left, 2 = middle, 3 = right.
Bounds required: mouse events use the element's x, y, w, h. Panels always have them; a paint element only takes part if you give it explicit bounds. Pinned / hud elements receive mouse events even while the overlay is closed — and keyboard input too, once one of their textboxes has been clicked into.
local pnl = overlay.add("mymod.box", {
    mode = "pinned", x = 40, y = 40, w = 180, h = 90,
    children = { { type = "label", x = 10, y = 10, text = "drag me" } },
})
function pnl.OnMouseDown(self, x, y, button) log("down at " .. x .. "," .. y) end
function pnl.OnMouseUp(self, x, y, button)   log("released")                  end
function pnl.OnHoverEnter(self) self.style.border = {120,200,255,255} end
function pnl.OnHoverExit(self)  self.style.border = {200,200,215,255} end
fieldOnResizefunction(self, w, h)

Overridable callback: the user is resizing this element (resizable = true). Fires live, on every step of the drag, after the new size has been written into self.w, self.h — re-place your children here so the panel looks right while it is being pulled.

Callback argTypeNotes
selfElement (table)The element itself.
w, hnumberThe new size, already clamped to minW, minH and the render size.
fieldOnDragEndfunction(self)

Overridable callback: a move or a resize finished. Fires once, on release, and only if the element actually moved or changed size — a click that merely landed on the background does not count. This is the place to persist geometry: read self.x, self.y, self.w, self.h and write them out, rather than saving on every step of the drag.

Callback argTypeNotes
selfElement (table)The element itself.
local note = overlay.add("mymod.note", {
    mode = "pinned", x = 24, y = 56, w = 260, h = 178,
    resizable = true, minW = 150, minH = 96,   -- movable is already on
    children = {
        { type = "button",  x = 230, y = 6,  w = 22,  h = 20,  text = "x" },
        { type = "textbox", x = 10,  y = 32, w = 240, h = 136, multiline = true },
    },
})
function note.OnResize(self, w, h)
    self.children[1].x = w - 30            -- button stays on the right edge
    self.children[2].w, self.children[2].h = w - 20, h - 42
end
function note.OnDragEnd(self)
    data.write("note.txt", ("%d %d %d %d"):format(self.x, self.y, self.w, self.h))
end
fieldbuttontable { label, onClick }

Makes the element an auto-laid-out button in the centered overlay menu column. Button elements are ordinary elements underneath: they inherit passThrough, hovered, and the OnHoverEnter / OnHoverExit / OnMouseDown / OnMouseUp callbacks, and the engine writes the computed x, y, w, h back onto the element each frame so you can read where the button ended up.

FieldTypeNotes
labelstringButton text.
onClickfunctionCalled (no arguments) on left-click, after the element's own OnMouseDown.
fieldpaintfunction(d, self)

Makes the element a free-form drawing. Called every frame it's visible with a DrawContext d and the element itself. Paint elements have no built-in input handling.

self.x, self.y, self.w, self.h are final for this frame here — the "OverlayElement" hook has run and any drag the user is in the middle of is already in them. That makes this the one place a movable paint element can draw where it actually is; ignore self and draw from your own numbers instead, and the drag will move the element's clickable area out from under the picture.

Callback argTypeNotes
dDrawContextThe drawing surface.
selfElement (table)The element itself, with this frame's geometry.
fieldchildrenWidget[]

Makes the element a panel: a styled box at x, y, w, h containing native Widgets (positioned relative to the panel). Requires x, y, w, h; accepts a style.

FieldTypeNotes
x, y, w, hlengthPanel position & size: pixels, or "50%" / "30sw" / "5sh".
styletable?{ bg = {r,g,b,a?}, border = {r,g,b,a?} }
childrenWidget[]Array of Widgets.

Widgets

Native, styleable controls placed inside a panel element's children. Colors are Lua arrays {r, g, b} or {r, g, b, a} (0–255); font is the text height in pixels. All positions are relative to the parent panel. Positions, sizes and font are lengths: pixels, or "50%" of the panel (or row), "30sw" / "5sh" of the window.

Every widget type accepts hidden = true, which skips both its drawing and its click area. Use it to show different rows in different states rather than rebuilding children — a rebuild throws away whatever is half-typed into a textbox.
Every widget type also accepts tip = "…": a sentence or three explaining what that control does, shown in a card beside the panel while the pointer rests on the widget. Beside rather than under the cursor because the reader is usually comparing the tip against the row it describes, and a box over the next row hides exactly that. \n\n starts a paragraph; the card sizes and places itself, flipping to the panel's other side when there is no room. The engine's own Video Options uses it for the settings whose names cannot carry their meaning — "Grab late (VSync)" being the honest worst case.
widgettype = "label"

Static text. One line at a point by default; give it w and h and it wraps at word boundaries inside that box instead, clipping when it runs out of height. Size h for the longest text the label can be given — at font f each line costs f + 3, and the last one fits while it starts at or before h - f. Undersize it and the tail vanishes with nothing to show for it; the engine logs label doesn't fit its w/h to the developer console when that happens.

FieldTypeNotes
x, ynumberPosition within the panel.
w, hnumber?Optional. Both set = wrap inside this box. Omit for a single unwrapped line.
textstringThe text to show. \n starts a new line when wrapping.
hiddenboolean?Skip drawing entirely.
styletable?{ fg = {…}, font = number }
widgettype = "row"

A layout container: give it a height and a list of children, and the engine places them. Each child keeps its own x and w, and the row assigns its y — vertically centred, with a label measured at the width it will actually wrap to rather than assumed to be one line. A child is never placed above the row's top: one taller than its row starts at the top and clips there instead of bleeding into the row above.

The row draws its own background, which is what makes list striping a property of the row rather than a rectangle somebody has to keep aligned with it. Rows are layout, not widgets: they take no index of their own, so a child's position in children — for overlay.focus, for tab order — is the same number whether or not you later wrap it in one.

Use a row for anything with a left label and controls on the right. The alternative is computing each child's y by hand, and a hand-computed offset is a guess that silently goes wrong the first time a title wraps to a second line.

FieldTypeNotes
x, y, w, hlengthWhere the row sits in the panel, and how tall it is. Children are positioned inside it, and their "%" is of the row.
childrentableWidgets. Their y is the engine's — anything you set is overwritten.
hiddenboolean?Hides the row and everything in it.
styletable?{ bg, border, bgImage }. Omit bg for no background. Theme section: row.
-- a striped list: only the band differs between rows
for i = 1, ROWS do
    kids[#kids+1] = {
        type = "row", x = 12, w = 400, h = 34,
        style = { bg = (i % 2 == 0) and {255,255,255,12} or nil },
        children = {
            { type = "label",  x = 8,   w = 260, h = 34, text = "" },
            { type = "button", x = 290, w = 100, h = 26, text = "Go" },
        },
    }
end
-- ...and at layout time the panel sets ONE number per row:
row.y = y ; y = y + 34
widgettype = "rect"

A filled rectangle. No text, no interaction, and no click area — a press goes straight through to whatever is under it, so it can sit behind other widgets without stealing their clicks. It exists for lists: children draw in the order they are added, so a rect added just before a row's own widgets becomes that row's background band. Striping every other row is what lets the eye carry a long title across to the buttons that belong to it — the engine's own My mods and Download mods lists use it exactly that way.

FieldTypeNotes
x, y, w, hlengthPosition & size within the panel.
hiddenboolean?Skip drawing entirely.
styletable?{ bg = {…}, border = {…}, bgImage = "path" }. The border is only drawn when you give it one; the fill defaults to a faint white wash that reads on any panel colour. Theme section: rect.
-- every other row gets a band, added BEFORE that row's widgets
for i = 1, ROWS do
    if i % 2 == 0 then
        kids[#kids+1] = { type = "rect", x = 12, w = 400, h = 30,
                          style = { bg = {255,255,255,12} } }
    end
    kids[#kids+1] = { type = "label",  x = 16, w = 260, h = 30, text = "" }
    kids[#kids+1] = { type = "button", x = 290, w = 100, h = 26, text = "Go" }
end
widgettype = "button"

A clickable button with a hover highlight.

FieldTypeNotes
x, y, w, hlengthPosition & size within the panel.
textstringLabel, centered.
onClickfunctionCalled on left-click.
styletable?{ bg, bgHover, border, fg, font }
widgettype = "textbox"

An editable text field. Click it (or call overlay.focus) to focus; the engine handles typing, the blinking caret, and editing keys natively. Read/write its current contents via the text field.

FieldTypeNotes
x, y, w, hlengthPosition & size within the panel.
textstringCurrent contents (updated live as the user types).
caretnumber?Caret position as a byte offset into text (0 = before the first character). Maintained by the engine; if you replace text yourself, set it too (or leave it — it is clamped and snapped to a character boundary). Defaults to the end.
selnumber?Selection anchor, a byte offset like caret; the selected span is everything between the two. nil (or equal to caret) means nothing is selected. Maintained by the engine — set it yourself only to pre-select something.
placeholderstring?Shown dimmed when empty and unfocused.
disabledboolean?Draw the box dimmed and refuse focus: it is shown, not offered. Clicking it drops focus like clicking the panel background, and Tab skips it. Use this rather than hidden when the field's label still explains what would go in it.
multilineboolean?If true, Enter inserts a newline; otherwise Enter defocuses.
passwordboolean?Draw one * per character instead of the text. text still holds the real value.
onChangefunction?Called with the new text whenever it changes.
hiddenboolean?Skip drawing and hit-testing entirely.
styletable?{ bg, fg, border, font }
Editing keys: typing inserts at the caret; a click puts the caret where you clicked. Backspace/Delete remove around the caret, ←/→ move it, Home/End jump within the current line, ↑/↓ change line in a multiline box (keeping the column), Ctrl+V pastes at the caret (capped at 4096 characters, replacing the selection if there is one), Tab jumps to the next textbox in the same panel (wrapping round, skipping hidden ones), Enter = newline (multiline) or defocus, Esc = defocus. Tab order is the order the widgets appear in children. Only textboxes take focus — buttons can't be pressed from the keyboard, so tabbing onto one would be a dead end. Text input requires the app window to have OS keyboard focus.
Focus decides, not the overlay. A textbox in a pinned or hud panel can be clicked into while the overlay is closed, and it keeps receiving keys there: the panel is on screen during gameplay, so it is typeable during gameplay. While a field holds focus the keystrokes are text and nothing else — "KeyPress" keybinds do not fire from the letters being typed, and Esc lets go of the field instead of closing the program. Click elsewhere or press Esc to give it back.
Overflow scrolls instead of clipping: a single-line box slides horizontally to keep the caret in view, a multiline box scrolls by lines — the caret pulls its line on screen when it moves, the mouse wheel scrolls the box under the pointer, and small corner ticks mark more text above or below. No field for any of this: the engine owns it.
Selecting: drag through the text, or hold Shift while pressing an arrow / Home / End, or Ctrl+A for all of it. The selection is drawn as a highlight behind the glyphs and follows the pointer even if it leaves the field. Typing or pasting replaces it, Backspace/Delete remove it, an unshifted move collapses it. Ctrl+C copies and Ctrl+X cuts.
Copy and cut are refused on a password field, deliberately: the field most in need of pasting is a password one, and putting a password back onto the clipboard is a step in the wrong direction. Pasted control characters are dropped, and newlines survive only in a multiline box.
Use password = true for any secret. This app exists to put a screen in front of other people, and often a recording of it — a visible password field is on camera by definition. Masking is the engine's job, so don't substitute your own characters into text: you'd break what the user is actually typing.
widgettype = "combo"

A dropdown. Shows the selected option; clicking opens a list of options (drawn on top of everything), and picking one collapses it.

FieldTypeNotes
x, y, w, hlengthPosition & size of the collapsed box within the panel.
optionsstring[]The list of choices (a Lua array of strings).
selectednumber1-based index of the current choice. Updated for you when the user picks.
onSelectfunction?Called as onSelect(index, value) when a choice is made.
styletable?{ bg, fg, border, font }
Lists longer than 12 options are paged, with a < 3/17 (196) > row under the list — click either half to turn the page. Opening the dropdown jumps to the page holding the current selection. You don't have to do anything for this; hand it as many options as you like.
widgettype = "multi"

The same dropdown with more than one answer. The collapsed box names what is chosen — as many names as fit, then +2 for the rest, so the first names are still the answer and the number is only what is missing from it. The open list gives every option a tick box, and clicking one toggles it and leaves the list open — picking several things is the reason this exists, and closing after each would make it a slower "combo". Click outside to close. Paged the same way past 12 options.

FieldTypeNotes
x, y, w, hlengthPosition & size of the collapsed box within the panel.
optionsstring[]Everything that can be picked.
valuesstring[]What is picked now. Rewritten for you on every toggle, always in options order whatever order they were clicked in.
onChangefunction?Called as onChange(values) with the new array.
placeholderstring?Shown dimmed when nothing is chosen. Defaults to "None".
styletable?{ bg, fg, border, placeholder, font }
A mod's own settings get this for free from addSetting(…, "multi", options) — you only need the widget directly when you are building a panel of your own.
widgettype = "slider"

A draggable value bar. Click or drag the track to set the value.

FieldTypeNotes
x, y, w, hlengthPosition & size within the panel.
min, maxnumberValue range. Default 0 and 1.
valuenumberCurrent value. Updated for you as the user drags.
onChangefunction?Called as onChange(value) while dragging.
styletable?{ bg, fill, border }
A panel with a label, a textbox, and a button:
overlay.add("mymod.form", {
    mode = "menu", x = 40, y = 60, w = 260, h = 120,
    style = { bg = {30,32,42,235}, border = {120,130,160,255} },
    children = {
        { type = "label", x = 12, y = 10, text = "Name:",
          style = { fg = {230,230,240}, font = 16 } },
        { type = "textbox", x = 12, y = 34, w = 236, h = 30,
          placeholder = "type here",
          onChange = function(t) log("name = " .. t) end },
        { type = "button", x = 12, y = 74, w = 100, h = 32, text = "OK",
          onClick = function() overlay.close() end,
          style = { bg = {56,60,78}, bgHover = {74,80,104} } },
    },
})

DrawContext

The drawing surface d passed to an element's paint callback. Pixel coordinates; colors are separate r, g, b arguments (0–255) with optional a (default 255). Use d:w() / d:h() for responsive layout, like GMod's ScrW() / ScrH().

methodd:w() → number

Current screen width in pixels.

methodd:h() → number

Current screen height in pixels.

methodd:rect(x, y, w, h, r, g, b, a?) → nil

Fills a rectangle.

ParamTypeNotes
x, ynumberTop-left corner, in pixels.
w, hnumberWidth & height, in pixels.
r, g, bnumberColour channels, 0–255.
anumber (optional)Alpha 0–255. Defaults to 255 (opaque).
methodd:outline(x, y, w, h, r, g, b, a?) → nil

Draws a 1px rectangle outline.

ParamTypeNotes
x, ynumberTop-left corner, in pixels.
w, hnumberWidth & height, in pixels.
r, g, bnumberColour channels, 0–255.
anumber (optional)Alpha 0–255. Defaults to 255.
methodd:line(x1, y1, x2, y2, r, g, b, a?) → nil

Draws a line between two points.

ParamTypeNotes
x1, y1numberStart point, in pixels.
x2, y2numberEnd point, in pixels.
r, g, bnumberColour channels, 0–255.
anumber (optional)Alpha 0–255. Defaults to 255.
methodd:text(x, y, str, size, r, g, b, a?) → nil

Draws text with the built-in font.

ParamTypeNotes
x, ynumberTop-left of the text, in pixels.
strstringThe text to draw.
sizenumberText height in pixels (each glyph is ~size wide).
r, g, bnumberColour channels, 0–255.
anumber (optional)Alpha 0–255. Defaults to 255.
methodd:image(path, x, y, w, h) → boolean

Draws a PNG or JPEG scaled into the given rectangle. Returns true if it drew, false if the image couldn't be loaded — check the result and fall back to text rather than assuming it worked. Images are loaded once and cached, so calling this every frame is cheap (the first call does the disk read).

ParamTypeNotes
pathstringPath relative to the app folder, e.g. "addons/" .. MOD_NAME .. "/cart.png". Absolute paths and .. are refused.
x, ynumberTop-left corner, in pixels.
w, hnumberSize to scale the image to, in pixels.
if not d:image("addons/" .. MOD_NAME .. "/logo.png", 20, 20, 64, 64) then
    d:text(20, 20, "MyMod", 16, 230, 230, 240)   -- fallback
end

shader

Run your own pixel shader over the captured picture before it is drawn: anti-aliasing, sharpening, a colour fix, a CRT look. The engine knows none of these. It runs the shader your mod hands it, on the GPU, at the card's own resolution, before the picture is scaled into the window, so your shader sees the console's real pixels and never a stretched copy.

You ship it compiled. Write it in HLSL, compile it once with fxc (Microsoft's shader compiler, part of the Windows SDK), and put the .cso file in your mod. MCCP contains no shader compiler. It hands your bytes to Direct3D, which checks them.

Windows, Direct3D 11. On any other renderer shader.available() is false and shader.load says why, so a mod can leave its setting off instead of failing. While no shader is on, nothing extra runs at all. Workers have no shader.

functionshader.available() → boolean

Whether frame shaders can run here.

functionshader.load(path) → Shader | nil, why

Loads a compiled pixel shader and switches it on at once. Shaders run one after another, in the order they were loaded, across all mods: each one reads what the one before it wrote. Returns nil and a sentence when the file is missing, is not a compiled shader, is refused by Direct3D, or your mod already has 4.

ParamTypeNotes
pathstringLooked up like your mod's images ("addons/mymod/fx.cso", also inside a packaged mod). Inside the app's folder only. At most 1 MB.

Keep a reference. A shader your mod drops all references to is removed when the collector takes it, and all of your mod's shaders go when it is unloaded.

local fx, why = shader.load("addons/mymod/fx.cso")
if not fx then print("no shader: " .. why) end
methodfx:set(slot, value) → ok, why

Hands your shader a number. Slots 1 to 32 land in mccp_params: slot 1 is mccp_params[0].x, slot 2 .y, slot 5 mccp_params[1].x, and so on. Every slot starts at 0 and keeps its value until you set it again, so set them when your settings change, not every frame.

methodfx:enable(on) → boolean

Switches it on or off without unloading it. Off costs nothing, and it keeps its place in the order.

methodfx:remove()

Unloads it. The object does nothing afterwards.

guideWriting one

A shader is one main function that returns the colour of one pixel. It gets the picture as t0 and two samplers: s0 blends between pixels, s1 takes the nearest one. Copy this header exactly, because the engine fills it in this layout:

cbuffer MCCP : register(b0) {
    float2 mccp_size;       // the picture, in pixels (1920, 1080)
    float2 mccp_texel;      // one pixel in uv: 1 / mccp_size
    float  mccp_time;       // seconds since MCCP started
    float  mccp_frame_no;   // pictures drawn through shaders so far
    float2 mccp_pad;
    float4 mccp_params[8];  // fx:set(1..32)
};
Texture2D    t0 : register(t0);   // the picture
SamplerState s0 : register(s0);   // linear, clamped at the edges
SamplerState s1 : register(s1);   // nearest, clamped at the edges

// pos: this pixel's centre in pixels. uv: 0..1, top-left is (0, 0).
float4 main(float4 pos : SV_Position, float2 uv : TEXCOORD0) : SV_Target {
    float strength = mccp_params[0].x;                    // fx:set(1, ...)
    float3 c = t0.Sample(s1, uv).rgb;
    float3 g = dot(c, float3(0.299, 0.587, 0.114));
    return float4(lerp(c, g, strength), 1.0);             // fade to grey
}

Compile it with fxc, which is in C:\Program Files (x86)\Windows Kits\10\bin\<version>\x64\:

fxc /nologo /O3 /T ps_5_0 /E main /Fo fx.cso fx.hlsl

Colours are as the card sends them (0 to 1, not linearised). The alpha you return is ignored. ps_4_0 works too.

What it changes: the picture in the window, and the clean and overlay screenshots, which are taken from the window. It does not change what mods read from the frame (Frame, buffer, and recordings made from them) or the raw screenshots, which stay the card's own pixels.

data

Per-mod file storage. Every call is sandboxed to data/<MOD_NAME>/ — a mod can only read and write inside its own folder. Absolute paths and .. are rejected.

functiondata.write(name, contents) → boolean

Writes contents to name (overwriting), creating sub-folders as needed.

ParamTypeNotes
namestringFile path relative to your folder, e.g. "save.txt" or "sub/x.dat".
contentsstringData to write (may contain binary bytes).
returnsboolean — true on success; false if the write failed or the path was rejected.
functiondata.read(name) → string | nil

Reads a file back.

ParamTypeNotes
namestringFile path relative to your folder.
returnsstring with the contents, or nil if it doesn't exist / can't be read.
functiondata.append(name, contents) → boolean

Appends contents to the end of name (creating it if needed).

ParamTypeNotes
namestringFile path relative to your folder.
contentsstringData to append.
returnsboolean — success.
functiondata.write / append(name, buf, from, len) → boolean

Both take a slice of a Buffer instead of a string. The bytes go from the buffer to the file with no copy at all — where data.append(name, buf:sub(from, len)) would build a Lua string of the whole slice first, which for anything big is the entire cost of the write, and the garbage afterwards.

ParamTypeNotes
bufBufferSource.
fromnumber0-based offset to start at.
lennumberHow many bytes. Clamped to the end of the buffer.
functiondata.exists(name) → boolean

Whether a file exists in your data folder.

ParamTypeNotes
namestringFile path relative to your folder.
returnsboolean.
functiondata.delete(name) → boolean

Deletes a file.

ParamTypeNotes
namestringFile path relative to your folder.
returnsboolean — whether a file was removed.
functiondata.list() → string[]

Lists your saved files. Takes no parameters. Returns a Lua array (table) of string relative paths of all files in your data folder, recursively.

functiondata.path() → string

The absolute path of your data folder (informational). Takes no parameters. Returns a string.

Persist and restore a value:
local score = tonumber(data.read("score.txt")) or 0
score = score + 1
data.write("score.txt", tostring(score))
log(MOD_NAME .. " score is now " .. score)

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.

ParamTypeNotes
pathstringRelative 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.

ParamTypeNotes
jobanyCopied, 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(...) (colours included, as for a mod) 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

Sandbox

Mods run deny-by-default. Only the standard Lua listed below is available, plus the engine APIs on this page. There is no raw filesystem, process, or code-loading access — write files through data. All mods share one global environment (globals, functions, and hooks cross mod boundaries — like GMod); only data, MOD_NAME, MOD_VERSION, MOD_SOURCE, log, print, LOG_LEVEL, clipboard, net and shader are per-mod.

availableStandard Lua

print, pairs, ipairs, next, type, tostring, tonumber, select, error, assert, pcall, xpcall, setmetatable, getmetatable, rawget, rawequal, rawlen, and the string, table, math, coroutine, utf8 libraries.

limitedos

A time-only subset: os.time, os.date, os.clock, os.difftime. (No execute, remove, rename, getenv, exit.)

limitedcollectgarbage

Only the verbs that ask, never the ones that tune: "collect", "step" (with an optional size in KB) and "count". A mod that has just released a lot of memory — a recorder finishing a take — can hand it back at a moment of its choosing instead of waiting for the collector to notice. "setpause", "setstepmul", "incremental", "generational" and "stop" are refused: the sandbox is shared, so one mod retuning the collector would retune it for every mod in the process.

blockedio · load · loadfile · dofile · package

Removed entirely. These are the usual ways to escape a sandbox — file I/O goes through data, and there is no runtime code loading.

limitedrequire

Opens native modules - DLLs in bin/, reviewed before anyone can download them - and nothing else. It does not load Lua files.

blockedrawset

Removed, while rawget, rawequal and rawlen stay. camera and account are read-only faces: an empty table whose metatable resolves reads through __index, refuses writes through __newindex, and hides itself so setmetatable cannot reach it. rawset walked straight past __newindex, and a raw key on the empty face shadows __index — so one mod could have replaced account.signedIn for every mod in the shared sandbox. The read-only raw accessors cannot forge anything and are untouched.

engine onlyauth · settings · video · audio · capture · console · upload · network

These exist, but not for mods. They change the user's device, window, volume, installed mods or sign-in state, and auth additionally holds the session tokens — in a sandbox shared by every mod, one of these reachable would be all of them reachable. network is what the user answered about every mod's connections, and its forget would let a mod ask again forever. The slices mods do get are camera (what the card reports), account (who is signed in), and sound.play (one-shot playback that follows the user's volume, never changes it).

Native mods

A native mod is a DLL, for what the engine cannot do yet at all, or for what a Lua mod can do but too slowly - reading text off the screen, say. It is a module, the way Garry's Mod binary modules are: a Lua mod says require("name") and calls its functions like any other. Lua is the only thing it sees. It never talks to the engine, so what a module can reach inside MCCP is what a Lua mod could already reach, plus its own code.

A mod is either Lua or native, never both: the module and the Lua mod that uses it are two mods, the Lua one listing the native one under dependencies. Native mods run on Windows (x64) for now.

filemod.txt

The source folder (or the repository's root) holds a mod.txt that says what it is and which version this is. The version is stated once, here, and is what the submission goes in as.

kind    = native
version = 1.0.0

Without kind = native a folder is a Lua mod and is packed and published as one.

A GitHub repository does not need one. MCCP looks at the repository as soon as you have typed its address: if it has a mod.txt at the top, that is used and the panel says which version it states; if not, a box appears holding a mod.txt to edit, filled in to start from, and that is what the version is read from. A repository's own mod.txt always wins over a typed one.

exportMCCP_MODULE_OPEN() · MCCP_MODULE_CLOSE()

Everything a module needs is one header, mccp_module.h. Nothing to link: MCCP hands the module an interface of virtual functions (mccp::Lua, the LUA in every function), so it never touches Lua's own library and keeps working when MCCP updates its Lua.

MCCP_MODULE_OPEN runs on the first require of the module, not when MCCP starts: build what require returns - usually a table of functions - and return how many values you pushed. MCCP_MODULE_CLOSE is optional and runs once at exit, while Lua is still whole, so references you kept can be freed there. A function Lua can call is a LUA_FUNCTION: read its arguments, push its results, return how many.

Here is the whole way through: Lua passes a frame in, the module reads its pixels in place and answers with a string. Put an OCR library where the comment is and it is a text reader.

// pixels.cpp - built as pixels.dll, dropped in bin/
#include "mccp_module.h"
#include <cstdio>
#include <string>

// pixels.read(frame) -> a string about the frame
LUA_FUNCTION(Read) {
    mccp::Frame f;
    if (!LUA->ToFrame(1, &f)) return LUA->ArgError(1, "frame expected");

    // f.data points at the frame itself: no copy was made. This is where
    // an OCR library would read f.width x f.height pixels, f.pitch apart.
    std::string text = std::to_string(f.width) + "x" + std::to_string(f.height) +
                       " " + f.format + ", first pixels:";
    for (int i = 0; i < 8; ++i) text += " " + std::to_string(f.data[i]);

    LUA->PushString(text.c_str(), text.size());
    return 1;                                     // one value back to Lua
}

MCCP_MODULE_OPEN() {                              // the first require("pixels")
    LUA->CreateTable();
    LUA->PushCFunction(Read);
    LUA->SetField(-2, "read");
    return 1;                                     // what require gives back
}

Build it as a 64-bit DLL (MSVC or clang-cl) with the header on the include path. A DLL your module needs can sit beside it in bin/: its own folder is searched first.

functionrequire(name) → any

Opens bin/<name>.dll as a module and returns what its open returned (true if it returned nothing). The first call opens it; every later one, from any mod, returns the same value. The name is the DLL's file name without .dll, in any case.

-- addons/reader/lua/autorun/reader.lua
local pixels = require("pixels")                  -- bin/pixels.dll

hook.Add("OnFrameReceived", "reader", function(frame)
    print(pixels.read(frame))
    -- 1920x1080 SDL_PIXELFORMAT_NV12, first pixels: 16 16 16 17 16 16 16 16
end)

Only native modules can be required. Lua files can't: put them in lua/autorun. When there is nothing to open, the error says why - not in bin/, downloaded and waiting for the restart, switched off by the crash guard, not a module, or built for a newer MCCP.

interfacemccp::Lua

What LUA-> offers. It follows Lua's own C API, so its documentation answers the details.

Top() · Pop(n) · Push(idx)How many values are on the stack; remove n; push a copy of one. Indices work as in Lua: 1 is the first argument, -1 the top.
GetType(idx) · IsType(idx, type) · GetTypeName(type)Lua's type numbers, as mccp::TNUMBER, mccp::TSTRING…; past the top is TNONE.
GetNumber · GetInteger · GetBool · GetStringRead without failing: the wrong type is 0 or nullptr. GetBool is Lua's truth (only nil and false are false). GetString(idx, &len) handles zero bytes; its pointer lives while the value stays on the stack.
CheckNumber · CheckInteger · CheckStringRead an argument that must be there: the wrong type fails the call with Lua's own wording (bad argument #2 to 'add' (number expected, got string)). Return right away after one fails; until then they give 0 or "", never a null pointer.
PushNil · PushBool · PushNumber · PushInteger · PushString(s, len?) · PushCFunction(fn)Values for Lua. PushString with a length takes any bytes, zeros included.
CreateTable · GetField(idx, key) · SetField(idx, key) · GetTable(idx) · SetTable(idx)Tables, metamethods included. SetField sets t[key] to the top value and pops it; GetTable and SetTable take the key (and value) from the top, as in Lua.
PushGlobals()The mods' shared globals, the same _G every Lua mod sees.
Call(nargs, nresults)Call Lua: the function, then its arguments, on top. Always protected: true and the results, or false and the error message. nresults -1 keeps them all.
ReferenceCreate() · ReferencePush(ref) · ReferenceFree(ref)Keep a Lua value between calls (a callback, a table): create pops it and returns a number to push it back by. Free it when you are done.
ArgError(arg, msg) · Error(msg)Fail the call with a Lua error, which the calling mod can pcall. They return 0 rather than jumping, so write return LUA->ArgError(1, "...").
ToFrame(idx, &frame) · ToBuffer(idx, &buffer)MCCP's own values, without a copy. A OnFrameReceived frame gives data, size, width, height, pitch and format (writing the bytes changes what is shown, as frame:setByte does); a buffer.new buffer gives data and size. false for anything else, and for a frame kept past its hook.
Log(text)A line in MCCP's log, under the module's name.
rulesThreads, errors, crashes

Only on the thread that called you. LUA is valid during the call it was handed to and nowhere else: never keep it, never use it from a thread of your own. A module function runs while a frame waits for it, so slow work belongs on your own thread, with a function Lua can poll for the result:

// Slow work on your own thread; Lua asks for the answer.
LUA_FUNCTION(Start) { /* copy the pixels you need, hand them to a worker */ return 0; }
LUA_FUNCTION(Poll)  {
    std::string done;
    if (!takeResult(done)) return 0;              // nothing yet: Lua gets nil
    LUA->PushString(done.c_str(), done.size());
    return 1;
}

Frames and buffers are valid only during the call that got them. Copy what a worker thread needs before you return.

Errors from ArgError, Error or a failed Check* end the call with a Lua error once your function returns - nothing jumps out of your code, so its destructors run. A C++ exception that leaves your function is caught and becomes a Lua error too.

Crashes - an access violation, a division by zero, a stack overflow - are caught as well, in every call into a module: open, its functions and close. The mod that called gets a Lua error (pixels.dll crashed (access violation)) and MCCP keeps running. The module is then switched off until MCCP restarts: after a crash its memory can't be trusted, so it is not called again, not even to close. A crash on a thread the module started itself can't be caught - nothing called it, so there is nowhere to return to - and takes MCCP down.

filemccp_module.h

The header, whole. It also ships as docs/mccp_module.h beside this reference. Its interface only grows at the end, and MCCP_MODULE_API counts it: a module built against an older header keeps working with a newer MCCP, and one built against a newer header than the MCCP it runs in is not opened.

// mccp_module.h - write a native module for MCCP. The only file you need.
//
// A native module is a DLL that Lua mods load with require() and call like any
// other function, the way Garry's Mod binary modules work:
//
//     #include "mccp_module.h"
//
//     LUA_FUNCTION(Add) {
//         LUA->PushNumber(LUA->CheckNumber(1) + LUA->CheckNumber(2));
//         return 1;                                  // values returned to Lua
//     }
//
//     MCCP_MODULE_OPEN() {                           // on the first require
//         LUA->CreateTable();
//         LUA->PushCFunction(Add); LUA->SetField(-2, "add");
//         return 1;                                  // what require() gives back
//     }
//
//     MCCP_MODULE_CLOSE() { return 0; }              // optional; at exit
//
// and in a Lua mod:
//
//     local mymod = require("mymod")                 -- bin/mymod.dll
//     print(mymod.add(1, 2))                         -- 3
//
// Nothing to link: MCCP hands the module an interface (mccp::Lua) of virtual
// functions, so a module never touches Lua's own library and keeps working
// when MCCP updates its Lua.
//
// THE RULES
//   * Only on the thread that called you. LUA is valid during the call it was
//     handed to, on that thread, and nowhere else: never keep it, never use it
//     from a thread of your own. Do slow work on your own thread and let Lua
//     ask for the result (a poll function), or the frame waits for you.
//   * Errors: ArgError/Error, or a Check* that fails, end the call with a Lua
//     error once your function returns - they do not jump out of it, so return
//     right away (`return LUA->ArgError(1, "...")`). A C++ exception that
//     leaves your function is caught and becomes a Lua error too.
//   * Frame and Buffer pointers are valid only during the call that got them.
//   * Stack indices work like Lua's: 1 is the first argument, -1 the top.
//
// The interface only ever grows at the end, and MCCP_MODULE_API counts it. A
// module built against an older header keeps working with a newer MCCP; one
// built against a newer header than the MCCP it runs in is not loaded.
#pragma once
#include <cstddef>
#include <cstdint>

#define MCCP_MODULE_API 1

namespace mccp {

// Lua's own type numbers.
enum Type {
    TNONE = -1, TNIL = 0, TBOOL = 1, TLIGHTUSERDATA = 2, TNUMBER = 3, TSTRING = 4,
    TTABLE = 5, TFUNCTION = 6, TUSERDATA = 7, TTHREAD = 8
};

// A captured frame, as OnFrameReceived hands it to Lua. The bytes are the
// frame itself, not a copy: writing them changes what is shown, exactly as
// frame:setByte() does.
struct Frame {
    uint8_t*    data;     // the pixel buffer
    size_t      size;     // bytes in it (on NV12: the luma plane, then chroma)
    int         width;    // pixels
    int         height;   // pixels
    int         pitch;    // bytes per row of the first plane
    const char* format;   // e.g. "SDL_PIXELFORMAT_NV12"
};

// A Lua mod's buffer.new(n): bytes it owns, read and written in place.
struct Buffer {
    uint8_t* data;
    size_t   size;
};

class Lua;
typedef int (*CFunc)(Lua* LUA);

class Lua {
public:
    // ---- the stack ---------------------------------------------------------
    virtual int  Top() = 0;                        // number of values on it
    virtual void Pop(int n = 1) = 0;
    virtual void Push(int idx) = 0;                // a copy of the value at idx

    // ---- types -------------------------------------------------------------
    virtual int         GetType(int idx) = 0;      // a mccp::Type
    virtual const char* GetTypeName(int type) = 0;
    virtual bool        IsType(int idx, int type) = 0;

    // ---- reading -----------------------------------------------------------
    // Get* never fail: the wrong type reads as 0 / nullptr, and GetBool is
    // Lua's truth (only nil and false are false). Numbers read as strings and
    // numeric strings as numbers, as in Lua. GetInteger truncates 2.7 to 2.
    // GetString's pointer lives as long as the value stays on the stack.
    virtual double      GetNumber(int idx) = 0;
    virtual long long   GetInteger(int idx) = 0;
    virtual bool        GetBool(int idx) = 0;
    virtual const char* GetString(int idx, size_t* len = nullptr) = 0;
    // Check* fail the call (see THE RULES) when the type is wrong, and return
    // 0 / "" meanwhile - never nullptr - so the code up to your return is safe.
    virtual double      CheckNumber(int idx) = 0;
    virtual long long   CheckInteger(int idx) = 0;
    virtual const char* CheckString(int idx, size_t* len = nullptr) = 0;

    // ---- pushing -----------------------------------------------------------
    virtual void PushNil() = 0;
    virtual void PushBool(bool v) = 0;
    virtual void PushNumber(double v) = 0;
    virtual void PushInteger(long long v) = 0;
    // Any bytes, zeros included, when len is given; else up to the first zero.
    virtual void PushString(const char* s, size_t len = static_cast<size_t>(-1)) = 0;
    virtual void PushCFunction(CFunc f) = 0;

    // ---- tables ------------------------------------------------------------
    virtual void CreateTable() = 0;
    virtual void GetField(int idx, const char* key) = 0;   // pushes t[key]
    virtual void SetField(int idx, const char* key) = 0;   // t[key] = top; pops it
    virtual void GetTable(int idx) = 0;                    // key on top -> t[key]
    virtual void SetTable(int idx) = 0;                    // t[key] = value; pops both
    virtual void PushGlobals() = 0;                        // the mods' shared _G

    // ---- calling Lua -------------------------------------------------------
    // The function, then nargs arguments, on top. Protected: true and
    // nresults values, or false and the error message.
    virtual bool Call(int nargs, int nresults) = 0;

    // ---- keeping a value between calls (a callback, a table) ---------------
    virtual int  ReferenceCreate() = 0;            // pops the top, returns its ref
    virtual void ReferencePush(int ref) = 0;
    virtual void ReferenceFree(int ref) = 0;

    // ---- errors ------------------------------------------------------------
    // Both return 0 so they can be returned. The call ends in a Lua error.
    virtual int ArgError(int arg, const char* msg) = 0;
    virtual int Error(const char* msg) = 0;

    // ---- MCCP's own values -------------------------------------------------
    virtual bool ToFrame(int idx, Frame* out) = 0;    // false: not a live frame
    virtual bool ToBuffer(int idx, Buffer* out) = 0;  // false: not a buffer
    virtual void Log(const char* text) = 0;           // MCCP's log, under your name

protected:
    ~Lua() = default;   // MCCP owns it; a module never deletes one
};

}  // namespace mccp

#ifdef _WIN32
#define MCCP_EXPORT extern "C" __declspec(dllexport)
#else
#define MCCP_EXPORT extern "C" __attribute__((visibility("default")))
#endif

// A function Lua can call: LUA_FUNCTION(Name) { ...; return <values pushed>; }
#define LUA_FUNCTION(name) static int name(mccp::Lua* LUA)

#define MCCP_MODULE_OPEN()                                          \
    MCCP_EXPORT int mccp_module_api() { return MCCP_MODULE_API; }  \
    MCCP_EXPORT int mccp_module_open(mccp::Lua* LUA)

#define MCCP_MODULE_CLOSE() MCCP_EXPORT int mccp_module_close(mccp::Lua* LUA)
workflowSubmitting for review

You upload the source, never a DLL. In Upload a mod, pick the folder in addons/ (its .gitignore is honoured the way git would, so build output stays home) or a public GitHub repository (a private one is refused: add the files instead, or make it public). For a repository, the newest commit of the branch in the URL is what is reviewed, and its mod.txt is read at that commit (or typed into the panel, when it has none). A changelog is required: it is the reviewer's first read and the changelog entry once approved.

A reviewer reads the source and builds the DLL themselves. Prebuilt binaries in the source are fine to leave in for them, but expect to be asked where they came from, and to have them rebuilt and replaced. Nobody can download the mod until a build is approved, and you cannot publish it before that. You get an email when it is approved or declined; reviewers get one for every new submission.

A decline comes with a reason. Answer it from the mod's Edit panel: reply in words, reply pointing at the repository's newest commit (GitHub submissions), or submit new source. Once approved, Publish on the same panel makes it public. Submitting a new version starts a new review; until it is approved, the last approved build is what people download, and public stays public.

folderbin/

Native mods live in bin/ beside addons/, never in it. Downloads are checked against the hash of the reviewer's build before anything is kept, then wait as <name>.dll.new until the next start; a DLL in use is never replaced under itself. Uninstalling one that is running removes it at the next start.

Restart. A DLL loads at startup or not at all. A download that brings one restarts MCCP by itself, once, after everything it came with has arrived: the native mod first in the chain, the Lua mods that need it after it, then a single restart.

Crash guard. If MCCP goes down while loading a DLL, the next start leaves that DLL off (bin/<name>.disabled) and says so in the log and in My mods. A new version of it clears that, and so does deleting the file.

Testing your own. Put your build in bin/ by hand and start MCCP. It loads like any other, and the log notes it did not come from the site.