Skip to content

Asset handles

Looking an asset up by string is a runtime failure waiting to happen:

// Renames `player.png` to `player_idle.png` and this compiles fine.
const entry = archive.find("textures/player.png") orelse return error.MissingAsset;

The rename succeeds, the build succeeds, the tests that do not touch that asset succeed, and the failure arrives on a player’s machine. Asset handles move that failure to compile time.

  1. Pack your assets

    Terminal window
    zpack pack assets/ game.zpak
  2. Generate the handle enum

    Terminal window
    zpack ids game.zpak > src/assets.zig
    src/assets.zig
    // Generated by zpack from game.zpak. Do not edit.
    pub const Asset = enum(u64) {
    @"audio/blip.wav" = 0x40a8e93753577eb5,
    @"data/noise.bin" = 0xbc5d025c01217e9b,
    @"jimmy.png" = 0xda77d2ecbff2fabb,
    @"levels/level-01.json" = 0x3a43adffebb476d2,
    @"shaders/sprite.frag" = 0xc51bb341f35636a4,
    };
  3. Look assets up by handle

    const Asset = @import("assets.zig").Asset;
    fn load(archive: *const zpack.Archive, asset: Asset) ?zpack.Entry {
    return archive.findId(@intFromEnum(asset));
    }
    const entry = load(&archive, .@"jimmy.png") orelse return error.MissingAsset;
  4. Rename an asset and watch the build fail

    error: enum 'Asset' has no member named 'jimy.png'

    Regenerate assets.zig, fix the references the compiler points at, and move on.

XxHash64 of the path bytes under a fixed seed:

pub const id_seed: u64 = 0x5a7061636b496473;
pub fn assetId(path: []const u8) u64 {
return Hasher.hash(id_seed, path);
}

Three consequences worth knowing:

Nothing is stored in the archive. A handle is derived from the path, not recorded next to it. zpack ids computes them at generation time and findId computes them at lookup time, from the same function.

Handles survive a repack. Rebuilding the archive does not disturb them. Regenerate assets.zig when the set of paths changes, not every build.

They are comptime-evaluable. You can write one by hand without the CLI:

const Asset = enum(u64) {
@"jimmy.png" = zpack.format.assetId("jimmy.png"),
};

That is what example 03 does, so it stands alone without a generation step.

Two paths hashing to the same u64 would make one asset unreachable. Rather than tolerate that, both pack and Archive.open reject it:

Terminal window
$ zpack pack assets/ game.zpak
zpack: cannot pack 'assets/': AssetIdCollision

The failure lands on the change that introduced it, in your build, not on a player’s machine. With 64 bits, a coin-flip chance of one collision takes around five billion paths - but “unlikely” is not “handled”, so it is checked.

The generated file is a build artifact. Two options:

Check it in. Simple, diffable, and a missing regeneration shows up as a compile error rather than a silent mismatch. Add a CI step that regenerates and diffs:

Terminal window
zpack ids game.zpak > /tmp/assets.zig
diff src/assets.zig /tmp/assets.zig

Generate it during the build. No file to forget, at the cost of a build step. See build integration.

Either way, a stale enum fails loudly: a handle for a path no longer in the archive returns null from findId, and a path added since generation has no enum member to reference.

Handles are for assets your code names directly. They are not for:

  • Paths built at runtime - levels/level-{d}.json for a level number
  • User-supplied or moddable content, which by definition is not known at compile time
  • Iterating everything in a category, where you want archive.entries and a prefix check

find by path is a hash lookup either way. Handles buy compile-time checking, not speed - use them where the compile-time check is worth having.