# bundle.ludic — `ludic bundle`, turning a built game into a macOS .app. # # `ludic build` produces a Mach-O in build/. That is a program, not an # application: double-clicking it opens a Terminal window, it has the generic # executable icon, it reports its name as whatever the file is called, and it # carries none of its assets. This makes the thing you actually give someone. # # Maroon Lake.app/ # Contents/ # Info.plist the metadata Finder, the Dock and Launch Services read # PkgInfo the eight bytes that predate Info.plist and are still read # MacOS/Maroon Lake the binary `ludic build` produced # Resources/ # AppIcon.icns every size macOS asks for, from one source PNG # game.lpak every asset the game opens # packs.index what to mount, and where the game may write # # Everything it needs comes from package.ludic, so the same command works in any # project without arguments: # # app name "Maroon Lake" # app id "io.workshopsoft.maroon-lake" # app icon "assets/app/icon.png" # app splash "assets/app/splash.png" # app splash_bg "#0d1b2a" # app category "public.app-category.adventure-games" # app copyright "(c) 2026 Workshopsoft" # app min_macos "12.0" # app env "MallocLargeCache=0" (repeatable: LSEnvironment, what Launch Services starts it with) # # Only `app name` is really needed; everything else has a defensible default # derived from the manifest. # ---- reading the metadata --------------------------------------------------- # The display name: `app name`, else the package's own name. This is what shows # under the icon, so it is allowed spaces where the binary's name is not. function app_name(m: Manifest) -> pointer { let n = manifest_app(m, "name") if n != "" { return n } return project_name("") } # The bundle identifier. Launch Services keys almost everything off this - the # Dock, saved window state, the sandbox container, TCC permissions - and two # apps sharing one identifier is a class of bug that looks like haunting. When # the manifest does not set it, the package path makes a defensible one: # `git.workshopsoft.io/workshopsoft/maroon-lake` -> `io.workshopsoft.maroon-lake`. function app_id(m: Manifest) -> pointer { let id = manifest_app(m, "id") if id != "" { return id } let host = app_id_host(m.module) if host != "" { return `{host}.{project_name("")}` } return `local.ludic.{project_name("")}` } # the reversed host of a module path, or "" when it does not look like a URL function app_id_host(module: pointer) -> pointer { let slash = s_index(module, "/", 0) if slash <= 0 { return "" } let host = sslice(module, 0, slash) # reverse the dotted segments: git.workshopsoft.io -> io.workshopsoft.git var parts = new []pointer var start = 0 var i = 0 let n = slen(host) while i <= n { if i == n or host[i] == '.' { if i > start { push(parts, sslice(host, start, i)) } start = i + 1 } i += 1 } if len(parts) < 2 { return "" } # drop a leading "git"/"www" host label: it names the server, not the vendor var last = len(parts) - 1 var out = "" var k = last while k >= 0 { let seg = parts[k] if not (k == 0 and (seg == "git" or seg == "www")) { if out == "" { out = seg } else { out = `{out}.{seg}` } } k -= 1 } return out } function app_version(m: Manifest) -> pointer { let v = manifest_app(m, "version") if v != "" { return v } if m.ver != "" { return m.ver } return "0.1.0" } # "#0d1b2a" (or "0d1b2a") as a decimal 0xRRGGBB, which is what packs.index # carries and what the runtime's atoi can read back function hex_color(s: pointer) -> int { var i = 0 if slen(s) > 0 and s[0] == '#' { i = 1 } var v = 0 var seen = 0 while i < slen(s) and seen < 6 { let d = hex_digit(s[i]) if d < 0 { return 0 } v = v * 16 + d seen += 1 i += 1 } if seen != 6 { return 0 } return v } function hex_digit(c: int) -> int { if c >= '0' and c <= '9' { return c - '0' } if c >= 'a' and c <= 'f' { return c - 'a' + 10 } if c >= 'A' and c <= 'F' { return c - 'A' + 10 } return -1 } # ---- the icon --------------------------------------------------------------- # macOS wants ten renderings of the icon, from 16pt to 512pt at 1x and 2x, in an # .icns. `iconutil` builds one from a directory of exactly those PNGs, and `sips` # resizes. Both ship with macOS, so this needs nothing installed. # # A source icon should be 1024x1024; anything smaller is upscaled by sips and # will look it at the largest size. function build_icon(src: pointer, out_icns: pointer) -> bool { if src == "" { return false } if not file_exists(src) { err(`ludic bundle: no icon at {src}\n`) return false } let set = `{tmp_dir()}/AppIcon.iconset` shell(`rm -rf {set} && mkdir -p {set}`) if not icon_size(src, set, 16, "16x16") { return false } if not icon_size(src, set, 32, "16x16@2x") { return false } if not icon_size(src, set, 32, "32x32") { return false } if not icon_size(src, set, 64, "32x32@2x") { return false } if not icon_size(src, set, 128, "128x128") { return false } if not icon_size(src, set, 256, "128x128@2x") { return false } if not icon_size(src, set, 256, "256x256") { return false } if not icon_size(src, set, 512, "256x256@2x") { return false } if not icon_size(src, set, 512, "512x512") { return false } if not icon_size(src, set, 1024, "512x512@2x") { return false } if not shq(`iconutil -c icns {set} -o {out_icns} 2>/dev/null`) { err("ludic bundle: iconutil could not build the .icns\n") return false } return true } function icon_size(src: pointer, set: pointer, px: int, name: pointer) -> bool { if not shq(`sips -z {string(px)} {string(px)} {src} --out {set}/icon_{name}.png > /dev/null 2>&1`) { err(`ludic bundle: sips could not make the {name} icon\n`) return false } return true } # ---- Info.plist ------------------------------------------------------------- # Written as XML text rather than through PlistBuddy: it is a fixed set of keys, # and generating it here keeps the whole bundle reproducible from the manifest # with no tool in between. function info_plist(m: Manifest, exe: pointer, has_icon: bool) -> pointer { let name = app_name(m) var s = "" + nl() s = s + "" + nl() s = s + "" + nl() s = s + "" + nl() s = s + plist_str("CFBundleName", name) s = s + plist_str("CFBundleDisplayName", name) s = s + plist_str("CFBundleIdentifier", app_id(m)) s = s + plist_str("CFBundleExecutable", exe) s = s + plist_str("CFBundleVersion", app_version(m)) s = s + plist_str("CFBundleShortVersionString", app_version(m)) s = s + plist_str("CFBundlePackageType", "APPL") s = s + plist_str("CFBundleSignature", "????") s = s + plist_str("CFBundleInfoDictionaryVersion", "6.0") if has_icon { s = s + plist_str("CFBundleIconFile", "AppIcon") } s = s + plist_str("LSMinimumSystemVersion", min_macos(m)) let cat = manifest_app(m, "category") if cat != "" { s = s + plist_str("LSApplicationCategoryType", cat) } let cr = manifest_app(m, "copyright") if cr != "" { s = s + plist_str("NSHumanReadableCopyright", cr) } # A game renders at the display's real resolution; without this the window is # upscaled from 1x and everything drawn in it is soft on any Retina screen. s = s + plist_bool("NSHighResolutionCapable", true) s = s + plist_env(m) s = s + "" + nl() s = s + "" + nl() return s } function min_macos(m: Manifest) -> pointer { let v = manifest_app(m, "min_macos") if v != "" { return v } # the OpenGL 4.1 core profile the renderer asks for, and fmemopen, are both # far older than this; 11.0 is simply the oldest macOS still worth naming return "11.0" } function plist_str(k: pointer, v: pointer) -> pointer { return ` {k}` + nl() + ` {xml_escape(v)}` + nl() } # every `app env "K=V"`: the environment Launch Services starts the app with (Finder, `open`, the # Dock) - a switch a library reads only as a process starts, such as libmalloc's MallocLargeCache function plist_env(m: Manifest) -> pointer { var body = "" var i = 0 while i + 1 < len(m.app) { if m.app[i] == "env" { let kv = m.app[i + 1] var eq = 0 while eq < len(kv) and kv[eq] != '=' { eq += 1 } if eq > 0 and eq < len(kv) { body = body + plist_str(kv[0..eq], kv[eq + 1..len(kv)]) } } i += 2 } if body == "" { return "" } return " LSEnvironment" + nl() + " " + nl() + body + " " + nl() } function plist_bool(k: pointer, v: bool) -> pointer { var t = "" if v { t = "" } return ` {k}` + nl() + ` {t}` + nl() } # &, < and > are the three that can break a plist; a copyright line with an # ampersand in it is not an exotic case function xml_escape(s: pointer) -> pointer { var out = "" var i = 0 let n = slen(s) while i < n { let c = s[i] if c == '&' { out = out + "&" } else if c == '<' { out = out + "<" } else if c == '>' { out = out + ">" } else { out = out + str_sub(s, i, i + 1) } i += 1 } return out } # ---- the command ------------------------------------------------------------ # ---- Windows: a folder -------------------------------------------------------- # # A Windows game ships as a directory a player unzips anywhere and double-clicks # inside: # # build//.exe windowed, GUI subsystem, the icon linked in as a resource # build//game.lpak the same pack a .app carries # build//packs.index pack / home / splash, read beside the executable # # `home ` points the game at %APPDATA%\, so saves live where a Windows # game's saves live and the folder itself stays read-only, as a .app does. # # The icon is `app icon` with its extension changed to .ico, when that file exists: # Windows reads icons from a resource inside the executable, and the resource # compiler takes .ico only. A project makes one from the same artwork as its .icns. # an LLVM tool beside the clang this toolchain uses (llvm-rc, ...) function llvm_tool(name: pointer) -> pointer { let env = getenv("LUDIC_CC") if env == null and Fs.exists(`C:/Program Files/LLVM/bin/{name}.exe`) { return `"C:/Program Files/LLVM/bin/{name}.exe"` } return name } # the icon resource for the executable, or "" when there is no .ico to make it from function windows_icon_res(icon: pointer) -> pointer { if icon == "" { return "" } var ico = icon let n = len(icon) if n > 4 and icon[n - 4..n] == ".png" { ico = icon[0..n - 4] + ".ico" } if not Fs.exists(ico) { return "" } let rc = tmp_path("app.rc") let res = tmp_path("app.res") # the resource compiler resolves a relative name against its own directory, not ours let abs = win_slashes(capture_line(`cygpath -m "$(pwd)/{ico}"`)) if not write_file(rc, `1 ICON "{abs}"` + nl()) { return "" } # -fo rather than /FO: Git Bash rewrites an argument that starts with "/" into a path if not shq(`{llvm_tool("llvm-rc")} -fo "{res}" "{rc}"`) { err("ludic bundle: warning: the icon resource did not compile; the game has no icon\n") return "" } return res } # copy what the last build linked into Contents/Frameworks # and drop the rpath to where each was built: a shipped binary names no path on this machine function bundle_natives_mac(contents: pointer, exe: pointer) -> bool { if len(g_built_natives) == 0 { return true } shell(`mkdir -p "{contents}/Frameworks"`) for i in 0 .. len(g_built_natives) { shq(`install_name_tool -delete_rpath "{dir_of_path(g_built_natives[i])}" "{exe}" 2>/dev/null`) if not shq(`cp "{g_built_natives[i]}" "{contents}/Frameworks/"`) { err(`ludic bundle: could not place {g_built_natives[i]}\n`) return false } bundle_native_notices(g_built_natives[i], `{contents}/Resources`) } return true } # a path with every backslash made a slash (a Windows path from the compiler) function fwd_path(p: pointer) -> pointer { var out = "" var i = 0 while p[i] != 0 { if p[i] == 92 { out = out + "/" } else { out = out + p[i..i + 1] } i += 1 } return out } # a native library's licence goes with every copy of it: the package's native/LICENSE* files, # found from the library's own path (/lib//) function bundle_native_notices(lib: pointer, dest: pointer) -> void { let pkg = dir_of_path(dir_of_path(dir_of_path(fwd_path(lib)))) shell(`cp "{pkg}"/native/LICENSE* "{dest}/" 2>/dev/null`) } # copy the .dll files ludicc wrote into .natives beside the executable function bundle_natives_win(list: pointer, root: pointer) -> bool { let txt = read_file(list) if txt == null { return true } let libs = split_lines(txt) for i in 0 .. len(libs) { if libs[i] != "" and not shq(`cp "{libs[i]}" "{root}/"`) { err(`ludic bundle: could not place {libs[i]}\n`) return false } if libs[i] != "" { bundle_native_notices(fwd_path(libs[i]), root) } } return true } function cmd_bundle_windows() -> int { let entry = parse_build_args(2) if g_argerr { return 1 } if entry == "" { return no_entry() } let m = read_root_manifest() let name = app_name(m) let root = `build/{name}` shell(`rm -rf "{root}"`) shell(`mkdir -p "{root}"`) # 1. the executable - staged under a name with no spaces, as on macOS let res = windows_icon_res(manifest_app(m, "icon")) let staged = tmp_path("bundle_exe") var flags = "--windowed --gui" if res != "" { flags = flags + ` --link "{res}"` } Os.set_env("LUDIC_OPT", "2") # a shipped game is -O2, whatever a developer's shell says if not shq(`{ludicc()} {flags}{title_flag()} {entry} --natives-out {staged}.natives -o {staged}`) { return 1 } if not shq(`cp "{staged}.exe" "{root}/{name}.exe"`) { err("ludic bundle: could not place the executable\n") return 1 } if not bundle_natives_win(`{staged}.natives`, root) { return 1 } # 2. the assets, exactly as the .app packs them let roots = pack_roots(m, 999) var packed = false let files = pack_gather_all(roots) pack_add_packages(files) pack_sort(files) if len(files.names) > 0 { let staged_pak = tmp_path("game.lpak") if not pack_write(staged_pak, files) { return 1 } if not shq(`cp "{staged_pak}" "{root}/game.lpak"`) { err("ludic bundle: could not place the asset pack\n") return 1 } packed = true } # 2b. `app native ""`: native libraries loaded at run time (NVIDIA Streamline's DLLs and # their licence texts) go beside the executable, where LoadLibrary finds them. Never packed: # a DLL has to be a real file. let native = manifest_app(m, "native") if native != "" { if not Fs.exists(native) { err(`ludic bundle: app native "{native}" does not exist\n`) return 1 } if not shq(`cp -R "{native}"/. "{root}/"`) { err("ludic bundle: could not place the native libraries\n") return 1 } } # 3. what to mount and where the game writes var idx = "" if packed { idx = idx + "pack game.lpak" + nl() } idx = idx + `home {name}` + nl() let splash = manifest_app(m, "splash") if splash != "" and packed { idx = idx + `splash {splash}` + nl() idx = idx + `splashbg {string(hex_color(manifest_app(m, "splash_bg")))}` + nl() } write_file(`{root}/packs.index`, idx) print(`bundled {root}`) if res == "" { print(" no icon: put an .ico beside `app icon` in package.ludic (assets/app/icon.ico)") } if not packed { print(" no assets packed: nothing under assets/ and no `pack` line in package.ludic") } return 0 } function cmd_bundle() -> int { if host_windows() { return cmd_bundle_windows() } if not is_darwin() { err("ludic bundle: this host is neither macOS (a .app) nor Windows (a folder)\n") return 1 } let entry = parse_build_args(2) if g_argerr { return 1 } if entry == "" { return no_entry() } let m = read_root_manifest() let name = app_name(m) var root = manifest_app(m, "out") if root == "" { root = `build/{name}.app` } # The executable's name is what shows in Activity Monitor, in a crash report # and in `ps`, so it takes the display name rather than the file name - spaces # and all, which is what every shipped Mac application does. let contents = `{root}/Contents` shell(`rm -rf "{root}"`) shell(`mkdir -p "{contents}/MacOS" "{contents}/Resources"`) # 1. the binary, built windowed - a bundled game never wants the headless path. # It is compiled to a scratch path and copied in, because a display name is # allowed spaces ("Maroon Lake.app") and compile_app builds shell commands # out of the path it is given. let exe = `{contents}/MacOS/{name}` let staged = `{tmp_dir()}/bundle_exe` if not compile_app(entry, staged, 1, false) { return 1 } if not shq(`cp {staged} "{exe}"`) { err("ludic bundle: could not place the executable\n") return 1 } # 1b. a package's native libraries (phase 15) go in Contents/Frameworks, where the binary's # @executable_path/../Frameworks rpath finds them - never in MacOS, which must hold mach-o # executables only (a Velopack update strips anything else's detached signature) if not bundle_natives_mac(contents, exe) { return 1 } # 2. the assets let roots = pack_roots(m, 999) # manifest/convention only, no argv var packed = false let files = pack_gather_all(roots) # the resources the packages themselves load at runtime - the renderer's # shaders above all, which resolve against a project or an install and would # find neither inside a .app pack_add_packages(files) pack_sort(files) if len(files.names) > 0 { let staged_pak = `{tmp_dir()}/game.lpak` if not pack_write(staged_pak, files) { return 1 } if not shq(`cp {staged_pak} "{contents}/Resources/game.lpak"`) { err("ludic bundle: could not place the asset pack\n") return 1 } packed = true } # 3. what to mount, and where the game may write. Without `home` a bundled # game cannot save at all: Finder starts it with the working directory at # "/", where nothing is writable. var idx = "" if packed { idx = idx + "pack game.lpak" + nl() } idx = idx + `home {name}` + nl() let splash = manifest_app(m, "splash") if splash != "" { if not packed { err("ludic bundle: `app splash` needs the splash to be in a pack; nothing was packed\n") return 1 } idx = idx + `splash {splash}` + nl() idx = idx + `splashbg {string(hex_color(manifest_app(m, "splash_bg")))}` + nl() } write_file(`{contents}/Resources/packs.index`, idx) # 4. the icon var has_icon = false let staged_icns = `{tmp_dir()}/AppIcon.icns` if build_icon(manifest_app(m, "icon"), staged_icns) { has_icon = shq(`cp {staged_icns} "{contents}/Resources/AppIcon.icns"`) } # 5. the metadata write_file(`{contents}/Info.plist`, info_plist(m, name, has_icon)) write_file(`{contents}/PkgInfo`, "APPL????") # 6. Sign it. Ad-hoc unless the manifest names an identity: on Apple silicon an # unsigned binary is killed outright rather than merely warned about, so this # is not optional the way it was on Intel. An ad-hoc signature does not get # the app past Gatekeeper on someone else's machine - that needs a Developer # ID and notarisation - but it does make it run here. var ident = manifest_app(m, "sign") if ident == "" { ident = "-" } # nested code is signed before what contains it for i in 0 .. len(g_built_natives) { let fw = `{contents}/Frameworks/{file_of_path(g_built_natives[i])}` if not shq(`codesign --force --timestamp=none --sign "{ident}" "{fw}" 2>/dev/null`) { err(`ludic bundle: warning: codesign failed on {fw}\n`) } } if not shq(`codesign --force --timestamp=none --sign "{ident}" "{root}" 2>/dev/null`) { err(`ludic bundle: warning: codesign failed; the app may not launch\n`) } print(`bundled {root}`) if not has_icon { print(" no icon: set `app icon \"path/to/icon.png\"` in package.ludic (1024x1024)") } if not packed { print(" no assets packed: nothing under assets/ and no `pack` line in package.ludic") } return 0 }