Skip to content

pack

pub fn pack(
gpa: Allocator,
io: Io,
src: Dir,
out_dir: Dir,
out_sub_path: []const u8,
) !Stats

Writes src as a .zpak archive at out_dir/out_sub_path.

const src = try Dir.cwd().openDir(io, "assets", .{ .iterate = true });
defer src.close(io);
const stats = try zpack.pack(gpa, io, src, .cwd(), "game.zpak");
std.log.info("packed {d} files, {d} -> {d} bytes", .{
stats.file_count,
stats.total_bytes,
stats.stored_bytes,
});

Every regular file under src, recursively. Directories are implied by entry paths, so empty ones are not preserved; symlinks, devices, and sockets are skipped.

Paths are stored relative to src and normalized to /, so an archive packed on Windows extracts identically on Linux.

Four things are excluded:

  1. Anything matching a pattern in src/.zpackignore
  2. The .zpackignore file itself
  3. A file whose relative path equals out_sub_path - which is what makes packing a directory into itself repeatable
  4. The temporary file pack builds into, created after the scan so it cannot appear in its own archive

The out_sub_path comparison is done against the /-separated form the scan produces, so it matches regardless of which separator the caller wrote.

The archive is built in a temporary file beside the destination and moved into place only once complete. A failure partway - a full disk, a vanished source file - leaves any existing archive at out_sub_path untouched.

Nothing at the destination changes until the very last step.

Each file is compressed with raw deflate at level 6. If the result is not smaller than the original, pack seeks back, re-reads the source, and writes it verbatim as store.

That rewind costs a second read of the file, which is the price of never producing an archive larger than its input. There is no level parameter.

If the file changed size between the two reads, pack returns error.SourceChanged rather than writing an entry whose length it cannot trust.

Directory walk order is undefined, so paths are sorted before the index is written. The same tree always produces the same bytes.

The index is fixed-size once the set of paths is known. pack reserves that much space, streams every file, then seeks back to fill the index in - so offsets and sizes describe what was actually written, not what stat predicted.

1. Load src/.zpackignore
2. Walk src, pruning ignored directories without entering them
3. Sort the paths
4. Reserve the index; seek past it
5. For each file: hash + compress into place, or rewind and store
6. Seek to 0, write the header and the filled-in index
7. Truncate to length, then move the temporary into place

Step 2 is selective: an ignored directory is never opened at all, so .git/ costs nothing to skip regardless of its size.

Step 7’s truncate matters because a store rewind can leave discarded compressed bytes past the last entry.

Error Cause
AssetIdCollision Two different paths hash to the same asset id
DuplicatePath The same path was scanned twice
InvalidPath A filename containing \, :, or NUL, or not valid UTF-8
PathTooLong A path over 65535 bytes
TooManyFiles More than maxInt(u32) files
TooManyRules More than 1024 rules in .zpackignore
SourceChanged A file’s length changed between compression and the store fallback

Plus whatever the filesystem and allocator return. See errors.

Data Where
Directory metadata An arena, discarded when pack returns
File contents Two reused 64 KiB buffers, one read, one write
Deflate window One flate.max_window_len buffer, reused
Deflate compressor ~230 KB, heap-allocated once and reused for every file
The index The same arena as the metadata

No file’s contents are ever resident whole. Packing a 40 GB tree uses the same working set as packing a 40 MB one - the peak scales with the number of paths, not their total size.