feat(bundle): ship a game as a macOS .app, with a splash it controls

`ludic build` produces a program. Double-clicked it opens a Terminal window, it
wears the generic executable icon, it calls itself whatever the file is called,
and it carries none of its assets. `ludic bundle` produces an application.

Everything it needs is in package.ludic, so the command takes no arguments: an
Info.plist and PkgInfo from `app` lines, an .icns built by sips and iconutil at
all ten sizes macOS asks for from a single source PNG, the asset pack in
Contents/Resources, and an ad-hoc signature - which is not optional on Apple
silicon, where an unsigned binary is killed rather than warned about. The bundle
identifier falls back to the package path reversed, so a project that never
thinks about it still gets a defensible one instead of two apps sharing a key
Launch Services hangs the Dock, saved state and permissions off.

A bundled game is moved to ~/Library/Application Support/<name> before main,
because Finder starts a .app with its working directory at "/" where no save
could ever be written. Reads come out of the pack, writes land somewhere real
and per-user, and the game's save code needs no change and no platform
knowledge.

The splash is the other half of looking like an application. A game that loads
165 MB spends a visible moment doing it with nothing on screen, which from the
outside is indistinguishable from a launch that failed. splash_show puts a
borderless window up from the same constructor that mounts the pack - before
main, so it appears while the process is still starting rather than after the
slow part it exists to cover - and reads the artwork out of the pack like any
other asset. It turns the run loop enough times to be mapped and composited
there and then; once composited the backing store survives a busy main thread,
so it stays up for the whole load.

Nothing hides it automatically. Only the game knows when its first real frame is
ready, and a splash that vanishes before that leaves the same black gap it was
covering, so the game calls App.splash_hide(). Headless there is no splash and
the call lowers to nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-10 16:57:27 +03:00
parent ce5bf0fe0e
commit be74b4de6f
14 changed files with 34667 additions and 33621 deletions

View file

