Skip to content

Manifests

A manifest is the archive’s index as ZON: everything the archive describes, without any of its contents.

Terminal window
zpack manifest game.zpak > manifest.zon

It is derived from the archive when the command runs, not written alongside it at pack time. There is no second file to forget to regenerate, and no way for the two to disagree.

manifest.zon
.{
.format_version = 1,
.archive_bytes = 12828,
.entry_count = 5,
.total_bytes = 13309,
.stored_bytes = 12565,
.entries = .{
.{
.path = "audio/blip.wav",
.id = 0x40a8e93753577eb5,
.size = 2444,
.stored_size = 2370,
.method = .deflate,
.hash = 0xfe514dafacec030e,
},
.{
.path = "data/noise.bin",
.id = 0xbc5d025c01217e9b,
.size = 4096,
.stored_size = 4096,
.method = .store,
.hash = 0x8ca43708f0ada27c,
},
},
}

Sizes are exact byte counts, not the rounded units list prints. Entries appear in sorted path order, so two manifests of the same tree diff cleanly - see the field reference.

ZON is Zig’s own literal syntax, so the standard library parses it with no dependency and no hand-written parser:

const Manifest = struct {
format_version: u32,
archive_bytes: u64,
entry_count: u32,
total_bytes: u64,
stored_bytes: u64,
entries: []const Entry,
const Entry = struct {
path: []const u8,
id: u64,
size: u64,
stored_size: u64,
method: enum { store, deflate },
hash: u64,
};
};
const source = try dir.readFileAllocOptions(io, "manifest.zon", gpa, .limited(1 << 24), .of(u8), 0);
defer gpa.free(source);
const manifest = try std.zon.parse.fromSliceAlloc(Manifest, gpa, source, null, .{});
defer std.zon.parse.free(gpa, manifest);

The struct is yours - leave out fields you do not care about and ZON parsing ignores them.

The failure mode this catches is an artist checking in a 400 MB uncompressed texture nobody notices until a release build.

const budget = 64 * 1024 * 1024;
if (manifest.archive_bytes > budget) {
std.log.err("archive is {d} bytes, budget is {d}", .{ manifest.archive_bytes, budget });
return error.OverBudget;
}
// Or per asset.
for (manifest.entries) |entry| {
if (entry.size > 8 * 1024 * 1024) {
std.log.err("{s} is {d} bytes", .{ entry.path, entry.size });
return error.AssetTooLarge;
}
}

Cheaper than opening the archive, and it runs anywhere the manifest is checked in:

const required = [_][]const u8{
"textures/player.png",
"audio/theme.ogg",
"levels/level-01.json",
};
outer: for (required) |want| {
for (manifest.entries) |entry| {
if (std.mem.eql(u8, entry.path, want)) continue :outer;
}
std.log.err("missing required asset: {s}", .{want});
return error.MissingAsset;
}

Because entries are sorted and sizes are exact, diff does the work:

Terminal window
zpack manifest old.zpak > old.zon
zpack manifest new.zpak > new.zon
diff old.zon new.zon
.size = 153216,
.hash = 0x1e4c43f38fdf17b6,
.size = 401992,
.hash = 0x9a3f0e2b7c118d44,

A changed hash with an unchanged size means the contents changed. A changed method means a file crossed the compress-or-store threshold. A new block means an asset was added.

const saved = manifest.total_bytes - manifest.stored_bytes;
const percent = 100.0 * @as(f64, @floatFromInt(saved)) /
@as(f64, @floatFromInt(manifest.total_bytes));
std.log.info("compression saved {d:.1}%", .{percent});

If that number drops sharply, something incompressible got added - usually a video or an already-compressed archive that would be better handled outside the .zpak.

Commit manifest.zon, then fail CI when a build disagrees with it:

Terminal window
zpack manifest game.zpak > /tmp/manifest.zon
diff manifest.zon /tmp/manifest.zon || {
echo "assets changed; commit the new manifest" >&2
exit 1
}

Because archives are reproducible, that diff is empty unless the assets genuinely changed. It makes an unintended asset change reviewable, in the diff, rather than invisible in a binary.

Archive.writeManifest takes any std.Io.Writer, so a build.zig step or a tool can produce a manifest without invoking the binary:

var archive = try zpack.Archive.open(gpa, io, .cwd(), "game.zpak");
defer archive.deinit();
const file = try Dir.cwd().createFile(io, "manifest.zon", .{});
defer file.close(io);
var buffer: [4096]u8 = undefined;
var writer = file.writer(io, &buffer);
try archive.writeManifest(&writer.interface);
try writer.interface.flush();

Only the index is read, so this is as cheap as list. See build integration for wiring it into a build step.

format_version describes the archive. The manifest itself is a reporting format, and its shape may gain fields between zpack releases without the archive format moving. Parse the fields you need and let ZON ignore the rest.