fix(os): save_dir/config_dir/cache_dir follow the platform, not just macOS

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.
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-11 19:46:47 +03:00
parent c069459343
commit 6bcb5f4ae1
7 changed files with 14780 additions and 14546 deletions

View file

@ -0,0 +1,23 @@
bump: patch
type: fix
**`Os.save_dir` / `Os.config_dir` / `Os.cache_dir` follow the platform.** They were the
macOS layout everywhere, 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 building a path.
| | macOS | Linux |
| --- | --- | --- |
| `save_dir` | `~/Library/Application Support/<app>` | `$XDG_DATA_HOME` or `~/.local/share/<app>` |
| `config_dir` | `~/Library/Application Support/<app>` | `$XDG_CONFIG_HOME` or `~/.config/<app>` |
| `cache_dir` | `~/Library/Caches/<app>` | `$XDG_CACHE_HOME` or `~/.cache/<app>` |
An XDG variable that is set but empty falls back to the default, as the spec requires.
macOS is unchanged to the byte — `save_dir` and `config_dir` stay the same directory
there, because 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.
One consequence worth stating plainly: a game already shipped on Linux was writing to
the old macOS-shaped path, and this moves it. Those files are not migrated for you —
they are wherever `~/Library/Application Support/<app>` ended up on that machine, and a
game that has Linux players should move them once on startup.

View file

@ -11,7 +11,14 @@ ns: Os
member: cache_dir
---
The per-user directory for an application's disposable cache — regenerable data the system may clear under disk pressure. On macOS this is under <code>~/Library/Caches/</code>. Never store anything here you cannot recreate.
The per-user directory for an application's disposable cache — regenerable data the system may clear under disk pressure. Never store anything here you cannot recreate.
| | |
| --- | --- |
| macOS | <code>~/Library/Caches/&lt;app&gt;</code> |
| Linux | <code>$XDG_CACHE_HOME/&lt;app&gt;</code>, or <code>~/.cache/&lt;app&gt;</code> |
An XDG variable that is set but empty falls back to the default, as the spec requires.
Parameters:
- `app` — the application name (used as the folder name)

View file

@ -11,7 +11,14 @@ ns: Os
member: config_dir
---
The per-user directory where an application should store configuration, named for the app. On macOS this resolves under <code>~/Library/Application Support/</code> (the platform keeps config and saves together there).
The per-user directory where an application should store configuration, named for the app.
| | |
| --- | --- |
| macOS | <code>~/Library/Application Support/&lt;app&gt;</code> |
| Linux | <code>$XDG_CONFIG_HOME/&lt;app&gt;</code>, or <code>~/.config/&lt;app&gt;</code> |
On macOS this is the same directory as <a href="os-save_dir"><code>save_dir</code></a> — the platform keeps config and saves together, and Apple's home for a config file that is not an <code>NSUserDefaults</code> plist is Application Support. On Linux XDG separates the two. An XDG variable that is set but empty falls back to the default, as the spec requires.
Parameters:
- `app` — the application name (used as the folder name)

View file

@ -11,7 +11,16 @@ ns: Os
member: save_dir
---
The per-user directory where an application should store save files, named for the app. On macOS this is under <code>~/Library/Application Support/</code>. The path is returned as a string; creating the directory and reading/writing files is the filesystem library's job (see the <a href="os-config_dir"><code>config_dir</code></a>/<a href="os-cache_dir"><code>cache_dir</code></a> companions).
The per-user directory where an application should store save files, named for the app. Which directory that is, is the platform's decision, not the program's:
| | |
| --- | --- |
| macOS | <code>~/Library/Application Support/&lt;app&gt;</code> |
| Linux | <code>$XDG_DATA_HOME/&lt;app&gt;</code>, or <code>~/.local/share/&lt;app&gt;</code> |
An XDG variable that is set but empty falls back to the default, as the spec requires. The path is returned as a string; creating the directory and reading/writing files is the filesystem library's job (see the <a href="os-config_dir"><code>config_dir</code></a>/<a href="os-cache_dir"><code>cache_dir</code></a> companions).
On macOS <code>save_dir</code> and <code>config_dir</code> are the same directory — Apple's home for a config file that is not an <code>NSUserDefaults</code> plist is Application Support too. On Linux XDG separates them, and so does this.
Parameters:
- `app` — the application name (used as the folder name)

View file

