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

@ -108,6 +108,55 @@ function install_layout_case() -> void {
# reasonably pick. `my-game` produced `program My-Game`, which is a subtraction,
# and `2048` produced an identifier starting with a digit — both scaffolded a
# project that failed on the very first `ludic run`.
# The asset pack, end to end: `ludic pack` writes it, the runtime mounts it
# before main from packs.index, and the game reads its assets out of it while
# running from a directory where none of them exist on disk. That last part is
# the whole feature - a built game used to run only from its project root.
function pack_roundtrip_case() -> void {
let lbl = "ludic pack -> a game reads its assets with none of them on disk"
let work = `{tmp_dir()}/packrt`
let root = capture_line("pwd")
run(`rm -rf {work} && mkdir -p {work}/assets/sub {work}/ship`)
write_file(`{work}/assets/a.txt`, "alpha")
write_file(`{work}/assets/sub/b.txt`, "beta")
write_file(`{work}/game.ludic`, pack_probe_src())
if not shq(`cd {work} && {root}/bin/ludic pack --out ship/game.lpak assets > pack.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/pack.out`)); return
}
if not shq(`cd {work} && {root}/bin/ludic pack --verify ship/game.lpak > verify.out 2>&1`) {
bad2(lbl, "the pack it just wrote does not verify"); return
}
if not shq(`cd {work} && {root}/bin/ludicc game.ludic -o ship/probe > cc.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/cc.out`)); return
}
write_file(`{work}/ship/packs.index`, "pack game.lpak" + nl())
# the assets exist only inside the pack from here on
run(`rm -rf {work}/assets`)
# run from somewhere with no relation to the project at all
let got = s_trim(capture(`cd / && {work}/ship/probe 2>&1`))
if got != "alpha|beta|1|5|0" {
bad2(lbl, `read back [{got}], wanted [alpha|beta|1|5|0]`); return
}
ok(lbl)
}
# the probe game: reads two packed files and asks after a third that is in no
# pack, so a false hit would show up as loudly as a miss
function pack_probe_src() -> pointer {
var s = "program PackProbe {" + nl()
s = s + " entry {" + nl()
s = s + " var a = Fs.read_text(\"assets/a.txt\")" + nl()
s = s + " var b = Fs.read_text(\"assets/sub/b.txt\")" + nl()
s = s + " if a == null { a = \"MISS\" }" + nl()
s = s + " if b == null { b = \"MISS\" }" + nl()
s = s + " print(`{a}|{b}|{string(Fs.exists(\"assets/a.txt\"))}|{string(Fs.size(\"assets/a.txt\"))}|{string(Fs.exists(\"assets/gone.txt\"))}`)" + 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`
@ -491,6 +540,7 @@ function cmd_dev_test() -> int {
# from its own location. This is the shape `curl … | sh` produces.
install_layout_case()
scaffold_names_case()
pack_roundtrip_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