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

@ -30,9 +30,16 @@ function emit_intrinsic(name: pointer, e: Node) -> Val {
let w = emit_bind(`zext i32 {n} to i64`)
return val(emit_bind(`call ptr @realloc(ptr {p}, i64 {w})`), "pointer")
}
# Every asset a Ludic program loads arrives through here — gltf_load, tex_load,
# Audio.load, Fs.read_text and the renderer's own shader loads all bottom out at
# file_open — so this one call is where a shipped game's asset pack is spliced
# in. @lp_pak_open serves the bytes from a mounted pack when the path is in one
# and falls through to @fopen when it is not, which is every open during
# development. See selfhost/backend/stdlib/emit_pak.ludic.
if (name == "file_open") {
use_pak()
let p = arg_code(e, 0); let m = arg_code(e, 1)
return val(emit_bind(`call ptr @fopen(ptr {p}, ptr {m})`), "pointer")
return val(emit_bind(`call ptr @lp_pak_open(ptr {p}, ptr {m})`), "pointer")
}
if (name == "file_read") or (name == "file_write") {
let f = arg_code(e, 0); let b = arg_code(e, 1); let n = arg_code(e, 2)