My game engine
  • C++ 87%
  • Python 6.7%
  • CMake 3.8%
  • Shell 1.4%
  • Rust 0.4%
  • Other 0.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Evilpasture 2f882c0b7c update COM
2026-11-08 19:56:42 +07:00
.github/workflows Refactor build and CI (#101) 2026-11-08 00:00:24 +07:00
app delete FrameStatus and change to monadic sumtypes 2026-11-08 17:07:11 +07:00
cmake clean up API 2026-11-08 10:27:18 +07:00
config Renderer update (#99) 2026-10-29 07:23:03 +07:00
configure update COM 2026-11-08 19:56:42 +07:00
docker Refactor build and CI (#101) 2026-11-08 00:00:24 +07:00
extensions refactor registry 2026-11-08 15:54:37 +07:00
extern Renderer update (#99) 2026-10-29 07:23:03 +07:00
gameplay refactor registry 2026-11-08 15:54:37 +07:00
include update COM 2026-11-08 19:56:42 +07:00
modules delete FrameStatus and change to monadic sumtypes 2026-11-08 17:07:11 +07:00
packaging Refactor build and CI (#101) 2026-11-08 00:00:24 +07:00
plugins refactor registry 2026-11-08 15:54:37 +07:00
resources Refactor build and CI (#101) 2026-11-08 00:00:24 +07:00
samples delete FrameStatus and change to monadic sumtypes 2026-11-08 17:07:11 +07:00
scripts Refactor build and CI (#101) 2026-11-08 00:00:24 +07:00
src delete FrameStatus and change to monadic sumtypes 2026-11-08 17:07:11 +07:00
tests update COM 2026-11-08 19:56:42 +07:00
third_party Renderer update (#99) 2026-10-29 07:23:03 +07:00
todo delete FrameStatus and change to monadic sumtypes 2026-11-08 17:07:11 +07:00
utils delete FrameStatus and change to monadic sumtypes 2026-11-08 17:07:11 +07:00
.clang-format reformat project 2026-07-10 18:56:30 +07:00
.clang-tidy Refactor renderer and lots more (#79) 2026-09-23 09:41:11 +07:00
.clangd Renderer update (#99) 2026-10-29 07:23:03 +07:00
.envrc Renderer update (#99) 2026-10-29 07:23:03 +07:00
.gitattributes Add optional procedural character animation toolkit (#27) 2026-08-21 10:37:51 +07:00
.gitignore add project manager 2026-11-01 14:02:20 +07:00
.gitmodules Renderer update (#99) 2026-10-29 07:23:03 +07:00
.gpl-spdx-template releng: apply SPDX GPL-3.0-or-later license headers across project source 2026-06-12 19:27:46 +07:00
.licenserc releng: apply SPDX GPL-3.0-or-later license headers across project source 2026-06-12 19:27:46 +07:00
.luarc.json add config for Lua language server 2026-05-14 13:04:42 +07:00
CMakeLists.txt clean up API 2026-11-08 10:27:18 +07:00
CMakePresets.json add presets 2026-06-18 10:09:21 +07:00
DESCRIPTOR_HEAPS.md Refactor build and CI (#101) 2026-11-08 00:00:24 +07:00
LICENSE.md add license 2026-06-12 18:59:54 +07:00
lsan.supp update sanitizer options 2026-11-01 08:16:17 +07:00
MESH_SHADERS.md Refactor build and CI (#101) 2026-11-08 00:00:24 +07:00
README.md Refactor build and CI (#101) 2026-11-08 00:00:24 +07:00
tsan.supp add tsan.supp 2026-07-22 08:15:20 +07:00
ubsan.supp update tests 2026-08-14 17:56:45 +07:00

Project Zahlen

A simple project that integrates modern Vulkan, Jolt Physics, fast and straightforward creative work conversions and game development with pure C++ interface.

This project's renderer proudly has zero:

  • VkRenderPass: Killed by dynamic rendering.
  • VkFramebuffer: Killed by dynamic rendering.
  • VkDescriptorSetLayout: Killed by descriptor heaps.
  • VkDescriptorPool: Killed by descriptor heaps.
  • VkDescriptorSet: Killed by descriptor heaps.
  • VkSampler handles: Avoided by using descriptor heaps; the create info goes straight to vkWriteSamplerDescriptorsEXT.
  • VkPipelineLayout: Killed by descriptor heaps.
  • VkShaderModule: Deprecated in Vulkan 1.4 and killed by VK_KHR_maintenance5.
  • VkPipelineCache: Killed by pipeline binaries.
  • VkBufferView: Never existed due to buffer device addresses.
  • VkEvent: Never existed due to synchronization2.
  • VkFence: Replaced entirely by monotonic VK_KHR_timeline_semaphore completion values.

Besides that, it's kinda like Bevy but C++26 and Vulkan.

Build Requirements

  • C++26 Compiler with -freflection: Supporting C++26 standard features (GCC 16.1.1 or later). At this point, if you have a modern compiler I assume you know what you're doing.

Build Instructions

For creative works, the build system expects Blender files in ./blender/ depending on your CMakeLists.txt. Blender itself is optional: cmake/BlenderProbe.cmake resolves it from BLENDER_PATH, then PATH, then the platform's own install locations, and reports the answer as ZHLN_HAS_BLENDER. Without it the cook graph carries no .blend extraction edge at all -- textures, fonts, glTF and the pak still cook -- so a tree with no .blend sources never needs Blender installed. Pass -DZHLN_HAS_BLENDER=OFF to skip them on a host that has Blender, or -DZHLN_HAS_BLENDER=ON to make a missing Blender a configure error. For compiled assets, the build system expects usually in resources/assets/ as an unofficial convention.

Run scripts/build.sh and fix every error you go along the way if you're missing dependencies.

Runtime Directories

A dev tree keeps its caches and data under build/, while a distributed or hand-launched copy writes to the per-user cache directory (~/Library/Caches/Zahlen, $XDG_CACHE_HOME/zahlen, %LOCALAPPDATA%\Zahlen\Cache) and looks for data/base.pak next to the executable. ZHLN_CACHE_DIR and ZHLN_DATA_DIR override that, and EngineConfig::crashDumpPath overrides the crash-dump path in code. See include/ARCHITECTURE.md section 9.

CDN and remote-download caches manage freshness, integrity, and retention: ETag/Last-Modified revalidation runs on every request in ZHLN_DEV_MODE (every 5 minutes otherwise), and persisted checksums catch local byte corruption. Bodies expire after 7 days; bodies plus metadata are bounded to 1 GiB per cache directory. Cleanup runs on open and normal use, without caller invalidation. See CDN caching for server requirements, validation limits, configuration, and the lifetime of returned paths.

Architecture

The optional source tree is split by role: plugins/ contains asset formats and codecs, extensions/ contains reusable engine subsystems plus network/platform I/O, and gameplay/ contains domain-specific systems and project integrations. The existing top-level modules/ remains reserved for Core C++ module interfaces.

For a detailed breakdown of the engine's architecture, frame loop execution order and deferred render graph topology, see include/ARCHITECTURE.md.

Integrating as a Submodule

You can embed project-zahlen directly into an external game repository as a Git submodule. This allows you to develop your game logic independently while using the engine as a library.

1. Add Submodule

Add the engine to your game repository's dependencies folder (e.g., extern/zahlen):

git submodule add https://github.com/Evilpasture/project-zahlen.git extern/zahlen
git submodule update --init --recursive

To utilize the Ninja asset cooking pipeline and declarative state files, structure your game repository as follows:

MyGame/
├── CMakeLists.txt             # Game build script
├── extern/
│   └── zahlen/                # Engine submodule
├── gameplay/                  # Game C++ source files (.cpp, .cppm)
├── config/                    # Declarative Lua / TOML configuration (graphics.lua)
├── resources/
│   └── assets/                # Raw game assets (.blend, .png, .wav, etc.)
└── src/
    └── main.cpp               # Game launcher entry point

3. Game CMakeLists.txt

A game only needs to link the application preset; the engine, CLI, crash handling and available default gameplay extensions are its transitive dependencies.

cmake_minimum_required(VERSION 3.28)
project(MyGame LANGUAGES C CXX ASM)
set(CMAKE_CXX_STANDARD 26)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

add_subdirectory(extern/zahlen)
add_executable(my_game src/main.cpp)
target_link_libraries(my_game PRIVATE zahlen_application)
target_compile_definitions(my_game PRIVATE JPH_DOUBLE_PRECISION)
zahlen_enable_reflection(my_game)
zahlen_add_runtime_dlls(my_game) # Windows: stage engine/Jolt DLLs beside the game

The engine requires C++26 static reflection (see Build Requirements). For an opt-in core-only build, set ZHLN_BUILD_EXTRAS=OFF before add_subdirectory; zahlen_application still exists, just without optional modules. Project-specific asset cooking and data/profile iteration targets can be added separately when needed.

4. Game Entry Point (src/main.cpp)

Link the reusable application layer (target_link_libraries(my_game PRIVATE zahlen_application)) instead of composing engine services, optional modules, crash handling and the main loop in each game. The target publishes the app-owned headers from app/include/Zahlen/ (including App.hpp and AppOutcome.hpp); Core does not expose this include root. The #include <Zahlen/App.hpp> spelling is unchanged. For example:

#include <Zahlen/App.hpp>
#include <Zahlen/Engine/PrefabFactory.hpp>
#include <cstdlib>

int main(int argc, char* argv[]) {
    const auto result = ZHLN::App()
        .OnStart([](ZHLN::Engine& engine) {
            // Persistent entities are created ONCE, after the default scene exists.
            (void) ZHLN::PrefabFactory::CreateBox(engine, JPH::Vec3(1.0f, 1.0f, 1.0f));
        })
        .Run(argc, argv, [](ZHLN::Engine& engine, float dt) {
            // Input has been pumped; this runs once each frame before engine.Tick(dt).
            // Put input/gameplay updates here, not a new CreateBox every frame.
            (void) engine;
            (void) dt;
        });
    return result ? EXIT_SUCCESS : EXIT_FAILURE; // only main translates to an OS code
}

App().Run([](ZHLN::Engine& engine) { /* per-frame code */ }) also works when you don't need command-line flags. The (Engine&, float dt) form gets the exact delta passed to Tick. App().Run() without a callback is for games composed entirely from ECS systems and installed modules. The CLI path takes std::span<char* const> (with or without a gameplay callback); the Run(argc, argv, ...) overloads shown above validate C-style arguments and adapt them to that span. Standard engine flags such as --headless, --vsync and --fps-limit work; --help, --version and --print-graph exit before engine creation.

Games can register their own flags before running, without pre-filtering argv:

// Inside main(int argc, char* argv[]):
std::string level = "courtyard";
auto app = ZHLN::App();
app.AddCliOption(
    "--level", "<name>", "Initial level",
    [&](std::string_view name) -> std::expected<void, ZHLN::CommandLineError> {
        if (name.empty()) return std::unexpected(ZHLN::CommandLineError::MissingValue);
        level.assign(name);
        return {};
    },
    "-l"
);
app.Install([&](ZHLN::Engine& engine) {
    // The exact config used to create the engine is available on all Run paths.
    auto config = engine.Service<ZHLN::EngineConfig>();
    if (config) { /* use config->headless, config->vsync, and level here */ }
});
const auto result = app.Run(argc, argv); // --level=courtyard and -l courtyard both work
return result ? EXIT_SUCCESS : EXIT_FAILURE; // in main() only

Run returns ZHLN::AppRunResult (std::expected<AppExitReason, ErrorCode>), not an OS status. Successful reasons distinguish UserClosed (including an application-requested primary-window close), CliHelpRequested, CliVersionRequested, and CliGraphRequested. CLI errors keep the exact CommandLineError (e.g. result.error().Is(CommandLineError::InvalidValue)), and engine creation errors preserve the underlying ErrorCode. AppError::InvalidArguments, SceneInitFailed, and FrameTickFailed cover failures that have no lower-level error code. Only executable main() translates the result to EXIT_SUCCESS or EXIT_FAILURE. KernelApp uses the same result type and preserves Kernel/render/device-recovery error codes. Typed results allow embedding and precise assertions; process-wide platform/task/signal ownership still requires callers to coordinate simultaneous runs.

.AddCliOption(ZHLN::CliFlag{...}) and .WithCliOptions(std::span<const ZHLN::CliFlag>) are also available. Actions run during parsing, return CommandLineError on failure, and may borrow application state during synchronous Run. The generic zahlen_command_line parser knows nothing about the engine. In zahlen_application (and zahlen_host_runner for Kernel-only tools), ParseEngineConfig reflects EngineConfig, adds host-only --print-graph / --debug-attach, and then adds game flags. Help and version are generic parser stops. There is no engine CLI preset or raw argv service. Installers, gameplay systems and OnStart can read Engine::Service<EngineConfig>() or ECS::Res<EngineConfig> on both CLI and no-CLI runs.

.WithConfig(launch) binds reflected fields to flags, converting godMode to --god-mode (and fpsLimit to --fps-limit). It supports non-bool integrals, bool, float, double, std::string, ZHLN::FixedString<N> (including String32 / String64 / String128 / String256), and enums. For example:

enum class Quality { Low, High };
struct Launch {
    bool godMode = false;
    float gamma ZHLN_ANNOTATION(ZHLN::CliRange<0.5f, 2.0f> {}) = 1.0f;
    std::string scene = "arena";
    ZHLN::String64 server {"localhost"};
    Quality quality = Quality::Low;
};
Launch launch;
auto app = ZHLN::App();
app.WithConfig(launch); // --god-mode, --gamma, --scene, --server, --quality

A bare bool flag sets true; --god-mode=off / --god-mode=false set false (also on, true, 1 / 0, yes / no). Non-bool flags require a nonempty value; floats use std::from_chars in general format, reject non-finite values, and may use CliRange<Min, Max> just like integers. Enums accept exact Reflect::StringToEnum<E>() names and show the Reflect::EnumNames<E>() choices in --help (e.g. --quality <Low|High>). ZHLN::Description<"..."> supplies help text and ZHLN::CliShort<'c'> supplies a short alias; the sandbox demonstrates both for --cubes in app/main.cpp. Generated flag names, aliases and enum-help text are owned by the application, including after a copy. Fixed-capacity strings reject overlong inputs rather than truncating them, and each field changes only after its input is valid. Keep the borrowed launch alive through synchronous Run. Because the tokenizer treats a leading - as another flag, spell negative numbers as --threshold=-0.125.

EngineConfig is the single configuration value for launch, physics, renderer and frame pacing. Every supported scalar field binds automatically (e.g. --width=640, --headless=on, --fps-limit=120, --validation-mode=GPU, --log-level=Verbose); nested aggregates use prefixes (--physics-max-bodies=4096). The non-owning initialPipelineBinaryData span is intentionally C++-only. Duplicate keys or aliases between engine, game and custom flags fail with CommandLineError::DuplicateFlag rather than shadowing or mirroring another value. Invalid values retain typed errors without committing a partial field. Former spellings such as --validation=gpu and --renderdoc are not aliases; update scripts to the reflected flags.

For applications that must validate settings or acquire resources before an engine exists, .BeforeEngineCreate([&]() -> std::expected<void, ZHLN::ErrorCode> { /* prepare */ return {}; }) runs after successful CLI parsing (but not for help/version/graph early exits) and before process/engine creation. A failure is returned unchanged as a typed AppRunResult error. For example, the optional ProjectLight client uses it to validate its launch settings and connect only after CLI validation, without creating an engine on failure.

Games may also use .AddLayer(layer) for a move-only or copyable object implementing any of Start(Engine&), Tick(Engine&, float), UI(Engine&), and Stop(Engine&); UI-only and Stop-only layers are valid. Layers are owned by the application. Start runs after scene creation, Tick runs after events but before an optional per-frame Run(callback), and UI runs in registration order in the Engine's UI frame phase. Once scene initialization succeeds, Stop runs in reverse registration order with the Engine still alive, on normal closure or frame failure (but not on CLI early exits or failed initialization). Dispatch uses function pointers, not virtual methods; state is allocated once per layer. The sandbox registers SandboxGame as a layer instead of storing its update in a capturing callback.

The default zahlen binary (app/main.cpp) is a compiled C++ sandbox: it spawns a physics ground and falling boxes, lights them with a sun and moving point light, fires impulse-driven cubes with F, and plays a short procedural sound. --cubes=<0..64> (-c) selects the initial box count and --seed=<integer> reproducibly jitters their positions; both appear in zahlen --help. R resets the view; Esc quits. When extras are built, FreeCam provides WASD movement, right-mouse look and Shift sprint. The sandbox needs no scene-document parser or optional-extension #ifdefs; the optional CharacterController module is installed by App but this fly-camera demo does not spawn a character. Move larger projects into their own GameSession and ECS systems rather than growing main.cpp indefinitely.

App owns the outer frame loop: the shared ProcessScope initializes Platform/TaskSystem, then App creates an Engine, installs the available baked default font and CharacterController, target camera, Interaction, Terrain, Animation/IK, FreeCam and RagdollAuthoring, builds the scene, polls events, calls your game logic, then Engine::Tick(dt) and tears down in order. CMake generates the ordered default-module calls from targets actually present in the build; app/Application.cpp contains no optional-module preprocessor guards. Kernel::ProcessEvents handles primary renderer resizing and Engine::ProcessEvents consumes the corresponding ECS input notification before gameplay; there is no resize/skip branch in App's loop. FramePacer owns App's optional FPS cap. It does not install the scene-host diagnostic fallback (which would replace a compiled game's scene). A ZHLN_BUILD_EXTRAS=OFF build uses just Core. Override the preset with .WithDefaultModules(false), add project modules with .Install([](ZHLN::Engine& engine) { /* install */ }) before the first scene, implement UI(Engine&) on each layer that draws an overlay, and pass ZHLN::EngineConfig{...} to App(config) for launch settings. .OnStart(...) and .Install(...) are application methods, not config delegates. Engine::Run remains available for bespoke hosts; its fourth EventPoller argument is an explicit low-level frame seam.

Standalone tools (with ZHLN_BUILD_EXTRAS=ON)

Executable Entrypoint Purpose
zahlen app/main.cpp Ordinary game using ZHLN::App(); built even with extras OFF
zahlen-editor utils/editor/main.cpp World editor with its own paused-simulation loop; `--scene <file.toml
zahlen-benchmark utils/benchmark/main.cpp Headless logic workload; tune with ZHLN_BENCH_ENTITIES and ZHLN_BENCH_TICKS
zahlen-scene-host utils/scene/main.cpp Scene/document and file-watcher integration; --scene and --graphics-config (Lua when available), optional Discord presence and a diagnostic scene when no document is selected
zahlen_ui_editor app/UIEditor.cpp Separate UI-tree editor using the Kernel-only KernelApp runner

Kernel-only tools can link zahlen_host_runner and use <Zahlen/KernelApp.hpp> rather than creating a 3D/ECS Engine. KernelApp().Named("Tool").Input(receiver).OnStart(...).OnInputFrame(...).OnUpdate(...).OnRender(...).OnDeviceRestored(...).OnShutdown(...).Run(argc, argv) owns CLI early exits, ProcessScope, input pumping, frame acquisition/presentation, device-loss recovery, and pacing. The UI-tree editor supplies only its document, two-window UI, and font-atlas rebuild policy; Engine games still use App and the Engine's own frame graph.

The world editor and scene host share document import in utils/scene/SceneDocument.cpp. Only the scene host installs the Lua graphics file watcher; it installs a diagnostic fallback when no scene document was requested, so a loaded document is not overwritten by it. The benchmark does not initialize a window or engine. For interactive benchmark panels, use the existing zahlen_benchmark_viewer library in a custom host; the ordinary game has no F3/F4 benchmark overlay. zahlen --editor and zahlen --benchmark are not engine flags: the game rejects them as unknown arguments. Launch zahlen-editor or zahlen-benchmark directly. (The independent ztest --benchmark test-runner flag is unaffected.)

Hardware expectations

The project is designed to run on a system with a discrete GPU and a dedicated CPU. Tested on NVIDIA RTX 3050 6GB in Arch Linux. Should work on macOS too if you manage to compile it, but you need a CPU rasterizer due to missing VK_EXT_descriptor_heap support in MoltenVK and KosmicKrisp.

System & Platform Dependencies

The project is primarily developed on Linux and macOS and Windows. You can compile this project. That is true, if your compiler supports standard C++26 features. Otherwise, tough luck. Compile GCC 16.1.1+ yourself.

The project can also be compiled with Bloomberg Clang with its own libcxx and libunwind and work effectively. However, precompiled headers are buggy due to unknown internal compiler errors. scripts/build.sh --p2996 should handle some of the work as long as you have your build ready.

See here for detailed compiler support information.

These packages are expected to be installed on the host operating system: read the Containerfiles in docker/, starting with docker/ci/Containerfile.

Installing engine packages without root

Released toolchains are consumed with zpkg, a per-user package manager. It installs engine packages into ${XDG_DATA_HOME:-~/.local/share}/zahlen, keeps each toolchain in its own immutable directory, and switches them with a single symlink. No root, no system prefix, no LD_LIBRARY_PATH:

zpkg install ./toolchain-output --require-checksums   # or ./zahlen-engine-*.tar.gz
cmake -S my_game -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
    -DCMAKE_PREFIX_PATH="$(zpkg prefix)"
zpkg list                                            # what is installed, active marked

Only /usr/bin/zpkg itself comes from the distribution. Development and the engine runtime stay entirely in user space; see packaging/README.md for the store layout, the package metadata, and the invariants CI enforces.

Bundled / External Libraries

These are located in the extern/ and third_party/ directories. To not update this everytime I add or remove a dependency, ls the directories. Take my words, it's all open source.

LICENSE

This project is licensed under the GNU General Public License version 3.0 or later versions.

All dependencies are licensed under their respective licenses too.

Prebuilt entry-point development container

CI promotes ghcr.io/evilpasture/project-zahlen-builder:latest after a successful build and test of current main, alongside commit-SHA tags. The builder contains the source, toolchain, configured build tree and prebuilt engine; the runtime image is a separate, smaller deployment image. The GHCR builder package must be public for anonymous pulls. latest follows successful builds and may lag the repository; select a SHA tag with ZHLN_DEV_IMAGE for a known revision.

With Podman or Docker installed, from a checkout of this repository:

scripts/dev-container.sh init
# Edit .zahlen-dev/main.cpp (initially copied from the image).
scripts/dev-container.sh build
scripts/dev-container.sh run --headless
scripts/dev-container.sh test

build copies only the edited entry point into the persistent container and builds the zahlen target against its existing Ninja build tree. A no-op build need not recompile it. Other changes, including engine source and CMake changes, are not automatically synced; use shell to inspect the container or a full source-based build for those. Copy your changes back into the checkout when ready to commit. reset removes the container and its build changes but keeps .zahlen-dev/main.cpp; run init again to pull a newer image. The container storage itself must persist between invocations; transient environments may need to re-pull after a reset. Default host networking is intended to work around rootless Podman's /dev/net/tun restriction in Arena and does not isolate the container's network. Use a trusted image and source code.

The builder sets the Lavapipe ICD and includes Vulkan validation layers. Actual Vulkan feature availability, display access, and headless execution depend on the Mesa version and environment; installing Lavapipe alone does not guarantee that every renderer path or test works. The builder uses CI's Release + ASan/UBSan settings. Its Arch packages are rolling, so use a SHA or image digest when matching a specific toolchain matters.

Capture a frame

The zahlen executable can capture the sandbox scene to a PNG and exit:

./build/bin/zahlen --headless --width 320 --height 180 \
  --capture-frame frame.png --warmup-frames 10

The default warm-up is 10 completed frames; --warmup-frames accepts 1 through 100000. The option does not itself enable headless mode. The output path is relative to the process working directory, its parent must already exist, and an existing file is overwritten. A failed capture or write exits nonzero. With the persistent dev container, the path is inside the container; for example, use scripts/dev-container.sh run --headless --capture-frame /workspace/frame.png, then podman cp zahlen-dev:/workspace/frame.png ./frame.png (or docker cp when using Docker). Rendering features still depend on the Vulkan driver.

Sanitized and optimized container images

CI builds and tests two independent Release variants. The asan variant uses ASan/UBSan; release sets USE_SANITIZERS=OFF and retains CMake's portable Release optimization flags (normally -O3 -DNDEBUG with GCC). It is the appropriate starting point for performance measurements. Only the release variant is packaged and published: the sanitized build is a correctness probe and never leaves the runner that built it. This does not remove Vulkan validation, change renderer features, or enable CPU-specific -march=native/LTO. Compare the same workloads and runtime settings when timing.

For successful current-main runs GHCR publishes:

Image ASan/UBSan Use
ghcr.io/evilpasture/project-zahlen:latest or :release off optimized runtime
ghcr.io/evilpasture/project-zahlen-builder:latest or :release off prebuilt dev/benchmark tree

Both packages also publish :<commit-sha>-release convenience tags, and CI tests and packages the matching run-specific <sha>-<run-id>-<attempt>-release tag so overlapping reruns cannot replace the tested input. Registry tags are mutable; pin an image digest for strict reproducibility. The legacy :<sha> tag on both packages selects release. Instrumented images are no longer published: build one locally with --build-arg ZHLN_USE_SANITIZERS=ON. Superseded commits are not promoted. Promotion updates several tags sequentially, so a failure during promotion can leave a partial update.

The two variants are two workflows, not one matrix. .github/workflows/ci.yml is the release lane: it builds, tests, packages the optimized build and promotes :latest/:release/-builder:latest/-builder:release. .github/workflows/ci-sanitizers.yml is the ASan/UBSan lane: it builds the instrumented tree on its own runner and runs ztest against it, on every push and pull request plus nightly, publishing nothing. A matrix leg is part of a job id, so keeping both in one workflow made every release step wait for the hourly instrumented compile; split in two, a slow sanitizer run delays nothing that ships.

For a local Docker build, pass --build-arg ZHLN_USE_SANITIZERS=OFF for release or ON for ASan/UBSan (the default), for either the builder or runner target. The runner normally packages that local builder; CI instead supplies ZHLN_BUILDER_IMAGE to package the exact builder image that passed tests.