ludic/tools/ludic-cli/pack.ludic
Orkuncakilkaya 55b0e2ebf6 feat(windows): the ludic CLI and ludic bundle on Windows
The CLI's shell commands go through shell(), which is run() on POSIX and a
scratch script handed to Git for Windows' bash on Windows (exit codes read
directly there). compile_app links through `ludicc -o` on Windows, ludic run
starts the .exe, and ludic-dev build and ensure_ludicc assemble
selfhost/ludicc.win.seed.ll, which ludic-dev reseed now writes beside the
macOS seed. ludic bundle makes build/<name>/ with a GUI-subsystem exe carrying
the .ico beside `app icon` as an llvm-rc resource, game.lpak and packs.index;
ludicc gains --gui and --link, and quotes its whole link line for cmd.exe.

Verified: ludic-dev test 135/135, selfhost-test 32/32; on the PC a checkout
bootstraps from the Windows seed, ludic-dev build makes the toolchain, and
`ludic bundle` makes build/Maroon Lake/, which runs from its own folder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 03:01:33 +03:00

467 lines
16 KiB
Text

# 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 ----------------------------------------------------
#
# A pack entry has a NAME (what the game asks for) and a PATH (where the bytes
# are right now). They are usually the same string, but not always: a package's
# shaders live in the toolchain install and have to be stored under the
# `packages/<module>/` name the renderer looks them up by, or a bundled game
# cannot find them and comes up with no shaders at all.
property PackList { names: []pointer, paths: []pointer }
# how many files `.packignore` kept out of the last gather, and whether to read one
# at all (`ludic pack --no-ignore` turns it off to see what a rule is costing)
var pk_skipped: int = 0
var pk_use_ignore: bool = true
function pack_list_new() -> PackList {
let l = new PackList
l.names = new []pointer
l.paths = new []pointer
return l
}
function pack_add(l: PackList, name: pointer, path: pointer) -> void {
push(l.names, name)
push(l.paths, path)
}
# 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.
#
# `base` is the directory find runs in and `prefix` what the results are stored
# under: with both empty the name is the path, which is the ordinary case.
function pack_gather_into(l: PackList, base: pointer, root: pointer, prefix: pointer) -> void {
pack_gather_filtered(l, base, root, prefix, null)
}
# the same, with a `.packignore` set applied to the paths as they are gathered
function pack_gather_filtered(l: PackList, base: pointer, root: pointer, prefix: pointer, ig: PackIgnore) -> void {
var cd = ""
if base != "" { cd = `cd {base} && ` }
# -L follows symlinks. An asset root is very often a link to a shared or
# fetched tree - `ludic assets` writes one, and a game with two checkouts
# sharing a texture set will have several - and a plain `find` walks straight
# past them, packing nothing while reporting success.
# `.packignore` is a build-time control file - nothing reads one at run time - so it
# is dropped here rather than by the rules it carries, and `--no-ignore` does not
# bring it back.
let listing = capture(`{cd}find -L {root} -type f ! -name '.DS_Store' ! -name '.packignore' 2>/dev/null | 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 rel = s_trim(line)
if slen(rel) == 0 { continue }
if ig != null and pi_ignored(ig, rel) { pk_skipped += 1; continue }
var name = rel
if prefix != "" { name = `{prefix}/{rel}` }
var path = rel
if base != "" { path = `{base}/{rel}` }
pack_add(l, name, path)
}
}
# the project's own asset roots, stored under the names the game already uses
function pack_gather_all(roots: []pointer) -> PackList {
let l = pack_list_new()
pk_skipped = 0
# `.packignore` (packignore.ludic): the project's own roots only. A package's
# resources are added after this and are not the game's to exclude.
var ig: PackIgnore = null
if pk_use_ignore {
ig = pi_new()
pi_load_dir(ig, "")
var r = 0
while r < len(roots) { pi_load_root(ig, roots[r]); r += 1 }
if pi_count(ig) == 0 { ig = null }
}
var i = 0
while i < len(roots) {
if not file_exists(roots[i]) {
err(`ludic pack: no such directory: {roots[i]}\n`)
} else {
let before = len(l.names)
pack_gather_filtered(l, "", roots[i], "", ig)
# A declared root that contributes nothing is almost always a mistake -
# a typo, or a link into a tree that was never fetched - and the failure
# it causes is a game that starts and then behaves as though half its
# assets do not exist. Say so here rather than at the player.
if len(l.names) == before and pk_skipped == 0 { err(`ludic pack: warning: {roots[i]} is empty, nothing packed from it\n`) }
}
i += 1
}
return pack_sort(l)
}
# insertion sort by byte order, carrying each name's path along with it. 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(l: PackList) -> PackList {
var i = 1
while i < len(l.names) {
let vn = l.names[i]
let vp = l.paths[i]
var j = i - 1
while j >= 0 and pack_cmp(l.names[j], vn) > 0 {
l.names[j + 1] = l.names[j]
l.paths[j + 1] = l.paths[j]
j -= 1
}
l.names[j + 1] = vn
l.paths[j + 1] = vp
i += 1
}
return l
}
# 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, l: PackList) -> bool {
let names = l.names
let paths = l.paths
let n = len(names)
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(names[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(paths[i])
if blob == null { err(`ludic pack: cannot read {paths[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
}
shell(`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, names[i], slen(names[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(paths[i])
if blob == null { err(`ludic pack: {paths[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)
var note = ""
if pk_skipped > 0 { note = `, {string(pk_skipped)} skipped by .packignore` }
print(`packed {string(n)} files, {string(total / 1024)} KiB{note} -> {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 packages a shipped game still needs --------------------------------
#
# A package can carry runtime resources, not just code: ludic.render3d loads its
# shaders from `packages/ludic.render3d/shaders/` at startup, resolving that
# against the project or the toolchain install. A bundled .app has neither, so
# without this a shipped 3D game starts, finds no shaders, and draws nothing -
# which is a spectacular way to fail and an easy one to ship by accident.
#
# So the resources go in the pack under exactly the name the package looks them
# up by. Source (.ludic) and documentation are left out: the game is already
# compiled, and nobody reads a package's FEATURES.md out of a .app.
function pack_add_packages(l: PackList) -> void {
var base = "packages"
if not file_exists(base) { base = `{ludic_home()}packages` }
if not file_exists(base) { return }
let mods = capture(`ls {base} 2>/dev/null`)
let n = slen(mods)
var i = 0
while i < n {
let line = line_at(mods, i)
i = i + slen(line) + 1
let mod = s_trim(line)
if slen(mod) == 0 { continue }
pack_gather_pkg(l, base, mod)
}
}
# one package's resource files, named `packages/<module>/<path>`
function pack_gather_pkg(l: PackList, base: pointer, mod: pointer) -> void {
let listing = capture(`cd {base}/{mod} 2>/dev/null && find -L . -type f ! -name '*.ludic' ! -name '*.md' ! -path './build/*' ! -name '.DS_Store' 2>/dev/null | 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
var rel = s_trim(line)
if slen(rel) == 0 { continue }
if s_starts(rel, "./") { rel = sslice(rel, 2, slen(rel)) }
pack_add(l, `packages/{mod}/{rel}`, `{base}/{mod}/{rel}`)
}
}
# ---- 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 }
if a == "--no-ignore" { pk_use_ignore = false }
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
}