How to properly propagate options to dependency

Suppose I have two separate Zig projects - an engine, and a game (uses engine). The engine depends on some libs (sokol, cimgui, stbi, etc), and the game depends on just the engine to do the heavy lifting.

Transitive dependencies like sokol have options of their own which we can pass. The question is - how to pass them in my case? It would be as easy as defining b.option(bool, ...), but my web build complicates things and I feel like I got it the wrong way. Because linkWasm resolves dependencies again to do emscripten specific stuff like sysroot injection - I suppose it needs these options as well since otherwise it will be two completely different resolutions - one with .with_tracing and the other without it.

I would like to keep all the wiring inside the engine’s build.zig, and expose helpers. pacman.zig project gave me some ideas, but it does all the wiring by itself and depends on sokol directly whereas in my case it is game->engine->sokol+cimgui+zstbi and a lot more to wire - but maybe this is the correct way?

Could you give advice on how to re-structure my build so it becomes less complicated..

Engine’s build.zig:

const std = @import("std");
const Build = std.Build;
const builtin = @import("builtin");
const cimgui = @import("cimgui");
const sokol = @import("sokol");

// Hard-coded as of now.
const use_docking = false;

pub fn build(b: *Build) !void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    // Example option
    const sokol_tracing = b.option(bool, "sokol_tracing", "Enable sokol's with_tracing option") orelse false;
    const cimgui_conf = cimgui.getConfig(use_docking);

    const dep_sokol = b.dependency("sokol", .{
        .target = target,
        .optimize = optimize,
        .with_sokol_imgui = true,
        .with_tracing = sokol_tracing,
    });
    const dep_cimgui = b.dependency("cimgui", .{
        .target = target,
        .optimize = optimize,
    });
    const dep_zstbi = b.dependency("zstbi", .{
        .target = target,
        .optimize = optimize,
    });

    dep_sokol.artifact("sokol_clib").root_module.addIncludePath(dep_cimgui.path(cimgui_conf.include_dir));

    _ = b.addModule("zen", .{
        .root_source_file = b.path("src/zen.zig"),
        .target = target,
        .optimize = optimize,
        .imports = &.{
            .{ .name = "sokol", .module = dep_sokol.module("sokol") },
            .{ .name = "zstbi", .module = dep_zstbi.module("root") },
            .{ .name = cimgui_conf.module_name, .module = dep_cimgui.module(cimgui_conf.module_name) },
        },
    });

    // Compile engine shaders
    const shaders_step = b.step("shaders", "Compile engine shaders");
    const dep_shdc = dep_sokol.builder.dependency("shdc", .{});
    const engine_shaders = .{"sprite"};
    inline for (engine_shaders) |name| {
        const step = try sokol.shdc.createSourceFile(b, .{
            .shdc_dep = dep_shdc,
            .input = "src/gx/shaders/" ++ name ++ ".glsl",
            .output = "src/gx/shaders/" ++ name ++ ".zig",
            .slang = default_slang,
        });
        shaders_step.dependOn(step);
    }
}

const default_slang: sokol.shdc.Slang = .{
    .glsl430 = false,
    .glsl300es = true,
    .metal_macos = true,
    .hlsl5 = true,
};

pub const ShaderOptions = struct {
    input: []const u8,
    output: []const u8,
};

pub fn buildShaders(
    b: *Build,
    dep_zen: *Build.Dependency,
    compile: *Build.Step.Compile,
    shaders: []const ShaderOptions,
) !void {
    const dep_sokol = dep_zen.builder.dependency("sokol", .{});
    const dep_shdc = dep_sokol.builder.dependency("shdc", .{});

    for (shaders) |s| {
        const step = try sokol.shdc.createSourceFile(b, .{
            .shdc_dep = dep_shdc,
            .input = s.input,
            .output = s.output,
            .slang = default_slang,
        });
        compile.step.dependOn(step);
    }
}

