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:
parent
9ba093bf07
commit
ce5bf0fe0e
11 changed files with 14901 additions and 13587 deletions
|
|
@ -34,6 +34,7 @@ var g_uses_osrt: bool = false # Os.* (prelude-backed methods) was emitted -> em
|
|||
var g_uses_pid: bool = false # Os.pid() was emitted -> declare libc getpid
|
||||
var g_uses_unicodert: bool = false # Unicode.* was emitted -> emit the UTF-8 runtime
|
||||
var g_uses_fsrt: bool = false # Fs.*/Path.*/Mime.* was emitted -> emit the filesystem runtime
|
||||
var g_uses_pak: bool = false # file_open was emitted -> emit the asset-pack runtime (implies fsrt)
|
||||
var g_uses_datert: bool = false # Date.*/DateTime.* was emitted -> emit the civil<->epoch conversions
|
||||
var g_uses_expect: bool = false # expect/expect_eq/expect_near was emitted -> emit the test-assert globals
|
||||
var g_uses_panic: bool = false # panic/assert was emitted -> declare @fprintf + the panic format
|
||||
|
|
|
|||
|
|
@ -229,6 +229,7 @@ function emit_program() -> void {
|
|||
if g_uses_pid { emith("declare i32 @getpid()\n") }
|
||||
if g_uses_unicodert { emit_unicode_prelude() } # @lp_uni_len/valid/decode/case/truncate/grapheme (UTF-8)
|
||||
if g_uses_fsrt { emit_fs_prelude() } # @fn_fs_*/fn_path_*/fn_mime_* (libc + string ops)
|
||||
if g_uses_pak { emit_pak_prelude() } # @lp_pak_* asset packs + the pre-main mount ctor
|
||||
if g_uses_datert { emit_datetime_prelude() } # @lp_days_from_civil / @lp_civil_from_days conversions
|
||||
if g_uses_panic { # panic/assert: located abort to stderr
|
||||
emith("declare i32 @fprintf(ptr, ptr, ...)\n")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ function is_mime_ns(meth: pointer) -> bool {
|
|||
}
|
||||
|
||||
function emit_fs_ns(meth: pointer, e: Node) -> Val {
|
||||
g_uses_fsrt = true
|
||||
use_pak()
|
||||
let a = emit_expr(e.kids[0])
|
||||
if (meth == "exists") { return val(emit_bind(`call i32 @lp_fs_exists(ptr {a.code})`), "bool") }
|
||||
if (meth == "is_dir") { return val(emit_bind(`call i32 @lp_fs_is_dir(ptr {a.code})`), "bool") }
|
||||
|
|
@ -69,7 +69,7 @@ function emit_fs_ns(meth: pointer, e: Node) -> Val {
|
|||
}
|
||||
|
||||
function emit_path_ns(meth: pointer, e: Node) -> Val {
|
||||
g_uses_fsrt = true
|
||||
use_pak()
|
||||
let a = emit_expr(e.kids[0])
|
||||
if (meth == "dir") { return val(emit_bind(`call ptr @lp_path_dir(ptr {a.code})`), "string") }
|
||||
if (meth == "base") { return val(emit_bind(`call ptr @lp_path_base(ptr {a.code})`), "string") }
|
||||
|
|
@ -82,7 +82,7 @@ function emit_path_ns(meth: pointer, e: Node) -> Val {
|
|||
}
|
||||
|
||||
function emit_mime_ns(meth: pointer, e: Node) -> Val {
|
||||
g_uses_fsrt = true
|
||||
use_pak()
|
||||
let a = emit_expr(e.kids[0])
|
||||
if (meth == "sniff") { return val(emit_bind(`call ptr @lp_mime_sniff(ptr {a.code})`), "string") }
|
||||
return val(emit_bind(`call ptr @lp_mime_of(ptr {a.code})`), "string")
|
||||
|
|
@ -240,8 +240,13 @@ function emit_fs_normalize() -> void {
|
|||
|
||||
# ---- Fs.* (libc) -----------------------------------------------------------
|
||||
function emit_fs_io() -> void {
|
||||
# A path that is in a mounted pack exists, whether or not anything is on disk
|
||||
# at that name: a game that guards a load with Fs.exists (the sound layer does)
|
||||
# has to keep finding its assets once they are packed.
|
||||
emith("define i32 @lp_fs_exists(ptr %p) {\n")
|
||||
emith("entry:\n %r = call i32 @access(ptr %p, i32 0)\n %ok = icmp eq i32 %r, 0\n %z = zext i1 %ok to i32\n ret i32 %z\n}\n")
|
||||
emith("entry:\n %ip = call i32 @lp_pak_has(ptr %p)\n %inpak = icmp ne i32 %ip, 0\n br i1 %inpak, label %yes, label %disk\n")
|
||||
emith("yes:\n ret i32 1\n")
|
||||
emith("disk:\n %r = call i32 @access(ptr %p, i32 0)\n %ok = icmp eq i32 %r, 0\n %z = zext i1 %ok to i32\n ret i32 %z\n}\n")
|
||||
|
||||
# is_dir: opendir succeeds iff it is a directory
|
||||
emith("define i32 @lp_fs_is_dir(ptr %p) {\n")
|
||||
|
|
@ -251,7 +256,9 @@ function emit_fs_io() -> void {
|
|||
|
||||
# size: bytes via fseek/ftell, or -1 if it cannot be opened
|
||||
emith("define i32 @lp_fs_size(ptr %p) {\n")
|
||||
emith("entry:\n %f = call ptr @fopen(ptr %p, ptr @fn_str_rb)\n %nz = icmp ne ptr %f, null\n br i1 %nz, label %ok, label %bad\n")
|
||||
emith("entry:\n %pn = call i32 @lp_pak_bytes(ptr %p)\n %inpak = icmp sge i32 %pn, 0\n br i1 %inpak, label %packed, label %disk\n")
|
||||
emith("packed:\n ret i32 %pn\n")
|
||||
emith("disk:\n %f = call ptr @fopen(ptr %p, ptr @fn_str_rb)\n %nz = icmp ne ptr %f, null\n br i1 %nz, label %ok, label %bad\n")
|
||||
emith("bad:\n ret i32 -1\n")
|
||||
emith("ok:\n call i32 @fseek(ptr %f, i64 0, i32 2)\n %n = call i64 @ftell(ptr %f)\n call i32 @fclose(ptr %f)\n %ni = trunc i64 %n to i32\n ret i32 %ni\n}\n")
|
||||
|
||||
|
|
@ -259,7 +266,7 @@ function emit_fs_io() -> void {
|
|||
# failure. The buffer is NUL-terminated (one past the length) so text callers
|
||||
# can use it directly while binary callers use the length.
|
||||
emith("define ptr @lp_fs_readall(ptr %p, ptr %lenout) {\n")
|
||||
emith("entry:\n store i32 0, ptr %lenout\n %f = call ptr @fopen(ptr %p, ptr @fn_str_rb)\n %nz = icmp ne ptr %f, null\n br i1 %nz, label %ok, label %bad\n")
|
||||
emith("entry:\n store i32 0, ptr %lenout\n %f = call ptr @lp_pak_open(ptr %p, ptr @fn_str_rb)\n %nz = icmp ne ptr %f, null\n br i1 %nz, label %ok, label %bad\n")
|
||||
emith("bad:\n ret ptr null\n")
|
||||
emith("ok:\n call i32 @fseek(ptr %f, i64 0, i32 2)\n %n64 = call i64 @ftell(ptr %f)\n call i32 @fseek(ptr %f, i64 0, i32 0)\n %n = trunc i64 %n64 to i32\n")
|
||||
emith(" %cap = add i64 %n64, 1\n %m = call ptr @malloc(i64 %cap)\n %rd = call i64 @fread(ptr %m, i64 1, i64 %n64, ptr %f)\n call i32 @fclose(ptr %f)\n")
|
||||
|
|
|
|||
262
selfhost/backend/stdlib/emit_pak.ludic
Normal file
262
selfhost/backend/stdlib/emit_pak.ludic
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
# emit_pak.ludic — the asset pack: one file beside the binary that holds every
|
||||
# asset the game opens, and a lookup that makes `file_open` find them there
|
||||
# first.
|
||||
#
|
||||
# The problem it solves: a built game opened its assets by a path relative to the
|
||||
# working directory, so `build/mygame` only ran from the project root and there
|
||||
# was nothing to hand anyone but "the binary, and also this whole tree". A game
|
||||
# is not one file; shipping it has to be.
|
||||
#
|
||||
# The shape of the answer is deliberately small. `file_open` is the single 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 — so the pack is spliced in at exactly that point:
|
||||
#
|
||||
# file_open(path, mode) -> @lp_pak_open
|
||||
# |
|
||||
# mode is read and path is in a pack?
|
||||
# yes -> fmemopen over the mapped bytes
|
||||
# no -> fopen, as before
|
||||
#
|
||||
# Nothing above it changes. A game reads "assets/kit/hiker/hiker.gltf" whether
|
||||
# that is a file on disk during development or a run of bytes inside
|
||||
# Maroon Lake.app/Contents/Resources/game.lpak, and cannot tell which.
|
||||
#
|
||||
# The pack is mmap'd, never read into memory: 165 MB of textures and terrain
|
||||
# costs one syscall at boot and pages in only what the game actually touches.
|
||||
# Entries are stored, not compressed — PNG, JPEG and glTF binary are already
|
||||
# compressed, and a decompressor on the load path would spend CPU to make the
|
||||
# file no smaller.
|
||||
#
|
||||
# ---- the format -------------------------------------------------------------
|
||||
#
|
||||
# All integers are 32-bit little-endian. The writer is `ludic pack`
|
||||
# (tools/ludic-cli/pack.ludic), which is the only thing that produces one.
|
||||
#
|
||||
# offset 0 "LPAK" magic
|
||||
# 4 version 1
|
||||
# 8 count number of entries
|
||||
# 12 names absolute offset of the name heap
|
||||
# 16 data absolute offset of the first blob
|
||||
# 20 reserved x 3 zero
|
||||
# 32 entries[count], 16 bytes each, SORTED by name (strcmp order):
|
||||
# 0 name offset absolute, NUL-terminated
|
||||
# 4 data offset absolute
|
||||
# 8 data length
|
||||
# 12 FNV-1a hash of the blob
|
||||
#
|
||||
# Sorted names are what make the lookup a binary search rather than a scan, and
|
||||
# `ludic pack` sorts because the runtime is entitled to assume it.
|
||||
#
|
||||
# ---- finding the packs at boot ----------------------------------------------
|
||||
#
|
||||
# @lp_pak_boot runs from @llvm.global_ctors, before main, so a pack is mounted
|
||||
# before the first line of the game's own code. It looks beside the executable —
|
||||
# ../Resources first (where it lands inside a .app), then the executable's own
|
||||
# directory (where it lands beside a plain binary) — for `packs.index`, a plain
|
||||
# text file the bundler writes:
|
||||
#
|
||||
# pack game.lpak mount this, in this order
|
||||
# home Maroon Lake chdir to ~/Library/Application Support/Maroon Lake
|
||||
#
|
||||
# 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
|
||||
# is how a patch or an add-on replaces a file without rewriting the base pack.
|
||||
#
|
||||
# `home` is what makes a bundled game able to save. A .app launched from Finder
|
||||
# starts with the working directory at "/", so every relative write would fail;
|
||||
# pointing the process at its own Application Support directory means reads come
|
||||
# out of the pack and writes land somewhere real and per-user.
|
||||
#
|
||||
# Note the asymmetry: the pack is searched BEFORE the filesystem, so a stray or
|
||||
# corrupt file in that directory cannot shadow a shipped asset. An override is a
|
||||
# pack listed after the base one, never a loose file.
|
||||
#
|
||||
# Without a packs.index — which is every `ludic run` during development — nothing
|
||||
# is mounted, no chdir happens, and every open goes straight to fopen against the
|
||||
# working directory exactly as it did before. Packing is a shipping step, and it
|
||||
# is invisible until you ship.
|
||||
|
||||
# The pack runtime leans on the Fs/Path prelude (lp_path_join, lp_path_dir, and
|
||||
# the libc declarations both need), so asking for one asks for the other.
|
||||
function use_pak() -> void { g_uses_pak = true; g_uses_fsrt = true }
|
||||
|
||||
# emit_pak_prelude — emitted once per program that opens a file, straight after
|
||||
# the Fs prelude whose helpers it calls.
|
||||
function emit_pak_prelude() -> void {
|
||||
emith("declare i32 @open(ptr, i32, ...)\n")
|
||||
emith("declare i32 @close(i32)\n")
|
||||
emith("declare i64 @lseek(i32, i64, i32)\n")
|
||||
emith("declare ptr @mmap(ptr, i64, i32, i32, i32, i64)\n")
|
||||
emith("declare ptr @fmemopen(ptr, i64, ptr)\n")
|
||||
emith("declare i32 @chdir(ptr)\n")
|
||||
emith("declare i32 @_NSGetExecutablePath(ptr, ptr)\n")
|
||||
|
||||
emith("@.pak_rb = private unnamed_addr constant [3 x i8] c\"rb\\00\"\n")
|
||||
emith("@.pak_idx = private unnamed_addr constant [12 x i8] c\"packs.index\\00\"\n")
|
||||
emith("@.pak_res = private unnamed_addr constant [13 x i8] c\"../Resources\\00\"\n")
|
||||
emith("@.pak_appsup= private unnamed_addr constant [28 x i8] c\"Library/Application Support\\00\"\n")
|
||||
emith("@.pak_home = private unnamed_addr constant [5 x i8] c\"HOME\\00\"\n")
|
||||
|
||||
# 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")
|
||||
|
||||
emit_pak_mount()
|
||||
emit_pak_lookup()
|
||||
emit_pak_open()
|
||||
emit_pak_boot()
|
||||
|
||||
# run before main, in every program shape (an `entry` game, the auto-loop, the
|
||||
# test runner) without any of them having to know about it
|
||||
emith("@llvm.global_ctors = appending global [1 x { i32, ptr, ptr }] ")
|
||||
emith("[{ i32, ptr, ptr } { i32 101, ptr @lp_pak_boot, ptr null }]\n")
|
||||
}
|
||||
|
||||
# ---- mounting ---------------------------------------------------------------
|
||||
|
||||
# mount(path): map the file and keep its base pointer if it is a pack. Returns 1
|
||||
# when it was mounted. The descriptor is closed straight away — the mapping
|
||||
# outlives it, and holding a descriptor open for the life of the process for no
|
||||
# reason is the kind of thing that runs a game out of them.
|
||||
function emit_pak_mount() -> void {
|
||||
emith("define i32 @lp_pak_mount(ptr %path) {\n")
|
||||
emith("entry:\n %n = load i32, ptr @L_pak_n\n %full = icmp sge i32 %n, 8\n br i1 %full, label %no, label %op\n")
|
||||
# O_RDONLY = 0
|
||||
emith("op:\n %fd = call i32 (ptr, i32, ...) @open(ptr %path, i32 0)\n %bad = icmp slt i32 %fd, 0\n br i1 %bad, label %no, label %sz\n")
|
||||
# SEEK_END = 2; a pack is at least a header
|
||||
emith("sz:\n %end = call i64 @lseek(i32 %fd, i64 0, i32 2)\n %small = icmp slt i64 %end, 32\n br i1 %small, label %clo, label %mp\n")
|
||||
# PROT_READ = 1, MAP_PRIVATE = 2
|
||||
emith("mp:\n %base = call ptr @mmap(ptr null, i64 %end, i32 1, i32 2, i32 %fd, i64 0)\n")
|
||||
emith(" %mbad = icmp eq ptr %base, inttoptr (i64 -1 to ptr)\n br i1 %mbad, label %clo, label %chk\n")
|
||||
# "LPAK" read as a little-endian i32
|
||||
emith("chk:\n %m = load i32, ptr %base\n %okm = icmp eq i32 %m, 1262571596\n br i1 %okm, label %keep, label %clo\n")
|
||||
emith("keep:\n %slot = getelementptr [8 x ptr], ptr @L_pak_base, i32 0, i32 %n\n store ptr %base, ptr %slot\n")
|
||||
emith(" %n1 = add i32 %n, 1\n store i32 %n1, ptr @L_pak_n\n %c1 = call i32 @close(i32 %fd)\n ret i32 1\n")
|
||||
emith("clo:\n %c2 = call i32 @close(i32 %fd)\n ret i32 0\n")
|
||||
emith("no:\n ret i32 0\n}\n")
|
||||
}
|
||||
|
||||
# ---- lookup -----------------------------------------------------------------
|
||||
|
||||
function emit_pak_lookup() -> void {
|
||||
# a leading "./" is the same path; strip any number of them so "./assets/x"
|
||||
# and "assets/x" are one key
|
||||
emith("define ptr @lp_pak_norm(ptr %p) {\n")
|
||||
emith("entry:\n %c0 = load i8, ptr %p\n %c0i = zext i8 %c0 to i32\n %isdot = icmp eq i32 %c0i, 46\n br i1 %isdot, label %chk2, label %same\n")
|
||||
emith("chk2:\n %p1 = getelementptr i8, ptr %p, i32 1\n %c1 = load i8, ptr %p1\n %c1i = zext i8 %c1 to i32\n %issl = icmp eq i32 %c1i, 47\n br i1 %issl, label %skip, label %same\n")
|
||||
emith("skip:\n %p2 = getelementptr i8, ptr %p, i32 2\n %r = call ptr @lp_pak_norm(ptr %p2)\n ret ptr %r\n")
|
||||
emith("same:\n ret ptr %p\n}\n")
|
||||
|
||||
# binary search one pack's sorted entry table; null when the name is not in it
|
||||
emith("define ptr @lp_pak_find1(ptr %base, ptr %name, ptr %outlen) {\n")
|
||||
emith("entry:\n %cntp = getelementptr i8, ptr %base, i32 8\n %cnt = load i32, ptr %cntp\n")
|
||||
emith(" %lop = alloca i32\n %hip = alloca i32\n store i32 0, ptr %lop\n %h0 = sub i32 %cnt, 1\n store i32 %h0, ptr %hip\n br label %loop\n")
|
||||
emith("loop:\n %lo = load i32, ptr %lop\n %hi = load i32, ptr %hip\n %fin = icmp sgt i32 %lo, %hi\n br i1 %fin, label %miss, label %mid\n")
|
||||
emith("mid:\n %sum = add i32 %lo, %hi\n %m = sdiv i32 %sum, 2\n %eo = mul i32 %m, 16\n %eo2 = add i32 %eo, 32\n %ep = getelementptr i8, ptr %base, i32 %eo2\n")
|
||||
emith(" %noff = load i32, ptr %ep\n %nptr = getelementptr i8, ptr %base, i32 %noff\n %c = call i32 @strcmp(ptr %nptr, ptr %name)\n")
|
||||
emith(" %iseq = icmp eq i32 %c, 0\n br i1 %iseq, label %hit, label %cmp\n")
|
||||
emith("cmp:\n %lt = icmp slt i32 %c, 0\n br i1 %lt, label %goup, label %godn\n")
|
||||
emith("goup:\n %m1 = add i32 %m, 1\n store i32 %m1, ptr %lop\n br label %loop\n")
|
||||
emith("godn:\n %m2 = sub i32 %m, 1\n store i32 %m2, ptr %hip\n br label %loop\n")
|
||||
emith("hit:\n %dop = getelementptr i8, ptr %ep, i32 4\n %doff = load i32, ptr %dop\n %dlp = getelementptr i8, ptr %ep, i32 8\n %dlen = load i32, ptr %dlp\n")
|
||||
emith(" store i32 %dlen, ptr %outlen\n %dp = getelementptr i8, ptr %base, i32 %doff\n ret ptr %dp\n")
|
||||
emith("miss:\n ret ptr null\n}\n")
|
||||
|
||||
# search every mounted pack, last mounted first, so a pack listed later in
|
||||
# packs.index shadows the ones before it
|
||||
emith("define ptr @lp_pak_find(ptr %path, ptr %outlen) {\n")
|
||||
emith("entry:\n %p = call ptr @lp_pak_norm(ptr %path)\n %np = load i32, ptr @L_pak_n\n")
|
||||
emith(" %ip = alloca i32\n %i0 = sub i32 %np, 1\n store i32 %i0, ptr %ip\n br label %loop\n")
|
||||
emith("loop:\n %i = load i32, ptr %ip\n %done = icmp slt i32 %i, 0\n br i1 %done, label %miss, label %body\n")
|
||||
emith("body:\n %slot = getelementptr [8 x ptr], ptr @L_pak_base, i32 0, i32 %i\n %base = load ptr, ptr %slot\n")
|
||||
emith(" %r = call ptr @lp_pak_find1(ptr %base, ptr %p, ptr %outlen)\n %hit = icmp ne ptr %r, null\n br i1 %hit, label %got, label %next\n")
|
||||
emith("got:\n ret ptr %r\n")
|
||||
emith("next:\n %i1 = sub i32 %i, 1\n store i32 %i1, ptr %ip\n br label %loop\n")
|
||||
emith("miss:\n ret ptr null\n}\n")
|
||||
|
||||
# Fs.exists / Fs.size ask the packs before they ask the filesystem, so a game
|
||||
# that guards a load with Fs.exists (the sound layer does) still finds it
|
||||
emith("define i32 @lp_pak_has(ptr %path) {\n")
|
||||
emith("entry:\n %lp = alloca i32\n store i32 0, ptr %lp\n %b = call ptr @lp_pak_find(ptr %path, ptr %lp)\n")
|
||||
emith(" %hit = icmp ne ptr %b, null\n %r = select i1 %hit, i32 1, i32 0\n ret i32 %r\n}\n")
|
||||
|
||||
emith("define i32 @lp_pak_bytes(ptr %path) {\n")
|
||||
emith("entry:\n %lp = alloca i32\n store i32 0, ptr %lp\n %b = call ptr @lp_pak_find(ptr %path, ptr %lp)\n")
|
||||
emith(" %hit = icmp ne ptr %b, null\n br i1 %hit, label %yes, label %no\n")
|
||||
emith("yes:\n %l = load i32, ptr %lp\n ret i32 %l\n")
|
||||
emith("no:\n ret i32 -1\n}\n")
|
||||
}
|
||||
|
||||
# ---- the splice into file_open ----------------------------------------------
|
||||
|
||||
# A read of a packed path becomes a FILE* over the mapped bytes, so fread/fseek/
|
||||
# ftell above it behave exactly as they did against a real file. Anything else —
|
||||
# a write, a path that is not packed — is the fopen it always was.
|
||||
function emit_pak_open() -> void {
|
||||
emith("define ptr @lp_pak_open(ptr %path, ptr %mode) {\n")
|
||||
emith("entry:\n %m0 = load i8, ptr %mode\n %m0i = zext i8 %m0 to i32\n %isr = icmp eq i32 %m0i, 114\n br i1 %isr, label %try, label %real\n")
|
||||
emith("try:\n %lp = alloca i32\n store i32 0, ptr %lp\n %blob = call ptr @lp_pak_find(ptr %path, ptr %lp)\n")
|
||||
emith(" %hit = icmp ne ptr %blob, null\n br i1 %hit, label %mem, label %real\n")
|
||||
emith("mem:\n %len = load i32, ptr %lp\n %lz = zext i32 %len to i64\n %f = call ptr @fmemopen(ptr %blob, i64 %lz, ptr @.pak_rb)\n ret ptr %f\n")
|
||||
emith("real:\n %rf = call ptr @fopen(ptr %path, ptr %mode)\n ret ptr %rf\n}\n")
|
||||
}
|
||||
|
||||
# ---- boot -------------------------------------------------------------------
|
||||
|
||||
function emit_pak_boot() -> void {
|
||||
# read a whole file into a fresh NUL-terminated buffer, or null
|
||||
emith("define ptr @lp_pak_slurp(ptr %path) {\n")
|
||||
emith("entry:\n %f = call ptr @fopen(ptr %path, ptr @.pak_rb)\n %bad = icmp eq ptr %f, null\n br i1 %bad, label %no, label %go\n")
|
||||
emith("go:\n %s1 = call i32 @fseek(ptr %f, i64 0, i32 2)\n %n64 = call i64 @ftell(ptr %f)\n %s2 = call i32 @fseek(ptr %f, i64 0, i32 0)\n")
|
||||
emith(" %cap = add i64 %n64, 1\n %buf = call ptr @malloc(i64 %cap)\n %rd = call i64 @fread(ptr %buf, i64 1, i64 %n64, ptr %f)\n")
|
||||
emith(" %e = getelementptr i8, ptr %buf, i64 %rd\n store i8 0, ptr %e\n %c = call i32 @fclose(ptr %f)\n ret ptr %buf\n")
|
||||
emith("no:\n ret ptr null\n}\n")
|
||||
|
||||
# Try one directory: mount everything its packs.index names, and honour a
|
||||
# `home` line. Returns the number of packs mounted from it.
|
||||
emith("define i32 @lp_pak_boot_dir(ptr %dir) {\n")
|
||||
emith("entry:\n %ix = call ptr @lp_path_join(ptr %dir, ptr @.pak_idx)\n %txt = call ptr @lp_pak_slurp(ptr %ix)\n")
|
||||
emith(" %none = icmp eq ptr %txt, null\n br i1 %none, label %no, label %scan\n")
|
||||
# walk the text line by line, NUL-terminating each in place (it is our copy)
|
||||
emith("scan:\n %ip = alloca i32\n %got = alloca i32\n store i32 0, ptr %ip\n store i32 0, ptr %got\n %jp = alloca i32\n br label %line\n")
|
||||
emith("line:\n %i = load i32, ptr %ip\n %lp = getelementptr i8, ptr %txt, i32 %i\n %c0 = load i8, ptr %lp\n %c0i = zext i8 %c0 to i32\n")
|
||||
emith(" %atend = icmp eq i32 %c0i, 0\n br i1 %atend, label %fin, label %eol\n")
|
||||
# find the end of this line
|
||||
emith("eol:\n store i32 %i, ptr %jp\n br label %escan\n")
|
||||
emith("escan:\n %j = load i32, ptr %jp\n %jq = getelementptr i8, ptr %txt, i32 %j\n %cj = load i8, ptr %jq\n %cji = zext i8 %cj to i32\n")
|
||||
emith(" %isnl = icmp eq i32 %cji, 10\n %isz = icmp eq i32 %cji, 0\n %stop = or i1 %isnl, %isz\n br i1 %stop, label %ehave, label %eadv\n")
|
||||
emith("eadv:\n %j1 = add i32 %j, 1\n store i32 %j1, ptr %jp\n br label %escan\n")
|
||||
emith("ehave:\n %je = load i32, ptr %jp\n %jep = getelementptr i8, ptr %txt, i32 %je\n store i8 0, ptr %jep\n %nexti = add i32 %je, 1\n store i32 %nexti, ptr %ip\n")
|
||||
# "pack " is 5 bytes, "home " is 5 bytes
|
||||
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("dohome:\n %hv = getelementptr i8, ptr %ln, i32 5\n call void @lp_pak_sethome(ptr %hv)\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")
|
||||
|
||||
# point the process at ~/Library/Application Support/<name>, creating it. A
|
||||
# bundled game starts with its working directory at "/", so without this every
|
||||
# relative write — every save — would fail.
|
||||
emith("define void @lp_pak_sethome(ptr %name) {\n")
|
||||
emith("entry:\n %h = call ptr @getenv(ptr @.pak_home)\n %nh = icmp eq ptr %h, null\n br i1 %nh, label %out, label %go\n")
|
||||
emith("go:\n %a = call ptr @lp_path_join(ptr %h, ptr @.pak_appsup)\n %b = call ptr @lp_path_join(ptr %a, ptr %name)\n")
|
||||
emith(" %mk = call i32 @mkdir(ptr %b, i32 493)\n %cd = call i32 @chdir(ptr %b)\n br label %out\n")
|
||||
emith("out:\n ret void\n}\n")
|
||||
|
||||
# The constructor. Look beside the executable: ../Resources first, which is
|
||||
# where the bundler puts things inside a .app, then the executable's own
|
||||
# directory, which is where they land beside a plain binary.
|
||||
emith("define void @lp_pak_boot() {\n")
|
||||
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("out:\n ret void\n}\n")
|
||||
}
|
||||
27756
selfhost/ludicc.seed.ll
27756
selfhost/ludicc.seed.ll
File diff suppressed because it is too large
Load diff
|
|
@ -25,6 +25,7 @@ program Ludic {
|
|||
import "project.ludic"
|
||||
import "pkg.ludic"
|
||||
import "assets.ludic"
|
||||
import "pack.ludic"
|
||||
|
||||
function usage() -> void {
|
||||
print("ludic — the toolchain for the Ludic language")
|
||||
|
|
@ -49,6 +50,11 @@ program Ludic {
|
|||
print(" build-lib <module.ludic> compile a package's module to a prebuilt dylib in lib/<target>/")
|
||||
print(" link-flags print the clang flags to link this project's prebuilt module dylibs")
|
||||
print("")
|
||||
print("shipping:")
|
||||
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("")
|
||||
print("the toolchain:")
|
||||
print(" version print the toolchain version")
|
||||
print(" upgrade [version] reinstall from the docs site (the same script that installed it)")
|
||||
|
|
@ -88,6 +94,7 @@ program Ludic {
|
|||
if (cmd == "verify") { return cmd_pkg_verify() }
|
||||
if (cmd == "vendor") { return cmd_pkg_vendor() }
|
||||
if (cmd == "assets") { return cmd_fetch_assets() }
|
||||
if (cmd == "pack") { return cmd_pack() }
|
||||
if (cmd == "build-lib") { return cmd_pkg_build_lib() }
|
||||
if (cmd == "link-flags") { return cmd_pkg_link_flags() }
|
||||
|
||||
|
|
|
|||
357
tools/ludic-cli/pack.ludic
Normal file
357
tools/ludic-cli/pack.ludic
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
# pack.ludic — `ludic pack`, the writer for the .lpak asset pack.
|
||||
#
|
||||
# The reader is in the compiler (selfhost/backend/stdlib/emit_pak.ludic), which
|
||||
# is also where the format is specified. This is the only thing that produces
|
||||
# one, and the two have exactly one contract between them: the entry table is
|
||||
# sorted by name, because the runtime binary-searches it.
|
||||
#
|
||||
# ludic pack pack the manifest's `pack` roots to build/<name>.lpak
|
||||
# ludic pack --out x.lpak a b pack directories a and b to x.lpak
|
||||
# ludic pack --list x.lpak print what is in a pack
|
||||
# ludic pack --verify x.lpak re-hash every entry against the table
|
||||
#
|
||||
# Paths are stored exactly as the game asks for them — a file at ./assets/kit/x.png
|
||||
# is stored as "assets/kit/x.png" — so packing changes nothing about how the game
|
||||
# is written. That is the whole point: `gltf_load("assets/kit/hiker", ...)` is the
|
||||
# same line of code before and after.
|
||||
|
||||
# ---- little-endian integers -------------------------------------------------
|
||||
#
|
||||
# The runtime reads these back with a plain i32 load, so the byte order here is
|
||||
# the byte order of the machine that runs the game. Both are little-endian on
|
||||
# every target Ludic supports today; a big-endian port would byte-swap in the
|
||||
# reader, not here.
|
||||
|
||||
function put_u32(f: pointer, v: int) -> void {
|
||||
let b = bytes(4)
|
||||
b[0] = v & 255
|
||||
b[1] = (v >> 8) & 255
|
||||
b[2] = (v >> 16) & 255
|
||||
b[3] = (v >> 24) & 255
|
||||
file_write(f, b, 4)
|
||||
}
|
||||
|
||||
# FNV-1a over `n` bytes of `buf` starting at `off`. Not a security hash - it is
|
||||
# there so `ludic pack --verify` can tell a truncated or bit-rotted pack from a
|
||||
# good one. The arithmetic wraps in 32 bits, which is exactly what FNV specifies
|
||||
# mod 2^32; the writer and the verifier compare the same bit pattern, so the
|
||||
# sign an int puts on it never matters.
|
||||
function fnv1a(buf: pointer, off: int, n: int) -> int {
|
||||
var h = -2128831035 # 0x811C9DC5 as a signed 32-bit int
|
||||
var i = 0
|
||||
while i < n {
|
||||
h = h ^ (buf[off + i] & 255)
|
||||
h = h * 16777619
|
||||
i += 1
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
# ---- reading one file whole -------------------------------------------------
|
||||
#
|
||||
# read_file in the prelude NUL-terminates and does not report a length, which is
|
||||
# fine for source text and useless for a PNG. This keeps the length.
|
||||
|
||||
var pk_len: int = 0
|
||||
|
||||
function read_blob(path: pointer) -> pointer {
|
||||
pk_len = 0
|
||||
let f = file_open(path, "rb")
|
||||
if f == null { return null }
|
||||
file_seek(f, 0, 2)
|
||||
let n = file_tell(f)
|
||||
file_seek(f, 0, 0)
|
||||
let buf = bytes(n + 1)
|
||||
let got = file_read(f, buf, n)
|
||||
file_close(f)
|
||||
buf[got] = 0
|
||||
pk_len = got
|
||||
return buf
|
||||
}
|
||||
|
||||
# ---- gathering the files ----------------------------------------------------
|
||||
|
||||
# Every file under `root`, in byte order. `find | sort` rather than a walk of
|
||||
# Fs.list: the runtime binary-searches on strcmp order, and a recursive walk
|
||||
# emits "assets/a.png" after "assets/ab/x.png" because it descends per directory,
|
||||
# which is not that order. LC_ALL=C is what makes sort agree with strcmp.
|
||||
function pack_gather(root: pointer) -> []pointer {
|
||||
var out = new []pointer
|
||||
if not file_exists(root) {
|
||||
err(`ludic pack: no such directory: {root}\n`)
|
||||
return out
|
||||
}
|
||||
let listing = capture(`find {root} -type f ! -name '.DS_Store' | LC_ALL=C sort`)
|
||||
let n = slen(listing)
|
||||
var i = 0
|
||||
while i < n {
|
||||
let line = line_at(listing, i)
|
||||
i = i + slen(line) + 1
|
||||
let t = s_trim(line)
|
||||
if slen(t) > 0 { push(out, t) }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
# every root's files, concatenated then re-sorted as one list, since the runtime
|
||||
# searches one table across all of them
|
||||
function pack_gather_all(roots: []pointer) -> []pointer {
|
||||
var all = new []pointer
|
||||
var i = 0
|
||||
while i < len(roots) {
|
||||
let one = pack_gather(roots[i])
|
||||
var k = 0
|
||||
while k < len(one) { push(all, one[k]); k += 1 }
|
||||
i += 1
|
||||
}
|
||||
return pack_sort(all)
|
||||
}
|
||||
|
||||
# insertion sort by byte order. The lists are hundreds of entries, not millions,
|
||||
# and this keeps the one ordering guarantee the format makes in one readable place.
|
||||
function pack_sort(xs: []pointer) -> []pointer {
|
||||
var i = 1
|
||||
while i < len(xs) {
|
||||
let v = xs[i]
|
||||
var j = i - 1
|
||||
while j >= 0 and pack_cmp(xs[j], v) > 0 {
|
||||
xs[j + 1] = xs[j]
|
||||
j -= 1
|
||||
}
|
||||
xs[j + 1] = v
|
||||
i += 1
|
||||
}
|
||||
return xs
|
||||
}
|
||||
|
||||
# strcmp order: negative, zero or positive, comparing unsigned bytes. This is the
|
||||
# ordering the format promises and the runtime's binary search assumes.
|
||||
function pack_cmp(a: pointer, b: pointer) -> int {
|
||||
var i = 0
|
||||
var r = 0
|
||||
var done = false
|
||||
while not done {
|
||||
let ca = a[i] & 255
|
||||
let cb = b[i] & 255
|
||||
if ca != cb { r = ca - cb; done = true }
|
||||
else if ca == 0 { r = 0; done = true }
|
||||
else { i += 1 }
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
# round `v` up to the next multiple of 16, so every blob starts aligned and a
|
||||
# reader can hand a mapped pointer straight to something that wants alignment
|
||||
function align16(v: int) -> int {
|
||||
let r = v & 15
|
||||
if r == 0 { return v }
|
||||
return v + (16 - r)
|
||||
}
|
||||
|
||||
# ---- writing ----------------------------------------------------------------
|
||||
|
||||
function pack_write(out_path: pointer, files: []pointer) -> bool {
|
||||
let n = len(files)
|
||||
if n == 0 { err("ludic pack: nothing to pack\n"); return false }
|
||||
|
||||
# Layout is decided before a byte is written: the entry table has to carry
|
||||
# absolute offsets, and those are only knowable once every size is known.
|
||||
let names_at = 32 + 16 * n
|
||||
var name_off = new []int
|
||||
var names_len = 0
|
||||
var i = 0
|
||||
while i < n {
|
||||
push(name_off, names_at + names_len)
|
||||
names_len = names_len + slen(files[i]) + 1
|
||||
i += 1
|
||||
}
|
||||
let data_at = align16(names_at + names_len)
|
||||
|
||||
var data_off = new []int
|
||||
var data_len = new []int
|
||||
var hash = new []int
|
||||
var total = data_at
|
||||
i = 0
|
||||
while i < n {
|
||||
let blob = read_blob(files[i])
|
||||
if blob == null { err(`ludic pack: cannot read {files[i]}\n`); return false }
|
||||
push(data_off, total)
|
||||
push(data_len, pk_len)
|
||||
push(hash, fnv1a(blob, 0, pk_len))
|
||||
total = align16(total + pk_len)
|
||||
i += 1
|
||||
}
|
||||
|
||||
run(`mkdir -p "$(dirname {out_path})"`)
|
||||
let f = file_open(out_path, "wb")
|
||||
if f == null { err(`ludic pack: cannot write {out_path}\n`); return false }
|
||||
|
||||
# header
|
||||
let magic = bytes(5); magic[0] = 'L'; magic[1] = 'P'; magic[2] = 'A'; magic[3] = 'K'; magic[4] = 0
|
||||
file_write(f, magic, 4)
|
||||
put_u32(f, 1) # version
|
||||
put_u32(f, n) # count
|
||||
put_u32(f, names_at)
|
||||
put_u32(f, data_at)
|
||||
put_u32(f, 0); put_u32(f, 0); put_u32(f, 0)
|
||||
|
||||
# entry table, in the sorted order the runtime's binary search depends on
|
||||
i = 0
|
||||
while i < n {
|
||||
put_u32(f, name_off[i])
|
||||
put_u32(f, data_off[i])
|
||||
put_u32(f, data_len[i])
|
||||
put_u32(f, hash[i])
|
||||
i += 1
|
||||
}
|
||||
|
||||
# name heap
|
||||
i = 0
|
||||
while i < n {
|
||||
file_write(f, files[i], slen(files[i]) + 1)
|
||||
i += 1
|
||||
}
|
||||
|
||||
# pad up to the first blob, then each blob followed by its alignment padding
|
||||
pack_pad(f, data_at - (names_at + names_len))
|
||||
var at = data_at
|
||||
i = 0
|
||||
while i < n {
|
||||
let blob = read_blob(files[i])
|
||||
if blob == null { err(`ludic pack: {files[i]} vanished mid-pack\n`); file_close(f); return false }
|
||||
file_write(f, blob, pk_len)
|
||||
at = at + pk_len
|
||||
let want = align16(at)
|
||||
pack_pad(f, want - at)
|
||||
at = want
|
||||
i += 1
|
||||
}
|
||||
file_close(f)
|
||||
|
||||
print(`packed {string(n)} files, {string(total / 1024)} KiB -> {out_path}`)
|
||||
return true
|
||||
}
|
||||
|
||||
function pack_pad(f: pointer, k: int) -> void {
|
||||
if k <= 0 { return }
|
||||
let z = bytes(k + 1)
|
||||
var i = 0
|
||||
while i < k { z[i] = 0; i += 1 }
|
||||
file_write(f, z, k)
|
||||
}
|
||||
|
||||
# ---- reading a pack back (list / verify) ------------------------------------
|
||||
|
||||
# the NUL-terminated name at `off`, copied out. Ludic's `+` on a pointer is
|
||||
# string concatenation, not address arithmetic, so an offset into a buffer has to
|
||||
# be sliced rather than added.
|
||||
function pack_name(buf: pointer, off: int) -> pointer {
|
||||
var n = 0
|
||||
while buf[off + n] != 0 { n += 1 }
|
||||
return str_sub(buf, off, off + n)
|
||||
}
|
||||
|
||||
function pack_u32(buf: pointer, at: int) -> int {
|
||||
return (buf[at] & 255) | ((buf[at + 1] & 255) << 8) | ((buf[at + 2] & 255) << 16) | ((buf[at + 3] & 255) << 24)
|
||||
}
|
||||
|
||||
function pack_open_read(path: pointer) -> pointer {
|
||||
let buf = read_blob(path)
|
||||
if buf == null { err(`ludic pack: cannot read {path}\n`); return null }
|
||||
if pk_len < 32 or buf[0] != 'L' or buf[1] != 'P' or buf[2] != 'A' or buf[3] != 'K' {
|
||||
err(`ludic pack: {path} is not a pack\n`)
|
||||
return null
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
function cmd_pack_list(path: pointer) -> int {
|
||||
let buf = pack_open_read(path)
|
||||
if buf == null { return 1 }
|
||||
let n = pack_u32(buf, 8)
|
||||
print(`{path}: {string(n)} entries`)
|
||||
var i = 0
|
||||
while i < n {
|
||||
let e = 32 + 16 * i
|
||||
let no = pack_u32(buf, e)
|
||||
let dl = pack_u32(buf, e + 8)
|
||||
print(` {string(dl)}\t{pack_name(buf, no)}`)
|
||||
i += 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function cmd_pack_verify(path: pointer) -> int {
|
||||
let buf = pack_open_read(path)
|
||||
if buf == null { return 1 }
|
||||
let n = pack_u32(buf, 8)
|
||||
var bad = 0
|
||||
var i = 0
|
||||
while i < n {
|
||||
let e = 32 + 16 * i
|
||||
let no = pack_u32(buf, e)
|
||||
let dof = pack_u32(buf, e + 4)
|
||||
let dl = pack_u32(buf, e + 8)
|
||||
let want = pack_u32(buf, e + 12)
|
||||
if dof + dl > pk_len {
|
||||
err(` truncated: {pack_name(buf, no)}\n`); bad += 1
|
||||
} else if fnv1a(buf, dof, dl) != want {
|
||||
err(` corrupt: {pack_name(buf, no)}\n`); bad += 1
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
if bad > 0 { err(`ludic pack: {string(bad)} of {string(n)} entries failed\n`); return 1 }
|
||||
print(`OK {string(n)} entries verified`)
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---- the command ------------------------------------------------------------
|
||||
|
||||
# The roots to pack: an explicit list of directories on the command line, else
|
||||
# the manifest's `pack` lines, else assets/ when it exists. A game that keeps its
|
||||
# assets where the convention puts them needs no configuration at all.
|
||||
function pack_roots(m: Manifest, from: int) -> []pointer {
|
||||
var roots = new []pointer
|
||||
var i = from
|
||||
while i < arg_count() {
|
||||
let a = arg(i)
|
||||
if a == "--out" { i += 1 } # its value is the pack, not a root
|
||||
else if not s_starts(a, "--") { push(roots, a) }
|
||||
i += 1
|
||||
}
|
||||
if len(roots) > 0 { return roots }
|
||||
i = 0
|
||||
while i < len(m.packs) { push(roots, m.packs[i]); i += 1 }
|
||||
if len(roots) > 0 { return roots }
|
||||
if file_exists("assets") { push(roots, "assets") }
|
||||
return roots
|
||||
}
|
||||
|
||||
# the pack a project builds by default: build/<name>.lpak
|
||||
function pack_default_out(m: Manifest) -> pointer {
|
||||
return `build/{project_name("")}.lpak`
|
||||
}
|
||||
|
||||
function cmd_pack() -> int {
|
||||
var out_path = ""
|
||||
var i = 2
|
||||
while i < arg_count() {
|
||||
let a = arg(i)
|
||||
if a == "--list" { return cmd_pack_list(argn(i + 1, "")) }
|
||||
if a == "--verify" { return cmd_pack_verify(argn(i + 1, "")) }
|
||||
if a == "--out" { out_path = argn(i + 1, ""); i += 1 }
|
||||
i += 1
|
||||
}
|
||||
let m = read_root_manifest()
|
||||
if out_path == "" { out_path = pack_default_out(m) }
|
||||
let roots = pack_roots(m, 2)
|
||||
if len(roots) == 0 {
|
||||
err("ludic pack: nothing to pack.\n")
|
||||
err(" put the assets under assets/, or name the roots in package.ludic:\n")
|
||||
err(" pack \"assets/kit\"\n")
|
||||
return 1
|
||||
}
|
||||
let files = pack_gather_all(roots)
|
||||
if not pack_write(out_path, files) { return 1 }
|
||||
return 0
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ function selfhost_frags() -> []pointer {
|
|||
push(f, "selfhost/backend/stdlib/emit_os.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_unicode.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_fs.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_pak.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_list.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_ease.ludic")
|
||||
push(f, "selfhost/backend/stdlib/emit_anim.ludic")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue