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

@ -60,9 +60,9 @@ ludic run # compiles src/main.ludic and opens a native window
```
`ludic new` writes a manifest, a program that already moves something on screen,
and a test. `ludic build` stops at the binary — one self-contained executable
with nothing to ship beside it. Rendering is deterministic, so a frame can be
produced without a window, which is what CI diffs:
and a test. `ludic build` stops at the binary; `ludic bundle` goes on to the
thing you can actually give someone. Rendering is deterministic, so a frame can
be produced without a window, which is what CI diffs:
```bash
ludic test
@ -114,6 +114,28 @@ bin/ludic-dev test # the regression suite
[API reference](https://workshopsoft.pages.workshopsoft.io/ludic/api.html)
documents every symbol on its own page.
## Shipping
A built binary is a program, not an application: it opens its assets by a path
relative to the working directory, so it runs from the project root and nowhere
else, and it wears the generic executable icon.
```bash
ludic pack # every asset the game opens, into one .lpak
ludic bundle # ...and that, the binary, an icon and the metadata, as a .app
```
Nothing about how the game is written changes. `gltf_load("assets/kit/hiker",
…)` reads a file during development and a run of bytes inside the bundle once
shipped, and cannot tell which — the pack is spliced in at `file_open`, the one
place every asset in a Ludic program comes through. A bundled game also gets a
boot splash it controls (`App.splash_hide()`) and a writable home under
Application Support, because Finder starts a `.app` at `/` where no save could
be written.
Without a pack beside it — which is every `ludic run` — nothing mounts and every
open goes to the filesystem exactly as before. See [docs/SHIPPING.md](docs/SHIPPING.md).
## Packages
Dependencies are identified by URL, resolved with minimal version selection, and

12
changes/asset-packs.md Normal file
View file

@ -0,0 +1,12 @@
bump: minor
type: feat
**Asset packs.** `ludic pack` writes every asset a game opens into one `.lpak`
beside the binary, and the runtime mounts it before `main`. Nothing about how a
game is written changes: the pack is spliced in at `file_open`, the one place
every asset comes through, so `gltf_load`, `tex_load`, `Audio.load`,
`Fs.read_text` and the renderer's own shader loads all find it without knowing
it exists. `Fs.exists` and `Fs.size` consult the packs too. Entries are stored
rather than compressed and the pack is `mmap`'d, so 165 MB of terrain costs one
syscall at startup and pages in only what is touched. Several packs can be
mounted in order, and a later one shadows an earlier one - which is how a patch
replaces individual files without rewriting the base pack. See `docs/SHIPPING.md`.

9
changes/boot-splash.md Normal file
View file

@ -0,0 +1,9 @@
bump: minor
type: feat
**A boot splash**, customised by the game. `app splash` in `package.ludic` names
the artwork and `app splash_bg` the colour behind it; the runtime raises a
borderless window from a constructor that runs before `main`, reading the image
out of the asset pack, so it is on screen while the process is still starting
rather than after the slow part it exists to cover. Nothing hides it
automatically - only the game knows when its first real frame is ready, so the
game calls `App.splash_hide()`.

9
changes/macos-bundle.md Normal file
View file

@ -0,0 +1,9 @@
bump: minor
type: feat
**`ludic bundle`** turns a built game into a real macOS `.app`: `Info.plist` and
`PkgInfo` from the manifest, an `.icns` built from one source PNG at every size
macOS asks for, the asset pack in `Contents/Resources`, and an ad-hoc signature
so it launches on Apple silicon. Everything comes from `app` lines in
`package.ludic`, so the command takes no arguments. A bundled game is also moved
to `~/Library/Application Support/<name>` at startup, because Finder starts a
`.app` with its working directory at `/` where no save could ever be written.

173
docs/SHIPPING.md Normal file
View file

@ -0,0 +1,173 @@
# Shipping a Ludic game
`ludic build` gives you a program. `ludic bundle` gives you an application.
The difference matters more than it sounds. A built binary opens its assets by a
path relative to the working directory, so it runs from the project root and
nowhere else; double-clicked it opens a Terminal window; it wears the generic
executable icon and calls itself whatever the file is called. None of that is
something you can hand to a player.
```bash
ludic pack # every asset into one file
ludic bundle # ...and that, the binary, an icon and the metadata, as a .app
```
## What goes in the manifest
Everything the bundler needs lives in `package.ludic`, so the command takes no
arguments:
```ludic skip
package "git.workshopsoft.io/workshopsoft/maroon-lake"
version "0.1.0"
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 "11.0"
pack "assets"
```
| key | what it does | default |
| --- | --- | --- |
| `app name` | the name under the icon, spaces and all | the package's name |
| `app id` | the bundle identifier | derived from the package path |
| `app version` | `CFBundleVersion` | the manifest's `version` |
| `app icon` | a source PNG, ideally 1024x1024 | no icon |
| `app splash` | shown from process start until the game says otherwise | no splash |
| `app splash_bg` | `#RRGGBB` painted behind the splash | black |
| `app category` | `LSApplicationCategoryType` | omitted |
| `app copyright` | `NSHumanReadableCopyright` | omitted |
| `app min_macos` | `LSMinimumSystemVersion` | `11.0` |
| `app sign` | a codesigning identity | ad-hoc (`-`) |
| `app out` | where to write the bundle | `build/<name>.app` |
| `pack` | an asset root, repeatable | `assets/` if it exists |
Only `app name` is worth setting deliberately. The identifier is derived from the
package path when you omit it - `git.workshopsoft.io/workshopsoft/maroon-lake`
becomes `io.workshopsoft.maroon-lake` - but it is worth pinning, because Launch
Services keys the Dock, saved window state and permissions off it, and two apps
sharing one identifier is a class of bug that looks like haunting.
## The asset pack
`ludic pack` writes a `.lpak`: a header, an entry table sorted by name, a name
heap and the blobs. Entries are **stored, not compressed** - PNG, JPEG and glTF
binary arrive compressed already, and a decompressor on the load path would
spend CPU to make the file no smaller.
Nothing about how the game is written changes. This line:
```ludic skip
let model = gltf_load("assets/kit/hiker", "hiker.gltf", "hiker")
```
reads a file during development and a run of bytes inside
`Maroon Lake.app/Contents/Resources/game.lpak` once shipped, and cannot tell
which. That works because the pack is spliced in at `file_open`, the one place
every asset in a Ludic program comes through - `gltf_load`, `tex_load`,
`Audio.load`, `Fs.read_text` and the renderer's own shader loads all bottom out
there. `Fs.exists` and `Fs.size` consult the packs too, so a game that guards a
load with `Fs.exists` keeps finding its assets once they are packed.
The pack is `mmap`'d rather than read, so 165 MB of terrain costs one syscall at
startup and pages in only what the game touches.
```bash
ludic pack --list build/maroon-lake.lpak # what is in it
ludic pack --verify build/maroon-lake.lpak # re-hash every entry
```
### More than one pack
`Contents/Resources/packs.index` is a plain list, and the order is load-bearing:
```
pack base.lpak
pack patch.lpak
```
Packs mount in the order listed and a later one **shadows** an earlier one, so a
patch or an add-on replaces individual files without rewriting the base pack. An
override is always a pack, never a loose file: the pack is searched before the
filesystem, so a stray or corrupt file cannot shadow a shipped asset.
### Where a bundled game saves
A `.app` launched from Finder starts with its working directory at `/`, where
nothing is writable, so `packs.index` also carries:
```
home Maroon Lake
```
and the runtime moves the process to `~/Library/Application Support/Maroon Lake`
before `main`. Reads come out of the pack; writes land somewhere real and
per-user. A game's save code needs no change and no platform knowledge.
## The splash
A game that loads 165 MB spends a noticeable moment doing it, and until it is
done there is nothing on screen - the window is not open yet, because opening it
is part of what the game does once it has something to draw. From the outside
that is indistinguishable from a launch that failed.
The splash is raised from a constructor that runs **before `main`**, so it
appears while the process is still starting rather than after the expensive
part, which is the only ordering that helps. It is a borderless window holding
the game's own artwork, read out of the pack like any other asset.
Nothing hides it automatically. Only the game knows when its first real frame is
ready, so the game says so:
```ludic skip
App.splash_hide()
```
## During development
None of this is in the way. Without a `packs.index` beside the executable -
which is every `ludic run` - nothing mounts, no directory change happens, no
splash appears, and every open goes to the filesystem exactly as before.
Packing is a shipping step, and it is invisible until you ship.
## Signing and distribution
`ludic bundle` ad-hoc signs the result. On Apple silicon that is not optional
the way it was on Intel: an unsigned binary is killed outright rather than
merely warned about, so without a signature the app will not run even on the
machine that built it.
An ad-hoc signature is **not** enough to get the app past Gatekeeper on someone
else's Mac. That needs a Developer ID certificate and notarisation, which are
Apple's, not Ludic's:
```ludic skip
app sign "Developer ID Application: Your Name (TEAMID)"
```
```bash
ludic bundle
xcrun notarytool submit "build/Maroon Lake.app" --keychain-profile NOTARY --wait
xcrun stapler staple "build/Maroon Lake.app"
```
## The bundle it makes
```
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 game
Resources/
AppIcon.icns every size macOS asks for, from one source PNG
game.lpak every asset the game opens
packs.index what to mount, where to save, what to show at boot
```

View file

@ -0,0 +1,7 @@
---
id: app
title: App
order: 48
---
The running application, as distinct from its window. Today that is the boot splash: the runtime raises it before <code>main</code> from the game's asset pack, and <a href="app-splash_hide"><code>App.splash_hide</code></a> takes it down when the game has something to show instead.

View file

@ -0,0 +1,33 @@
---
id: app-splash_hide
name: App.splash_hide
category: app
kind: namespace-method
tokens: App.splash_hide
sig: App.splash_hide() -> void
tip: Dismiss the boot splash.
order: 1
ns: App
member: splash_hide
---
Dismisses the boot splash. Safe to call when there is no splash, and safe to call twice.
A bundled game shows a splash while it starts: `ludic bundle` records `app splash` from the manifest into the bundle, and the runtime puts it on screen before `main` runs, out of the asset pack. Nothing takes it down on its own, because nothing else knows when the game has a first frame worth showing - a splash that disappears while the terrain is still loading leaves the same black gap it was there to cover.
So the game says when:
```ludic skip
program MyGame {
entry {
load_the_world() # the slow part the splash is covering
open_the_window()
draw_one_frame()
App.splash_hide() # ...and only now
}
}
```
Headless there is no splash and no window, and the call lowers to nothing.
See also: <a href="../../SHIPPING">shipping a game</a>.

View file

@ -1408,3 +1408,216 @@ go:
out:
ret void
}
; ============================================================================
; The boot splash.
;
; A game that loads 165 MB of terrain, models and audio spends a noticeable
; moment doing it, and until it is done there is nothing on screen: the window
; is not open yet, because opening it is part of what the game does once it has
; something to draw. From the outside that is indistinguishable from a launch
; that failed.
;
; splash_show puts a borderless window on screen with the game's own artwork in
; it. It is called from @lp_pak_boot, before main, so it appears while the
; process is still starting rather than after the expensive part - which is the
; only ordering that helps.
;
; The image comes from the pack as bytes rather than a path, because by the time
; this runs the pack is mounted and the file it came from may not exist on disk
; at all. NSImage reads PNG, JPEG and TIFF from an NSData without being told
; which it is.
;
; The game decides when it goes away, with App.splash_hide(). Nothing hides it
; automatically: only the game knows whether its first real frame is ready, and
; a splash that vanishes before it is leaves the same black gap it was there to
; cover.
;
; splash_show(bytes, len, bg) bg is 0xRRGGBB, painted behind the image
; splash_hide() orders it out; safe when there is no splash
; ============================================================================
@.c_image = private unnamed_addr constant [8 x i8] c"NSImage\00"
@.c_imgview = private unnamed_addr constant [12 x i8] c"NSImageView\00"
@.c_data = private unnamed_addr constant [7 x i8] c"NSData\00"
@.c_color = private unnamed_addr constant [8 x i8] c"NSColor\00"
@.s_datawb = private unnamed_addr constant [22 x i8] c"dataWithBytes:length:\00"
@.s_initdat = private unnamed_addr constant [14 x i8] c"initWithData:\00"
@.s_size = private unnamed_addr constant [5 x i8] c"size\00"
@.s_setimg = private unnamed_addr constant [10 x i8] c"setImage:\00"
@.s_imgscl = private unnamed_addr constant [17 x i8] c"setImageScaling:\00"
@.s_ofr = private unnamed_addr constant [21 x i8] c"orderFrontRegardless\00"
@.s_oout = private unnamed_addr constant [10 x i8] c"orderOut:\00"
@.s_setlvl = private unnamed_addr constant [10 x i8] c"setLevel:\00"
@.s_setopq = private unnamed_addr constant [11 x i8] c"setOpaque:\00"
@.s_shadow = private unnamed_addr constant [14 x i8] c"setHasShadow:\00"
@.s_srgb = private unnamed_addr constant [35 x i8] c"colorWithSRGBRed:green:blue:alpha:\00"
@.s_setbg = private unnamed_addr constant [20 x i8] c"setBackgroundColor:\00"
@.s_finlau = private unnamed_addr constant [16 x i8] c"finishLaunching\00"
@W_splash = internal global ptr null
define void @splash_show(ptr %bytes, i32 %len, i32 %bg) {
entry:
; only ever one, and nothing to show without bytes
%had = load ptr, ptr @W_splash
%again = icmp ne ptr %had, null
%nodata = icmp eq ptr %bytes, null
%empty = icmp sle i32 %len, 0
%skip0 = or i1 %again, %nodata
%skip = or i1 %skip0, %empty
br i1 %skip, label %out, label %app
app:
; the shared application, as a foreground app. win_open does this too; both
; are idempotent, and whichever runs first wins.
%appcls = call ptr @objc_getClass(ptr @.c_app)
%sel_sh = call ptr @sel_registerName(ptr @.s_shared)
%nsapp = call ptr @objc_msgSend(ptr %appcls, ptr %sel_sh)
%sel_pol = call ptr @sel_registerName(ptr @.s_policy)
%p0 = call ptr (ptr, ptr, i64) @objc_msgSend(ptr %nsapp, ptr %sel_pol, i64 0)
; NSData over the packed bytes, then an NSImage from it
%datacls = call ptr @objc_getClass(ptr @.c_data)
%sel_dwb = call ptr @sel_registerName(ptr @.s_datawb)
%lz = zext i32 %len to i64
%data = call ptr (ptr, ptr, ptr, i64) @objc_msgSend(ptr %datacls, ptr %sel_dwb, ptr %bytes, i64 %lz)
%imgcls = call ptr @objc_getClass(ptr @.c_image)
%sel_alloc = call ptr @sel_registerName(ptr @.s_alloc)
%i0 = call ptr @objc_msgSend(ptr %imgcls, ptr %sel_alloc)
%sel_iwd = call ptr @sel_registerName(ptr @.s_initdat)
%img = call ptr (ptr, ptr, ptr) @objc_msgSend(ptr %i0, ptr %sel_iwd, ptr %data)
%noimg = icmp eq ptr %img, null
br i1 %noimg, label %out, label %size
size:
; the window takes the artwork's own size, so the game decides how big its
; splash is by the pixels it ships
%sel_size = call ptr @sel_registerName(ptr @.s_size)
%sz = call %NSPoint (ptr, ptr) @objc_msgSend(ptr %img, ptr %sel_size)
%wd = extractvalue %NSPoint %sz, 0
%ht = extractvalue %NSPoint %sz, 1
%tiny = fcmp olt double %wd, 1.0
br i1 %tiny, label %out, label %mkwin
mkwin:
%r0 = insertvalue %CGRect undef, double 0.0, 0
%r1 = insertvalue %CGRect %r0, double 0.0, 1
%r2 = insertvalue %CGRect %r1, double %wd, 2
%rect = insertvalue %CGRect %r2, double %ht, 3
%appcls2 = call ptr @objc_getClass(ptr @.c_app)
%sel_sh2 = call ptr @sel_registerName(ptr @.s_shared)
%nsapp2 = call ptr @objc_msgSend(ptr %appcls2, ptr %sel_sh2)
%wincls = call ptr @objc_getClass(ptr @.c_win)
%w0 = call ptr @objc_msgSend(ptr %wincls, ptr %sel_alloc)
%sel_initw = call ptr @sel_registerName(ptr @.s_initw)
; NSWindowStyleMaskBorderless = 0, NSBackingStoreBuffered = 2
%win = call ptr (ptr, ptr, %CGRect, i64, i64, i8) @objc_msgSend(ptr %w0, ptr %sel_initw, %CGRect %rect, i64 0, i64 2, i8 0)
; the colour behind the artwork, so a splash with transparent edges or a
; non-matching aspect sits on the game's own backdrop rather than white
%rr = lshr i32 %bg, 16
%rm = and i32 %rr, 255
%gg = lshr i32 %bg, 8
%gm = and i32 %gg, 255
%bm = and i32 %bg, 255
%rf = sitofp i32 %rm to double
%gf = sitofp i32 %gm to double
%bf = sitofp i32 %bm to double
%rn = fdiv double %rf, 255.0
%gn = fdiv double %gf, 255.0
%bn = fdiv double %bf, 255.0
%colcls = call ptr @objc_getClass(ptr @.c_color)
%sel_srgb = call ptr @sel_registerName(ptr @.s_srgb)
%col = call ptr (ptr, ptr, double, double, double, double) @objc_msgSend(ptr %colcls, ptr %sel_srgb, double %rn, double %gn, double %bn, double 1.0)
%sel_setbg = call ptr @sel_registerName(ptr @.s_setbg)
%b0 = call ptr (ptr, ptr, ptr) @objc_msgSend(ptr %win, ptr %sel_setbg, ptr %col)
%sel_opq = call ptr @sel_registerName(ptr @.s_setopq)
%o0 = call ptr (ptr, ptr, i8) @objc_msgSend(ptr %win, ptr %sel_opq, i8 1)
%sel_shd = call ptr @sel_registerName(ptr @.s_shadow)
%s0 = call ptr (ptr, ptr, i8) @objc_msgSend(ptr %win, ptr %sel_shd, i8 1)
; NSStatusWindowLevel = 25: above the game's own window when it opens, so the
; splash is not covered by a window that has nothing in it yet
%sel_lvl = call ptr @sel_registerName(ptr @.s_setlvl)
%l0 = call ptr (ptr, ptr, i64) @objc_msgSend(ptr %win, ptr %sel_lvl, i64 25)
; an image view filling it, scaled proportionally (NSImageScaleProportionallyUpOrDown = 3)
%ivcls = call ptr @objc_getClass(ptr @.c_imgview)
%v0 = call ptr @objc_msgSend(ptr %ivcls, ptr %sel_alloc)
%sel_initf = call ptr @sel_registerName(ptr @.s_initf)
%view = call ptr (ptr, ptr, %CGRect) @objc_msgSend(ptr %v0, ptr %sel_initf, %CGRect %rect)
%sel_simg = call ptr @sel_registerName(ptr @.s_setimg)
%v1 = call ptr (ptr, ptr, ptr) @objc_msgSend(ptr %view, ptr %sel_simg, ptr %img)
%sel_scl = call ptr @sel_registerName(ptr @.s_imgscl)
%v2 = call ptr (ptr, ptr, i64) @objc_msgSend(ptr %view, ptr %sel_scl, i64 3)
%sel_setcv = call ptr @sel_registerName(ptr @.s_setcv)
%v3 = call ptr (ptr, ptr, ptr) @objc_msgSend(ptr %win, ptr %sel_setcv, ptr %view)
%sel_center = call ptr @sel_registerName(ptr @.s_center)
%c0 = call ptr @objc_msgSend(ptr %win, ptr %sel_center)
; orderFrontRegardless, not makeKeyAndOrderFront: the process has not finished
; launching and is not the active app yet, and a splash has nothing to type into
; The application has to have finished launching before AppKit will map a
; window; without this the window exists, draws into its backing store, and is
; never composited - which looks exactly like the splash not working.
%sel_fl = call ptr @sel_registerName(ptr @.s_finlau)
%fl = call ptr @objc_msgSend(ptr %nsapp2, ptr %sel_fl)
%sel_actv = call ptr @sel_registerName(ptr @.s_act)
%av = call ptr (ptr, ptr, i8) @objc_msgSend(ptr %nsapp2, ptr %sel_actv, i8 1)
%sel_ofr = call ptr @sel_registerName(ptr @.s_ofr)
%f0 = call ptr @objc_msgSend(ptr %win, ptr %sel_ofr)
%sel_disp = call ptr @sel_registerName(ptr @.s_disp)
%d0 = call ptr @objc_msgSend(ptr %win, ptr %sel_disp)
; Turn the run loop a few times so the window is mapped and composited now,
; rather than whenever the game next happens to pump events - which, during the
; asset load this is covering, is never. Once composited the backing store
; survives a busy main thread, so it stays on screen while the game loads.
%np = alloca i32
store i32 0, ptr %np
br label %pump
pump:
%pi = load i32, ptr %np
%pdone = icmp sge i32 %pi, 24
br i1 %pdone, label %kept, label %pumpgo
pumpgo:
%strcls2 = call ptr @objc_getClass(ptr @.c_str)
%sel_u8 = call ptr @sel_registerName(ptr @.s_utf8)
%mode2 = call ptr (ptr, ptr, ptr) @objc_msgSend(ptr %strcls2, ptr %sel_u8, ptr @.mode)
%datecls2 = call ptr @objc_getClass(ptr @.c_date)
%sel_dp2 = call ptr @sel_registerName(ptr @.s_dpast)
%date2 = call ptr @objc_msgSend(ptr %datecls2, ptr %sel_dp2)
%sel_nx = call ptr @sel_registerName(ptr @.s_next)
%ev = call ptr (ptr, ptr, i64, ptr, ptr, i8) @objc_msgSend(ptr %nsapp2, ptr %sel_nx, i64 -1, ptr %date2, ptr %mode2, i8 1)
%noev = icmp eq ptr %ev, null
br i1 %noev, label %pumpnx, label %pumpsend
pumpsend:
%sel_sd = call ptr @sel_registerName(ptr @.s_send)
%sd = call ptr (ptr, ptr, ptr) @objc_msgSend(ptr %nsapp2, ptr %sel_sd, ptr %ev)
br label %pumpnx
pumpnx:
%pi1 = add i32 %pi, 1
store i32 %pi1, ptr %np
br label %pump
kept:
store ptr %win, ptr @W_splash
br label %out
out:
ret void
}
define void @splash_hide() {
entry:
%win = load ptr, ptr @W_splash
%none = icmp eq ptr %win, null
br i1 %none, label %out, label %go
go:
%sel_oo = call ptr @sel_registerName(ptr @.s_oout)
%r = call ptr (ptr, ptr, ptr) @objc_msgSend(ptr %win, ptr %sel_oo, ptr null)
store ptr null, ptr @W_splash
br label %out
out:
ret void
}

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

