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:
parent
ce5bf0fe0e
commit
be74b4de6f
14 changed files with 34667 additions and 33621 deletions
28
README.md
28
README.md
|
|
@ -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
12
changes/asset-packs.md
Normal 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
9
changes/boot-splash.md
Normal 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
9
changes/macos-bundle.md
Normal 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
173
docs/SHIPPING.md
Normal 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
|
||||
```
|
||||
7
docs/language/app/_section.md
Normal file
7
docs/language/app/_section.md
Normal 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.
|
||||
33
docs/language/app/app-splash_hide.md
Normal file
33
docs/language/app/app-splash_hide.md
Normal 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>.
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
67340
selfhost/ludicc.seed.ll
67340
selfhost/ludicc.seed.ll
File diff suppressed because it is too large
Load diff
324
tools/ludic-cli/bundle.ludic
Normal file
324
tools/ludic-cli/bundle.ludic
Normal 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 + "&" }
|
||||
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 ------------------------------------------------------------
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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() }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue