Skip to content

Concepts

Five terms recur throughout these docs. They are worth ten minutes up front.

A single .zpak file. It has two parts: an index at the head describing what the archive holds, and a data region after it holding the bytes.

Opening an archive reads and validates only the index. The data region stays on disk and is read on demand, one entry at a time. That is why Archive.open on a 4 GB archive costs the same as on a 4 KB one, and why the whole thing is never resident in memory.

The header plus one entry record per file, in sorted path order. Its size is fixed once the set of paths is known, which is what lets pack reserve it, stream all the file data, and come back to fill it in with offsets and sizes that describe what was actually written rather than what stat predicted.

Because the index comes first, list and find never touch the data region.

One file’s metadata:

pub const Entry = struct {
path: []const u8, // relative, `/`-separated, UTF-8
offset: u64, // absolute, from the start of the archive
size: u64, // the original file
stored_size: u64, // what the archive holds
hash: u64, // XxHash64 of the original bytes
method: Method, // .store or .deflate
};

find returns this and reads nothing. The separation matters: because entry.size is known before any I/O, a caller can size its own buffer and read needs no allocator at all.

Each file is compressed with raw deflate at level 6. If the result is not smaller than the original, pack rewinds and writes the file verbatim instead, recording method = .store.

That fallback is why an archive of PNGs, MP3s, and video never grows. Those formats are already compressed; deflating them costs CPU and adds bytes. A mixed asset tree ends up with both methods in one archive, and reading does not care which - read and entryReader handle both.

When an entry is stored, stored_size == size. An archive whose index claims otherwise for a stored entry is rejected as corrupt.

A u64 handle for a path: XxHash64 of the path bytes under a fixed seed.

const id = zpack.format.assetId("textures/player.png");

The same path always yields the same id, so an id survives a repack and can be computed anywhere - nothing about it is stored in the archive. That is what makes zpack ids work: it emits a Zig enum of these handles, so a mistyped asset path becomes a compile error rather than a null at load time.

Both pack and Archive.open reject an archive in which two different paths hash alike, so a collision is a build failure rather than a wrong asset served at runtime.

Zig 0.16 routes all filesystem work through std.Io. Every zpack entry point takes an Io and a std.Io.Dir rather than a bare path string:

var archive = try zpack.Archive.open(gpa, io, .cwd(), "game.zpak");

.cwd() is the usual Dir, but any directory handle works - which is what lets pack write into a directory it also reads from, and lets unpack be confined to a destination it cannot escape.