# 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 # Os.heap_bytes() -> long malloc's live bytes (macOS; 0 elsewhere): a test that a # path allocates nothing, with no renderer linked in # Os.heap_relief() -> long give the heap's freed-but-cached memory back to the system # (macOS malloc_zone_pressure_relief, Windows HeapCompact; # 0 elsewhere): once after a load, never per frame # # 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 } if (meth == "heap_bytes") or (meth == "heap_relief") { 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 == "heap_bytes") { if g_target_win { return val("0", "long") } g_uses_heap = true return val(emit_bind("call i64 @lp_os_heap_bytes()"), "long") } if (meth == "heap_relief") { g_uses_relief = true return val(emit_bind("call i64 @lp_os_heap_relief()"), "long") } 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 fd = 1 if (meth == "stderr_write") { fd = 2 } let s = emit_expr(e.kids[0]) let f = emit_bind(stdstream_rhs(fd)) 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 fresh_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 fresh_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 fresh_val(emit_bind(`call ptr @lp_os_cache_dir(ptr {a.code})`), "string") } # temp_dir: the environment's own text on POSIX, a copy of its own on Windows if g_target_win { return fresh_val(emit_bind("call ptr @lp_os_temp_dir()"), "string") } 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 { if not g_target_win { # defined by emit_win_prelude on Windows 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 @lp_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 call void @lp_free(ptr %ab)\n ret ptr %r\n}\n") # Windows has its own known folders and no uname; emit_win.ludic supplies them if g_target_win { emit_os_win_known(k_dot, k_slash); return } # 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/ # cache ~/Library/Caches/ # Linux save $XDG_DATA_HOME or ~/.local/share/ # config $XDG_CONFIG_HOME or ~/.config/ # cache $XDG_CACHE_HOME or ~/.cache/ # # 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 @lp_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 @lp_free(ptr %buf)\n store i32 %r, ptr @L_os_darwin\n ret i32 %r\n}\n") # $ when it is set and non-empty, else $HOME; 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 call void @lp_free(ptr %b)\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 @lp_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") # uname once, into a buffer kept for the program's life: platform() and arch() are asked every # frame by some callers, and each call once malloc'd 8 KB for it and let it go emith("@L_os_uts = internal global ptr null\n") emith("define ptr @lp_os_uts() {\n") emith("entry:\n %c = load ptr, ptr @L_os_uts\n %have = icmp ne ptr %c, null\n br i1 %have, label %done, label %make\n") emith("done:\n ret ptr %c\n") emith("make:\n %buf = call ptr @lp_malloc(i64 8192)\n call i32 @uname(ptr %buf)\n store ptr %buf, ptr @L_os_uts\n ret ptr %buf\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 @lp_os_uts()\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 @lp_os_uts()\n") emith(" %m = getelementptr i8, ptr %buf, i64 1024\n ret ptr %m\n}\n") } # Os.heap_relief: what the allocator keeps cached after a free (a large block libmalloc holds EMPTY) # handed back, the bytes it says it released. Weak outside Windows, so a libc without it reads 0 function emit_os_relief() -> void { emith("define i64 @lp_os_heap_relief() {\nentry:\n") if g_target_win { emith(" %h = call ptr @GetProcessHeap()\n %n = call i64 @HeapCompact(ptr %h, i32 0)\n ret i64 %n\n}\n") emith("declare ptr @GetProcessHeap()\ndeclare i64 @HeapCompact(ptr, i32)\n") return } emith(" %mz = icmp ne ptr @malloc_zone_pressure_relief, null\n br i1 %mz, label %mac, label %none\n") emith("mac:\n %n = call i64 @malloc_zone_pressure_relief(ptr null, i64 0)\n ret i64 %n\n") emith("none:\n ret i64 0\n}\n") emith("declare extern_weak i64 @malloc_zone_pressure_relief(ptr, i64)\n") } # Os.heap_bytes: malloc_zone_statistics' in-use bytes, all zones. The symbol is weak, so a libc # without it links and reads 0. function emit_os_heap() -> void { if not g_mzs_declared { emith("declare extern_weak void @malloc_zone_statistics(ptr, ptr)\n"); g_mzs_declared = true } emith("define i64 @lp_os_heap_bytes() {\n") emith("entry:\n %has = icmp ne ptr @malloc_zone_statistics, null\n br i1 %has, label %read, label %none\n") emith("none:\n ret i64 0\n") emith("read:\n %st = alloca [4 x i64], align 8\n call void @malloc_zone_statistics(ptr null, ptr %st)\n") emith(" %p = getelementptr [4 x i64], ptr %st, i32 0, i32 1\n %n = load i64, ptr %p\n ret i64 %n\n}\n") }