Skip to content

Build integration

Packing by hand works until you forget. This wires it into build.zig so zig build produces the archive, generates the asset-handle enum, and rebuilds both when an asset changes - with nothing generated checked into your source tree.

Everything on this page is taken from a working project, not sketched.

Terminal window
zig fetch --save git+https://github.com/masonschafercodes/zpack#v0.0.2

zpack installs its CLI as an artifact, so a dependent can run it during the build as well as import the library.

build.zig
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// Build the zpack CLI for the *host*, not the target: it runs during the
// build, so cross-compiling the game must not cross-compile the tool.
const zpack_dep = b.dependency("zpack", .{
.target = b.graph.host,
.optimize = .ReleaseFast,
});
const zpack_exe = zpack_dep.artifact("zpack");
// assets/ -> game.zpak, in the cache.
const pack = b.addRunArtifact(zpack_exe);
pack.addArg("pack");
pack.addDirectoryArg(b.path("assets"));
const archive = pack.addOutputFileArg("game.zpak");
// A directory argument is hashed by path, not by contents, so editing an
// asset would otherwise leave the cached archive in place. Register every
// file under assets/ as an input so the hash tracks what is actually there.
addTreeAsInputs(b, pack, "assets");
// game.zpak -> a Zig enum of asset handles, captured from stdout.
const ids = b.addRunArtifact(zpack_exe);
ids.addArg("ids");
ids.addFileArg(archive);
const assets_zig = ids.captureStdOut(.{ .basename = "assets.zig" });
const exe = b.addExecutable(.{
.name = "game",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zpack", .module = zpack_dep.module("zpack") },
},
}),
});
// The generated enum becomes a module, so it never lands in the source tree.
exe.root_module.addAnonymousImport("assets", .{ .root_source_file = assets_zig });
b.installArtifact(exe);
b.getInstallStep().dependOn(&b.addInstallBinFile(archive, "game.zpak").step);
const run = b.addRunArtifact(exe);
run.step.dependOn(b.getInstallStep());
run.setCwd(.{ .cwd_relative = b.getInstallPath(.bin, "") });
b.step("run", "Run the game").dependOn(&run.step);
}
/// Registers every file under `sub_path` as an input of `run`, so the step
/// reruns when any of them changes. Walked at configure time, which is why a
/// newly added asset needs one `zig build` to be noticed.
fn addTreeAsInputs(b: *std.Build, run: *std.Build.Step.Run, sub_path: []const u8) void {
const io = b.graph.io;
var dir = b.build_root.handle.openDir(io, sub_path, .{ .iterate = true }) catch |err| {
std.debug.panic("cannot open '{s}': {s}", .{ sub_path, @errorName(err) });
};
defer dir.close(io);
var walker = dir.walk(b.allocator) catch @panic("OOM");
defer walker.deinit();
while (walker.next(io) catch @panic("walk failed")) |entry| {
if (entry.kind != .file) continue;
run.addFileInput(b.path(b.pathJoin(&.{ sub_path, entry.path })));
}
}

Consuming it needs no path strings at all:

src/main.zig
const std = @import("std");
const zpack = @import("zpack");
const Asset = @import("assets").Asset;
pub fn main(init: std.process.Init) !void {
var archive = try zpack.Archive.open(init.gpa, init.io, .cwd(), "game.zpak");
defer archive.deinit();
const entry = archive.findId(@intFromEnum(Asset.@"textures/player.png")) orelse
return error.MissingAsset;
std.debug.print("player.png is {d} bytes\n", .{entry.size});
}

Mistype the path and the build stops:

src\main.zig:12:53: error: enum 'assets.Asset' has no member named 'textures/plaeyr.png'
const entry = archive.findId(@intFromEnum(Asset.@"textures/plaeyr.png")) orelse
^~~~~~~~~~~~~~~~~~~~~~
  1. Build the tool for the host

    const zpack_dep = b.dependency("zpack", .{
    .target = b.graph.host,
    .optimize = .ReleaseFast,
    });
    const zpack_exe = zpack_dep.artifact("zpack");

    b.graph.host rather than target is the important part. The CLI runs on the machine doing the build, so zig build -Dtarget=aarch64-macos from Linux must not produce a macOS zpack and then try to execute it.

    zpack_dep.module("zpack") gives you the library from the same dependency, built for your actual target.

  2. Pack into the cache

    const pack = b.addRunArtifact(zpack_exe);
    pack.addArg("pack");
    pack.addDirectoryArg(b.path("assets"));
    const archive = pack.addOutputFileArg("game.zpak");

    addOutputFileArg returns a LazyPath to a cache location, so the archive is a tracked build artifact rather than a file in your tree. Later steps depend on the LazyPath, and Zig orders them for you.

  3. Capture the enum from stdout

    const ids = b.addRunArtifact(zpack_exe);
    ids.addArg("ids");
    ids.addFileArg(archive);
    const assets_zig = ids.captureStdOut(.{ .basename = "assets.zig" });
    exe.root_module.addAnonymousImport("assets", .{ .root_source_file = assets_zig });

    addFileArg(archive) is what orders ids after pack - no manual dependOn required. The captured stdout becomes a module, so there is no generated file to check in, gitignore, or forget to regenerate.

addTreeAsInputs walks at configure time, so a brand-new file is not an input until build.zig runs again. Zig re-runs configure when the build script or its inputs change, and any zig build after that picks it up - in practice you may need one extra build the first time you add a file. Editing an existing file is tracked immediately.

If that bothers you, force the pack step to always run:

pack.has_side_effects = true;

That repacks on every build. For a small asset tree it costs milliseconds; for a large one it costs real time, which is why file inputs are the default recommendation.

// Next to the binary, in zig-out/bin/
b.getInstallStep().dependOn(&b.addInstallBinFile(archive, "game.zpak").step);
// Or into a data directory, zig-out/assets/game.zpak
b.getInstallStep().dependOn(
&b.addInstallFileWithDir(archive, .{ .custom = "assets" }, "game.zpak").step,
);
const verify = b.addRunArtifact(zpack_exe);
verify.addArg("verify");
verify.addFileArg(archive);
b.step("verify-assets", "Rehash every entry").dependOn(&verify.step);
const manifest = b.addRunArtifact(zpack_exe);
manifest.addArg("manifest");
manifest.addFileArg(archive);
const manifest_zon = manifest.captureStdOut(.{ .basename = "manifest.zon" });
b.getInstallStep().dependOn(
&b.addInstallFileWithDir(manifest_zon, .prefix, "manifest.zon").step,
);

See manifests for asserting on it.

Nothing is special about one. Give each its own Run step and its own output name:

const core = packTree(b, zpack_exe, "assets/core", "core.zpak");
const dlc = packTree(b, zpack_exe, "assets/dlc", "dlc.zpak");

Factor the three lines into a helper the way addTreeAsInputs is factored out above.

If you would rather not run a subprocess, zpack.pack is an ordinary function. Write a small generator executable in your own project that imports the zpack module and calls it, then run that as a build step. You get full programmatic control - several archives, custom ignore rules via Ignore.parse, a manifest written through Archive.writeManifest - at the cost of one more executable in your build graph.

The CLI route above is simpler and is what most projects want.