The biggest release since the JIT. You can run low-trust code in a sandbox, enum values say what they are instead of printing as numbers, and a saved session survives you editing the program that made it.
Two file formats changed in ways that break old files. Both are called out below. Both were affordable only because neither has shipped to anyone yet.
#Run code in a sandbox
import sandbox
let rule = sandbox.actor(user_supplied_source, { score = 0 })
let result = rule.ask("on_hit", { damage = 12 })
import sandbox compiles a source string into a fresh
partition that holds nothing. No files, no network, no environment, no
clock, and none of your program's capabilities however many it was
granted. sandbox.run(source, input) gives you a one-shot
answer. sandbox.actor(source, state) gives you an ordinary
actor to tell and ask, for code you call
repeatedly: a mod, an entity behaviour, a rule that keeps score.
This is for the code you cannot vouch for. Player-authored scripts and UGC, mods from a workshop, a rule an LLM just wrote, a formula a customer typed into a field. All of it is code you want to run and cannot read first, and the usual answer is either a review queue or a language you have to lock down yourself. Here the confinement is the default and there is nothing to remember to switch on.
What crosses the boundary is data. Numbers, strings, arrays, records, tagged values. A function cannot cross in either direction, because inside a sandbox a function is an index into its code and would mean something else entirely in yours. Nor can an actor reference, which would let the code inside send messages with your permissions.
Work, memory, source size and function count are all bounded, with
defaults sized for something called every frame rather than a
long-running script. Code inside cannot catch the work limit. Failures
come back as a record with kind, line and
message, because "the code is wrong", "it's too slow" and
"it tried to hold too much" want three different responses.
There is no setting that relaxes the isolation. The tier is decided by which function you called, so there is nothing to misconfigure.
#Enum values know what they are
enum Colour { Red, Green, Blue }
print(Colour.Green) -- Colour.Green, not 1
A flags mask says Perm.Read|Perm.Exec
instead of 5, and it stays a mask. Combining,
testing and removing flags all hand back the flags type, where before it
collapsed to a plain integer the first time you touched it. The names
show up in logs, in tostring, in interpolation, inside
printed arrays and tables, and in error messages.
The number is still there whenever you use a variant as a position.
tiles[dir] indexes, dir + 1 counts,
Monster = 52 and Read = 1 << 0 mean what
they always did, and .to_int() hands you the integer for a
wire or a C boundary. One sentence covers it: an array index is a
position; a table key is a value.
Arrays of variants stay packed, so a tile grid costs what it did when a variant was an integer. 200,000 values in 5.62 MB, the same as an array of ints.
Three things changed behaviour.
Colour.Green == 1 is now false, and a compile error in
typed code that tells you to write .to_int().
t[Colour.Green] and t[1] are different table
keys. Printed output changed, so a test asserting on "1"
will now see "Colour.Green".
Enum.variants() lists every variant in declaration
order, and e.variant_name() / e.enum_name()
give you the strings. Enum.from_int(n) checks its argument
now instead of manufacturing a value that matched nothing;
try_from_int answers nil when a number from
outside may legitimately be unknown.
#Default values for parameters
fn greet(name, greeting = "Hello")
print(greeting .. ", " .. name)
end
greet("Ada") -- Hello, Ada
The rule is one sentence: a default fills the argument when it is
missing or nil. So a parameter with a default is never
nil inside the body, and a value that might not be there
picks the default without an if at the call site. Only
nil triggers it. false and 0 are
values and pass straight through.
The default is an ordinary expression evaluated on each call that
omits the argument, so fn f(xs = []) gives you a fresh
array every time rather than one shared list. Defaults run left to right
in the function's own frame, so a later one can use an earlier
parameter: fn slice_from(s, start, stop = len(s)).
#Saves got three times smaller
A typed, versioned durable save of a five-field record
used to cost about 88 bytes. It now costs about 28, which is smaller
than the same record as JSON, smaller than a Python pickle
of it, and still carrying the types neither of those keeps. Numbers and
lengths are stored in as few bytes as their value needs, and a field
name is written once per save rather than once per record. Saving and
loading both got faster.
More kinds of value can be saved too: money,
frame data tables, vectors, matrices, quaternions, colours,
the geometry types, localized strings, and compiled regexes. The one
that mattered most was a frame with a money column, the
schema the data-table guide is built around, which previously could not
be saved at all.
This is a format change and old saves do not load. A
clean break rather than a compatibility layer, taken now because the
format is pre-release. durable.digest values change for
every value, so anything signed against an old digest needs
re-signing.
#A saved session survives editing your code
For embedders using session hibernation. A saved session used to store each function by its position in the compiled program, so adding or removing a function anywhere in the script shifted those positions, and the session's handler resolved to whichever function had moved into its slot.
A session now records what each function is rather
than where it sat. Adding, removing, renaming and reordering functions
all leave saved sessions loadable, and a bugfix deploy binds them to the
new code. Where a save genuinely cannot be honoured, the load refuses
and names the function: "restock is not in this
program, it was renamed or removed since the save was written".
One asymmetry is worth knowing. Editing the body of a named function is fine. Editing the body of an anonymous one invalidates saved references to it, because an anonymous function has no name to be recognised by, so a save cannot prove which of two lambdas it meant. Guessing is how you end up running the wrong one. If a function is stored in session state and you expect to edit it, give it a name.
Enum values in saves got the same treatment. A durable
save records a variant by name, so reordering or renumbering a
declaration no longer changes what existing saves mean, and renaming one
is refused with a message that names it rather than reporting the file
as corrupt. Cluster values work the same way, which matters most during
a rolling deploy when two versions of your code are running at once.
#The rest of it
importstays inside the folder it is searched in.import "../other/thing"used to walk out of the importing file's directory and run a.scuafile from anywhere on disk. To use modules from another directory, add it with--mod-path DIR, or depend on it by name withscua-pkg.- A deeply nested message can't take the process
down. Copies into a receiving partition are capped at 256
levels, the same limit
durablealready used, and going past it is an ordinary catchable fault. - A data table owns its columns. Building a frame
copies what you pass in, and
column/column_namesgive you copies, so an edit to the source array no longer changes what the frame reports. - Past 255 locals in one function, the compiler names the binding that did not fit and suggests what to do, rather than panicking with a trace from inside itself.
tty = falsein a capability file is obeyed under--allow-all, so "everything except the terminal" is a policy you can write.
#One note on the package manager
scua-pkg emits for scua 0.15 to 0.17, so package commands hold off until a scua-pkg release catches up with 0.18. It says so plainly rather than emitting something subtly wrong. Running programs never needs it.
The changelog has the full accounting.