feat(pack): asset packs, so a built game is a thing you can hand someone

A Ludic game opened its assets by a path relative to the working directory, so
`build/mygame` ran only from the project root and there was nothing to give
anyone but "the binary, and also this whole tree". A game is not one file, but
shipping it has to be.

`ludic pack` writes a .lpak: a header, a name-sorted entry table, a name heap
and the blobs, 16-byte aligned. Entries are stored rather than 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.

The reader is spliced into the compiler at the one place every asset in a Ludic
program comes through: file_open. gltf_load, tex_load, Audio.load, Fs.read_text
and the renderer's own shader loads all bottom out there, so routing it through
@lp_pak_open reaches every one of them without any of them knowing. A read of a
packed path becomes an fmemopen over the mapped bytes; everything else is the
fopen it always was. 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: 165 MB of textures costs one syscall at
boot and pages in only what is touched. @lp_pak_boot runs from
@llvm.global_ctors, before main, so a pack is mounted before the game's first
line - and it reads packs.index, a plain list, so the mount order is explicit
and a later pack shadows an earlier one.

Without a packs.index nothing mounts and every open goes to the filesystem
exactly as before, which is every `ludic run` during development. Packing is a
shipping step and is invisible until you ship.

The compiler reproduces itself byte-exactly and the C-free bootstrap from the
seed still holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-10 16:37:23 +03:00
parent 9ba093bf07
commit ce5bf0fe0e
11 changed files with 14901 additions and 13587 deletions

View file

@ -32,7 +32,9 @@ property Manifest {
hash: pointer = "", # content hash, filled in after a snapshot
provides: []pointer, # the Foo.* namespace(s) this package registers
targets: []pointer, # prebuilt: the targets it ships (native-arm64, wasm32, …)
deps: []Dep
deps: []Dep,
packs: []pointer, # `pack "<dir>"` — asset roots that go into the .lpak
app: []pointer # `app <key> "<value>"` — flattened key, value, key, value…
}
function manifest_new() -> Manifest {
@ -44,6 +46,8 @@ function manifest_new() -> Manifest {
m.provides = new []pointer
m.targets = new []pointer
m.deps = new []Dep
m.packs = new []pointer
m.app = new []pointer
return m
}
@ -159,11 +163,30 @@ function parse_manifest(text: pointer) -> Manifest {
else if head == "require" {
if len(ts) > 2 { let d = new Dep; d.module = ts[1]; d.ver = ts[2]; push(m.deps, d) }
}
# `pack "assets/kit"` — an asset root that `ludic pack` walks into the
# .lpak. Repeatable, and the order is the order they are walked.
else if head == "pack" { var k = 1; while k < len(ts) { push(m.packs, ts[k]); k += 1 } }
# `app name "Maroon Lake"` — the metadata a macOS bundle is built from.
# Held as a flat key/value list rather than a property per key, so a new
# Info.plist field is a line in the bundler and nothing here.
else if head == "app" {
if len(ts) > 2 { push(m.app, ts[1]); push(m.app, ts[2]) }
}
}
}
return m
}
# the value of one `app <key>` line, or "" when the manifest does not set it
function manifest_app(m: Manifest, key: pointer) -> pointer {
var i = 0
while i + 1 < len(m.app) {
if m.app[i] == key { return m.app[i + 1] }
i += 2
}
return ""
}
# read a package.lock.ludic body into a list of pinned entries
function parse_lock(text: pointer) -> []Manifest {
var out = new []Manifest