SCUA

How-to

Save the player's progress

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 an ask to it, so saves happen one at a time, and the reply is the result: Ok(nil), or an Error the 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 migrate hook, loaded with json.decode(bytes, Save), which decodes and then checks and converts every field, as Save, load, and migrate game state teaches. A save written before version existed 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() and save() first read only its version. 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 as version keeps its name and stays an int in 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 note to show the player. When a save exists and no copy decodes, the load is an Error, never a new game, and nothing is overwritten.
  • slotN.json exists 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 to slotN.json.bak (or, when it is damaged, to slotN.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 against Save as it encodes, and gives an Error instead 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 the Error'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 passes at_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 an Error, 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:.migrated exists 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 writes user:.migrated last, outside saves/, so each computer makes its own copy. One cut short resumes at the next start. An old file that cannot be read is an Error at every start, naming it, and .migrated is 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/sh on Debian and Ubuntu) before the || can run.
  • The .bat shows the log and pauses when the run fails, so the player sees why before the window closes.
  • --user-adopt lets 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.exe is 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: WinAppDataRoaming on Windows, MacAppSupport on macOS and LinuxXdgDataHome on Linux, with the subdirectory com.unabated.tinykeep/saves, and the root overrides that make the three one location.
  • Two patterns: *.json and *.json.bak, so the backup syncs and the temporary (*.new) and *.damaged files stay on the computer that made them.
  • One more entry, not recursive, for .scua-user in com.unabated.tinykeep itself, so a second computer receives the folder's marker too. (--user-adopt covers a computer where it does not arrive.)
  • Only saves/ and the marker, never the whole folder and never mods/.

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.