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.
Archive layout
Section titled “Archive layout”Header
Section titled “Header”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 |
Entry record
Section titled “Entry record”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.
| 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 |
Data region
Section titled “Data region”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.
Compression
Section titled “Compression”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 <= sizealways 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.
Constants
Section titled “Constants”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 + 4pub 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.
Properties that fall out of this
Section titled “Properties that fall out of this”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.