SCUA

How-to

Embed SCUA in a host program

SCUA is built to run inside another program: a game engine, a server, a tool. The host owns the process, the files, and the network; SCUA runs scripts and holds their state. This page shows how a C (or C-compatible) host drives the runtime: creating state, saving and loading it, running scripts, and exposing host functions to those scripts.

Everything here comes from the public C header include/scua.h and the worked example examples/embed.c, which builds, persists, reloads, and scripts a partition end to end.

#The model

You link one static library (libscua.a) and include one header (include/scua.h). The runtime is written in Zig, but a host only ever sees C. Explicitly marked experimental sections of the header are not yet stable ABI commitments.

For packed native results, the experimental output-buffer recipes cover fixed-length buffers, single-use prefix output, owned C allocations and callback-safe copying. Retained C-owned resources still use userdata and binding-specific update functions, not array adoption.

This page covers the "run a script, read the results" model — the right fit for a tool or a request handler. If your host is a game engine that owns the frame loop and wants to call into the script each frame (update(dt), draw()), read Drive a script from your engine after this one: it builds on everything here and adds the host→script call and the durable-handle API.

#Building against the library

Compile your host with include/ on the include path and link the static library with your own C/C++ toolchain. The default library is self-contained (no other dependencies) — with a few platform notes. The optional -Dffi=true pilot additionally links libffi; see Call native libraries.

# macOS
cc -std=c11 -Iinclude examples/embed.c lib/libscua.a -o embed

# Linux — add -lm (the runtime uses the C math library)
cc -std=c11 -Iinclude examples/embed.c lib/libscua.a -lm -o embed

# Windows — link ws2_32 (the runtime uses Winsock); the library is lib/scua.lib
cl /I include examples\embed.c lib\scua.lib ws2_32.lib

./embed

