Skip to content

Version 1 layout

All integers are little-endian. There is no padding and no alignment requirement; every field is written at the byte following the previous one.

The index is written before the data, so list, ids, manifest, and Archive.find only ever read the head of the file.

Index - validated in full by open()Data region - read on demandheaderentry 0entry 1file 0file 112 B35 + N B35 + N Boffsetoffset

12 bytes, at offset 0.

Offset Size Field Value
0 4 magic The ASCII bytes ZPAK
4 4 version u32, currently 1
8 4 entry_count u32, the number of entry records that follow

One per file, immediately after the header, in sorted path order. Each record is 35 + path_len bytes - variable, because the path is stored inline.

path_lenpathoffsetsizestored_sizehashmethod2 BN B8 B8 B8 B8 B1 B
Size Field Type Meaning
2 path_len u16 Length of path in bytes. Caps a path at 65535 bytes
N path [N]u8 UTF-8, relative to the archive root, /-separated. Not NUL-terminated
8 offset u64 Absolute byte offset of this entry’s stored bytes, from the start of the archive
8 size u64 Size of the original file
8 stored_size u64 Bytes this entry occupies in the data region. Equals size when stored
8 hash u64 XxHash64 of the original bytes, seed 0
1 method u8 0 = store, 1 = deflate

Begins immediately after the last entry record. Every entry’s offset points into it, and the reader rejects any offset below that boundary - which is what stops an entry from claiming bytes inside the index.

Entries are written in index order, but nothing in the format requires it. Reading uses offset and stored_size alone.

A file’s bytes are either raw (method = store) or a raw deflate stream (method = deflate) - raw meaning no zlib or gzip wrapper and no checksum of its own. Integrity comes from the entry’s hash field instead.

Every file is compressed at deflate level 6. If the compressed form is not smaller than the original, pack rewinds and writes the file verbatim as store. Consequently:

  • stored_size <= size always holds in an archive zpack wrote
  • an already-compressed asset never grows the archive
  • the format itself permits stored_size > size, and readers accept it; only the writer declines to produce it

There is no per-file setting and no other method. A future version adding one would bump version - see compatibility.

Exposed from zpack.format for anyone implementing a reader:

pub const magic = "ZPAK".*;
pub const version: u32 = 1;
pub const endian: std.builtin.Endian = .little;
/// 4 + 4 + 4
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;
/// Densest possible deflate encoding: a 258 byte match in two bits.
pub const max_deflate_ratio: u64 = 1032;
pub const Hasher = std.hash.XxHash64;
pub const hash_seed: u64 = 0;
pub const id_seed: u64 = 0x5a7061636b496473;

id_seed is deliberately different from hash_seed, so an asset id and a content digest can never collide with each other.

list is O(index). The data region is never touched.

Reading one entry is one seek. offset and stored_size fully describe where an entry’s bytes are - no scanning, and no central directory at the tail to find first.

Archives are reproducible. Sorted entries mean the filesystem’s walk order never reaches the output.

Appending is not possible. Adding an entry lengthens the index, which moves every offset. Repack instead.

Empty directories vanish. Directories exist only as / separators inside entry paths.

Serialization is field-by-field in src/format.zig. Zig structs are never written to disk directly, so struct layout, padding, and field-order changes cannot alter the format by accident.