Pack API
Builds, reads, verifies, and edits .zpk bundles, the single-file container the CLI uses to ship runnable scripts and their assets, headless or wrapped in a native executable stub.
Overview
Pack assembles ZPK bundles: the on-disk container the CLI uses to ship runnable scripts (and their assets) as a single file. It is also how scripts read entries back out of a bundle, whether that bundle is the one the running process was launched from or an arbitrary .zpk file on disk. Pack.build writes a whole bundle from one description; bundle handles (Pack.openFile, Pack.openBuffer) read entries back out; edit handles (Pack.editFile, Pack.editBuffer) stage mutations against an existing bundle and commit them as one atomic rewrite; and Pack.inspectBin / Pack.splice operate directly on native executables that carry a ZPK payload.
Pack is a grantable CLI native, on the same footing as File, Process, and AES. The root VM has Pack because it receives the full catalog at boot. A child VM created via Zym.newVM(...) has Pack only when its parent script explicitly grants it (e.g. registerCliNative("Pack")). A child that was not granted Pack has no Pack global at all and cannot assemble bundles. This matches the policy used by every other grantable native and by Zym itself; bundling capability is never auto-installed.
Building Bundles
Assembles a complete bundle from a single spec map and writes it to spec.output. The whole bundle is described in one call. The underlying writer is batch-shaped, so a streaming builder layered on top would only re-buffer the same data on the script side.
spec(map) — the bundle description; keys below
Returns: true on success, meaning the file at spec.output exists and is a valid ZPK bundle. false on any I/O or validation failure (could not open the stub, could not stream a path entry, short write, and so on). The writer's human-facing diagnostics are emitted on stderr; the bool is the programmatic signal.
The spec Map
output(string, required) — destination path. When it ends in.zpka headless bundle is produced andstubis ignored.entries(list, required) — non-empty list of entry maps (see below).entryIndex(number, optional) — index intoentriesof the program entry point. Auto-resolves in the common case; details below.stub(string, optional) — path to a CLI runtime binary to prepend as the executable stub. Ignored whenoutputends in.zpk.compression(bool, optional, defaultfalse) — bundle-wide compression default (zstd). Whentrue, every entry is compressed unless it setscompression: false; whenfalseor omitted, entries default to uncompressed and opt in withcompression: true.level(number, optional, default3) — default zstd level (1–22). A per-entryleveloverrides it, and it is ignored on entries that resolve to uncompressed. 3 matches zstd's own default; 19 and up is the release-build sweet spot.setExecutable(bool, optional, defaultfalse) — whentrue, marks the output file as executable after writing. Platform behavior below.
entryIndex is optional. If the bundle contains exactly one entry_source / entry_bytecode entry, entryIndex auto-resolves to that entry's index. Set it only to disambiguate or to mark a non-entry-kind entry as significant in a general archive. When supplied alongside exactly one entry-kind entry, it must point at that entry. A bundle containing no entry-kind entry at all is treated as a general archive: not runnable, with the on-disk footer carrying the ZPK_NO_ENTRY sentinel (0xFFFFFFFF). A bundle may contain at most one entry-kind entry.
If the file named by stub already carries a ZPK payload, only its native portion is taken. The existing payload is dropped and replaced with the new one. A stub-wrapped binary can therefore be re-packed in place without ever stacking multiple ZPK regions; Pack enforces exactly one ZPK per executable by construction. No mode flag is needed: append-versus-swap is decided by what the stub file actually contains.
setExecutable checks the host OS running Pack.build, not the stub's target OS. On a POSIX host (Linux/macOS) it adds execute bits mirrored from the read bits, masked by the process umask, which is the same behavior as chmod +x honoring umask. It works for any output, including Linux/macOS stubs and Windows .exe stubs (the bit is harmless on PE files). A chmod failure on POSIX warns to stderr but does not fail the build: the bundle bytes were written successfully, and the chmod can be retried by hand.
setExecutable is a silent no-op. The POSIX mode-bit APIs are not compiled into the Windows build of zym, so the flag has no effect even when the output is a Linux ELF stub. A Windows-host build packing a Linux binary produces a valid ELF with mode 0644; run chmod +x on the Linux target.
Entry Maps
Each element of entries is a map:
name(string, optional) — logical name stored in the bundle's string table (e.g."main.zbc"). Entries without one are unnamed.kind(string, required) — one of the kind strings.flags(number, optional, default0) — per-entry flag bits, forwarded to the on-diskflagsfield. 16-bit unsigned; valid range 0–65,535 (0x0000–0xFFFF). Scripts that don't want to do bit math can just pass a plain number in that range; values outside it are truncated touint16_t.custom(number, optional, default0) — free per-kind 32-bit field, forwarded verbatim. 32-bit unsigned; valid range 0–4,294,967,295 (0x00000000–0xFFFFFFFF). Treat it as either 32 bitflag/tag slots or a plain numeric tag. Pack doesn't interpret it. Values outside the range are truncated touint32_t.data(Buffer) — in-memory bytes. Use this when the data already lives in script memory.path(string) — absolute or relative file path. The writer streams this file from disk; the bytes never round-trip through a script-side Buffer.compression(bool, optional) — per-entry override of the bundle-widecompression. Always wins over the bundle default.level(number, optional) — per-entry override of the bundle-widelevel(1–22). Ignored when the entry resolves to uncompressed.
Every entry must set exactly one of data or path. Setting both, or neither, raises a runtime error.
Errors Raised vs. Returned
Type and shape mistakes raise a runtime error of the form Pack.build(spec) .... Examples:
Pack.build(42): the argument is not a mapspec.outputmissing or not a stringspec.entriesnot a list, or empty- an entry's
kindis not a recognized string - an entry sets both
dataandpath, or neither - an entry's
datais not a Buffer entryIndexout of rangeentryIndexsupplied alongside exactly one entry-kind entry but pointing somewhere else: either omitentryIndexso it auto-resolves, or set it to the index of theentry_source/entry_bytecodeentry- more than one entry has an entry-kind (
entry_source/entry_bytecode) in the same bundle
Recoverable failures (a file that cannot be opened, a short read, a short write, out-of-memory while assembling) return false instead.
Entry Kinds
The accepted kind strings. Strings are used, rather than numeric constants, so scripts don't have to know the on-disk byte values.
| String | Meaning |
|---|---|
"entry_source" |
The program entry point's raw source (.zym). The runtime loader compiles it on boot, then runs. Only one of entry_source / entry_bytecode is permitted per bundle. |
"entry_bytecode" |
The program entry point's compiled bytecode (.zbc). The runtime loader deserializes and runs it directly. |
"source_map" |
A source map for the entry's bytecode (or any other consumer). The pairing is by name; the runtime loader does not consume source maps itself. |
"file" |
A named, path-addressable resource consumed as a coherent unit, analogous to a file in a filesystem. Use this for configuration, templates, scripts loaded by name, and text or binary content that a script looks up via a path-shaped name. Names should be normalized paths (forward slashes, no leading /, no ..). |
"blob" |
Opaque, id-addressed bytes whose meaning is determined by the producer/consumer pair. Use this for anything that's just bytes by id: binary handoff between cooperating scripts, attached signatures, additional .zbc modules loaded by a script into an in-process VM, ML weights. Sub-kind discrimination (encoding, MIME, format tag) goes in the per-entry custom u32 (32 bitflag/tag slots) or flags u16, which the writer forwards verbatim. |
Compression
Pack supports zstd as the only compression codec. The on-disk format records compression per entry, so each entry can be compressed or stored verbatim independently. There is no whole-bundle codec.
The bundle default and per-entry override resolve as follows:
- With
spec.compressionomitted orfalse, entries default to uncompressed; an entry setscompression: trueto opt in. - With
spec.compression: true, entries default to compressed; an entry setscompression: falseto opt out. - A per-entry
compressionalways wins over the bundle default. levelfollows the same shape: a bundle-level default (3 if omitted) overridden by a per-entrylevel. The range is 1–22, matchingBuffer.compress("zstd", level)in the Buffer API.
Auto-fallback to uncompressed. If an entry resolved to compressed but the zstd output isn't strictly smaller than the raw input, the writer stores the raw bytes instead and records compression: none on disk. Already-compressed assets (PNG, opus, etc.) therefore don't get a worse-than-passthrough re-encode just because the bundle's default is true.
Reads are transparent. open(arg) always hands back the decompressed payload as a Buffer. Scripts that want to know what the on-disk codec actually was can check info(arg).compression ("zstd" or "none").
Source vs. Bytecode Entries
A bundle's program entry can be either compiled bytecode (entry_bytecode) or raw .zym source (entry_source). Pick one:
entry_bytecode: the runtime loader deserializes and runs the chunk directly. Use this for shipping production builds. Modules are resolved at compile time, so a fully-compiledentry_bytecodechunk already contains everything its entry script imported. No runtime resolution against the bundle is performed (or needed).entry_source: the runtime loader compiles the source on every boot, then runs it. Useful for small single-file tools, tweak-and-run debugging workflows, and patch-the-script, re-launch iteration.
Module resolution policy. Module imports are a compile-time concept; ZPK never resolves modules from inside the bundle at runtime, regardless of entry kind. When the entry is entry_source, the loader compiles on boot and module imports are resolved from disk only, relative to the running process's working directory. When the entry is entry_bytecode, the chunk was compiled ahead of time with all of its imports already inlined, so no runtime resolution happens at all. For a self-contained, no-disk-required bundle, compile to entry_bytecode. Additional .zbc chunks meant to be read by name from the bundle should be stored as blob entries (read explicitly via open(arg)); they are never consulted by any import statement.
A bundle may contain at most one entry-kind entry; mixing entry_bytecode and entry_source in the same bundle is rejected at Pack.build time.
entry_source entry surface at boot time (when the loader compiles), not at pack time. That's the intended debug-iteration behavior, but worth knowing when shipping source-entry bundles to other users.
var ok = Pack.build({ output: "dist/app", // not .zpk → wrapped exe stub: "vendor/zym-runtime", entries: [ { name: "app.zym", kind: "entry_source", path: "src/app.zym" } ] })
Verbose Diagnostics
Controls whether Pack's speculative reader probes (the ones used internally by Pack.build, Pack.splice, and Pack.inspectBin to sniff whether an input file already carries a .zpk payload) print diagnostics on stderr. The default is quiet (false): the common case for these probes is a fresh native stub with no payload yet, where the reader would otherwise print messages like zpk: no .zpk payload found (footer magic missing). Those messages are pure noise, since no payload is the expected signal these calls are looking for. Pass true to opt in to chatty output, e.g. when debugging a pack or splice that isn't producing what you expect.
verbose(bool) —truefor chatty probe output,falsefor quiet. A non-bool argument raises a runtime error.
Returns: the new value, which is the argument echoed back. The setting is module-global and persists until changed again.
This switch only governs the probe paths. User-facing opens (Pack.openFile, Pack.openBuffer) keep their normal diagnostics regardless of this flag, because the script explicitly asked to open that bundle and expects feedback when it isn't a valid .zpk.
Pack.setVerboseOutput(true) Pack.build({ output: "dist/app", stub: "vendor/zym-runtime", entries: [ { name: "main.zbc", kind: "entry_bytecode", data: bc } ] }) Pack.setVerboseOutput(false)
Reading Bundles
Opens an arbitrary .zpk (or stub-wrapped binary) from a filesystem path. The handle caches the parsed reader for its lifetime and frees it on close(). A bundle with a bad footer CRC is rejected at open time.
path(string) — path to the bundle on disk
Returns: a bundle handle on success, null if the input is not a valid bundle.
Opens a .zpk whose bytes already live in script memory: fetched over the network, decrypted in-process, generated on the fly. The reader takes its own copy of the bytes, so the source Buffer is independent and may be reused or discarded immediately. The returned handle behaves identically to one returned by openFile: same methods, same caching, same close() lifecycle, same GC-finalizer safety net.
buffer(Buffer) — the bundle's bytes
Returns: a bundle handle on success, null if the bytes are not a valid bundle.
Bundle Handle Methods
Returns a list of entryInfo maps, one per manifest entry, in manifest order. Returns null after the handle has been closed.
Returns the name of the bundle's program entry point as a string, or null.
Returns the manifest index of the bundle's program entry point as a number, or null.
Returns true when the bundle contains an entry with the given name.
name(string) — the entry name to look for
Looks up an entry by name or manifest index and returns its payload as a Buffer. The payload is always handed back decompressed, regardless of the on-disk codec. Returns null when no entry matches.
arg(string or number) — entry name, or 0-based manifest index
Looks up an entry by name or manifest index and returns its entryInfo map. Returns null when no entry matches.
arg(string or number) — entry name, or 0-based manifest index
Reports the on-disk format_version of the bundle as a number, or null when the handle has been closed. Useful for tooling such as zym pack info that wants to print the format level a bundle was written against.
Frees the handle's cached reader. After close, every method on the handle returns null / false.
Name or Index Lookup
bundle.open and bundle.info both accept either a string entry name or a numeric manifest index. open("main.zbc") / info("main.zbc") look up the first entry whose name matches and return null if no entry has that name; open(0) / info(0) look up the entry at that 0-based manifest position and return null if the index is out of range.
Bundles may legally contain multiple entries that share the same name. Each manifest slot is independent. When that happens the string form resolves to the first match only; use the numeric index to address any subsequent entry. bundle.list() returns entries in manifest order, so a typical pattern is to walk list() to find duplicates and then call open(index) / info(index) on the specific entries of interest. The single-argument verify(arg) follows the same string/number dispatch.
The entryInfo Map
bundle.list and bundle.info return per-entry maps with every field of the underlying on-disk entry. Fields unused in v1 (the compression byte, reserved slots) are still surfaced verbatim, so scripts can introspect bundles authored by future writers without an API churn.
| Key | Type | Description |
|---|---|---|
index | number | Position in the manifest (0-based). |
name | string | Logical name; empty string when the entry was unnamed. |
kind | string | One of the kind strings, or "reserved:0xNN" / "user:0xNN" for bytes outside the documented set. |
kindByte | number | Raw kind byte (0–255). |
compression | string | "none" or "zstd". ("unknown" is reported for any other on-disk byte read from a forward-compatible bundle.) |
compressionByte | number | Raw compression byte. |
flags | number | Raw 16-bit flag bits. |
required | bool | Convenience: the required flag bit is set. |
lazy | bool | Convenience: the lazy flag bit is set. |
nameOffset | number | Offset into the bundle's string table. |
nameLength | number | Bytes in the name; 0 for unnamed entries. |
reserved | number | The manifest entry's reserved 32-bit field (must be 0 in v1; surfaced for future use). |
dataOffset | number | Absolute offset of the entry's bytes in the file. |
dataSize | number | On-disk size (post-compression). Equal to uncompressedSize in v1. |
uncompressedSize | number | Logical (decompressed) size. |
size | number | Alias of uncompressedSize for convenience. |
dataCrc32 | number | CRC-32 of the on-disk bytes. |
custom | number | Free per-kind 32-bit field, surfaced verbatim. |
isEntry | bool | true when this entry is the program entry point. |
Handle Lifecycle
Pack.openFile(path) and Pack.openBuffer(buffer) return a bundle handle on success and null if the input is not a valid bundle. Each handle owns its own reader, cached until bundle.close() is called. After close, every method on the handle returns null / false. Forgetting to call close() is not a leak (a GC finalizer closes the reader when the handle is collected), but the explicit close() is the recommended pattern because it bounds memory use the moment the script is done.
var b = Pack.openFile("dist/app.zpk") if (b == null) { print("not a valid bundle") } else { print("entry: " + b.entryName()) var info = b.info("main.zbc") if (info != null) { // info.dataSize, info.dataCrc32, info.kind, ... } var first = b.open(0) // Buffer of the first entry's bytes b.close() // free the cached reader }
Verifying CRCs
Every bundle stores three independent CRC-32s: one over the footer, one over the manifest table (entries plus the string table), and one per entry over its on-disk bytes. The footer CRC is enforced when a bundle is opened: a bundle with a bad footer CRC is rejected, so Pack.openFile (and Pack.openBuffer) returns null. The manifest CRC and per-entry data CRCs are not enforced at open time. They're surfaced through verify() on the bundle handle so scripts can decide what to do on mismatch.
Runs all three CRC checks and returns a structured report. Returns null when the handle has been closed.
{
ok: <bool>, // true iff every CRC matches
footer: { ok, expected, computed },
manifest: { ok, expected, computed },
entries: [
{ index, name, ok, expected, computed, readable },
... // one per manifest entry
]
}
The top-level ok is the AND of footer.ok, manifest.ok, and every entries[i].ok. expected is the value stored in the bundle; computed is the value computed locally. Both are surfaced as numbers so scripts can log and compare them on mismatch. The per-entry readable is false only when the entry's dataOffset / dataSize falls outside the file (a corrupt manifest); in that case computed is reported as 0 and ok is false.
Quick per-entry CRC check. Returns true when the entry exists and its on-disk bytes hash to the recorded CRC; false when the entry doesn't exist, the index is out of range, the bytes are bounds-busted, the CRC doesn't match, or the handle has been closed. Use verify() for a full report; use verify(arg) for a one-shot bool on a single entry. The full report already contains the per-entry detail, so the one-argument form is purely a convenience for the common case.
arg(string or number) — entry name, or 0-based manifest index
var b = Pack.openFile("dist/app.zpk") if (b != null) { var rep = b.verify() if (!rep.ok) { // rep.entries[i] tells you exactly which entry tripped } if (b.verify("main.zbc")) { // ready to load } b.close() }
What the CRCs Cover
All three CRCs hash only bundle content, the bytes that live inside the ZPK region of the file. They are independent of anything the operating system tracks about the file:
- Filename / path. Not covered. Renaming
apptomyapp, or moving the file to a different directory, does not invalidate any CRC. The reader locates the footer atfileSize − footerSizeand validates from there; the path is just how you got to the bytes. - Filesystem mode bits. Not covered. Toggling the executable bit (
chmod +x/chmod -x), changing ownership, or altering ACLs has no effect on the CRCs. (chmod -xwill of course stop the OS from running a stub-wrapped bundle, butPack.openFile/bundle.verifyon the same file will still succeed.) - Modification timestamps, extended attributes, etc. Not covered, for the same reason.
- The native stub portion of a wrapped executable. Not covered. Replacing or modifying the stub (e.g. via Pack.splice, or by building from a newer runtime binary) does not invalidate the payload's CRCs, because the stub lives outside the hashed region.
What is covered:
footer_crc32: every byte of the footer struct (with the CRC field itself zeroed during the hash).manifest_crc32: the manifest entries concatenated with the string table.dataCrc32(per entry): that entry's on-disk bytes (the zstd frame for compressed entries, the raw bytes for uncompressed).
In practice this means a freshly built bundle can be renamed, chmod'd, copied between filesystems, or have its stub replaced via Pack.splice, and bundle.verify().ok will still return true as long as the bundle bytes themselves were not corrupted in transit.
Editing Bundles
Pack.build is the from-scratch path: it writes a whole bundle from a script-built description. Once a bundle exists, the edit transaction API lets a script open it, stage an arbitrary number of mutations against an in-memory op log, and commit() them as a single atomic rewrite. Use this for random-access authoring (add an entry, swap one out, rename a few, reorder some, point the entry index somewhere else) without re-running the whole Pack.build pipeline.
The shape is a handle with a commit() step, on purpose: every mutation in a .zpk re-emits the string table, manifest, and footer at minimum (offsets are chained), so a per-call mutator API would force one rewrite per call. The edit handle stages everything in memory and commits once, no matter how many ops were queued.
commit() last silently overwrites the work of any prior commit. There is no advisory lock, no conflict marker, no stale-handle error. If a script can run two edits against the same target, it must serialize them itself.
Opens an existing .zpk (headless or stub-wrapped) from disk for editing. commit() writes the new bundle to a sibling temp file, fsyncs it, mirrors the source's mode bits (so +x survives), then atomically renames it over the source path. On failure the temp file is removed and the source is untouched.
path(string) — path to the bundle on disk
Returns: an edit handle, or null if the input isn't a valid .zpk (footer magic missing, footer CRC mismatch, etc.). Invalid input does not raise.
Opens a .zpk whose bytes already live in a script Buffer for editing. commit() builds the new bundle in memory, then resizes and overwrites the borrowed Buffer in place. Every live script reference to the same Buffer (including the caller's local in a function that received it as a parameter) observes the new contents on return.
buffer(Buffer) — the bundle's bytes; mutated in place on commit
Returns: an edit handle, or null if the bytes aren't a valid .zpk. Invalid input does not raise.
func patchConfig(bundleBuf, newCfg) { var e = Pack.editBuffer(bundleBuf) e.replace("config.json", { data: newCfg }) e.commit() // mutates bundleBuf in place e.close() } var buf = File.readAllBytes("dist/app.zpk") patchConfig(buf, newCfg) // buf now contains the rewritten bundle File.writeAllBytes("dist/app.zpk", buf)
Lifecycle
The lifecycle is: open, stage any number of ops, commit(), optionally stage more, then close(). A successful commit() clears the staged op log and re-opens the reader against the freshly-committed bytes, so further ops chain off the new state. A failed commit() (e.g. an invalid entry index after staged ops, an unreadable stub path, a write error) leaves the handle's staged ops intact so the script can fix the problem and retry. close() drops staged state without writing; it's idempotent, and calling it on a committed handle is fine. A GC finalizer closes the handle if the script forgets.
Pack.editFile(p).commit() with zero staged ops produces a byte-identical file. Useful for a verify-and-rewrite pass without ops, e.g. future format-version migrations.
Entry-Argument Dispatch
Every op that targets an existing entry (remove, replace, rename, the source side of move, setEntryIndex, setFlags, setCustom) accepts either a string or a number. A string is a first-hit match against the staged view's entry names. A number is a manifest index into the staged view, in [0..view.size()). This is the same dispatch as bundle.open(arg) / bundle.info(arg) / bundle.verify(arg). Mixed-type arrays of args work fine.
Inspecting the Staged View
Returns the staged view as a list of entryInfo maps. It reflects every queued op, not the on-disk source. An entry that was staged for add or replace has fromSource: false in its entryInfo; unchanged source entries have fromSource: true.
Returns the entryInfo map of a staged-view entry by name or index, or null when no entry matches.
arg(string or number) — entry name, or staged-view manifest index
Returns the staged program entry point's manifest index as a number, or null when the staged state has none.
Op Reference
Appends a new entry, or inserts it at spec.index. The spec mirrors a Pack.build entry spec exactly; kind and exactly one of data / path are required, the other fields are optional. An index out of range (negative, or greater than the current view size) is rejected at stage time.
{
name: "<entry name>",
kind: "file" | "blob" | "entry_source" | "entry_bytecode" | "source_map",
data: <Buffer>, // OR
path: "<path on disk>",
flags: <number> | { required, lazy, mask }, // optional
compression: "none" | "zstd", // optional
level: <number>, // optional
custom: <u32>, // optional
index: <number> // optional; insertion point
}
Removes the entry resolved by arg. If the removed slot was the entry index, the staged entry index becomes null until either another add shifts in or a setEntryIndex is staged. The view is rebuilt before commit; an entry index of null at commit time is an error.
Identity-preserving update: the manifest slot index stays the same, so an entry index pointing here is preserved. spec is partial. Every field is optional, and omitted fields keep their source values. Same spec shape as add, except index is ignored.
Pure string-table-side edit. newName must be a string; emptiness and path-shape validation are the same as the writer's, applied at commit time.
Reorders the manifest: the entry at srcArg moves to dstIndex, shifting others as needed. dstIndex must be in [0..view.size()). The staged entry index is auto-updated so it keeps pointing at the same logical entry.
Re-targets the program entry pointer. The kind check (the target must resolve to an entry_source or entry_bytecode entry) runs at commit time against the final staged state, so it's fine to stage setEntryIndex before the add that creates the target.
var e = Pack.editFile("dist/app.zpk") e.add({ name: "main.zbc", kind: "entry_bytecode", data: newBc }) e.setEntryIndex("main.zbc") e.commit() e.close()
Sets the per-entry flags word. flags is either a raw number, used directly as the u16 mask, or a map mirroring the reverse-shape of entryInfo.
edit.setFlags("main.zbc", { required: true, lazy: false }) edit.setFlags("main.zbc", { mask: 0x01 }) // raw bits
Sets the per-entry custom field. Truncated to uint32_t.
Replaces, attaches, or strips the native stub on commit.
arg(string) — load the stub bytes from a file patharg(Buffer) — use the Buffer's bytes directly; they are copied into the staged op, so the source Buffer can be reused or discarded immediatelyarg(null) — strip the stub; the committed bundle is headless
If multiple setStub ops are staged, the last one wins. With no setStub op queued, the source bundle's existing stub is preserved verbatim.
edit.setStub overlap in functionality but are separate APIs. Pack.splice is a one-shot fast path that swaps the stub on a standalone .zpk and writes the result to a new path without opening a transaction. Prefer Pack.splice for the pure stub-swap case; use editFile(...).setStub(...).commit() to combine the stub change with other mutations in the same transaction.
var e = Pack.editFile("dist/app") // stub-wrapped e.setStub(null) e.commit() // result is now headless e.close()
Materializes the staged ops as a single rewrite. For editFile, it writes to <path>.zym-edit.tmp.<pid>, fsyncs on POSIX, mirrors the source mode bits via chmod on POSIX, then atomically renames over the source path; after a successful rename the reader is re-opened from the new file, and on any failure before the rename the temp file is removed and the source is untouched. For editBuffer, it builds the new bundle in memory, then resizes and overwrites the source Buffer's underlying bytes; after write-back, the reader is re-opened against the buffer's new contents. On success the staged op log is cleared and further ops chain off the freshly-committed state.
commit(), that snapshot is independent and will not auto-update when the source Buffer is mutated by the commit. Standard mutator caveat; same as any other in-place Buffer write.
Drops staged state and closes the reader without writing. Idempotent; safe to call on a committed handle. A GC finalizer is a safety net, but the explicit close() is recommended.
Inspecting & Splicing Binaries
Pack exposes two file-level operations for working with executables that already carry a ZPK payload, or are about to. They are cross-platform: ELFs, PE/COFF, Mach-O binaries, and raw blobs are all treated identically, because the operation only looks at the trailing ZPK footer.
Read-only geometry probe. Opens the file, validates the trailing footer, and returns the boundary between the native portion and the ZPK payload. Cheap; does not iterate entry payloads.
path(string) — path to the binary or bundle on disk
{
fileSize: <number>, // total size of the file in bytes
stubSize: <number>, // bytes 0..stubSize are the native stub
payloadSize: <number>, // bytes stubSize..fileSize are the ZPK payload
formatVersion: <number>, // ZPK format version recorded in the footer
entryCount: <number>,
entryIndex: <number>, // ZPK_NO_ENTRY (0xFFFFFFFF) for a general archive
hasEntry: <bool>, // false for general archives (no runnable entry point)
isHeadless: <bool>, // stubSize == 0 (a plain .zpk)
hasStub: <bool> // !isHeadless
}
A bundle reports hasEntry: false when its footer carries the ZPK_NO_ENTRY sentinel, meaning it was produced as a general archive with no entry_source or entry_bytecode entry. The runtime loader refuses to execute such a bundle; the script-side surfaces (Pack.openFile, Pack.openBuffer, the edit handle) still open it normally and let you read or rewrite its entries.
Returns: the map above, or null when the file does not contain a valid trailing ZPK payload (no magic, bad CRC, truncated footer, file unreadable), so scripts can branch cheaply on whether a binary is already packed.
var info = Pack.inspectBin("dist/app") if (info == null) { print("no payload yet — fresh build needed") } else { print("stub: " + str(info.stubSize) + " payload: " + str(info.payloadSize)) }
Combines an already-built standalone .zpk with a native stub binary and writes the result to outputPath. The file-level peer of Pack.build: it doesn't decompose the source .zpk back through the writer, so a pre-built bundle can be shipped on top of any stub without round-tripping the entries through script memory.
stubPath(string) — the native stub binaryzpkPath(string) — the bundle to graft onto the stuboutputPath(string) — where to write the combined executable
If the stub at stubPath already carries a payload, only its native portion is taken. The previous payload is dropped, preserving the exactly-one-ZPK-per-executable invariant. Like Pack.build's stub option, Pack.splice transparently replaces an existing ZPK on the stub rather than appending a second one; no mode flag is needed, because append-versus-replace is decided by what the stub file actually contains. If the source .zpk argument is itself a stub-wrapped binary, only its payload is taken and grafted onto the new stub.
On POSIX (Linux/macOS), the output file inherits the permission bits of the source stub. Splicing an executable stub yields an executable result, and splicing a non-executable file yields a non-executable result. No setExecutable field is needed on Pack.splice: the mode is mirrored automatically, because the result is whatever the input stub already was. On Windows this is a silent no-op (executability is decided by extension / PE header). A chmod failure warns to stderr but does not fail the splice.
After concatenation, the appended payload's absolute offsets (the footer's manifest_offset / strtab_offset, every entry's data_offset) are rewritten to account for the new stub prefix, and the manifest and footer CRCs are recomputed. Per-entry dataCrc32 values are preserved unchanged, because they hash entry bytes rather than their position in the file. The output is therefore a fully valid bundle, indistinguishable from one written by Pack.build directly.
Returns: true on success; false (with a stderr line) on I/O failure or when zpkPath is not a valid bundle. Type/shape mistakes (non-string arguments) raise a runtime error.
// Build a portable .zpk once, ship it on top of platform-specific stubs. Pack.build({ output: "dist/app.zpk", entries: [...] }) Pack.splice("vendor/zym-runtime-linux-x86_64", "dist/app.zpk", "dist/app") Pack.splice("vendor/zym-runtime-windows-x86_64.exe", "dist/app.zpk", "dist/app.exe")
Examples
Headless Bundle with an Entry and a Named Blob
var bytecodeMain = File.readAllBytes("build/main.zbc") var bytecodeUtil = File.readAllBytes("build/util.zbc") var ok = Pack.build({ output: "dist/app.zpk", entries: [ { name: "main.zbc", kind: "entry_bytecode", data: bytecodeMain }, { name: "util.zbc", kind: "blob", data: bytecodeUtil } ] }) if (!ok) { print("packing failed") }
Stub-Wrapped Executable with Assets from Disk
The asset contents never enter script memory; the writer streams them directly from disk.
var bytecode = File.readAllBytes("build/main.zbc") var ok = Pack.build({ output: "dist/app", // not .zpk → wrapped exe stub: "vendor/zym-runtime", // CLI runtime stub entryIndex: 0, entries: [ { name: "main.zbc", kind: "entry_bytecode", data: bytecode }, { name: "assets/level1.bin", kind: "file", path: "assets/level1.bin" }, { name: "assets/credits.txt", kind: "file", path: "assets/credits.txt" } ] })
Patching a Config Inside a Shipped Binary
The edit transaction rewrites the bundle atomically; the executable bit on the stub-wrapped binary survives the commit.
var e = Pack.editFile("dist/app") // stub-wrapped e.replace("config.json", { data: newCfgBytes }) e.commit() // atomic rename; +x preserved e.close()