View file

@ -0,0 +1,324 @@
# 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"
#
# 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`
run(`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 = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + nl()
s = s + "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">" + nl()
s = s + "<plist version=\"1.0\">" + nl()
s = s + "<dict>" + 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 + "</dict>" + nl()
s = s + "</plist>" + 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 ` <key>{k}</key>` + nl() + ` <string>{xml_escape(v)}</string>` + nl()
}
function plist_bool(k: pointer, v: bool) -> pointer {
var t = "<false/>"
if v { t = "<true/>" }
return ` <key>{k}</key>` + 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 + "&amp;" }
else if c == '<' { out = out + "&lt;" }
else if c == '>' { out = out + "&gt;" }
else { out = out + str_sub(s, i, i + 1) }
i += 1
}
return out
}
# ---- the command ------------------------------------------------------------
function cmd_bundle() -> int {
if not is_darwin() {
err("ludic bundle: a .app is a macOS bundle; this host is not macOS\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`
run(`rm -rf "{root}"`)
run(`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
}
# 2. the assets
let roots = pack_roots(m, 999) # manifest/convention only, no argv
var packed = false
if len(roots) > 0 {
let files = pack_gather_all(roots)
if len(files) > 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 = "-" }
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
}

View file

@ -26,6 +26,7 @@ program Ludic {
import "pkg.ludic"
import "assets.ludic"
import "pack.ludic"
import "bundle.ludic"
function usage() -> void {
print("ludic — the toolchain for the Ludic language")
@ -54,6 +55,7 @@ program Ludic {
print(" pack [--out FILE] [dirs...] pack the assets into build/<name>.lpak")
print(" pack --list FILE print what is in a pack")
print(" pack --verify FILE re-hash every entry against the table")
print(" bundle [file] build a macOS .app: icon, metadata, assets, splash")
print("")
print("the toolchain:")
print(" version print the toolchain version")
@ -95,6 +97,7 @@ program Ludic {
if (cmd == "vendor") { return cmd_pkg_vendor() }
if (cmd == "assets") { return cmd_fetch_assets() }
if (cmd == "pack") { return cmd_pack() }
if (cmd == "bundle") { return cmd_bundle() }
if (cmd == "build-lib") { return cmd_pkg_build_lib() }
if (cmd == "link-flags") { return cmd_pkg_link_flags() }

View file

@ -157,6 +157,73 @@ function pack_probe_src() -> pointer {
return s
}
# `ludic bundle` has to produce something macOS actually accepts as an
# application, not a directory that looks like one: a plist that plutil parses,
# an executable where Info.plist says it is, an .icns, and a signature. The
# app's own assets go in the pack beside it, and the display name is allowed a
# space - which is where the shell quoting in the bundler earns its keep.
function bundle_case() -> void {
let lbl = "ludic bundle -> a .app with metadata, icon, assets and a signature"
if not is_darwin() { skip(lbl); return }
let work = `{tmp_dir()}/bundle`
let root = capture_line("pwd")
run(`rm -rf {work} && mkdir -p {work}/src {work}/assets`)
write_file(`{work}/assets/hello.txt`, "packed")
# a 64x64 source icon: small, but sips will scale it and iconutil accept it
run(`sips -s format png --resampleHeightWidth 64 64 /System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/GenericApplicationIcon.icns --out {work}/assets/icon.png > /dev/null 2>&1`)
write_file(`{work}/package.ludic`, bundle_manifest_src())
write_file(`{work}/src/main.ludic`, bundle_probe_src())
if not shq(`cd {work} && {root}/bin/ludic bundle > bundle.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/bundle.out`)); return
}
let app = `{work}/build/Probe App.app`
# quoted: the display name has a space in it, which is the whole point, and
# the prelude's file_exists builds an unquoted `test -e`
if not there(`{app}/Contents/MacOS/Probe App`) { bad2(lbl, "no executable in Contents/MacOS"); return }
if not there(`{app}/Contents/Resources/game.lpak`) { bad2(lbl, "no asset pack"); return }
if not there(`{app}/Contents/Resources/packs.index`) { bad2(lbl, "no packs.index"); return }
if not there(`{app}/Contents/Resources/AppIcon.icns`) { bad2(lbl, "no icon"); return }
if not there(`{app}/Contents/PkgInfo`) { bad2(lbl, "no PkgInfo"); return }
if not shq(`plutil -lint "{app}/Contents/Info.plist" > /dev/null 2>&1`) {
bad2(lbl, "Info.plist does not parse"); return
}
# the identifier is derived from the package path when the manifest omits it
let id = capture_line(`plutil -extract CFBundleIdentifier raw "{app}/Contents/Info.plist" 2>/dev/null`)
if id != "io.workshopsoft.probe-app" { bad2(lbl, `bundle id came out [{id}]`); return }
if not shq(`codesign --verify "{app}" > /dev/null 2>&1`) { bad2(lbl, "the signature does not verify"); return }
# and it runs from a directory with none of its assets, reading them from the pack
let got = s_trim(capture(`cd / && "{app}/Contents/MacOS/Probe App" 2>&1`))
if got != "packed" { bad2(lbl, `the bundled app printed [{got}], wanted [packed]`); return }
ok(lbl)
}
# `test -e` with the path quoted, so a bundle whose name contains a space can
# be inspected at all
function there(path: pointer) -> bool { return shq(`test -e "{path}"`) }
function bundle_manifest_src() -> pointer {
var s = "package \"git.workshopsoft.io/workshopsoft/probe-app\"" + nl()
s = s + "version \"2.1.0\"" + nl()
s = s + "kind source" + nl()
s = s + "app name \"Probe App\"" + nl()
s = s + "app icon \"assets/icon.png\"" + nl()
s = s + "app copyright \"(c) 2026 A & B\"" + nl()
s = s + "pack \"assets\"" + nl()
return s
}
function bundle_probe_src() -> pointer {
var s = "program Probe {" + nl()
s = s + " entry {" + nl()
s = s + " var t = Fs.read_text(\"assets/hello.txt\")" + nl()
s = s + " if t == null { t = \"MISS\" }" + nl()
s = s + " print(t)" + nl()
s = s + " }" + nl()
s = s + "}" + nl()
return s
}
function scaffold_names_case() -> void {
let lbl = "ludic new scaffolds a project that compiles (hyphens, digits, dots)"
let work = `{tmp_dir()}/scaffold`
@ -541,6 +608,7 @@ function cmd_dev_test() -> int {
install_layout_case()
scaffold_names_case()
pack_roundtrip_case()
bundle_case()
# install.sh is what the landing page tells people to pipe into sh, and it is
# published with the docs site — so it is checked here rather than discovered