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.
Add the dependency
Section titled “Add the dependency”zig fetch --save git+https://github.com/masonschafercodes/zpack#v0.0.2zpack installs its CLI as an artifact, so a dependent can run it during the build as well as import the library.
The complete build.zig
Section titled “The complete 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:
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 ^~~~~~~~~~~~~~~~~~~~~~The three pieces
Section titled “The three pieces”-
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.hostrather thantargetis the important part. The CLI runs on the machine doing the build, sozig build -Dtarget=aarch64-macosfrom Linux must not produce a macOSzpackand then try to execute it.zpack_dep.module("zpack")gives you the library from the same dependency, built for your actual target. -
Pack into the cache
const pack = b.addRunArtifact(zpack_exe);pack.addArg("pack");pack.addDirectoryArg(b.path("assets"));const archive = pack.addOutputFileArg("game.zpak");addOutputFileArgreturns aLazyPathto a cache location, so the archive is a tracked build artifact rather than a file in your tree. Later steps depend on theLazyPath, and Zig orders them for you. -
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 ordersidsafterpack- no manualdependOnrequired. The captured stdout becomes a module, so there is no generated file to check in, gitignore, or forget to regenerate.
Adding a new asset file
Section titled “Adding a new asset file”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.
Variations
Section titled “Variations”Ship the archive somewhere else
Section titled “Ship the archive somewhere else”// 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.zpakb.getInstallStep().dependOn( &b.addInstallFileWithDir(archive, .{ .custom = "assets" }, "game.zpak").step,);Verify in CI as a build step
Section titled “Verify in CI as a build step”const verify = b.addRunArtifact(zpack_exe);verify.addArg("verify");verify.addFileArg(archive);b.step("verify-assets", "Rehash every entry").dependOn(&verify.step);Emit a manifest for size budgets
Section titled “Emit a manifest for size budgets”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.
Multiple archives
Section titled “Multiple archives”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.
Without the CLI
Section titled “Without the CLI”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.