@ -234,6 +234,23 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
# through a freelist (L_alloc pops @L_freen before growing @L_entc), and component
# storage is fixed per-entity arrays — so there is no per-spawn heap allocation or
# fragmentation. Pool.* just reads those counters so a game can watch reuse.
# App.* — the process itself, as distinct from the window. Today that is the
# boot splash: the runtime raises it before main from the pack, and only the
# game knows when its first real frame is ready to replace it, so taking it
# down is the game's call and never the runtime's.
#
# Headless there is no splash and no cocoa.ll to hold one, so the call lowers
# to nothing rather than to a symbol that would not link.
if (ns == "App") {
if (meth == "splash_hide") {
# the declaration lives in the pack prelude, and a splash is a packed
# asset, so asking to dismiss one asks for the runtime that raised it
use_pak()
if g_windowed { emit(" call void @splash_hide()\n") }
return val("0", "void")
}
perr(`unknown builtin App.{meth}`)
}
if (ns == "Pool") {
if (meth == "capacity") { return val(itoa(MAX_ENT), "int") } # max entities
if (meth == "reserved") { return val(emit_bind("load i32, ptr @L_entc"), "int") } # slots ever allocated (high-water)

View file

@ -58,6 +58,12 @@
#
# pack game.lpak mount this, in this order
# home Maroon Lake chdir to ~/Library/Application Support/Maroon Lake
# splash assets/splash.png show this until the game says otherwise
# splashbg 858370 the colour behind it, 0xRRGGBB as a decimal
#
# splashbg is decimal because the runtime parses it with atoi, which is already
# declared; asking it to parse hex would be a hex parser in LLVM IR to save the
# bundler one conversion.
#
# The order is explicit rather than a sorted glob because it is load-bearing:
# packs mount in the order listed and a later one shadows an earlier one, which
@ -101,6 +107,17 @@ function emit_pak_prelude() -> void {
# up to 8 packs may be mounted; the base pointer of each mapping, in mount order
emith("@L_pak_base = internal global [8 x ptr] zeroinitializer\n")
emith("@L_pak_n = internal global i32 0\n")
# the splash, named by packs.index and served out of the pack like any asset
emith("@L_pak_splash = internal global ptr null\n")
emith("@L_pak_splashbg = internal global i32 0\n")
# The splash lives in cocoa.ll, which is linked only into a windowed binary.
# A headless build must not so much as name it, so the whole thing is emitted
# behind this flag rather than guarded at runtime.
if g_windowed {
emith("declare void @splash_show(ptr, i32, i32)\n")
emith("declare void @splash_hide()\n")
}
emit_pak_mount()
emit_pak_lookup()
@ -232,13 +249,21 @@ function emit_pak_boot() -> void {
emith(" %ln = getelementptr i8, ptr %txt, i32 %i\n %isp = call i32 @strncmp(ptr %ln, ptr @.pak_kw_pack, i64 5)\n %pk = icmp eq i32 %isp, 0\n br i1 %pk, label %dopack, label %chkhome\n")
emith("dopack:\n %pv = getelementptr i8, ptr %ln, i32 5\n %full = call ptr @lp_path_join(ptr %dir, ptr %pv)\n %ok = call i32 @lp_pak_mount(ptr %full)\n")
emith(" %g = load i32, ptr %got\n %g1 = add i32 %g, %ok\n store i32 %g1, ptr %got\n br label %line\n")
emith("chkhome:\n %ish = call i32 @strncmp(ptr %ln, ptr @.pak_kw_home, i64 5)\n %hm = icmp eq i32 %ish, 0\n br i1 %hm, label %dohome, label %line\n")
emith("chkhome:\n %ish = call i32 @strncmp(ptr %ln, ptr @.pak_kw_home, i64 5)\n %hm = icmp eq i32 %ish, 0\n br i1 %hm, label %dohome, label %chkspbg\n")
emith("dohome:\n %hv = getelementptr i8, ptr %ln, i32 5\n call void @lp_pak_sethome(ptr %hv)\n br label %line\n")
# splashbg before splash: they share a prefix, and only the byte at 6 tells
# them apart, so the longer keyword has to be offered the line first
emith("chkspbg:\n %isb = call i32 @strncmp(ptr %ln, ptr @.pak_kw_spbg, i64 9)\n %bg = icmp eq i32 %isb, 0\n br i1 %bg, label %dospbg, label %chksp\n")
emith("dospbg:\n %bv = getelementptr i8, ptr %ln, i32 9\n %bi = call i32 @atoi(ptr %bv)\n store i32 %bi, ptr @L_pak_splashbg\n br label %line\n")
emith("chksp:\n %isS = call i32 @strncmp(ptr %ln, ptr @.pak_kw_splash, i64 7)\n %sp = icmp eq i32 %isS, 0\n br i1 %sp, label %dosp, label %line\n")
emith("dosp:\n %sv = getelementptr i8, ptr %ln, i32 7\n store ptr %sv, ptr @L_pak_splash\n br label %line\n")
emith("fin:\n %gf = load i32, ptr %got\n ret i32 %gf\n")
emith("no:\n ret i32 0\n}\n")
emith("@.pak_kw_pack = private unnamed_addr constant [6 x i8] c\"pack \\00\"\n")
emith("@.pak_kw_home = private unnamed_addr constant [6 x i8] c\"home \\00\"\n")
emith("@.pak_kw_pack = private unnamed_addr constant [6 x i8] c\"pack \\00\"\n")
emith("@.pak_kw_home = private unnamed_addr constant [6 x i8] c\"home \\00\"\n")
emith("@.pak_kw_splash = private unnamed_addr constant [8 x i8] c\"splash \\00\"\n")
emith("@.pak_kw_spbg = private unnamed_addr constant [10 x i8] c\"splashbg \\00\"\n")
# point the process at ~/Library/Application Support/<name>, creating it. A
# bundled game starts with its working directory at "/", so without this every
@ -256,7 +281,24 @@ function emit_pak_boot() -> void {
emith("entry:\n %buf = alloca [1024 x i8]\n %szp = alloca i32\n store i32 1024, ptr %szp\n")
emith(" %rc = call i32 @_NSGetExecutablePath(ptr %buf, ptr %szp)\n %bad = icmp ne i32 %rc, 0\n br i1 %bad, label %out, label %go\n")
emith("go:\n %dir = call ptr @lp_path_dir(ptr %buf)\n %res = call ptr @lp_path_join(ptr %dir, ptr @.pak_res)\n")
emith(" %n1 = call i32 @lp_pak_boot_dir(ptr %res)\n %any = icmp sgt i32 %n1, 0\n br i1 %any, label %out, label %try2\n")
emith("try2:\n %n2 = call i32 @lp_pak_boot_dir(ptr %dir)\n br label %out\n")
emith(" %n1 = call i32 @lp_pak_boot_dir(ptr %res)\n %any = icmp sgt i32 %n1, 0\n br i1 %any, label %splash, label %try2\n")
emith("try2:\n %n2 = call i32 @lp_pak_boot_dir(ptr %dir)\n br label %splash\n")
emith("splash:\n")
if g_windowed { emith(" call void @lp_pak_splash_go()\n") }
emith(" br label %out\n")
emith("out:\n ret void\n}\n")
# The splash is an asset like any other: named by packs.index, found in the
# pack by the same lookup the game's own loads use, and handed to AppKit as
# bytes. Nothing here knows what format it is - NSImage reads PNG, JPEG and
# TIFF from the same NSData without being told.
if g_windowed {
emith("define void @lp_pak_splash_go() {\n")
emith("entry:\n %sp = load ptr, ptr @L_pak_splash\n %none = icmp eq ptr %sp, null\n br i1 %none, label %out, label %find\n")
emith("find:\n %lp = alloca i32\n store i32 0, ptr %lp\n %blob = call ptr @lp_pak_find(ptr %sp, ptr %lp)\n")
emith(" %miss = icmp eq ptr %blob, null\n br i1 %miss, label %out, label %show\n")
emith("show:\n %len = load i32, ptr %lp\n %bg = load i32, ptr @L_pak_splashbg\n")
emith(" call void @splash_show(ptr %blob, i32 %len, i32 %bg)\n br label %out\n")
emith("out:\n ret void\n}\n")
}
}

File diff suppressed because it is too large Load diff