(If you have this language's source checkout rather than the distribution package, zig build embed builds and runs the same example through the build system instead.)

The unit of state is a partition: a self-contained graph of tables, arrays, and scalars. The host reads and writes it by path, runs scripts against it, and can serialize the whole thing to a flat blob. At this layer the host never holds a live pointer into the partition. You address data by /-separated path string, which keeps the host safe across garbage collection and reloads (the data can move in memory without invalidating anything you hold, because you hold nothing).

#Create and free a partition

For native objects containing C-side state, the source checkout also has an experimental runtime-owned userdata API and paired C/SCUA example: run zig build embed-userdata. See Bind runtime-owned userdata for its lifecycle and current limits.

The source checkout also has experimental checked buffer sessions for compiled bindings: zig build embed-access runs examples/embed_access.c. It demonstrates zero-staging-copy F64 output, host read spans, copied error diagnostics, save refusal while borrowed, and automatic cleanup of a deliberately leaked native session. This path needs no libffi. It is P1 validation, not a supported binding generator.

Prepare handles and convert arguments before scua_access_begin_native; acquire exact-kind spans, call C, then always scua_access_end before constructing results or calling SCUA. No allocation, script entry, persistence or resource destruction is allowed during the interval, even through another partition on that thread. Native leaks become faults after automatic release; host sessions require explicit release. Writes are not rolled back on failure. See the experimental section of include/scua.h for token, overlap, thread, alignment and diagnostic rules.

After releasing a session, a native callback can use the experimental scua_native_error_message(ctx, status, message, length) to attach copied detail. It can enrich the first matching error once; later errors cannot replace it. Supply valid UTF-8: the helper reads at most 512 bytes and truncates long messages at a character boundary with .... Keep secrets out of messages. Empty detail retains the static fallback. The private prefix-output builder used in runtime tests is not a public C API.

For callback-capable compiled calls, the private zig build embed-callback callback-exercises fixture demonstrates an explicit scratch snapshot and copied fresh output. No session or managed pointer crosses script re-entry. Callback mutations affect the original, not the snapshot; faults discard output without rolling back side effects. See the source checkout's examples/callback-exercises.md for its abort policy and limits.

#include "scua.h"

scua_partition *p = scua_partition_new();
// ... use p ...
scua_partition_free(p);

#Read and write data by path

Writes create any missing intermediate tables. Reads safe-navigate: a path that resolves to nothing returns SCUA_ERR_NOT_FOUND rather than failing.

scua_set_str(p, "player/profile/name", "Ed", 2);
scua_set_int(p, "player/profile/level", 42);
scua_set_float(p, "player/stats/health", 99.5);
scua_set_bool(p, "player/flags/admin", 1);

int64_t level = 0;
scua_get_int(p, "player/profile/level", &level);   // level == 42

int64_t missing = 0;
scua_get_int(p, "player/profile/missing", &missing); // returns SCUA_ERR_NOT_FOUND

Integers are exact 64-bit, so a large id round-trips without loss. Strings copy into a buffer you provide; scua_get_str always reports the full length, so on SCUA_ERR_RANGE you can resize and call again:

char name[16];
size_t name_len = 0;
scua_get_str(p, "player/profile/name", name, sizeof(name), &name_len);
name[name_len] = '\0';

#Save and load

This is the part that makes SCUA worth embedding for stateful work. scua_persist compacts the partition into a flat blob. The host owns those bytes and writes them wherever it likes (a file, a database, a network message). SCUA itself never touches the filesystem. scua_load reinstates a partition from the blob, at a new address, with no fix-up step:

uint8_t *blob = NULL;
size_t blob_len = 0;
scua_persist(p, &blob, &blob_len);
// write blob[0..blob_len] to disk however you want
scua_partition_free(p);

scua_partition *q = scua_load(blob, blob_len);   // reinstated, possibly in a new process
scua_blob_free(blob, blob_len);

The blob is position-independent: cycles and shared references inside the partition come back intact, so you can save a player on one machine and resume them on another without writing any serialization code. That is the property the whole design is built around. See Core concepts for why it holds.

When the bytes pass through storage you don't fully control, sign them: give the partition your deployment's keys with scua_set_blob_keys, and scua_persist signs the save; load it with scua_load_keyed, which refuses a save that changed after it was written. See Signing saves with your deployment's keys.

Records in the partition come back as the same record types, with their methods, once you load a script over them with scua_load_script: the blob records each record type it holds by name, and the script's declarations are matched against it. If the script changed one of those records (a field added, removed or retyped, or the record renamed), scua_load_script returns SCUA_ERR_FORMAT and its error text names the record and both field lists. See Hibernate a session.

#Run a script against the partition

A script sees the partition's data as a global called world, the same graph the host reads and writes by path. So the host sets inputs, runs the script, and reads the results back:

char errbuf[128];
scua_eval_file(q, "examples/embed_script.scua", 0, 0, errbuf, sizeof(errbuf));
// the script read `world` and wrote a result the host now reads:
int64_t score = 0;
scua_get_int(q, "player/profile/score", &score);

A script's return value comes back at the path __return:

const char *expr = "let lvl = get(world, \"player/profile/level\")\nreturn lvl * 2\n";
scua_eval(q, expr, strlen(expr), 0, 0, errbuf, sizeof(errbuf));
int64_t ret = 0;
scua_get_int(q, "__return", &ret);   // ret == 84

scua_eval and scua_eval_file are protected calls. A lexer, parser, compile, or runtime error returns a non-SCUA_OK code and writes a located message (line N: ...) into errbuf; the host shows it and carries on. A bad script can't crash the host:

const char *broken = "set(world, \"oops\", n + missing_var)\n";
if (scua_eval(q, broken, strlen(broken), 0, 0, errbuf, sizeof(errbuf)) != SCUA_OK)
    fprintf(stderr, "script error: %s\n", errbuf);

A script can also stop on purpose. sys.exit(0) returns SCUA_OK. A failure the script chose returns SCUA_ERR_FAILED: sys.fail(reason), sys.exit(n) with n not 0, or ? or return Error(e) at the script's top level. errbuf then holds line N: and the reason, or exit N for a bare sys.exit. For a top-level Error, __return holds the Error's value, so the paths __return/kind and __return/message read with the usual accessors. Nothing is written to the host process's stderr. SCUA_ABI_VERSION is 1 from this release, because these calls used to return SCUA_OK.

#Bound an untrusted script

Two arguments — mem_cap and op_budget — let the host cap what a script may consume. Their 0 values mean different things, so read this carefully for untrusted input:

  • mem_cap = 0 means unlimited (no memory bound). So for an untrusted script you must pass a positive mem_cap — the memory guard is off by default.
  • op_budget = 0 means the built-in finite default — the CPU kill-switch is always armed even at 0.

mem_cap caps the extra live memory a script may hold, counting every actor the script creates as well as its top-level code. Over it, the call returns SCUA_ERR_NOMEM instead of letting the script exhaust the host:

// a script that tries to grow without bound is stopped at the cap, not OOM-killed:
scua_eval(q, hog_src, strlen(hog_src), 64 * 1024, 0, errbuf, sizeof(errbuf)); // SCUA_ERR_NOMEM

op_budget caps the reduction steps (loop iterations and calls) any single turn may run. When a script exceeds it, an uncatchable kill-switch stops it with SCUA_ERR_BUDGET — so a runaway loop is bounded in CPU, not just memory, and can't wedge the host thread:

const char *spin = "while true do end\n";
scua_eval(q, spin, strlen(spin), 0, 100000, errbuf, sizeof(errbuf)); // SCUA_ERR_BUDGET

The default op_budget (selected by 0) is a large but finite ceiling, so the kill-switch is always armed even when you don't set one. Lower it for untrusted input; raise it for a script you trust to run a long, legitimate computation. For a trusted, host-owned loop that runs unbounded work each frame (a game's update/draw), pass SCUA_OP_BUDGET_UNLIMITED to disarm the kill-switch entirely — a host-only, explicit choice (a script can never reach this argument, so it can't disarm its own budget). The budget is per turn, so it also bounds each actor or coroutine turn the script spawns, not only its top-level body.

#Let scripts call host functions

The host can expose a C function under a name; the script then calls it like any other function. The function receives a context it queries for arguments and sets a result on, plus a userdata pointer you supplied at registration:

static void host_add(scua_ctx *ctx) {
    int64_t *calls = (int64_t *)scua_userdata(ctx);
    if (calls) (*calls)++;
    scua_return_int(ctx, scua_arg_int(ctx, 0) + scua_arg_int(ctx, 1));
}

int64_t calls = 0;
scua_register(q, "host_add", host_add, &calls);
scua_eval(q, "set(world, \"sum\", host_add(40, 2))\n", /*len*/ 32, 0, 0, errbuf, sizeof(errbuf));
// world.sum == 42, and calls == 1

Host functions are not part of a saved partition (they're raw process addresses), so after scua_load you re-register them on the new partition.

Registering a function on a partition is what gives that partition the right to call it. Every actor the script starts takes that right from whoever started it, so its handlers can call your functions too. A sandbox the script starts with sandbox.actor or sandbox.run never has it: it cannot reach your functions by name, cannot be sent one, and a call that somehow reached one would be refused with cannot call a host function here.

Which thread a host function runs on. Unless you turn workers on, everything a partition's script does — its top level, and every actor handler and task it starts — runs on the thread that called into SCUA (scua_eval, scua_load_script, scua_pcall and the rest), one piece at a time. So a host function is only ever called on that thread, never from two threads at once for one partition, and it can read and change your engine's state without locking. The one way to get two at once is yours: if you drive two partitions from two threads and register the same function on both, it can run on both together. A host that wants its frame to bound the script's work, or its own clock to pace it, drives the drains itself, and one that wants the script's partitions on several cores turns workers on; a function registered with scua_register_module still runs only on your thread then.

scua_arg_bool and scua_to_bool give a value's plain truthiness: 0 for nil and false, 1 for anything else. That includes an Ok/Error Result and a flags set, which a script's own if refuses with an error. A C function can't raise that error, so these two keep the plain rule. When you need to know whether a Result succeeded, look at the value itself rather than its truthiness; the simplest way is to have the script hand you r matches Ok, a real boolean.

When a binding exposes many functions (a whole graphics or audio library), register them under a namespace instead of as bare globals, so scripts call module.name and your hundreds of functions don't crowd the global namespace:

scua_register_module(q, "gfx", "draw_rectangle", host_draw_rectangle, NULL);
// the script calls it as a field of the module:
scua_eval(q, "gfx.draw_rectangle(0, 0, 64, 32)\n", /*len*/ 33, 0, 0, errbuf, sizeof(errbuf));

A registered module is read-only to scripts, like a built-in module: gfx.draw_rectangle = nil stops with an error, in the main program and in every actor, because every piece of code that uses gfx shares the one record. If you want scripts to have a table they can change, pass a plain table as a value instead (for example with scua_set_* into world) rather than registering a module.

The entry script reaches gfx by name directly. Code in an imported module brings it in the same way it would the standard library — import gfx — so you can organize host-API-using script code across files instead of cramming it all into the entry:

-- draw.scua
import gfx
fn scene() gfx.draw_rectangle(0, 0, 64, 32) end
return { scene = scene }

Imports resolve relative to the entry file's own directory. When the modules live elsewhere, a package's examples/ directory importing the module in its parent, say, add the directory with scua_add_module_path before running the script. It is the host-side twin of the CLI's --mod-path:

scua_add_module_path(q, "/path/to/sqlite-package");    /* tried after the entry file's own directory */
scua_eval_file(q, "/path/to/sqlite-package/examples/query.scua", 0, 0, errbuf, sizeof(errbuf));

Directories are tried after the entry file's own directory, in the order you add them. A module found in two of them is refused with both named, as on the command line, rather than one silently winning. The list is host-side state like a grant: it is not part of a save, so add it again after scua_load. Pass NULL to clear it.

#Use a project's dependencies

When the script belongs to a project with dependencies (a scua.toml and a deps/ directory that scua-pkg filled), point the host at the project with scua_use_project. Imports then resolve through the project's lock exactly as they do when you run the script with scua:

char err[512];
if (scua_use_project(q, "/path/to/my-project", err, sizeof(err)) != SCUA_OK) {
    fprintf(stderr, "project refused: %s\n", err);    /* e.g. E-RECORD-HASH-MISMATCH: … */
    return 1;
}
if (scua_eval_file(q, "/path/to/my-project/main.scua", 0, 0, err, sizeof(err)) != SCUA_OK)
    fprintf(stderr, "%s\n", err);                     /* e.g. E-FILE-HASH-MISMATCH: … */

Every dependency is checked the way the command line checks it: the lock has to be readable, each package's release record has to match what the lock recorded, and each file a script imports has to match its record. A file that has been changed since it was fetched is refused with E-FILE-HASH-MISMATCH and never runs. The error codes and messages are the command line's own.

  • Return codes. SCUA_OK when the project is in use. A project with no deps/ or no lock is also SCUA_OK: it has nothing vendored, which is how scua treats it too. SCUA_ERR_NOT_FOUND when the path is not a directory. SCUA_ERR_FORMAT when the lock or a release record is refused, with the reason in err. A failed call leaves no project in use, so the imports then fail to resolve rather than resolving unchecked.
  • A changed file is reported by the scua_eval or scua_eval_file that imports it: SCUA_ERR_FORMAT, with E-FILE-HASH-MISMATCH in that call's error buffer.
  • Order. The entry file's directory first, then the project's dependencies, then any scua_add_module_path directories.
  • Calling it again replaces the project in use; NULL stops using one. Like the module path list, it is not part of a save: call it again after scua_load.

⚠️ Don't reach a dependency with scua_add_module_path("…/deps/…"). It finds the same files but checks none of them, so a vendored file someone edited after it was fetched runs without a word, where scua_use_project and the command line both refuse it.

A project's vendored dependencies are not used when you embed. The scua CLI reads deps/scua-deps.lock and resolves each locked package from deps/<scope>/<name>/, checking every file against the hash its release recorded. The embedding API does neither, so an import of a vendored package that works under scua fails in a host with "cannot resolve module". Until it can, add each locked package's directory yourself:

scua_add_module_path(q, "/path/to/project/deps/scua/jsonschema");   /* one per locked package */

⚠️ That finds the same files but skips the check the CLI makes on them. Under scua, a vendored file that no longer matches the hash its release recorded is refused with E-FILE-HASH-MISMATCH. Added as a module path, the same tampered file runs, silently. Only point a host at a vendored tree you know has not been modified since it was fetched.

#Expose constants

A binding often has named integer constants too — key codes, blend modes, flags. Bind them as fields of the same module record with scua_register_consts, and the script reads gfx.KEY_SPACE as a plain field — no call, no setup:

static const scua_const gfx_keys[] = {
    { "KEY_SPACE", 32 }, { "KEY_RIGHT", 262 }, { "KEY_LEFT", 263 },
};
scua_register_consts(q, "gfx", gfx_keys, sizeof(gfx_keys) / sizeof(gfx_keys[0]));
scua_eval(q, "set(world, \"k\", gfx.KEY_SPACE)\n", /*len*/ 31, 0, 0, errbuf, sizeof(errbuf));
// world.k == 32

There are single-value forms too (scua_register_const_int / _float / _bool). Constants are scalars only by design (a number carries no authority into the sandbox), and they aren't part of a save — re-register them after scua_load, just like host functions. Registering a name already taken under the module (a function or another constant) is rejected rather than silently overwritten. In a hot loop, hoist a constant into a local (let RIGHT = gfx.KEY_RIGHT) the same way you would local floor = math.floor in Lua.

If you ship a declarations file so the editor type-checks gfx.*, include the constants there too — scua_write_decls emits a typed const NAME: <type> = value line for each (int/float/bool, matching how it was registered), which keeps the closed gfx namespace from flagging gfx.KEY_SPACE as an unknown field.

#Hand the script an opaque resource

Sometimes a host function needs to give the script a native resource — a texture, a font, an open handle — that the script holds and passes back to later calls but never looks inside. Register a resource type (with an optional finalizer that frees it), return it from one function, and accept it in another:

static const scua_HostType TextureType = { "Texture2D", unload_texture /* finalizer */ };

static void host_load_texture(scua_ctx *ctx) {        // returns an opaque handle
    Texture2D *t = load_texture_somehow(...);
    scua_return_handle(ctx, t, &TextureType);
}
static void host_draw_texture(scua_ctx *ctx) {        // takes it back, type-checked
    Texture2D *t = (Texture2D *)scua_arg_handle(ctx, 0, &TextureType);
    if (t) draw(t);
}

From the script's side the value is opaque: hold it in a variable or table, pass it to host functions that accept it — that's all. type() on it returns "value", and any other operation (field access, arithmetic) faults. The raw pointer never lives in script memory; SCUA keeps it host-side and hands the script a tagged handle, so a saved partition never carries a stale pointer (re-create resources after scua_load, like host functions).

#Give your editor the host API (declarations)

By default your editor can't see host functions — they're registered from C, so hovering gfx.draw_rectangle in a script shows nothing and the type checker treats gfx as untyped. You fix that with a declarations file: a tooling-only description of your host API that the language server and checker read but never run (the model is TypeScript's .d.ts / Lua's ---@meta).

The easiest way to produce one is to let the host emit it from its own registry. Register your natives with a signature and a one-line doc, then call scua_write_decls:

scua_register_module_doc(p, "gfx", "draw_rectangle", host_draw_rectangle, NULL,
    "draw_rectangle(x: int, y: int, w: int, h: int, color: Color)",  /* signature (no `fn`) */
    "Draw a filled rectangle.");                                     /* one-line doc        */
/* ... register the rest ... */
scua_write_decls(p, "gfx", "decls/gfx.scua");   /* writes a --!declare file for the `gfx` module */

That writes a file like:

--!declare gfx
-- Draw a filled rectangle.
fn draw_rectangle(x: int, y: int, w: int, h: int, color: Color) end

Point your project's scua.toml at it:

[tooling.declarations]
gfx = "decls/gfx.scua"

(The key on the left is just a free label; the namespace a script actually sees comes from the --!declare gfx marker inside the file.)

Now, in any script in that project, gfx.draw_rectangle(...) hovers with its signature and doc, jumps to its declaration, and is type-checked — a wrong argument count or type is flagged in the editor. The script is unchanged (it still calls the bare gfx global your host registers), and nothing about runtime changes: declarations never compile or run, and a project without them simply gets the old untyped behaviour. (Add record declarations to the file for any host types your signatures mention, like Color above — by hand, or extend your generator.)

For an opaque resource the script holds but can't look inside — a texture, a sound, a socket, a database connection (the opaque handles above) — declare a handle type. It has no fields and no constructor; the script only passes it back to your functions:

--!declare gfx
handle Texture                                  -- an opaque host resource
fn load_texture(path: string) -> Texture end
fn draw_texture(tex: Texture, x: int, y: int) end

Then load_texture returns a Texture, draw_texture only accepts one, and the editor flags passing the wrong handle type (a Sound where a Texture is wanted) or reading a field off it (tex.width) — mirroring what the runtime already enforces, with no runtime cost. Each handle type is distinct by name.

A declarations file is for tooling only: running one with scua is refused (it's not a program), and it grants no capabilities or authority — it only tells the editor what your host API looks like.

#The script/host boundary

A SCUA script cannot open a file, read stdin, make a network call, or read environment variables. There is no ambient I/O in the language, on purpose. Everything a script touches comes from one of two places: the world data the host set, or a host function the host registered. So a script is sandboxed by default, and the host decides exactly what it can reach.

For a host, that means the integration pattern is always the same: pull whatever the script needs into world (or expose it as a registered function), run the script, then read the results back out. The host does the I/O; the script does the logic. This is what lets the same script run unchanged on a server, in a tool, or inside a game, and it's why an untrusted script can't reach past the data you handed it.

The script does get the pure standard library — math, rand (seeded, deterministic), str, json, the exact-number modules, and so on. Those are safe by construction: no clock, no filesystem, no network. What it does not get is anything that touches the outside world, unless you grant it — which is the next section.

#Grant a capability: file access

When a script genuinely needs to read or write files, you grant it the fs capability, rooted at a directory. After that the script can import fs and read/write within that directory — and only within it. Without the grant, import fs is a compile error, so file access can never appear in a script you didn't deliberately enable.

scua_grant_fs(q, "/var/data/tenant-42");   // root the capability at one directory

const char *src =
    "import fs\n"
    "match fs.read(\"config.json\")\n"
    "  Ok(text) -> set(world, \"config\", text)\n"
    "  Error(why) -> set(world, \"config_error\", why)\n"
    "end\n";
scua_eval(q, src, strlen(src), 0, 0, errbuf, sizeof(errbuf));

The script reaches config.json under the granted root, but fs.read("../other-tenant/secret") comes back as an Error — .. and absolute paths are rejected, so it cannot climb out. Root a different partition at a different directory and neither can reach the other's. Pass "" to revoke.

The grant is host-side state: it is not saved by scua_persist. After scua_load, re-grant the capability (just as you re-register host functions) — capabilities are dropped on persistence and re-bound by the host on reactivation, on purpose: authority is a live decision the host makes, not something baked into a blob. See Read and write files for the fs surface from the script side.

fs isn't the only capability. Two more grants cover the network, and every grant follows the same default-deny, re-grant-after-load rule as fs:

  • scua_grant_net(p, hosts) grants the outbound net capability — the http client (http.get/http.post) and net.dial. hosts is a comma-separated allowlist ("api.example.com,cdn.example.com"), "" for any host, or NULL to revoke. This is the embedder's side of --allow-net.

    scua_grant_net(q, "api.example.com,cdn.example.com");   // outbound HTTP/TCP to these hosts
    scua_grant_net(q, "");                                   // any host
    scua_grant_net(q, NULL);                                 // revoke
  • scua_grant_serve(p, bind) grants the separate serve capability — the ability to listen: http.serve, net.listen, net.accept. bind names the address the script may bind ("127.0.0.1:8080"), "*" for any bind, or "" to revoke. Listening is deliberately its own authority: scua_grant_net never confers it, so a script that can call out still can't open a port unless you say so.

    scua_grant_serve(q, "127.0.0.1:8080");   // the script may bind exactly this address

    Grant serve only when the script itself opens the port. More often the host owns the socket — accepting connections in its own reactor — and drives the script's handler per request through the normal eval/pcall path. Then you don't grant serve at all; you drive the script's async I/O yourself, which is the next section.

#Give the script its data (res:)

A game's sprites and levels, or a tool's templates, are the program's own data. A script reads them as res: paths, such as fs.read("res:maps/keep.json"), from a stack of read-only layers. They need no grant: a script with layers but no scua_grant_fs can import fs and read res: paths, and nothing else. See Ship a program's data with it for the script side.

  • The project's res/ folder is the bottom layer, as on the command line. scua_eval_file and scua_load_script_file add it: the res/ folder beside the project's scua.toml, or beside the script.
  • scua_mount(p, path, …) adds a pack file or a directory above it.
  • scua_mount_memory(p, data, len, label, release, ctx, …) adds a pack held in your memory. Leave the bytes alone until release runs. It runs exactly once: when the layer is dropped, or before the call returns if the mount is refused.
char err[256];
if (scua_mount(q, "/opt/mygame/base.pak", err, sizeof err) != SCUA_OK)
    fprintf(stderr, "mount failed: %s\n", err);       /* e.g. E-PACK-CORRUPT: … */
scua_mount_memory(q, patch_bytes, patch_len, "patch1", free_patch, NULL, err, sizeof err);

The rules:

  • One stack per process. Every partition reads the same layers.
  • Mount before any partition starts an actor. The first actor fixes the stack, and a later mount returns SCUA_ERR_BUSY.
  • Each layer goes above the ones already there, and the higher layer wins.
  • A pack is checked before anything reads it. A refused one returns SCUA_ERR_FORMAT with the E-PACK-… code and the reason in errbuf.
  • A pack inside the directory you granted with scua_grant_fs is refused, since the script could rewrite it for its next run.

scua_res_layers(p, out, n) returns the layers, top first, with the same fields fs.mounts() gives a script. scua_res_reset() drops every layer and runs the memory layers' release, once every partition has been freed.

An actor's save records the layers it ran with. Reviving one with scua_load_actor refuses it (-SCUA_ERR_FORMAT, with the layer named in the log) when a layer it had is not mounted. A layer that changed is reported instead, in scua_load_report's version 3 fields (res_changed_count and res_changed). scua_set_res_revive(p, SCUA_RES_MISSING_ALLOW) revives without the missing layers, and SCUA_RES_STRICT refuses a changed one too. scua_actor_stats version 2 counts each outcome.

Ship a game as one file. Append a pack to your executable and mount the executable itself: scua_mount(q, own_path, …). A pack is found from the end of the file, so the bytes in front of it, the program, are ignored. Build the pack with scua res build res/ -o assets.pak, then append it with cat mygame assets.pak > mygame-release.

#Give the script a folder for saves and settings (user:)

A game's saves and a tool's settings belong to the person using it, not to the program. A script writes them as user: paths, such as fs.write("user:saves/slot1.json", text), in a folder you grant with scua_grant_user. That folder is a second writable root beside the one scua_grant_fs gives, and a script that has only this folder can still import fs.

char err[512];
int rc = scua_use_project(q, "/opt/mygame", err, sizeof err);  /* reads scua.toml, so the [app] id is known */
const char *id = scua_app_id(q);                                 /* "com.example.mygame", or NULL */
rc = scua_grant_user(q, id, NULL, SCUA_USER_NOWAIT, err, sizeof err);
if (rc == SCUA_ERR_USER_LOCKED)
    show_message("The game is already running.");
else if (rc != SCUA_OK)
    fprintf(stderr, "%s\n", err);                               /* E-USER-…: the reason */
  • path NULL is the folder the platform keeps for the app ID, as --allow-user finds it: %APPDATA%\ID on Windows, ~/Library/Application Support/ID on macOS, ~/.local/share/ID on Linux. On Android and under WebAssembly there is no such folder, so pass path.
  • A path is the folder itself, with nothing added: a sandbox container, an app's files folder plus the ID, or a Steam user's subfolder.
  • id NULL grants the folder without an ID, or under the project's [app] id when it declares one. An ID that differs from the declared one is refused with E-USER-MISMATCH, because it would be a different, empty save folder.

The folder is created (mode 0700), marked with a .scua-user file holding the ID, locked so that a second copy of the program cannot write it at the same time, and checked to belong to the user, as a scua run checks it. Nothing is printed: a refusal is a return code, with the E-USER-… code and the reason in errbuf.

return E-USER-… codes
SCUA_ERR_TYPE E-USER-ID (a bad ID, or both id and path NULL), E-USER-MISMATCH
SCUA_ERR_NOT_FOUND E-USER-NOPARENT (the folder above it is missing: an unplugged drive), E-USER-NOBASE
SCUA_ERR_USER_LOCKED E-USER-LOCKED: another run holds the folder
SCUA_ERR_OTHER E-USER-FOREIGN, E-USER-LINK, E-USER-OWNER, E-USER-OVERLAP
SCUA_ERR_RANGE a flag bit scua.h does not define

Without SCUA_USER_NOWAIT a held folder is waited for, about 5 seconds, which blocks the calling thread. A game that grants before its window opens passes the flag and shows its own message. SCUA_USER_ADOPT marks an existing platform folder that has no marker yet, such as the one Steam Cloud downloads on a second machine.

The rules:

  • Grant before the load. Without a project, the declared ID is checked at the first scua_eval_file or scua_load_script_file of a program whose project declares one: that call returns SCUA_ERR_TYPE with E-USER-MISMATCH in errbuf.
  • Grant again after scua_load, which returns a new partition, as for every grant.
  • Grant before scua_load_actor. An actor's save records the ID its folder was granted under, never the path, and the revived actor gets the folder only when you hold one under the same ID.
  • The lock is held until the last partition holding the grant lets it go: actors the partition spawned keep the folder after scua_revoke_user. Release it before you delete the folder: on Windows the lock is an open file inside it.
  • scua_user_folder(p) is the folder's resolved path, for your own code: valid until the next grant or revoke on p.

A script that writes user: paths and runs without the grant stops at the first such call, naming what is missing, and a user: path that arrives as data (a file name read from a list) is an Error value.

#Drive the script's async I/O

By default a capability call blocks: fs.read or http.get runs to completion before the script moves on, and a scua_eval blocks the calling thread while it does. That's the right model for a CLI or a tool. But a host with its own reactor — a game loop, an async server — doesn't want a script's http.get blocking its thread. So the host can install an I/O platform: a script's capability calls inside an actor handler turn then park instead of blocking, the host performs the I/O on its own schedule, and the host resumes the turn when the result is ready.

You install it once with scua_set_io_platform, handing over a context pointer and a submit callback:

int scua_set_io_platform(scua_partition *p, void *ctx, scua_io_submit_fn submit);

When a handler-turn capability call parks, the runtime calls your submit synchronously, with a token that identifies the request and the details of what was asked:

typedef void (*scua_io_submit_fn)(void *ctx, uint64_t token,
                                  const char *module, const char *op,
                                  const char *arg0, size_t arg0_len);

module/op are NUL-terminated ("http"/"GET", "fs"/"read"); arg0/arg0_len is the first string argument (the URL or path) and is not NUL-terminated. Copy whatever you need before returning — the pointers aren't yours to keep. Your submit just records the request (queues it on your reactor) and returns immediately; it must not block.

Later, when your reactor has the result, you complete the token. Pick the completion that matches the parked op — each hands the script the right Ok/Error shape:

int scua_io_complete_bytes(scua_partition *p, uint64_t token, const void *data, size_t len);  // Ok(bytes)   — fs.read
int scua_io_complete_text (scua_partition *p, uint64_t token, const void *str,  size_t len);  // Ok(string)  — fs.read_text (must be valid UTF-8)
int scua_io_complete_http (scua_partition *p, uint64_t token, int status, const void *body, size_t len);  // Ok({status,body}) — http.get/post
int scua_io_complete_nil  (scua_partition *p, uint64_t token);                                // Ok(nil)     — fs.write
int scua_io_complete_error(scua_partition *p, uint64_t token, const char *msg);               // Error(msg)  — any failure

Each returns 1 if it was delivered, or 0 if the token is no longer live — timed out, reloaded, or already completed. That 0 is a defined no-op, not an error: completing a token twice, or one that a snapshot already retired, is safe.

Completing a token only stages the result. To actually resume the parked turn(s), call scua_io_poll, which drives one drain cycle and flushes output:

char errbuf[128];
if (scua_io_poll(p, errbuf, sizeof(errbuf)) != SCUA_OK)
    fprintf(stderr, "resumed turn faulted: %s\n", errbuf);   // a located message

So the loop your reactor runs is: install the platform once → a call parks and fires submit → you do the I/O → scua_io_complete_* by token → scua_io_poll to resume. To uninstall, call scua_set_io_platform(p, NULL, NULL), and everything goes back to blocking (only handler turns ever parked — a top-level scua_eval blocks regardless).

user: paths stay inline unless you say otherwise. Your submit gets a path as the script wrote it, user:saves/slot1.json, and a reactor that roots every path at its own directory would write a file of that name there. So a user: read or write parks only after scua_io_platform_caps(p, SCUA_IO_CAP_USER) (or'd with any other bits you declare); then you resolve the path against scua_user_folder(p) yourself. Without the bit it runs to completion where it is called.

This is the embedder's side of --io=async: the host owns the socket and the event loop, and the script's http.get/fs.read become non-blocking without the script changing at all. It also inherits the snapshot rule: if a partition is persisted while a token is still parked, the retired call resumes with Error({ kind = "interrupted" }) (or kind = "timeout" if its deadline had passed) and is never re-issued — so a mid-flight request is never silently re-sent across a reload.

#Drive the script from a game loop

By default every host call finishes the work it starts. scua_pcall(update) returns once update has run and every task and partition it set going has nothing left to do, with wait skipping ahead in no real time. That suits a tool. A game wants something else: the script's waits measured in game time, a fixed slice of each frame for the script's background work, and whatever doesn't fit carried to the next frame. Four calls give a host that control:

scua_set_drain_mode(p, SCUA_DRAIN_EXPLICIT);  // a host call runs the call, and nothing else
scua_set_clock(p, 0);                         // the script's time is the game's
scua_set_fault_hook(p, on_fault, NULL);       // told of each fault once
scua_post_open(p, 1024);                      // other threads may post (see below)
scua_load_script(p, src, len, 64u << 20, 1000000, err, sizeof err);

scua_Handle update = scua_get_global(p, "update");
scua_turn_budget budget = { sizeof budget, 0, 2000000 };     // 2 ms of each frame
scua_turn_report report = { sizeof report };

while (running) {
    if (!paused) scua_advance_clock(p, (int64_t)(dt * time_scale * 1000));
    scua_Handle dt_h = scua_num(p, dt);
    scua_pcall(p, update, &dt_h, 1, NULL, err, sizeof err);  // update alone
    scua_run_turns(p, &budget, &report, err, sizeof err);     // tasks and partitions, within budget
    render();
}
scua_partition_free(p);
  • scua_set_drain_mode(p, SCUA_DRAIN_EXPLICIT): scua_pcall, scua_io_poll and the load itself run only what they are asked to, so a script whose top level starts a task that loops forever still loads. Set it before scua_load_script; it is kept over a hot reload.
  • scua_run_turns runs ready tasks and partition turns until nothing is left or the budget is spent: a number of turns, or a number of nanoseconds of wall time. A turn that is running finishes (its op_budget bounds it), and what hasn't run stays queued for the next call. The report says how many turns ran, whether more are ready, and when the next timer comes due.
  • scua_set_clock and scua_advance_clock make the script's clock the host's. now() reads it, and a wait(500ms) comes due when the host's clock has moved half a second, however many frames that takes. Pausing is not advancing; slow motion is advancing by less. The clock never runs backwards. A host clock bounds the default mode too, so even without SCUA_DRAIN_EXPLICIT a script that ticks forever returns from every call.
  • scua_set_fault_hook hears each fault a partition's turn or a spawned task survives, once, as it happens. The call that ran it returns SCUA_ERR_OTHER with the first one, and later calls don't repeat it, so one broken enemy doesn't fail every frame after it.

A script written for this keeps its background work in tasks and partitions and lets the game's clock pace it:

spawn(fn()
  while true do
    think()          -- every 100 ms of game time, paused when the game is
    wait(100ms)
  end
end)

Posting from other threads. Every call above belongs to the thread that owns the partition. The one exception is scua_post_text(p, id, msg, len): once scua_post_open has made an inbox, any thread (a loader, a network thread) may post a text message to a partition. It is copied into the inbox and delivered at the start of the owner thread's next drain, in the order it was posted. A full inbox returns SCUA_ERR_LIMIT, so the poster sees the backpressure. Stop every posting thread before freeing the partition.

Engine objects in partitions. A host function a partition's handler calls can make and read runtime-owned resources of the types registered on your partition, just as it can from the main script. Such an object stays in the partition that made it: a message carrying one faults. Objects a partition makes are destroyed when they're closed or when the partition stops.

examples/embed_frames.c is the whole loop, with a loader thread and a fault, and runs with zig build embed-frames. Everything still runs on the owner thread: a host function is never called on two threads at once.

#Run the script's partitions on several cores

A game's AI, pathfinding and procedural generation are partitions that mostly compute. Turn workers on before the load, and each scua_run_turns runs the script's partitions on your thread and the workers together:

scua_set_drain_mode(p, SCUA_DRAIN_EXPLICIT);
scua_set_workers(p, 4);                       // your thread and three workers
scua_register_module_mode(p, "phys", "raycast", raycast, host, SCUA_PARALLEL_READ);
scua_register_module_mode(p, "phys", "impulse", impulse, host, SCUA_DEFERRED);
scua_register_module_mode(p, "noise", "perlin", perlin, NULL, SCUA_ANY_THREAD);
scua_register_module(p, "audio", "play", play, host);       // main-thread: the default
scua_load_script(p, src, len, 64u << 20, 1000000, err, sizeof err);

while (running) {
    scua_advance_clock(p, dt_ms);
    scua_pcall(p, update, &dt_h, 1, NULL, err, sizeof err);  // serial: the main script
    scua_run_turns(p, &budget, &report, err, sizeof err);     // PARALLEL: partitions on four threads
    scua_apply_deferred(p, err, sizeof err);                  // serial: queued engine writes, in order
    physics_step();                                           // serial: callbacks may call into SCUA
    render();
}

Each frame now has a parallel phase, scua_run_turns, which returns once every worker has stopped, and a serial phase, everything else, where your engine and the main script run as they always have.

Every host function runs as its mode says:

Mode Called from a worker, it Use it for You promise
SCUA_MAIN_THREAD (the default) runs on your thread, within the same scua_run_turns, while the calling partition waits anything that changes engine state: audio, UI, creating resources nothing new
SCUA_PARALLEL_READ runs at once, on the worker raycasts, visibility, map lookups it only reads, and what it reads doesn't change during scua_run_turns
SCUA_DEFERRED returns nothing at once; the call runs later, in scua_apply_deferred forces, moves, spawning effects, sounds at a position its effect can wait for the serial phase, and it needs no answer
SCUA_ANY_THREAD runs at once, on whichever thread called noise, maths, string helpers it touches no engine state at all

Leave a function at the default and it is correct with workers on; it just runs on one thread. Classify the busy ones, and scua_get_parallel_stats counts how many calls your thread ran for the workers, so you can see which.

  • Deferred calls carry copies of their arguments: nil, booleans, numbers, strings and bytes. Pass an engine object by its id. scua_apply_deferred runs them in a fixed order — partitions with an order key (scua_set_order_key) first, in key order, then the rest by id, and each partition's calls in the order it made them — so the same frame gives the same world on two workers or twelve. A parallel-read function reads the engine as it was when the phase began: it doesn't see this phase's deferred writes.
  • During the parallel phase every call on your partition returns SCUA_ERR_PHASE, from any thread. A host function's own scua_arg_* and scua_return_* calls are unaffected, and scua_post_text works throughout. A function scua_apply_deferred runs may not run the script either. Engine callbacks that call back into the script, such as physics contacts, belong in the serial phase, where they work exactly as before.
  • Engine objects a worker's host function creates are initialized on your thread while the worker waits, unless the type's initializer is thread-safe and you say so with scua_userdata_set_any_thread. Objects are destroyed on your thread, never a worker's.
  • Faults reach the fault hook on your thread, once each.
  • Repeatable runs: scua_set_workers(p, 0) or 1 runs everything on your thread, every mode in place, exactly as without workers. For tests, scua_set_schedule_order(p, "shuffle:7") runs each round's partitions in a different legal order; a game whose results change with it depends on an order SCUA doesn't promise.
  • The worker count takes effect at the next scua_load_script, and freeing the partition stops the workers.
  • Where it works: macOS and Windows. The Linux library is built without libc, so it can't start threads inside your process, and scua_set_workers(p, n) with n above 1 returns SCUA_ERR_THREAD there; your script runs on one thread, unchanged.

examples/embed_multicore.c is a frame with six agents on four threads, and runs with zig build embed-multicore.

#Running under a WASI host (WebAssembly)

That "the host owns I/O" design is what lets SCUA run on platforms with no operating system of their own. The CLI cross-compiles to WebAssembly with no source changes:

zig build wasm        # produces zig-out/scua.wasm

Run it under any WASI host — here, wasmtime:

wasmtime run --dir=.::. zig-out/scua.wasm script.scua

The interesting part is --dir. A WebAssembly module has no ambient filesystem; it can only reach directories the host preopens for it. So --dir=.::. is the host granting the sandboxed module access to the current directory — and nothing else. The module physically cannot open a path outside it, because the capability to do so was never handed in. That's the same default-deny, host-grants-authority model the embedding API uses, enforced here by the runtime itself: SCUA brings no event loop and no OS assumptions, and the host decides what the script can reach. The same .scua file runs unchanged native, embedded in a C host, or sandboxed in WebAssembly.

A program's per-user folder is a second preopen, named to --user-dir exactly as the module sees it. There is no platform folder under WebAssembly, so --allow-user alone stops with E-USER-NOBASE, and file locks do not exist there, so the run says it cannot lock the folder:

wasmtime run --dir=.::. --dir=./saves::/saves zig-out/scua.wasm --user-dir=/saves game.scua