@ -132,6 +132,14 @@ function emit_os_prelude() -> void {
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")
@ -156,10 +164,52 @@ function emit_os_prelude() -> void {
# 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 (macOS/BSD layout)
emith(`define ptr @lp_os_save_dir(ptr %app) {{\n %h = call ptr @lp_os_home()\n %r = call ptr @lp_os_join3(ptr %h, ptr {k_appsp}, ptr %app)\n ret ptr %r\n}}\n`)
emith(`define ptr @lp_os_config_dir(ptr %app) {{\n %h = call ptr @lp_os_home()\n %r = call ptr @lp_os_join3(ptr %h, ptr {k_appsp}, ptr %app)\n ret ptr %r\n}}\n`)
emith(`define ptr @lp_os_cache_dir(ptr %app) {{\n %h = call ptr @lp_os_home()\n %r = call ptr @lp_os_join3(ptr %h, ptr {k_cache}, ptr %app)\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

File diff suppressed because it is too large Load diff

View file

@ -157,6 +157,51 @@ function pack_probe_src() -> pointer {
return s
}
# Os.save_dir / config_dir / cache_dir have to name the directory the PLATFORM keeps
# those things in - that is the entire reason a program calls them instead of building a
# path. They were the macOS layout on every platform, so a game built on Linux wrote its
# saves to ~/Library/Application Support, which is a directory that means nothing there.
#
# The assert is the host's own convention, so a macOS dev box covers the Apple branch and
# CI covers XDG. The env cases matter as much as the defaults: XDG_* must be honoured
# where it applies and ignored where it does not, and a variable that is set but EMPTY
# falls back, which is what the spec says and what an exported-but-unset variable looks
# like from a shell.
function os_dirs_case() -> void {
let lbl = "Os.save_dir / config_dir / cache_dir follow the platform's own convention"
let work = `{tmp_dir()}/osdirs`
let root = capture_line("pwd")
run(`rm -rf {work} && mkdir -p {work}`)
var src = "program OsDirs {" + nl()
src = src + " entry {" + nl()
src = src + " print(`{Os.save_dir(\"App X\")}|{Os.config_dir(\"App X\")}|{Os.cache_dir(\"App X\")}`)" + nl()
src = src + " }" + nl()
src = src + "}" + nl()
write_file(`{work}/d.ludic`, src)
if not shq(`cd {work} && {root}/bin/ludicc d.ludic -o d > cc.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/cc.out`)); return
}
let home = capture_line("printf %s \"$HOME\"")
# with every XDG variable set: honoured on Linux, irrelevant on macOS
let set_env = "XDG_DATA_HOME=/x/d XDG_CONFIG_HOME=/x/c XDG_CACHE_HOME=/x/k"
let with_xdg = s_trim(capture(`cd {work} && env {set_env} ./d`))
# with none of them set, and with them set but empty
let no_xdg = s_trim(capture(`cd {work} && env -u XDG_DATA_HOME -u XDG_CONFIG_HOME -u XDG_CACHE_HOME ./d`))
let empty_xdg = s_trim(capture(`cd {work} && env XDG_DATA_HOME= XDG_CONFIG_HOME= XDG_CACHE_HOME= ./d`))
var want_plain = `{home}/.local/share/App X|{home}/.config/App X|{home}/.cache/App X`
var want_set = "/x/d/App X|/x/c/App X|/x/k/App X"
if is_darwin() {
# Apple keeps saves and config together; XDG has no standing here at all
want_plain = `{home}/Library/Application Support/App X|{home}/Library/Application Support/App X|{home}/Library/Caches/App X`
want_set = want_plain
}
if no_xdg != want_plain { bad2(lbl, `no XDG: got [{no_xdg}], wanted [{want_plain}]`); return }
if with_xdg != want_set { bad2(lbl, `XDG set: got [{with_xdg}], wanted [{want_set}]`); return }
if empty_xdg != want_plain { bad2(lbl, `XDG empty: got [{empty_xdg}], wanted [{want_plain}]`); return }
ok(lbl)
}
# `.packignore` — the pack's exclude file, with gitignore's semantics. The feature it
# exists for is that a pack root is packed wholesale: the preview renders, bake
# intermediates and .blend files a model pipeline leaves beside its meshes all shipped,
@ -673,6 +718,7 @@ function cmd_dev_test() -> int {
scaffold_names_case()
pack_roundtrip_case()
packignore_case()
os_dirs_case()
bundle_case()
# install.sh is what the landing page tells people to pipe into sh, and it is