Skip to content

format

zpack.format holds the wire format: the constants that define it, the functions that serialize it, and the two helpers most callers actually want - assetId and validatePath.

const format = zpack.format;

For the byte layout these describe, see version 1 layout.

pub fn assetId(path: []const u8) u64

The stable u64 handle for an archive path.

const id = zpack.format.assetId("textures/player.png");
const entry = archive.findId(id) orelse return error.MissingAsset;

It is XxHash64 of the path bytes under a fixed seed, so:

  • The same path always yields the same id, on every platform and every version
  • Handles survive a repack - nothing about them is stored in the archive
  • It is comptime-evaluable, which is what lets a generated enum bake handles into the binary:
const Asset = enum(u64) {
@"jimmy.png" = zpack.format.assetId("jimmy.png"),
};

Both pack and Archive.open reject a set of paths in which two different paths hash alike, so a collision is a build error rather than a wrong asset at runtime. See asset handles.

pub fn validatePath(p: []const u8) Error!void

Rejects anything that could resolve outside the archive root or that cannot round-trip through a filesystem.

zpack.format.validatePath("textures/player.png") catch |err| {
std.log.err("unusable path: {t}", .{err});
};
Rejected Error Why
Empty InvalidPath Names nothing
Over 65535 bytes PathTooLong Cannot be described by the u16 length prefix
Not valid UTF-8 InvalidPath Cannot round-trip through a filesystem reliably
Contains \ InvalidPath Would mean one thing on Linux, another on Windows
Contains NUL InvalidPath Truncates the path at the syscall boundary
Contains : InvalidPath On Windows, names an alternate data stream
An empty segment (a//b) InvalidPath Ambiguous
A . or .. segment InvalidPath Could resolve outside the root

The colon rule covers a leading C:/ drive letter, but applies to a colon anywhere - cfg:bak.txt would write into a stream on cfg, somewhere unpack never reports. See the security model.

pub const magic = "ZPAK".*;
pub const version: u32 = 1;
pub const endian: std.builtin.Endian = .little;
/// magic + version + entry_count
pub const header_size: u64 = 12;
/// A path longer than this cannot be described by the u16 length prefix.
pub const max_path_len: usize = 65535;
/// Upper bound on how far one stored byte can expand when inflated.
pub const max_deflate_ratio: u64 = 1032;
pub const Hasher = std.hash.XxHash64;
pub const hash_seed: u64 = 0;
/// Separate from `hash_seed` so path handles and content digests never
/// collide with each other.
pub const id_seed: u64 = 0x5a7061636b496473;

max_deflate_ratio is deflate’s theoretical ceiling - its densest possible encoding is a 258-byte match in two bits. No honest entry approaches it, so a declared size beyond it means the index is lying. That is the decompression-bomb bound.

pub const Method = enum(u8) {
store = 0,
deflate = 1,
};
pub const Entry = struct {
path: []const u8,
offset: u64,
size: u64,
stored_size: u64,
hash: u64,
method: Method,
};

Both are re-exported from the root module as zpack.Method and zpack.Entry.

pub fn entrySize(path_len: usize) u64
pub fn indexSize(paths: []const []const u8) u64
pub fn maxOriginalSize(method: Method, stored_size: u64) u64

entrySize returns 35 + path_len, the size of one entry record.

indexSize returns the header plus every entry record - what pack reserves before streaming, since the index is fixed-size once the paths are known.

maxOriginalSize returns the largest original size that stored_size bytes could legitimately hold: stored_size for store, and stored_size * 1032 for deflate. The multiplication saturates rather than wrapping, so an absurd stored size yields a usable ceiling rather than a small one.

pub fn writeHeader(w: *Writer, entry_count: u32) Writer.Error!void
pub fn readHeader(r: *Reader, file_size: u64) (Reader.Error || Error)!u32
pub fn writeEntry(w: *Writer, e: Entry) Writer.Error!void
pub fn readEntry(r: *Reader, gpa: Allocator) (Allocator.Error || Reader.Error || Error)!Entry

Field by field, over any std.Io.Reader or std.Io.Writer. Zig structs are never written to disk directly, so struct layout, padding, and field-order changes cannot alter the format by accident.

readHeader takes file_size so it can reject an entry_count the file is far too small to hold - before any allocation, which is what stops a corrupt header from causing a huge one.

readEntry allocates the path, which the caller owns. It also validates the path and rejects a store entry whose stored_size differs from its size.

These are what you need to write an independent reader. Most callers should use Archive instead.

pub const Error = error{
BadMagic,
UnsupportedVersion,
UnsupportedMethod,
CorruptArchive,
InvalidPath,
DuplicatePath,
PathTooLong,
AssetIdCollision,
PathConflict,
};

Every structural error the format can produce. See the error reference for causes, and validation for when each is checked.

pub fn lessThanPath(_: void, a: []const u8, b: []const u8) bool

Byte-order comparison, for sorting paths the way the format requires. Pass it to std.mem.sort to reproduce archive ordering.