Skip to content

Archive

An opened archive: its index held in memory, its contents left on disk.

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

open validates the whole index up front, so every later lookup and read works from data that has already been bounds checked.

gpa: Allocator,
io: Io,
file: File,
file_size: u64,
/// Sorted by path, owned by this `Archive`.
entries: []Entry,
/// Asset id to index.
ids: std.AutoHashMapUnmanaged(u64, u32),

entries is public and in sorted path order - iterate it directly to list an archive’s contents without a helper.

pub fn open(gpa: Allocator, io: Io, dir: Dir, sub_path: []const u8) !Archive

Opens the file and reads the entire index into memory. The data region is left on disk and read on demand, so open on a 4 GB archive costs the same as on a 4 KB one.

The file stays open for the lifetime of the Archive. Call deinit to close it and free the index.

Every structural check happens here - see validation for the full list and errors for what each one means. Contents are not read, so HashMismatch never comes from open.

pub fn deinit(self: *Archive) void

Frees every entry path, the entry array, and the id map, then closes the file. The Archive is left undefined - do not use it afterwards.

pub fn find(self: *const Archive, path: []const u8) ?Entry

Exact lookup by path. No I/O: this reads a hash map and returns metadata.

const entry = archive.find("textures/player.png") orelse return error.MissingAsset;
std.log.info("{d} bytes, stored as {t}", .{ entry.size, entry.method });

The path is compared after the id matches, so a path that is not in the archive can never resolve to a colliding entry.

pub fn findId(self: *const Archive, id: u64) ?Entry

Lookup by the u64 handle format.assetId produces, for callers holding a generated id rather than a path:

const Asset = @import("assets.zig").Asset;
const entry = archive.findId(@intFromEnum(Asset.@"textures/player.png")).?;

open rejects archives whose paths collide, so an id from this archive is unambiguous. See asset handles.

pub fn read(self: *const Archive, entry: Entry, buffer: []u8) ![]u8

Writes entry’s original bytes into buffer and returns the filled prefix. Nothing is allocated, and the contents are checked against the stored hash before returning.

var scratch: [64 * 1024]u8 = undefined;
const bytes = try archive.read(entry, &scratch);

buffer must hold at least entry.size bytes, which find told you before any I/O happened. A shorter buffer returns error.BufferTooSmall - including when entry.size does not fit in a usize, which only arises on 32-bit targets.

Decompression happens straight into buffer when the entry is deflated; the decompressor is given a zero-length window so it needs no scratch space of its own.

Error Means
BufferTooSmall buffer.len < entry.size, or entry.size exceeds usize
HashMismatch The bytes do not match the hash recorded at pack time
CorruptArchive The entry produced fewer bytes than the index promised, or the deflate stream is malformed
pub fn readAlloc(self: *Archive, gpa: Allocator, path: []const u8) ![]u8

Finds, allocates, and reads in one call. The caller owns the result:

const bytes = try archive.readAlloc(gpa, "levels/level-01.json");
defer gpa.free(bytes);

Returns error.FileNotFound when the path is not in the archive, and error.OutOfMemory if the size does not fit in a usize. Otherwise the same errors as read.

Convenient for one-off reads and configuration files. For a hot asset-loading path, prefer find plus read into a buffer you already own.

pub fn entryReader(self: *const Archive, entry: Entry, buffer: []u8) error{BufferTooSmall}!EntryReader

Returns a reader over one entry’s original bytes, decompressing as it goes, for assets too large to hold at once.

const buffer = try gpa.alloc(u8, zpack.stream_buffer_len);
defer gpa.free(buffer);
var entry_reader = try archive.entryReader(entry, buffer);
var header: [4]u8 = undefined;
try entry_reader.reader().readSliceAll(&header);

For a deflated entry, buffer must be at least zpack.stream_buffer_len. For a stored entry any size works, including smaller than the entry.

Full details, including the aliasing rule, in EntryReader.

pub fn verify(self: *Archive) !void

Rehashes every entry’s contents and returns error.HashMismatch at the first one that does not match. Writes nothing.

archive.verify() catch |err| {
std.log.err("archive is damaged: {t}", .{err});
return err;
};

Each entry is read with a limit of one byte past its declared size, so an entry that produces too many bytes is caught as a length mismatch rather than read to exhaustion. Both the length and the hash must match.

Allocates one stream_buffer_len buffer for the pass and frees it on return. Memory stays flat regardless of archive size.

pub fn unpack(self: *Archive, dest: Dir) !Stats

Extracts every entry into dest, creating parent directories as needed, and checks contents against their hashes as they stream.

const dest = try Dir.cwd().createDirPathOpen(io, "extracted", .{});
defer dest.close(io);
const stats = try archive.unpack(dest);
std.log.info("{d} files, {d} bytes", .{ stats.file_count, stats.total_bytes });

Entries are sorted, so files sharing a directory are contiguous and the same parent is never created twice. Paths are converted from / to the host separator on the way out.

Extraction is not transactional: entries written before a failure stay on disk. See unpack.

pub fn writeManifest(self: *const Archive, w: *Io.Writer) (Io.Writer.Error || format.Error)!void

Writes the index as ZON to any std.Io.Writer - what the archive holds, without its contents:

var buffer: [4096]u8 = undefined;
var out: Io.File.Writer = .initStreaming(.stdout(), io, &buffer);
try archive.writeManifest(&out.interface);
try out.interface.flush();

Derived from the archive rather than written alongside it, so it cannot fall out of sync. Returns error.CorruptArchive if the entry sizes sum past u64, which is reported rather than trapped.

See manifests for the output shape and how to parse it back.

pub const Entry = struct {
/// Relative to the archive root, `/`-separated, UTF-8. Never escapes the root.
path: []const u8,
/// Absolute byte offset of the stored bytes, from the start of the archive.
offset: u64,
/// Size of the original file.
size: u64,
/// Bytes this entry occupies in the archive. Equals `size` when stored.
stored_size: u64,
/// `Hasher` digest of the original bytes.
hash: u64,
method: Method,
};
pub const Stats = struct {
file_count: u32,
total_bytes: u64,
/// Bytes the entries occupy in the archive, after compression.
stored_bytes: u64,
};

An Entry is a value, copied out of the archive on lookup. Its path borrows from the Archive and is invalidated by deinit.