pub const LinkWasmOptions = struct {
    lib: *Build.Step.Compile,
    shell_file_path: ?Build.LazyPath = null,
    use_filesystem: bool = true,
    extra_args: []const []const u8 = &.{},
};

/// Do the emscripten link for a WASM build.
pub fn linkWasm(
    b: *Build,
    dep_zen: *Build.Dependency,
    options: LinkWasmOptions,
) !*Build.Step.InstallDir {
    const target = options.lib.root_module.resolved_target.?;
    const optimize = options.lib.root_module.optimize.?;

    const dep_sokol = dep_zen.builder.dependency("sokol", .{
        .target = target,
        .optimize = optimize,
        .with_sokol_imgui = true,
        .with_tracing = false, // Needs the option again
    });
    const dep_cimgui = dep_zen.builder.dependency("cimgui", .{
        .target = target,
        .optimize = optimize,
    });
    const dep_emsdk = dep_sokol.builder.dependency("emsdk", .{});
    const dep_zstbi = dep_zen.builder.dependency("zstbi", .{
        .target = target,
        .optimize = optimize,
    });

    const sokol_clib = dep_sokol.artifact("sokol_clib");
    const cimgui_clib = dep_cimgui.artifact("cimgui_clib");

    // C libs need the emscripten system headers
    const emsdk_incl = dep_emsdk.path("upstream/emscripten/cache/sysroot/include");
    dep_zstbi.module("root").addSystemIncludePath(emsdk_incl);
    sokol_clib.root_module.addSystemIncludePath(emsdk_incl);
    cimgui_clib.root_module.addSystemIncludePath(emsdk_incl);

    cimgui_clib.step.dependOn(&sokol_clib.step);

    const shell = options.shell_file_path orelse dep_zen.path("shell.html");

    return sokol.emLinkStep(b, .{
        .lib_main = options.lib,
        .target = target,
        .optimize = optimize,
        .emsdk = dep_emsdk,
        .use_webgl2 = true,
        .use_emmalloc = true,
        .use_filesystem = options.use_filesystem,
        .shell_file_path = shell,
        .extra_args = options.extra_args,
    });
}
2 Likes

I’m working with similar deps and have the same issue. I don’t currently have a game/engine package split, but I do have multiple packages existing in parallel such that it looks like this:

  • game: depends-on zgui, sokol sokol_imgui_fork
  • sokol_imgui_fork: depends on sokol
  • sokol: depends on cimgui

So cimgui is a transitive dependency for both game and sokol_imgui_fork, but it will work out fine provided they both pass the same dependency flags to b.dependency("sokol", .{...}).

If you mismatch the params for the sokol dependency in sokol_imgui_fork vs game, then you wind up with two different copies & configurations of sokol.

(Technically you still wind up with two different copies of sokol thanks to the global/p → zig-pkg change in zig 0.16.0, but at least they’ll produce byte-identical artifacts, but that’s a separate issue.)

I presume it’s desirable in some contexts to be able to use different versions of deps in packages you depend on, but for this case the sokol dep is clearly are supposed to be aligned/singular (I know we’re on the same page here, just restating for other folks to also be on the same page).

What I do currently is I have yet a local package (.path in the zon instead of .url) called sokol_config.

It just has a build.zig in it with a function fn depSokol(target: std.Build.ResolvedTarget, std.builtin.OptimizeMode) *std.Build.Dependency so that way there is a single source of truth for the sokol dependency config.

In game/build.zig and sokol_imgui_fork/build.zig:

const sokol_config = @import("sokol_config");

pub fn build(b: *std.Build) !void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const dep_sokol = sokol_config.depSokol(target, optimize);
    
    // ... use dep_sokol like normal
}

Unfortunately this does not alleviate the problem of ensuring that the sokol versions are aligned across all the packages. The --fork zig build CLI arg is sort of a half-solution to this, but is clearly more intended for working on the deps. It would be nice if we could specify overrides like that in the project root build, and not have to vendor the dep.