SCUA 0.38.0 is mostly about sharing: a game engine sharing its frame
with a script, programs sharing files, and a project sharing its machine
with the commands it runs. A host with a frame loop can now give the
script a time budget each frame and its own clock, so a
wait(500ms) lasts half a second of game time and pausing
the game pauses the script. The same host can spread the script's actors
across several cores, with each engine function saying how it may be
called. Programs that share files get a lock the operating system
releases however they end, a per-user folder several windows can use at
once, and exclusive writes that never show a half-written file. A
project can list the commands it may run in scua.toml, and
saves get a written promise that they keep loading in later
releases.
#Drive a script from a game loop
A game's frame has a budget, and an actor that ticks forever never finishes, so a host now decides how much script work each frame gets:
scua_set_drain_mode(p, SCUA_DRAIN_EXPLICIT); // a host call runs only the call
scua_set_clock(p, 0); // the script's time is the game's
scua_set_fault_hook(p, on_fault, host); // each fault, once
scua_post_open(p, 1024); // any thread may now post messages
scua_load_script(p, ...);
for (;;) {
scua_advance_clock(p, dt_ms); // paused: don't; slow motion: a smaller dt
scua_pcall(p, update, ...); // runs update alone
scua_turn_budget b = { sizeof b, 0, 2000000 }; // 2 ms of actor work
scua_run_turns(p, &b, &report, err, sizeof err);
simulate(); render();
}
scua_run_turns runs the script's tasks and actors until
the budget is spent, and whatever doesn't fit carries over to the next
frame. Waits and timers come due when the game's clock reaches them, and
now() reads that clock. A loader thread or a job system
hands results to an actor with scua_post_text, the one call
that works from any thread; the message arrives at the start of the next
frame's run. Engine functions an actor calls can create and read the
host's own objects, such as textures or bodies, just as the main
script's can. examples/embed_frames.c is the whole loop,
and the embedding guide walks through it.
#Actors on several cores
Call scua_set_workers(p, 4) before the load, and each
scua_run_turns runs the script's actors on the game's
thread and three workers. A frame then has two phases. In the parallel
phase the actors run. In the serial phase the game applies their engine
writes, steps physics and renders, and callbacks such as a physics
contact can call into the script as they always have.
Each engine function says how it may run:
scua_register_module_mode(p, "world", "raycast", world_raycast, host, SCUA_PARALLEL_READ);
scua_register_module_mode(p, "world", "spawn", world_spawn, host, SCUA_DEFERRED);
scua_register_module_mode(p, "fx", "lerp", fx_lerp, NULL, SCUA_ANY_THREAD);
// anything left unclassified runs on the game's thread
A parallel-read function runs straight away on the worker, against
engine state that holds still for the phase. A deferred one is queued
and run by scua_apply_deferred, so the engine sees the
writes in the same order on any number of workers. A function nobody
classified runs on the game's thread while the actor that called it
waits. That means a host can turn workers on before it has classified
anything and still be correct, then classify the busy functions to go
faster. The scripts don't change: the messages the actors send are the
same on one thread or eight. examples/embed_multicore.c
runs a frame of six agents on four threads. Workers run on macOS and
Windows.
#Several programs, one set of files
fs.lock takes a lock that every other running program
respects. The operating system releases it when the program ends,
however it ends, so a crash never leaves a file locked. Here four copies
of a program each add 50 to a shared total:
import fs
fn read_total()
match fs.read_text("total.txt")
Ok(text) -> return tonumber(text)
Error(_) -> return 0
end
end
for i in 1:51 do
let lock = fs.lock("total.lock", { timeout = 5000 })?
fs.write("total.txt", `{read_total() + 1}`)?
fs.close(lock)
end
$ for i in 1 2 3 4; do scua --allow-fs=data bump.scua & done; wait; cat data/total.txt
200
Take the lock out and the same four runs ended at 56, 52 and 52, because each overwrote the others' updates. In a web server, a handler waiting for a lock holds only its own request, and the server keeps answering everyone else.
fs.write(path, data, { exclusive = true }) writes the
file under a hidden name and renames it into place only if the name is
still free, so nobody reads it half-written. When it loses, its
Error has the kind exists;
fs.write and fs.lock errors both carry a kind
now, so a program can retry only what might pass. With
{ sync = true } on the write and on the rename, a save is
on the drive before the call returns, and a power cut can't undo it.
--user-shared lets several windows of one app use the
per-user folder at the same time.
#The commands a project may run
A project can now list the commands it runs in its
scua.toml, and --allow-exec lets the program
run those and nothing else:
[app]
id = "dev.example.build"
[exec]
commands = [
["git", "status", "--short"],
["git", "log", "--oneline", "-n", "{int}"],
]
import exec
match exec.run(["git", "log", "--oneline", "-n", "3"])
Ok(r) -> print("git log ran")
Error(e) -> print(e.message)
end
match exec.run(["git", "push", "--force"])
Ok(r) -> print("pushed?!")
Error(e) -> print(e.kind, e.message)
end
$ scua --allow-exec build.scua
git log ran
not_declared this command is not declared; the closest declaration is `git status --short`
The list travels with the program, so whoever installs it can read
what it may run. A program can't change its own list either: if the
folder it may write to holds that scua.toml, the launch
stops and suggests a folder of the program's own, such as
--allow-fs=./data. A dependency can't add to the list,
because only the project's own scua.toml is read.
--allow-exec-any still lets a program run anything, and now
covers exec.run as well.
#Saves that keep loading
The persistence guide now makes a promise: a blob that
durable.encode wrote in SCUA 0.26 or later loads in every
later release. If the format ever changes, later releases keep reading
this one. durable.format reads a blob's format without
decoding it, so a game can turn away a save from a newer version by
name:
import durable
let blob = durable.encode({ level = "keep", gold = 30 }, 1)
print(durable.format(blob), durable.current_format())
fn load(bytes)
match durable.format(bytes)
Ok(f) -> if f > durable.current_format() then return Error("saved by a newer version of the game") end
Error(e) -> return Error(`not a save: {e}`)
end
return durable.decode(bytes)
end
print(load(blob))
print(load(b""))
$ scua load.scua
Ok(2) 2
Ok({gold = 30, level = keep})
Error(not a save: not a durable blob (empty))
#Upgrading
fs.write'sErroris now{ kind, message }. Showe.messagewhere you printed the error before.- The folder
--allow-fsnames must exist. A missing one is refused before the program runs, with a message saying to create it. - A package's capability list names the grants its functions need as
well as the modules it imports, for example
serveforhttp.serveandttyforsys.readline. A budget that allowed the module alone needs the grant name added. - A
waitin an actor's message handler faults on the line where it's written, so apcallaround it now catches it. - A launch refused because another run holds the per-user folder exits with status 9, so a launcher can retry.
The changelog lists everything else in
0.38.0, including a server 500 that carries a reference matching its log
line, time.wall() and time.mono_ms() for
timing real work, json.encode(v, { canonical = true }), and
--max-total-ops to bound a whole run.