They were the macOS layout on every platform - the comment above them even said
"macOS/BSD layout" - so a game built on Linux wrote its saves to
~/Library/Application Support, a directory that means nothing there. Naming the
right directory is the entire reason a program calls these instead of joining a
path itself.
macOS save/config ~/Library/Application Support/<app>
cache ~/Library/Caches/<app>
Linux save $XDG_DATA_HOME or ~/.local/share/<app>
config $XDG_CONFIG_HOME or ~/.config/<app>
cache $XDG_CACHE_HOME or ~/.cache/<app>
macOS is unchanged to the byte, deliberately. save_dir and config_dir stay the
same directory there: Apple's home for a config file that is not an
NSUserDefaults plist is Application Support too, and a shipped game's settings
must not move out from under it. On Linux XDG separates the two and so does this.
An XDG variable that is set but EMPTY falls back to the default, which is what
the spec says and what an exported-but-unset variable looks like from a shell.
uname is read once and remembered rather than per call, because save_dir is
called on every save.
Verified on both branches. macOS by running it; the Linux branch by building the
probe's IR with the cached platform flag pinned, which exercises the emitted XDG
code exactly - unset, set, and set-but-empty all resolve as the spec says. The
suite's own case asserts the HOST's convention, so a macOS box covers the Apple
branch and CI covers XDG.
This is a backend change, so selfhost/ludicc.seed.ll is reseeded with it: the
bootstrap fixpoint (gen2 == gen3) and the C-free rebuild from the seed both pass.
240 lines
14 KiB
Text
240 lines
14 KiB
Text
# emit_os.ludic — the Os.* namespace: the environment *around* the game — the
|
|
# command line, environment variables, standard streams, process exit, the host
|
|
# platform, and the per-user known folders a game writes into. Go-flavored and
|
|
# game-scoped: no process spawning, signals, or permission APIs — just the facts
|
|
# a launcher, an asset pipeline, or a save system needs.
|
|
#
|
|
# Os.args() -> []string every command-line argument (argv[0..])
|
|
# Os.arg_count() -> int how many arguments there are
|
|
# Os.arg(i) -> string the i-th argument (0 = the program path)
|
|
# Os.env(name) -> string an environment variable, or null if unset
|
|
# Os.env_or(name, fb) -> string ...or `fb` when it is unset/empty-null
|
|
# Os.has_env(name) -> bool is the variable set?
|
|
# Os.set_env(name, val) -> bool set it (true on success)
|
|
# Os.unset_env(name) -> bool remove it (true on success)
|
|
# Os.exit(code) terminate the process with a status code
|
|
# Os.pid() -> int this process's id (unique among live processes)
|
|
# Os.platform() -> string "macos" | "linux" | ...(raw uname sysname)
|
|
# Os.arch() -> string machine arch, e.g. "arm64" | "x86_64"
|
|
# Os.stdout_write(s) write a string to standard output
|
|
# Os.stderr_write(s) write a string to standard error
|
|
# Os.save_dir(app) -> string per-user save directory for `app`
|
|
# Os.config_dir(app) -> string per-user config directory for `app`
|
|
# Os.cache_dir(app) -> string per-user cache directory for `app`
|
|
# Os.temp_dir() -> string the system temporary directory
|
|
#
|
|
# Determinism: args/env/platform are non-deterministic host input — read them at
|
|
# startup to configure the game, but keep them out of the replayable simulation.
|
|
#
|
|
# Platform coverage: this v1 targets the native (macOS/BSD) host, the only fully
|
|
# supported target today. platform() is portable (uname sysname is field 0 on
|
|
# every Unix); arch() and the known-folder layout assume the macOS/BSD utsname
|
|
# and directory conventions. Linux/Windows/wasm known-folder resolution and a
|
|
# target-aware arch() are documented follow-ups (see issue #21).
|
|
|
|
function is_os_ns(meth: pointer) -> bool {
|
|
if (meth == "args") or (meth == "arg_count") or (meth == "arg") { return true }
|
|
if (meth == "env") or (meth == "env_or") or (meth == "has_env") { return true }
|
|
if (meth == "set_env") or (meth == "unset_env") { return true }
|
|
if (meth == "exit") or (meth == "pid") or (meth == "platform") or (meth == "arch") { return true }
|
|
if (meth == "stdout_write") or (meth == "stderr_write") { return true }
|
|
if (meth == "save_dir") or (meth == "config_dir") or (meth == "cache_dir") or (meth == "temp_dir") { return true }
|
|
return false
|
|
}
|
|
|
|
function emit_os_ns(meth: pointer, e: Node) -> Val {
|
|
# arg_count / arg / exit / pid stay light — they mirror the bare intrinsics and
|
|
# need no Os runtime prelude, so a program using only these emits no extra IR.
|
|
if (meth == "arg_count") { return val(emit_bind("load i32, ptr @L_argc"), "int") }
|
|
if (meth == "arg") {
|
|
let i = emit_expr(e.kids[0])
|
|
let v = emit_bind("load ptr, ptr @L_argv")
|
|
let q = emit_bind(`getelementptr ptr, ptr {v}, i32 {i.code}`)
|
|
return val(emit_bind(`load ptr, ptr {q}`), "string")
|
|
}
|
|
if (meth == "pid") { g_uses_pid = true; return val(emit_bind("call i32 @getpid()"), "int") }
|
|
if (meth == "exit") {
|
|
let n = emit_expr(e.kids[0])
|
|
emit(` call void @exit(i32 {n.code})\n`)
|
|
emit(" unreachable\n")
|
|
g_term = true
|
|
return val("0", "void")
|
|
}
|
|
if (meth == "env") { # raw getenv: null when unset
|
|
let nm = emit_expr(e.kids[0])
|
|
return val(emit_bind(`call ptr @getenv(ptr {nm.code})`), "string")
|
|
}
|
|
if (meth == "has_env") {
|
|
let nm = emit_expr(e.kids[0])
|
|
let r = emit_bind(`call ptr @getenv(ptr {nm.code})`)
|
|
let ne = emit_bind(`icmp ne ptr {r}, null`)
|
|
return val(emit_bind(`zext i1 {ne} to i32`), "bool")
|
|
}
|
|
if (meth == "stdout_write") or (meth == "stderr_write") {
|
|
var strm = "@__stdoutp"
|
|
if (meth == "stderr_write") { strm = "@__stderrp" }
|
|
let s = emit_expr(e.kids[0])
|
|
let f = emit_bind(`load ptr, ptr {strm}`)
|
|
let n = emit_bind(`call i64 @strlen(ptr {s.code})`)
|
|
emit(` call i64 @fwrite(ptr {s.code}, i64 1, i64 {n}, ptr {f})\n`)
|
|
return val("0", "void")
|
|
}
|
|
# everything below is served by the Os runtime prelude
|
|
g_uses_osrt = true
|
|
if (meth == "args") { return val(emit_bind("call ptr @lp_os_args()"), "[]string") }
|
|
if (meth == "env_or") {
|
|
let nm = emit_expr(e.kids[0]); let fb = emit_expr(e.kids[1])
|
|
return val(emit_bind(`call ptr @lp_os_getenv_or(ptr {nm.code}, ptr {fb.code})`), "string")
|
|
}
|
|
if (meth == "set_env") {
|
|
let nm = emit_expr(e.kids[0]); let v = emit_expr(e.kids[1])
|
|
let r = emit_bind(`call i32 @setenv(ptr {nm.code}, ptr {v.code}, i32 1)`)
|
|
let ok = emit_bind(`icmp eq i32 {r}, 0`)
|
|
return val(emit_bind(`zext i1 {ok} to i32`), "bool")
|
|
}
|
|
if (meth == "unset_env") {
|
|
let nm = emit_expr(e.kids[0])
|
|
let r = emit_bind(`call i32 @unsetenv(ptr {nm.code})`)
|
|
let ok = emit_bind(`icmp eq i32 {r}, 0`)
|
|
return val(emit_bind(`zext i1 {ok} to i32`), "bool")
|
|
}
|
|
if (meth == "platform") { return val(emit_bind("call ptr @lp_os_platform()"), "string") }
|
|
if (meth == "arch") { return val(emit_bind("call ptr @lp_os_arch()"), "string") }
|
|
if (meth == "save_dir") {
|
|
let a = emit_expr(e.kids[0])
|
|
return val(emit_bind(`call ptr @lp_os_save_dir(ptr {a.code})`), "string")
|
|
}
|
|
if (meth == "config_dir") {
|
|
let a = emit_expr(e.kids[0])
|
|
return val(emit_bind(`call ptr @lp_os_config_dir(ptr {a.code})`), "string")
|
|
}
|
|
if (meth == "cache_dir") {
|
|
let a = emit_expr(e.kids[0])
|
|
return val(emit_bind(`call ptr @lp_os_cache_dir(ptr {a.code})`), "string")
|
|
}
|
|
# temp_dir
|
|
return val(emit_bind("call ptr @lp_os_temp_dir()"), "string")
|
|
}
|
|
|
|
# emit_os_prelude — the Os runtime, emitted once per program that uses the
|
|
# prelude-backed Os.* methods (g_uses_osrt). Pure libc over NUL-terminated
|
|
# strings; declares the few POSIX symbols the header does not already carry.
|
|
function emit_os_prelude() -> void {
|
|
emith("declare i32 @setenv(ptr, ptr, i32)\n")
|
|
emith("declare i32 @unsetenv(ptr)\n")
|
|
emith("declare i32 @uname(ptr)\n")
|
|
|
|
# string constants (escaped + length-counted by emit_str_const)
|
|
let k_home = emit_str_const("HOME")
|
|
let k_dot = emit_str_const(".")
|
|
let k_appsp = emit_str_const("/Library/Application Support/")
|
|
let k_cache = emit_str_const("/Library/Caches/")
|
|
let k_tmpk = emit_str_const("TMPDIR")
|
|
let k_tmp = emit_str_const("/tmp")
|
|
let k_darw = emit_str_const("Darwin")
|
|
# XDG (Linux): the variable, and the fallback the spec names for it
|
|
let k_xdg_data = emit_str_const("XDG_DATA_HOME")
|
|
let k_xdg_cfg = emit_str_const("XDG_CONFIG_HOME")
|
|
let k_xdg_cache = emit_str_const("XDG_CACHE_HOME")
|
|
let k_dot_share = emit_str_const("/.local/share/")
|
|
let k_dot_config = emit_str_const("/.config/")
|
|
let k_dot_cache = emit_str_const("/.cache/")
|
|
let k_slash = emit_str_const("/")
|
|
let k_macos = emit_str_const("macos")
|
|
let k_linux_k = emit_str_const("Linux")
|
|
let k_linux = emit_str_const("linux")
|
|
|
|
# getenv(name) or a fallback when it is unset
|
|
emith("define ptr @lp_os_getenv_or(ptr %name, ptr %fb) {\n")
|
|
emith("entry:\n %r = call ptr @getenv(ptr %name)\n %z = icmp eq ptr %r, null\n br i1 %z, label %use, label %got\n")
|
|
emith("use:\n ret ptr %fb\n")
|
|
emith("got:\n ret ptr %r\n}\n")
|
|
|
|
# concatenate two NUL-terminated strings into a fresh malloc'd buffer
|
|
emith("define ptr @lp_os_join2(ptr %a, ptr %b) {\n")
|
|
emith("entry:\n %la = call i64 @strlen(ptr %a)\n %lb = call i64 @strlen(ptr %b)\n")
|
|
emith(" %sum = add i64 %la, %lb\n %tot = add i64 %sum, 1\n %m = call ptr @malloc(i64 %tot)\n")
|
|
emith(" call ptr @memcpy(ptr %m, ptr %a, i64 %la)\n")
|
|
emith(" %m2 = getelementptr i8, ptr %m, i64 %la\n call ptr @memcpy(ptr %m2, ptr %b, i64 %lb)\n")
|
|
emith(" %end = getelementptr i8, ptr %m, i64 %sum\n store i8 0, ptr %end\n ret ptr %m\n}\n")
|
|
|
|
emith("define ptr @lp_os_join3(ptr %a, ptr %b, ptr %c) {\n")
|
|
emith("entry:\n %ab = call ptr @lp_os_join2(ptr %a, ptr %b)\n %r = call ptr @lp_os_join2(ptr %ab, ptr %c)\n ret ptr %r\n}\n")
|
|
|
|
# the user's home directory, or "." when HOME is unset
|
|
emith(`define ptr @lp_os_home() {{\n %r = call ptr @lp_os_getenv_or(ptr {k_home}, ptr {k_dot})\n ret ptr %r\n}}\n`)
|
|
|
|
# ---- per-user known folders ------------------------------------------------
|
|
#
|
|
# These used to be the macOS layout on every platform, so a game built on Linux
|
|
# wrote its saves to ~/Library/Application Support - a path that means nothing
|
|
# there. The directory a platform keeps save games in is not a detail a game
|
|
# should have to know, which is the whole reason these functions exist.
|
|
#
|
|
# macOS save/config ~/Library/Application Support/<app>
|
|
# cache ~/Library/Caches/<app>
|
|
# Linux save $XDG_DATA_HOME or ~/.local/share/<app>
|
|
# config $XDG_CONFIG_HOME or ~/.config/<app>
|
|
# cache $XDG_CACHE_HOME or ~/.cache/<app>
|
|
#
|
|
# macOS keeps save_dir and config_dir the same path, as it always has: Apple's
|
|
# place for a config file that is not an NSUserDefaults plist is Application
|
|
# Support too, and a game that shipped on the old behaviour must not have its
|
|
# settings moved out from under it. On Linux XDG separates the two and so do we.
|
|
#
|
|
# An XDG variable that is set but EMPTY falls back to the default, which is what
|
|
# the spec says and what a shell that exports an unset variable produces.
|
|
|
|
# is this macOS? uname once, then remembered - save_dir is called per save.
|
|
emith("@L_os_darwin = internal global i32 -1\n")
|
|
emith("define i32 @lp_os_is_darwin() {\n")
|
|
emith("entry:\n %c = load i32, ptr @L_os_darwin\n %known = icmp sge i32 %c, 0\n br i1 %known, label %done, label %probe\n")
|
|
emith("done:\n ret i32 %c\n")
|
|
emith("probe:\n %buf = call ptr @malloc(i64 8192)\n call i32 @uname(ptr %buf)\n")
|
|
emith(` %cd = call i32 @strncmp(ptr %buf, ptr {k_darw}, i64 6)\n %isd = icmp eq i32 %cd, 0\n`)
|
|
emith(" %r = select i1 %isd, i32 1, i32 0\n call void @free(ptr %buf)\n store i32 %r, ptr @L_os_darwin\n ret i32 %r\n}\n")
|
|
|
|
# $<var> when it is set and non-empty, else $HOME<rel>; the result always ends in '/'
|
|
emith("define ptr @lp_os_xdg(ptr %var, ptr %rel) {\n")
|
|
emith("entry:\n %v = call ptr @getenv(ptr %var)\n %z = icmp eq ptr %v, null\n br i1 %z, label %dflt, label %chk\n")
|
|
emith("chk:\n %c0 = load i8, ptr %v\n %empty = icmp eq i8 %c0, 0\n br i1 %empty, label %dflt, label %use\n")
|
|
emith(`use:\n %u = call ptr @lp_os_join2(ptr %v, ptr {k_slash})\n ret ptr %u\n`)
|
|
emith("dflt:\n %h = call ptr @lp_os_home()\n %d = call ptr @lp_os_join2(ptr %h, ptr %rel)\n ret ptr %d\n}\n")
|
|
|
|
# one known folder: the macOS path, or the XDG one
|
|
emith("define ptr @lp_os_known(ptr %app, ptr %mac, ptr %var, ptr %rel) {\n")
|
|
emith("entry:\n %d = call i32 @lp_os_is_darwin()\n %isd = icmp ne i32 %d, 0\n br i1 %isd, label %m, label %x\n")
|
|
emith("m:\n %h = call ptr @lp_os_home()\n %r1 = call ptr @lp_os_join3(ptr %h, ptr %mac, ptr %app)\n ret ptr %r1\n")
|
|
emith("x:\n %b = call ptr @lp_os_xdg(ptr %var, ptr %rel)\n %r2 = call ptr @lp_os_join2(ptr %b, ptr %app)\n ret ptr %r2\n}\n")
|
|
|
|
emith(`define ptr @lp_os_save_dir(ptr %app) {{\n %r = call ptr @lp_os_known(ptr %app, ptr {k_appsp}, ptr {k_xdg_data}, ptr {k_dot_share})\n ret ptr %r\n}}\n`)
|
|
emith(`define ptr @lp_os_config_dir(ptr %app) {{\n %r = call ptr @lp_os_known(ptr %app, ptr {k_appsp}, ptr {k_xdg_cfg}, ptr {k_dot_config})\n ret ptr %r\n}}\n`)
|
|
emith(`define ptr @lp_os_cache_dir(ptr %app) {{\n %r = call ptr @lp_os_known(ptr %app, ptr {k_cache}, ptr {k_xdg_cache}, ptr {k_dot_cache})\n ret ptr %r\n}}\n`)
|
|
emith(`define ptr @lp_os_temp_dir() {{\n %r = call ptr @lp_os_getenv_or(ptr {k_tmpk}, ptr {k_tmp})\n ret ptr %r\n}}\n`)
|
|
|
|
# Os.args() -> a %LSlice of the argv strings (data = argv, len = cap = argc), a
|
|
# snapshot the caller may iterate or index like any other []string.
|
|
emith("define ptr @lp_os_args() {\n")
|
|
emith("entry:\n %c = load i32, ptr @L_argc\n %v = load ptr, ptr @L_argv\n")
|
|
emith(" %h = call ptr @malloc(i64 16)\n")
|
|
emith(" %d0 = getelementptr inbounds %LSlice, ptr %h, i32 0, i32 0\n store ptr %v, ptr %d0\n")
|
|
emith(" %d1 = getelementptr inbounds %LSlice, ptr %h, i32 0, i32 1\n store i32 %c, ptr %d1\n")
|
|
emith(" %d2 = getelementptr inbounds %LSlice, ptr %h, i32 0, i32 2\n store i32 %c, ptr %d2\n")
|
|
emith(" ret ptr %h\n}\n")
|
|
|
|
# platform(): uname sysname (field 0, portable) mapped to a short id
|
|
emith("define ptr @lp_os_platform() {\n")
|
|
emith("entry:\n %buf = call ptr @malloc(i64 8192)\n call i32 @uname(ptr %buf)\n")
|
|
emith(` %cd = call i32 @strncmp(ptr %buf, ptr {k_darw}, i64 6)\n %isd = icmp eq i32 %cd, 0\n br i1 %isd, label %mac, label %chkl\n`)
|
|
emith(`mac:\n ret ptr {k_macos}\n`)
|
|
emith(`chkl:\n %cl = call i32 @strncmp(ptr %buf, ptr {k_linux_k}, i64 5)\n %isl = icmp eq i32 %cl, 0\n br i1 %isl, label %lin, label %other\n`)
|
|
emith(`lin:\n ret ptr {k_linux}\n`)
|
|
emith("other:\n ret ptr %buf\n}\n")
|
|
|
|
# arch(): the uname `machine` field. On macOS/BSD utsname each field is 256
|
|
# bytes, so `machine` (index 4) sits at offset 1024. Documented BSD-layout
|
|
# assumption (see the header note); other layouts are a follow-up.
|
|
emith("define ptr @lp_os_arch() {\n")
|
|
emith("entry:\n %buf = call ptr @malloc(i64 8192)\n call i32 @uname(ptr %buf)\n")
|
|
emith(" %m = getelementptr i8, ptr %buf, i64 1024\n ret ptr %m\n}\n")
|
|
}
|