A game's saves and settings belong to the player, not to the folder
the game is installed in. That folder may be read-only, Steam replaces
it on every update, and two players on one computer share it. SCUA gives
each program a per-user folder for this: the place the
operating system keeps for the program's data, under the player's own
account. The program writes it through user: paths, and
every fs function takes them:
import fs
fs.mkdir("user:saves")?
fs.write("user:saves/slot1.json", "{\"level\":\"keep\"}")?
This page shows how to grant the folder, and then a save recipe that does not lose a player's progress: not to a crash in the middle of a save, not to a damaged file, and not to an older build of the game meeting a newer save. It ends with what to ship, Steam Cloud, and what each refusal means.
#Give the game its folder
Choose an app id for the game: three or more
lowercase parts separated by dots, each a letter followed by letters and
digits, such as com.unabated.tinykeep. Use a domain you
own, reversed, so no other program picks the same one. The id names the
folder, so it cannot change later without moving every player's saves
(see An id that changes). A demo that
should carry its progress into the full game uses the same id.
Declare it in the project's scua.toml:
[app]
id = "com.unabated.tinykeep"
That is a check, not a grant. The launch grants the folder with
--allow-user:
$ scua --allow-user=com.unabated.tinykeep game.scua
The folder is %APPDATA%\com.unabated.tinykeep on
Windows,
~/Library/Application Support/com.unabated.tinykeep on
macOS, and ~/.local/share/com.unabated.tinykeep on Linux
($XDG_DATA_HOME, when it is set).
scua user path com.unabated.tinykeep prints it. The first
run creates it, private to the player, and says so on stderr:
scua: created the per-user folder for com.unabated.tinykeep: /Users/sam/Library/Application Support/com.unabated.tinykeep
A launch that names a different id from [app] id stops
with E-USER-MISMATCH, because a different id is a
different, empty folder. --allow-user
in the CLI reference has every rule.
While developing, use a folder beside the game with
--user-dir, so a development build never touches the saves
of the copy you play:
import fs
fs.write("user:settings.toml", "volume = 7")?
print(fs.read_text("user:settings.toml")?)
print(fs.mounts().user)
$ scua --no-warnings --user-dir=saves settings.scua
volume = 7
{id = , path = , created = true}
A relative --user-dir is taken from the program file's
folder, so this is saves/ beside settings.scua
from wherever you run it. (--no-warnings hides the line
saying the folder was created.) fs.mounts().user is
nil without a folder, and otherwise its id
(here none, since there is no scua.toml), its
path, and whether this run created it.
path is "" unless the run also holds
--allow-fs, so a program cannot pass the folder's location
on through a path it could not otherwise reach.
user: is the folder itself, so a file in it is
user:settings.toml, never user:/settings.toml.
fs.find("user:saves") returns
user:saves/slot1.json, which feeds straight back to
fs.read, and each record's rel is the part
below the folder searched, slot1.json.
#A program that runs without the folder
A program that uses user: paths still runs when it was
not granted the folder. A note on stderr says so before it starts, and
the first user: call it reaches stops the run with exit
status 4:
scua: note: tool.scua:3 uses user: paths and this run has no per-user folder, so those calls will fault — re-run with --allow-user=ID (lowercase reverse-DNS, three parts, such as dev.example.mytool), or --user-dir=DIR
scua: tool.scua:3: user:settings.toml: this run has no per-user folder — re-run with --allow-user=ID (lowercase reverse-DNS, three parts, such as dev.example.mytool), or --user-dir=DIR
That is for a run that holds fs some other way, such as
--allow-fs=.. A run with no fs grant at all
stops at import fs instead, exit status 4, and
its message says to grant the per-user folder with
--allow-user=ID.
So a tool that saves its settings only when it can checks first:
import fs
if fs.mounts().user != nil then
fs.write("user:settings.toml", "volume = 3")?
end
#Names in user:
The folder moves between computers, through Steam Cloud, iCloud or a
backup, and between Windows, macOS and Linux. So a name the program
creates there must be valid on all of them: no
:, <>"|?*, control characters, trailing
dot or space, and no device name such as con or
nul, in any part of the path. A literal that breaks the
rule is a compile error. A built name that breaks it is an
Error from fs.write, fs.mkdir,
fs.rename or fs.open, so the program can fix
it and try again. Reading keeps the platform's own rules, so a file the
player put there by hand still opens; fs.find lists it with
bad_name = true, and every function that writes refuses it
by name.
Use lowercase names. A Mac treats
Settings.toml and settings.toml as one file
and Linux does not, so a game that spells a name two ways works on one
and loses data on the other. A user: literal with an
upper-case letter gets a compile warning. For a name the player types,
make a slug:
import str
-- A player-typed name as part of a file name: lowercase, and anything outside a-z, 0-9 and - becomes -,
-- which also removes every character the user: name rule refuses. At most 64 characters.
fn slug(name)
let out = ""
for c in str.chars(str.lower(name)) do
if (c >= "a" and c <= "z") or (c >= "0" and c <= "9") then
out = out .. c
else
out = out .. "-"
end
end
return str.slice(out, 0, 64)
end
print(slug("My Keep"))
print(slug("Élan: Run 2"))
$ scua slug.scua
my-keep
-lan--run-2
Use it with a fixed prefix,
`user:saves/save-{slug(name)}.json`, so an empty slug or a
device name never makes the whole name. A slug loses letters outside
ASCII, so two names can meet: keep the name the player typed inside the
save, and treat the file name as a key.
#The save code
The code below is a complete way to save. It is long because each part stops a way a save gets lost:
- One save actor owns
user:saves/. Every save is anaskto it, so saves happen one at a time, and the reply is the result:Ok(nil), or anErrorthe game can show the player ("not saved") rather than a line on stderr nobody reads. Save at save points, never every frame: a save delays every actor's next turn for as long as it takes, the frame loop's included. - The save is a typed record with a
migratehook, loaded withjson.decode(bytes, Save), which decodes and then checks and converts every field, as Save, load, and migrate game state teaches. A save written beforeversionexisted becomes version 1. The hook gives a field added later a default, only in the saves that predate it; anything else that does not fit the record is damage. - A newer save is refused before it is read as a
record.
load()andsave()first read only itsversion. A save from a newer build is refused by name, whatever its shape, so an older build (a Steam beta branch, a rolled-back update) never reads it as damage, never loads the backup around it, and never replaces it. That holds as long asversionkeeps its name and stays anintin every version. - Missing is decided by a listing, never by an
error's text. A copy that cannot be read stops the load. A copy that
reads but does not decode is damage, and the backup is tried; the game
gets a
noteto show the player. When a save exists and no copy decodes, the load is anError, never a new game, and nothing is overwritten. slotN.jsonexists at every moment. Each file is written under a temporary name of its own with{ sync = true }, then renamed into place. Before a save, the copy on disk is copied toslotN.json.bak(or, when it is damaged, toslotN.json.damaged, so it is kept). A crash at any point leaves a slot that loads.save()checks before it writes, so a bad value fails when the game saves, not at the next load.json.encode(fresh, Save)checks the whole value againstSaveas it encodes, and gives anErrorinstead of text when anything doesn't fit or has no saved form (a function left in a field, say). Nothing on disk changes then: the last good save stays where it is, the game keeps playing, and theError's message names the path to the bad value, for the log and for telling the player "not saved".- No temporary is left behind, in a folder that may be synced to the cloud. A failed write removes its own, and the save actor removes stale ones when it starts.
- A rename is retried, five times 100 ms apart,
because on Windows a virus scanner or a sync client can hold the file
for a moment. A handler and top-level code pause differently, so the
caller passes the pause: the save actor passes
in_handler, and a tool that saves from its top level passesat_top_level.
Put it in the game's own file: the save actor is a partition, and a partition is declared in the file that runs it.
import fs
import json
import ids
import str
import sys
const current = 2 -- the version this build writes
const newest = current -- the newest it reads: current + 1 in the first release of a format change
-- A save, as this version of the game writes it. `parse(v, Save)` checks every field.
record Save {
version: int where version >= 1,
level: string,
gold: int where gold >= 0,
}
-- A save from before `version` existed becomes version 1. A field added later gets a default only in
-- the saves that predate it (`gold` came with version 2); a field every version wrote has none, so a
-- save without it is damage.
migrate Save(old)
let v = old.version ?? 1
return { version = v, level = old.level, gold = if v == 1 then old.gold ?? 0 else old.gold end }
end
-- Only the stamp, read before the full record, so a newer build's save is known as newer whatever
-- its shape: a renamed or retyped field in a newer version never reads as damage.
record Stamp { version: int }
fn is_newer(bytes)
match json.decode(bytes)
Ok(v) -> do
try
return parse(v, Stamp).version > newest
rescue err
return false
end
end
Error(_) -> return false
end
end
fn new_game()
return parse({ version = current, level = "keep", gold = 0 }, Save)
end
-- One copy of a slot, from its bytes: Ok(save) or Error(why). Bytes, so bad UTF-8 is damage too.
-- Decoded straight into a `Save`: bad bytes and a value that doesn't fit are both an Error.
fn decode(name, bytes)
match json.decode(bytes, Save)
Ok(s) -> return Ok(s)
Error(e) -> return Error(`{name}: {e.message}`)
end
end
-- Load slot n: Ok({ save, note }), where `note` is "" or a line to show the player; an Error stops the
-- game from loading, and nothing is written.
fn load(n)
fs.mkdir("user:saves")?
let names = fs.list("user:saves")?
let main = `slot{n}.json`
let found = []
for name in [main, `{main}.bak`] do
if contains(names, name) then found.push(name) end
end
if len(found) == 0 then return Ok({ save = new_game(), note = "" }) end
-- A copy that cannot be read (permissions, a directory) stops the load. A newer build's save is
-- refused before anything decodes it, so an older build never loads around it.
let bytes = {}
for name in found do
bytes[name] = fs.read(`user:saves/{name}`)?
if is_newer(bytes[name]) then
return Error(`{name} was saved by a newer version of the game; nothing was changed`)
end
end
-- A copy that reads but does not decode is damage, and the backup is tried.
for name in found do
match decode(name, bytes[name])
Ok(s) -> do
if name == main then return Ok({ save = s, note = "" }) end
let why = if contains(found, main) then "is damaged" else "is missing" end
let note = `{main} {why}; loaded its backup`
sys.write_err(`{note}\n`)
return Ok({ save = s, note = note })
end
Error(why) -> sys.write_err(`{why}\n`)
end
end
return Error(`no copy of {main} could be loaded; nothing was overwritten`)
end
-- The pause between rename tries. A message handler may pause only inside a `select`, and top-level
-- code only with `wait`, so each caller passes the one for where it runs.
fn in_handler()
select wait(100ms) -> nil end
end
fn at_top_level()
wait(100ms)
end
-- Rename, retrying a few times: on Windows a scanner or a sync client can hold the target for a moment.
fn rename_retry(from, to, pause)
let last = ""
for attempt in range(5) do
match fs.rename(from, to)
Ok(_) -> return Ok(nil)
Error(e) -> last = e
end
pause()
end
return Error(last)
end
-- Write `bytes` to `path` under a temporary name of its own, then rename it into place. A failure
-- removes the temporary, so nothing is left in the cloud-synced folder.
fn put(path, bytes, pause)
let tmp = `{path}.{ids.ulid()}.new`
let r = fs.write(tmp, bytes, { sync = true })
match r
Ok(_) -> r = rename_retry(tmp, path, pause)
Error(_) -> nil
end
match r
Ok(_) -> nil
Error(_) -> do
let _ = fs.remove(tmp) -- best effort; the save actor's start tidies what is left
end
end
return r
end
-- This version's stamp, every field checked now, so a bad save fails when the game saves, not when it
-- next loads. Name every field: a loaded older save is re-saved as `current`.
fn stamp(s)
try
return Ok(parse({ version = current, level = s.level, gold = s.gold }, Save))
rescue err
return Error(`not saved: {err.message}`)
end
end
-- The text to write. A typed encode checks every field again as it writes and refuses a value with no
-- saved form, so a save that could not load again is an Error here, before any file is touched.
fn encode(s)
match json.encode(s, Save)
Ok(text) -> return Ok(text)
Error(e) -> return Error(`not saved: {e.message}`)
end
end
-- Save slot n. The last copy that decodes is copied to the backup, so slotN.json exists at every
-- moment, and then the new copy is renamed over it. A crash at any point leaves a loadable slot.
fn save(n, s, pause)
let fresh = stamp(s)?
let text = encode(fresh)?
fs.mkdir("user:saves")?
let names = fs.list("user:saves")?
let main = `slot{n}.json`
-- Older code never backs up or replaces a newer build's save.
for name in [main, `{main}.bak`] do
if contains(names, name) and is_newer(fs.read(`user:saves/{name}`)?) then
return Error(`{name} was saved by a newer version of the game; not saved`)
end
end
let path = `user:saves/{main}`
if contains(names, main) then
let old = fs.read(path)?
match decode(main, old)
Ok(_) -> put(`{path}.bak`, old, pause)? -- the backup is a copy; main stays
Error(_) -> put(`{path}.damaged`, old, pause)? -- a damaged copy is kept, not overwritten
end
end
return put(path, text, pause)
end
-- Remove the temporaries a crash left behind, in the folder and in `saves/` (the migration copy writes
-- to both). One run holds the folder at a time, so at start every `*.new` is stale.
fn clean()
fs.mkdir("user:saves")?
for dir in ["user:", "user:saves"] do
for f in fs.find(dir, { kind = "file", filter = ["*.new"] })? do
fs.remove(f.path)?
end
end
return Ok(nil)
end
-- The save actor: every save goes through it, one at a time, and the caller gets the result.
partition Saver
on Start()
match clean()
Ok(_) -> nil
Error(e) -> sys.write_err(`could not tidy user: {e}\n`)
end
end
ask Store(n, s)
return save(n, s, in_handler)
end
end
On macOS, sync is fsync, which does not
force the drive's own cache to disk, and the folder is not synced after
the rename.
#Try it
Add these lines to the end of the file and run it. Each case prints what the game would be told:
-- Try the recipe: each line prints what the game would be told.
fn show(r)
match r
Ok(v) -> print(`Ok: {v}`)
Error(e) -> print(`Error: {e}`)
end
end
show(load(1)) -- nothing saved yet: a new game
show(save(1, { version = 2, level = "tower", gold = 12 }, at_top_level))
show(save(1, { version = 2, level = "crypt", gold = 30 }, at_top_level))
fs.write("user:saves/slot1.json", "{\"level\":")? -- the main copy, damaged
show(load(1)) -- the backup, and a note for the player
fs.write("user:saves/slot1.json", "{\"version\":3,\"coins\":5}")? -- a save from a newer build
show(load(1))
show(save(1, { version = 2, level = "keep", gold = 1 }, at_top_level))
show(save(2, { version = 2, level = "keep", gold = -5 }, at_top_level)) -- a bad value fails at the save
-- A rename that cannot succeed, at top level and in a handler: five tries, then an Error, and no
-- temporary left behind.
fs.mkdir("user:saves/stuck/inside")?
show(put("user:saves/stuck", "x", at_top_level))
partition Check
ask Put(path)
return put(path, "x", in_handler)
end
end
partition Run(check)
state check = check
on Go()
show(ask check.Put("user:saves/stuck"))
print(fs.list("user:saves")?)
end
end
tell Run(Check()).Go()
$ scua --no-warnings --user-dir=saves tryit.scua
Ok: {save = {version = 2, level = keep, gold = 0}, note = }
Ok: nil
Ok: nil
slot1.json: json.decode(bytes, Save): invalid JSON at line 1, column 10
slot1.json is damaged; loaded its backup
Ok: {save = {version = 2, level = tower, gold = 12}, note = slot1.json is damaged; loaded its backup}
Error: slot1.json was saved by a newer version of the game; nothing was changed
Error: slot1.json was saved by a newer version of the game; not saved
Error: not saved: parse(value, Save): value.gold violates Save's where rule (got -5)
Error: user:saves/stuck: the two ends are different kinds — a file cannot replace a directory, nor a directory a file
Error: user:saves/stuck: the two ends are different kinds — a file cannot replace a directory, nor a directory a file
[slot1.json, slot1.json.bak, stuck]
The damaged main copy is reported on stderr and in the
note, and the backup is loaded. The newer save is left
alone by both the load and the save. The rename that cannot succeed is
tried five times, in each place, and fails with an Error:
no fault, and no temporary left in saves/.
#Quitting: wait for the save
The save that matters most is the one when the player quits, and it
is easy to lose. sys.exit in a handler ends only that
handler's turn, and a top-level sys.exit after a
tell drops the save still waiting in the queue. So run the
frame loop in a handler, ask for the save, and return from
the handler when it succeeds. The run ends when that handler returns and
nothing else is running (stop any other timers too):
partition Game(saver)
state saver = saver
on Run()
let n = 0
while true do
select wait(16ms) -> nil end
n = n + 1
let f = play_frame(n)
if f.quit then
match ask saver.Store(f.slot, f.save) timeout 5s
Ok(_) -> return nil -- the save is on disk; the run ends here
Error(e) -> print(`Could not save: {e}`) -- the game stays open, and the player can try again
end
end
end
end
end
-- A stand-in for the game's own frame: the player quits on the third one.
fn play_frame(n)
if n < 3 then return { quit = false } end
print("quit: saving slot 1")
return { quit = true, slot = 1, save = { version = 2, level = "tower", gold = 12 } }
end
let game = Game(Saver())
tell game.Run()
$ scua --no-warnings --user-dir=saves quit.scua
quit: saving slot 1
play_frame(n) stands for the game's own frame (input,
update, draw) and returns what the player did. When the save fails, the
game stays open with the reason on screen, and the player can quit
again.
#Changing the save's format
Ship a change to the save's shape in two releases:
first one that reads the new format and still writes the old one, then
the one that writes the new format. A player who rolls back from the
second to the first (a beta branch, a bad update) still has every save.
The code keeps two numbers for this: current, the version a
build writes, and newest, the newest it reads.
Say version 3 renames gold to coins. The
release before it changes two parts:
const newest = current + 1 -- reads version 3, writes version 2
migrate Save(old)
let v = old.version ?? 1
return { version = v, level = old.level,
gold = if v == 1 then old.gold ?? 0 elseif v == 3 then old.coins else old.gold end }
end
A version 3 save loads into that release's record, and its next save
writes version 2, keeping the version 3 copy as the backup. The release
that writes version 3 then sets current = 3,
newest = current, and its own record and hook.
#Moving saves from a folder beside the game
A game that shipped before it had a per-user folder may have saved
into a save/ folder in its install, granted with
--allow-fs=save, with slot1.json at the top of
it and no version. Copy those saves across at start:
- The launcher grants the old folder only when it
exists (see Ship it). A
new player has no
save/, and a granted folder that is missing makes every host path anError, so a copy that always ran would fail at every start. With no old folder the run has no host root, and the copy returns at once. - Keep the copy for as long as you support upgrading from that
release. Steam updates a returning player straight to the
newest build, so a player who skipped releases still needs it. Once
user:.migratedexists it costs one listing. - List every file the old release wrote, not only the
slots, so settings or key bindings kept beside them are not reset
without a word. Mods are not copied: they go in
user:mods/. - The copy never deletes the old files. It copies each where the name
is not there yet, through
put(), and writesuser:.migratedlast, outsidesaves/, so each computer makes its own copy. One cut short resumes at the next start. An old file that cannot be read is anErrorat every start, naming it, and.migratedis not written until it can be.
-- Every file the old release wrote into its --allow-fs=save folder, and where it goes now. A file left
-- off this list stays behind.
const OLD_FILES = [
{ filter = "slot*.json", dir = "user:saves" },
{ filter = "settings.toml", dir = "user:" },
]
-- `user:` is the folder itself, so a name in it is user:name, never user:/name.
fn within(dir, name)
if dir == "user:" then return `user:{name}` end
return `{dir}/{name}`
end
-- Copy each file under the host folder `from` that matches a filter into its new folder, where that
-- name is not there yet. Never deletes the source.
fn copy_files(from, files, pause)
for spec in files do
fs.mkdir(spec.dir)?
let there = fs.list(spec.dir)?
for f in fs.find(from, { kind = "file", filter = [spec.filter] })? do
if not contains(there, f.name) then
put(within(spec.dir, f.name), fs.read(f.path)?, pause)?
end
end
end
return Ok(nil)
end
-- At every start, for as long as you support upgrading from a release that saved under
-- --allow-fs=save. The launcher grants that folder only when it exists, so a new player's run has no
-- host root and returns at once.
fn migrate_old_saves(pause)
if not str.starts_with(fs.mounts().state, "host") then return Ok(nil) end
if contains(fs.list("user:")?, ".migrated") then return Ok(nil) end
copy_files(".", OLD_FILES, pause)?
-- Last, at the folder's root, outside the cloud-synced saves/, so a second machine makes its own copy.
fs.write("user:.migrated", "")?
return Ok(nil)
end
-- No data move: the game keeps its old folder with --user-dir=save alone, and the root slot*.json
-- move into user:saves/ once. One root, so a rename moves each file.
fn move_root_saves()
fs.mkdir("user:saves")?
let there = fs.list("user:saves")?
for f in fs.find("user:", { kind = "file", filter = ["slot*.json"] })? do
if not contains(there, f.name) then fs.rename(f.path, `user:saves/{f.name}`)? end
end
return Ok(nil)
end
migrate_old_saves(at_top_level) runs at the top of the
game, before the frame loop starts. The copied saves have no
version, and the record's migrate hook makes
them version 1.
Keeping the old folder instead: launch with
--user-dir=save alone, and the game uses the folder it
already has. Its saves still have to move into saves/,
which move_root_saves() does once. Do not add
--allow-fs=save too: one folder granted twice stops the run
with E-USER-OVERLAP.
#An id that changes
A run holds one per-user folder, so the start-time copy cannot move saves from an old id's folder. Do it once from a shell, with the old folder as the host root:
$ scua --allow-fs="$(scua user path com.old.tinykeep)" --allow-user=com.new.tinykeep move.scua
move.scua calls
copy_files("saves", [{ filter = "slot*.json", dir = "user:saves" }], at_top_level),
since a per-user folder keeps its saves in saves/, then the
same for each file kept at the top of the folder. It does not check
.migrated. It names what it copies, so the old folder's
.scua-user marker is never copied (no fs call
can read or write that file anyway).
#Ship it: the launchers
A shipped game carries its own scua, and a launcher that
passes the flags. These grant the per-user folder, grant the old
save/ folder only where it exists, and send stderr to a log
in a folder that is always writable, because a game started from the
desktop has no console to show it:
#!/bin/sh
# launch.sh
cd "$(dirname "$0")" || exit 1
log="${TMPDIR:-/tmp}/tinykeep-launcher-$(id -u).log"
( : >"$log" ) 2>/dev/null || log=/dev/null # a log we cannot write must not stop the game
set -- --mount="patches/*.pak" --allow-user=com.unabated.tinykeep --user-adopt
[ -d save ] && set -- "$@" --allow-fs=save
exec ./scua "$@" game.scuapp 2>"$log"
@echo off
rem TinyKeep.bat
set "LOG=%TEMP%\tinykeep-launcher.log"
set "OLD="
if exist "%~dp0save\" set OLD=--allow-fs="%~dp0save"
"%~dp0scua.exe" --mount="patches/*.pak" --allow-user=com.unabated.tinykeep --user-adopt %OLD% "%~dp0game.scuapp" 2>"%LOG%"
if errorlevel 1 (type "%LOG%" & pause)
- They work from a read-only install and from any
working directory: nothing is written beside the game, and the log test
runs in a subshell, because a failed redirection on its own ends a POSIX
shell (dash, which is
/bin/shon Debian and Ubuntu) before the||can run. - The
.batshows the log and pauses when the run fails, so the player sees why before the window closes. --user-adoptlets the game take over its folder when it exists but scua did not make it, as on a second computer where Steam Cloud put the saves back before the first run. Every shipped launcher passes it.scua.exeis a console program, so Windows opens a console window behind the game. A game that must not show one ships inside a host program instead (see Embed SCUA in a host program).
#Steam Cloud
Steam Auto-Cloud needs no code: it syncs the files you list when the game starts and exits. Set it up in the Steamworks settings for the app:
- One root per platform:
WinAppDataRoamingon Windows,MacAppSupporton macOS andLinuxXdgDataHomeon Linux, with the subdirectorycom.unabated.tinykeep/saves, and the root overrides that make the three one location. - Two patterns:
*.jsonand*.json.bak, so the backup syncs and the temporary (*.new) and*.damagedfiles stay on the computer that made them. - One more entry, not recursive, for
.scua-userincom.unabated.tinykeepitself, so a second computer receives the folder's marker too. (--user-adoptcovers a computer where it does not arrive.) - Only
saves/and the marker, never the whole folder and nevermods/.
Test it on a fresh computer where the cloud restores the saves before the game's first run there: the game should start, adopt the folder and load them.
One account on the computer can hold several Steam accounts, and
scua cannot see which one is playing. A game that must keep
them apart runs inside a host program that grants a folder per Steam
user.
#Testing code that saves
scua test gives every test file a new, empty per-user
folder of its own, never the real one, so the recipe runs in a test as
it does in the game. See Testing code that
saves.
Keep one test that saves a representative state the way the game
does, with the typed encode. A change that lets a field hold something
with no saved form (a function in a field typed any, say)
then fails this test, rather than a player's save. In a game,
Save comes from the game's own module:
import json
record Item { name: string, count: int where count >= 0 }
record Save { version: int, level: string, gold: int, items: { Item } }
fn test_a_full_save_encodes()
let s: Save = { version = 2, level = "keep", gold = 30, items = [{ name = "key", count = 1 }] }
match json.encode(s, Save)
Ok(text) -> assert(len(text) > 0)
Error(e) -> assert(false, `the save would fail: {e.message}`)
end
end
$ scua test save_encode_test.scua
PASS save_encode_test.scua test_a_full_save_encodes
1 passed, 0 failed (1 file)
The compiler helps too: where a typed encode is written, it warns at
each field of the type that could hold a value with no saved form
(any, a collection of any, a function
type).
#When it goes wrong
Every refusal at launch is exit status 2, with an
E-USER-… code first on the line and the fix after it, and
nothing of the program runs:
| code | what happened | what to do |
|---|---|---|
E-USER-ID |
the id breaks the rule (an app id is lower case,
has three or more parts), or --allow-user was
given no id |
use an id such as com.example.mygame |
E-USER-FLAG |
--allow-fs=user:… or a similar flag names a
user: path |
use --allow-user=ID or
--user-dir=PATH |
E-USER-NOBASE |
no HOME (or %APPDATA%) to find the folder
from, or a platform with none (WebAssembly) |
pass --user-dir=PATH |
E-USER-MISMATCH |
the launch names a different id from [app] id, or the
folder's marker holds another id |
launch with the game's own id, or use another folder |
E-USER-FOREIGN |
the folder exists but scua did not make it (no
.scua-user marker) |
run scua user adopt ID --yes once, or launch with
--user-adopt |
E-USER-NOPARENT |
the folder above a --user-dir folder is missing, such
as an unplugged drive |
connect the drive, or create the folder above it |
E-USER-LINK |
the folder is a link to somewhere that does not exist | reconnect the drive it points to, or remove the link |
E-USER-OWNER |
the folder, or one above it, belongs to another account or another account can write to it | the message names the folder, its owner and mode, and the
chmod that fixes it |
E-USER-LOCKED |
another run of the same game holds the folder; this one waited about 5 seconds | close the other copy; run batch jobs one after another |
E-USER-OVERLAP |
the folder is, or contains, the --allow-fs folder, or a
file with authority (the program, scua.toml,
--mount, --blob-keys and the like) is inside
it |
keep the folder separate from both |
E-USER-POLICY |
an --allow-file policy that sits inside a writable
folder names the folder |
keep the policy file outside every folder the program can write |
The run waits for a held folder, and says so after a second
(scua: waiting for another run using user: com.unabated.tinykeep…),
because a game restarted at once may find the last run still closing.
Where the file system has no locks, such as some network shares, the run
goes ahead with a warning. --no-warnings hides the creation
and adoption lines and that warning.
#Saves are untrusted input
Other programs the player runs can write to the folder, and so can
sync clients, roaming profiles and a save file a friend sent. Treat
anything read from user: as hostile input: convert it with
parse(), as the recipe does, and never take a path, a
command or a grant from it. An actor revived from a snapshot kept there
gets its state and references from the file, not from your code, so a
game that revives from user: hands its revived actors only
send-only references to anything that holds authority.
Mods live in user:mods/ and are mounted at start with
fs.mount("user:mods/…") (see Mods:
mounting from the program). They are untrusted too.
#Embedded games and WebAssembly
A game inside a host program gets the folder from the host, with
scua_grant_user; see Give
the script a folder for saves and settings in the embedding guide.
Under a WASI host there is no per-user folder, so the host preopens one
and --user-dir names it exactly:
$ wasmtime run --dir=.::. --dir=./saves::/saves zig-out/scua.wasm --user-dir=/saves game.scua
There are no file locks under WASI, so a second run is not stopped there; the run says so.