Skip to content

Build System

This document describes the HelixScreen prototype build system, including automatic patch application, multi-display support, and development workflows.

For common development tasks, see DEVELOPMENT.md - this document covers advanced build system internals.

HelixScreen supports cross-compilation for embedded ARM targets using Docker-based toolchains. This allows building binaries for Raspberry Pi and other embedded displays directly from macOS or Linux development machines.

Terminal window
# Build for Raspberry Pi 64-bit (aarch64/ARM64)
make pi-docker
# Build for Raspberry Pi 32-bit (armhf/armv7l)
make pi32-docker
# Build for Flashforge Adventurer 5M (armv7-a/ARM32)
make ad5m-docker
# Build for Elegoo Centauri Carbon 1 (armv7-a/ARM32)
make cc1-docker
# Build for Creality K1 (MIPS32, static/musl)
make mips-docker # Or: make k1-docker (alias)
# Build for FlashForge AD5X (MIPS32r5, glibc)
make ad5x-docker
# Build for Creality K1 series (MIPS32, dynamic/glibc)
make k1-dynamic-docker
# Build for FlashForge Creator 5 Pro (MIPS32r2, static/musl, shares the K1 image)
make creator5-docker
# Build for Creality K2 series (ARM, tested on K2 Plus)
make k2-docker
# Verify the binaries
file build/pi/bin/helix-screen # ELF 64-bit LSB, ARM aarch64
file build/pi32/bin/helix-screen # ELF 32-bit LSB, ARM, EABI5
file build/ad5m/bin/helix-screen # ELF 32-bit LSB, ARM, EABI5
file build/cc1/bin/helix-screen # ELF 32-bit LSB, ARM, EABI5
file build/mips/bin/helix-screen # ELF 32-bit LSB, MIPS32 (static)
file build/ad5x/bin/helix-screen # ELF 32-bit LSB, MIPS32r5 (dynamic)
file build/k1-dynamic/bin/helix-screen # ELF 32-bit LSB, MIPS32 (dynamic)
file build/k2/bin/helix-screen # ELF 32-bit LSB, ARM, EABI5

Docker images are automatically built on first use - no manual setup required!

Target Command Architecture Display Output Directory
Raspberry Pi (64-bit) make pi-docker aarch64 (ARM64) DRM/fbdev build/pi/
Raspberry Pi (32-bit) make pi32-docker armv7-a (armhf) DRM/fbdev build/pi32/
Adventurer 5M make ad5m-docker armv7-a (hard-float) fbdev build/ad5m/
Centauri Carbon 1 make cc1-docker armv7-a (hard-float) fbdev build/cc1/
Creality K1 make mips-docker MIPS32r2 (musl) fbdev build/mips/
FlashForge AD5X make ad5x-docker MIPS32r5 (glibc) fbdev build/ad5x/
Creality K1 (dynamic) make k1-dynamic-docker MIPS32r2 (glibc) fbdev build/k1-dynamic/
Creality K2 make k2-docker armv7-a (musl) fbdev build/k2/
Native (SDL) make Host architecture SDL2 build/
  1. Docker Toolchains: Each target has a Dockerfile (docker/Dockerfile.pi, docker/Dockerfile.pi32, docker/Dockerfile.ad5m, etc.) that contains the cross-compiler, sysroot libraries, and build tools.

  2. Auto-Build Images: When you run make pi-docker, make pi32-docker, or make ad5m-docker, the build system automatically:

    • Checks if the Docker image exists
    • Builds the image if missing (takes 2-5 minutes first time)
    • Runs the cross-compilation inside the container
  3. Volume Mounting: Your source code is mounted into the container, so compiled binaries appear directly in your build/ directory.

    The mount is -v "$(CURDIR)":/src, and it must stay $(CURDIR) — never $(PWD). $(PWD) is inherited from the invoking shell; $(CURDIR) is make’s own working directory and is the one that follows -C. They agree for a plain make, so the difference is invisible until someone runs a cross build with -C:

    Terminal window
    cd /anywhere
    make -C .worktrees/my-branch snapmaker-u1-docker # with $(PWD): mounts /anywhere

    With $(PWD) that bind-mounted whatever directory the shell happened to be in, compiled that tree, and left the worktree’s artifact untouched — while exiting 0 and printing “✓ Build complete!”. The binary you then deployed was built from the wrong commit, and nothing in the log said so; the failure mode is a stale artifact, so neither mtime nor size changes to give it away. tests/shell/test_build_provenance.bats now fails the build if any docker run mounts $(PWD).

    Everything else the container needs from the host rides on $(DOCKER_HOST_CONTEXT) (mk/cross.mk), which every docker run that mounts the tree must pass — tests/shell/test_build_provenance.bats fails the build if one does not. It carries two things, both of which exist because a git worktree is not self-contained:

    • DOCKER_WORKTREE_MOUNT — scripts/setup-worktree.sh symlinks the shared lib/<submodule> entries to the main checkout by absolute path, so those links dangle inside a container that mounts only $(CURDIR). The real submodule tree is bind-mounted at its own absolute path so every lib/* link resolves identically inside and out. The detection asks every lib/ entry where it really lives rather than probing one of them: lib/lvgl is a private checkout under $(CURDIR), so a single-entry probe would read a worktree full of symlinks as a normal checkout and mount nothing.
    • DOCKER_GIT_HASH_ENV — a worktree’s .git is a file reading gitdir: $(MAIN)/.git/worktrees/<name>, a path outside the mount, so git cannot resolve HEAD in the container and scripts/gen-git-hash.sh used to stamp HELIX_GIT_HASH "unknown" — silently, since the build still succeeded, leaving any evidence gathered on a printer unattributable. The hash is resolved on the host and passed in as HELIX_GIT_HASH, which the script prefers over its own lookup.
  4. Build Features Stamp: The link rule writes build/<platform>/bin/.build-features recording which optional subsystems the binary actually contains (currently remote_control and diag_uploads). make deploy-* runs as a separate make invocation with no PLATFORM_TARGET and so cannot re-derive what the cross build chose; the stamp carries it across, and the deploy turns on the matching runtime switch on the device. See docs/devel/HELIXCTL.md and docs/devel/ENVIRONMENT_VARIABLES.md § HELIX_DIAGNOSTIC_UPLOADS for the upload gate, which rides the same mechanism: ENABLE_DIAGNOSTIC_UPLOADS defaults to yes only under HELIX_PACKAGING=1, and sync-device-features stamps HELIX_DIAGNOSTIC_UPLOADS=1 on rigs at deploy time so dev devices keep uploading.

  5. Display Backend Selection: Cross-compilation automatically selects the appropriate display backend:

    • Pi / Pi32: DRM (preferred) with fbdev fallback
    • AD5M / CC1: fbdev (framebuffer)
Terminal window
# Docker-based builds (recommended - no toolchain installation needed)
make pi-docker # Raspberry Pi 64-bit via Docker (DRM only)
make pi-all-docker # Raspberry Pi 64-bit — both DRM + fbdev (single pass)
make pi32-docker # Raspberry Pi 32-bit via Docker (DRM only)
make pi32-all-docker # Raspberry Pi 32-bit — both DRM + fbdev (single pass)
make ad5m-docker # Adventurer 5M via Docker
make cc1-docker # Centauri Carbon 1 via Docker
make k1-docker # Creality K1 series via Docker (static/musl)
make k1-dynamic-docker # Creality K1 series via Docker (dynamic/glibc)
make k2-docker # Creality K2 series via Docker (tested on K2 Plus)
make docker-toolchains # Pre-build all Docker images
# Direct cross-compilation (requires toolchain installed on host)
make pi # Raspberry Pi 64-bit (needs aarch64-linux-gnu-gcc)
make pi32 # Raspberry Pi 32-bit (needs arm-linux-gnueabihf-gcc)
make ad5m # Adventurer 5M (needs arm-linux-gnueabihf-gcc)
make cc1 # Centauri Carbon 1 (needs arm-linux-gnueabihf-gcc)
make k1 # Creality K1 static (needs Bootlin mips32el-musl toolchain)
make k1-dynamic # Creality K1 dynamic (needs custom NaN2008 GCC 7.5 toolchain)
make k2 # Creality K2 (needs Bootlin armv7-eabihf-musl toolchain)
# Information
make cross-info # Show cross-compilation help
  • CPU: Cortex-A72/A76 (64-bit ARM)
  • Toolchain: aarch64-linux-gnu-gcc (GCC 10+)
  • Display: DRM preferred, fbdev fallback
  • Input: libinput for touch
  • Docker Image: helixscreen/toolchain-pi (Debian Bullseye)
  • CPU: Cortex-A7/A53/A72 in 32-bit mode (armv7-a, hard-float + NEON)
  • Toolchain: arm-linux-gnueabihf-gcc (GCC 10+)
  • Display: DRM preferred, fbdev fallback
  • Input: libinput for touch
  • Docker Image: helixscreen/toolchain-pi32 (Debian Bullseye)
  • Coverage: Pi 2, 3, 4, 5 running 32-bit Raspberry Pi OS / MainsailOS
  • CPU: Cortex-A7 (32-bit ARM, hard-float)
  • Toolchain: arm-linux-gnueabihf-gcc (GCC 8.3)
  • Display: 800×480 framebuffer (/dev/fb0)
  • Input: evdev for touch (/dev/input/event4)
  • C Library: glibc 2.25 (requires older toolchain for compatibility)
  • RAM: 110MB total (~36MB available with Klipper running)
  • Docker Image: helixscreen/toolchain-ad5m (Debian Buster)
  • SoC: Allwinner R528 / sun8iw20 (Cortex-A7 dual-core, armv7-a hard-float)
  • Toolchain: ARM GCC 10.3 (arm-none-linux-gnueabihf-gcc)
  • Display: 480×272 framebuffer (/dev/fb0), 32bpp ARGB8888
  • Input: evdev for touch (Goodix gt9xxnew_ts on /dev/input/event1)
  • C Library: glibc 2.23 (static linking avoids version conflicts)
  • RAM: 112MB total (~34MB available with Klipper running)
  • Docker Image: helixscreen/toolchain-cc1 (Debian Bookworm)

Creality K1 Series — Static (K1C, K1 Max)

Section titled “Creality K1 Series — Static (K1C, K1 Max)”
  • CPU: Ingenic X2000E (MIPS32r2 dual-core @ 1.2 GHz)
  • Toolchain: Bootlin mips32el-musl (GCC 12, musl libc)
  • Display: 480×400 framebuffer
  • Input: evdev for touch
  • C Library: musl (fully static binary — no system library dependencies)
  • RAM: 256MB
  • Docker Image: helixscreen/toolchain-k1 (Debian Bookworm)

Creality K1 Series — Dynamic (K1C, K1 Max)

Section titled “Creality K1 Series — Dynamic (K1C, K1 Max)”
  • CPU: Ingenic X2000E (MIPS32r2 dual-core @ 1.2 GHz)
  • Toolchain: Custom mipsel-k1-linux-gnu- (GCC 7.5 built via crosstool-NG, NaN2008+FP64 ABI)
  • Display: 480×400 framebuffer
  • Input: evdev for touch
  • C Library: glibc 2.29 (links dynamically against K1’s native system libraries)
  • Linking: Mixed — project libraries (libhv, libnl, wpa) static; system libraries (libc, libstdc++, libm, libpthread) dynamic
  • RAM: 256MB
  • Docker Image: helixscreen/toolchain-k1-dynamic (custom, builds toolchain from source)
  • GCC 7.5 constraints: See GCC 7.5 Compatibility section above
  • Why two K1 targets? Static/musl is simpler and more portable. Dynamic/glibc produces smaller binaries (shared system libs) and avoids musl edge cases, but requires the custom NaN2008 toolchain.
  • CPU: Ingenic X2000 (XBurst2, MIPS32r2 dual-core @ 1.2 GHz)
  • Build: the unified mips target (make creator5 / make creator5-docker) — same binary and toolchain as the K1/AD5X (mipsel-k1-linux-musl-, GCC 13 + musl, NaN2008/FP64, fully static, LTO on)
  • Display: 480×800 portrait framebuffer (/dev/fb0); the creator5 preset rotates 90
  • Input: evdev for touch (/dev/input/event2)
  • NaN encoding: the Creator 5 Pro kernel refuses legacy-NaN executables with ENOEXEC; the unified target’s NaN2008 output is the encoding it execs
  • See docs/devel/printers/FLASHFORGE_CREATOR5_PRO_SUPPORT.md

Creality K2 Series (K2, K2 Pro, K2 Plus) — Tested on K2 Plus

Section titled “Creality K2 Series (K2, K2 Pro, K2 Plus) — Tested on K2 Plus”
  • CPU: Allwinner sun8iw20p1 (ARM Cortex-A7, dual-core, 57 BogoMIPS)
  • Toolchain: Bootlin armv7-eabihf-musl (GCC 12, musl libc)
  • Display: 480x800 fbdev (all K2 models; framebuffer is double-buffered → 480x1600 virtual)
  • Input: evdev for touch
  • C Library: musl (static linking)
  • RAM: ~488 MB
  • Moonraker: Port 7125 (direct), port 4408 (nginx proxy)
  • Docker Image: helixscreen/toolchain-k2 (Debian Bookworm)
  • OS: OpenWrt 21.02-SNAPSHOT, Linux 5.4.61 armv7l, procd init (NOT systemd)
  • See docs/devel/printers/CREALITY_K2_SUPPORT.md for full hardware details.
docker/
├── Dockerfile.pi # Pi 64-bit toolchain (Debian Bullseye, GCC 10)
├── Dockerfile.pi32 # Pi 32-bit toolchain (Debian Bullseye, GCC 10)
├── Dockerfile.ad5m # AD5M toolchain (Debian Buster, GCC 8)
├── Dockerfile.cc1 # CC1 toolchain (Debian Bookworm, ARM GCC 10.3)
├── Dockerfile.k1 # K1 static toolchain (Bootlin mips32el-musl, GCC 12)
├── Dockerfile.k1-dynamic # K1 dynamic toolchain (crosstool-NG, GCC 7.5, glibc 2.29)
├── Dockerfile.k2 # K2 toolchain (Bootlin armv7-eabihf-musl, GCC 12)
├── Dockerfile.ad5x # AD5X toolchain (MIPS, ZMOD)
├── Dockerfile.snapmaker-u1 # Snapmaker U1 toolchain
└── Dockerfile.x86 # x86 native/container build

The Dockerfiles handle:

  • Cross-compiler installation (crossbuild-essential-*)
  • Target architecture libraries (:arm64 / :armhf packages)
  • SSL/crypto libraries for Moonraker WebSocket
  • Environment variables for cross-compilation

Cross-compilation is handled by mk/cross.mk, which defines:

# Set target platform (native, pi, pi32, ad5m, cc1, k1, k1-dynamic, k2)
PLATFORM_TARGET ?= native
# Cross-compiler configuration
CROSS_COMPILE := arm-linux-gnueabihf- # For AD5M
CC := $(CROSS_COMPILE)gcc
CXX := $(CROSS_COMPILE)g++
# Target-specific flags
TARGET_CFLAGS := -march=armv7-a -mfpu=neon-vfpv4 -mfloat-abi=hard
TARGET_LDFLAGS := -lstdc++fs # GCC 8 requires this for std::filesystem
# Display backend selection
DISPLAY_BACKEND := fbdev # or drm, sdl

The K1 dynamic build uses a custom GCC 7.5 toolchain targeting the K1’s native glibc 2.29. GCC 7.5 only supports C++17 partially, so code must avoid certain features. This applies to all code in the codebase — even native builds should stay compatible.

What works:

  • Most of C++17 (std::optional, std::string_view, structured bindings, if constexpr, etc.)
  • <filesystem> via the compat shim at include/compat/filesystem (aliases std::experimental::filesystem → std::filesystem)

Gotchas to avoid:

Feature GCC 7 Status Workaround Example
std::from_chars (integers) Not available Use std::strtol / std::strtod src/util/version.cpp
std::atomic<time_point> Doesn’t compile Store as std::atomic<int64_t> (nanoseconds) include/gcode_streaming_controller.h
C++20 designated initializers Not supported ({.foo = 1}) Initialize struct explicitly, then assign fields src/ui/ui_fan_control_overlay.cpp
directory_entry member functions .is_regular_file(), .file_size(), .last_write_time() missing Use free functions: std::filesystem::is_regular_file(entry.path()) src/print/thumbnail_cache.cpp
-lstdc++fs Required for <experimental/filesystem> Added automatically for k1-dynamic in mk/cross.mk and mk/watchdog.mk —
LTO (-flto) GCC 7.5 static toolchain lacks liblto_plugin.so Disabled for k1-dynamic; uses plain ar/ranlib instead of gcc-ar/gcc-ranlib mk/cross.mk

Filesystem compat shim (include/compat/filesystem):

  • For GCC < 8: includes <experimental/filesystem> and aliases it into std::filesystem
  • For GCC 8+/Clang/MSVC: passes through to the real <filesystem> via #include_next
  • Activated by -isystem include/compat in the K1 dynamic target flags

When adding new code: Always use std::filesystem::is_regular_file(path) (free function) rather than entry.is_regular_file() (member function). The free-function forms work on both GCC 7 and modern compilers.

Docker not installed:

Terminal window
# macOS - Option 1: Docker Desktop (GUI)
brew install --cask docker
# macOS - Option 2: Colima (lightweight, CLI-only, recommended)
brew install colima docker
colima start --cpu 4 --memory 8 # Start VM with 4 cores, 8GB RAM
# Linux
sudo apt install docker.io
sudo usermod -aG docker $USER # Logout/login after this

Colima tips (macOS):

Terminal window
colima start # Start with defaults
colima start --cpu 4 --memory 8 # Custom resources (faster builds)
colima stop # Stop VM when not needed
colima status # Check if running

Docker image build fails:

Terminal window
# Rebuild with no cache
docker build --no-cache -t helixscreen/toolchain-ad5m -f docker/Dockerfile.ad5m docker/

“file format not recognized” linker error: This means a library was built for the wrong architecture. Clean and rebuild:

Terminal window
rm -rf build/ad5m lib/wpa_supplicant/wpa_supplicant/*.a
make ad5m-docker

std::filesystem undefined references (AD5M only): GCC 8 requires -lstdc++fs for std::filesystem. This is already configured in mk/cross.mk for AD5M target.

Using make targets (recommended for Pi):

Terminal window
# Full cycle: build + deploy + run on Pi
make pi-test
# Deploy only (after building)
make deploy-pi # Deploy binaries + assets, restart in background
make deploy-pi-fg # Deploy and run in foreground (debug)
# Customize target (default PI_HOST is 192.168.1.113, NOT helixpi.local — which doesn't resolve)
make deploy-pi PI_HOST=192.168.1.50 PI_USER=pi

Using make targets for AD5M:

Terminal window
# Full cycle: remote build on thelio + deploy + run
make ad5m-test
# Remote build only (builds on REMOTE_HOST, fetches binaries)
make remote-ad5m
# Deploy only (after building)
make deploy-ad5m # Deploy binaries + assets, restart in background
make deploy-ad5m-fg # Deploy and run in foreground (debug)
make deploy-ad5m-bin # Deploy binaries only (fast iteration)
# Customize target (mDNS may not resolve - use IP instead)
make deploy-ad5m AD5M_HOST=192.168.1.67

Note: The AD5M’s mDNS (ad5m.local) may not resolve reliably. Use the IP address directly:

Terminal window
# Find your AD5M's IP from your router or the printer's network settings
AD5M_HOST=192.168.1.67 make deploy-ad5m

Manual deployment:

Terminal window
# Raspberry Pi
scp build/pi/bin/helix-screen pi@mainsailos.local:~/
# Adventurer 5M (via SSH or SD card)
scp build/ad5m/bin/helix-screen root@192.168.1.x:/usr/data/

HelixScreen automatically detects the best logging backend:

Platform Default Backend View Logs
Linux + systemd journal journalctl -t helix -f
Linux (no systemd) syslog tail -f /var/log/syslog | grep helix
File fallback rotating file tail -f /var/log/helix-screen.log

Override via CLI:

Terminal window
./helix-screen --log-dest=journal # Force systemd journal
./helix-screen --log-dest=file --log-file=/tmp/debug.log

systemd service: The included config/helixscreen.service automatically logs to journal. View with:

Terminal window
sudo journalctl -u helixscreen -f

The build system automatically selects display backends:

Backend Define Libraries Use Case
SDL HELIX_DISPLAY_SDL SDL2 Desktop development
DRM HELIX_DISPLAY_DRM libdrm, libinput Pi with KMS
fbdev HELIX_DISPLAY_FBDEV (none) Embedded framebuffer

Display backend is selected via DISPLAY_BACKEND in mk/cross.mk and controls:

  • LVGL driver compilation (lv_conf.h conditionals)
  • Display initialization in display_backend.cpp
  • Input driver selection (SDL mouse, evdev touch, libinput)
Section titled “Pi Dual-Link Build (Compile Once, Link Twice)”

Pi release builds produce two binaries: DRM (vsynced page flips via dumb buffers) and fbdev (framebuffer fallback). Instead of compiling all ~900 source files twice, the dual-link build compiles everything once with DRM superset defines, then links two binaries with different display libraries and link flags.

This cuts Pi CI build time roughly in half (~40 min instead of 80+).

Terminal window
# Dual-link build (produces both DRM + fbdev in one pass)
make PLATFORM_TARGET=pi-both -j # Direct (requires toolchain)
make pi-all-docker # Docker (recommended)
# Individual builds still work (for development/debugging)
make PLATFORM_TARGET=pi -j # DRM only
make PLATFORM_TARGET=pi-fbdev -j # fbdev only
  1. Compile phase: All source files compile once using DRM superset defines (-DHELIX_DISPLAY_DRM -DHELIX_DISPLAY_FBDEV). Objects go to build/pi/obj/.

  2. Variant-specific compilation (only 4 files):

    • display_backend.cpp, display_backend_fbdev.cpp, touch_calibration.cpp → compiled into build/pi/display-fbdev/ without DRM defines, archived as libhelix-display-fbdev.a
    • crash_reporter.cpp → compiled into build/pi/fbdev-variant/ with -DHELIX_BINARY_VARIANT="fbdev"
  3. DRM link: All objects + LVGL DRM drivers + OpenGLES objects + -ldrm -linput -lEGL -lGLESv2 -lgbm → build/pi/bin/helix-screen

  4. fbdev link: Common objects (minus DRM drivers, OpenGLES objects, DRM display backend) + libhelix-display-fbdev.a + fbdev crash_reporter.o → build/pi-fbdev/bin/helix-screen

  5. Verification: verify-fbdev automatically checks the fbdev binary has no DRM/GLES undefined symbols.

build/
pi/
obj/ # ALL objects (shared between both links)
lib/
libhelix-display.a # DRM display library (used by splash/watchdog too)
libhelix-display-fbdev.a # fbdev display library
display-fbdev/ # fbdev display backend objects
fbdev-variant/ # fbdev crash_reporter.o
bin/
helix-screen # DRM binary
helix-splash # Splash (DRM only)
helix-watchdog # Watchdog (DRM only)
pi-fbdev/
bin/
helix-screen # fbdev binary (linked from pi/ objects)
  • #ifdef HELIX_DISPLAY_DRM in non-display files: The shared objects are compiled with DRM defines enabled. If you add #ifdef HELIX_DISPLAY_DRM to a non-display source file, that code path will execute in the fbdev binary too. Only display_backend*.cpp is recompiled for fbdev. Use runtime detection (DisplayBackend::get_type()) instead of compile-time guards for behavior that should differ between DRM and fbdev.
  • Adding new DRM-only sources: If you add a new source file that references DRM/GLES symbols (e.g., drmModeGetResources), you must add its object to LVGL_DRM_DRIVER_OBJS in mk/pi-dual-link.mk to exclude it from the fbdev link. The verify-fbdev target will catch this if you forget.
  • The pi and pi-both targets share build/pi/: Switching between them doesn’t trigger a clean rebuild — the arch-change detection maps both to the same build directory.
File Purpose
mk/pi-dual-link.mk Fbdev display lib, crash reporter variant, fbdev link rule, verify/strip targets
mk/cross.mk pi-both / pi32-both platform target definitions
mk/rules.mk Conditional all target (uses strip-both in dual-link mode)

Git worktrees allow parallel development on multiple branches without switching contexts. HelixScreen uses worktrees for feature development.

Use setup-worktree.sh to create and configure worktrees with fast builds:

Terminal window
# Create worktree with new branch (one command does everything)
./scripts/setup-worktree.sh feature/my-feature
# Creates at .worktrees/my-feature, builds automatically
cd .worktrees/my-feature
./build/bin/helix-screen --test -vv
Terminal window
# Create at custom path — the second argument is a PATH, not a base branch
./scripts/setup-worktree.sh feature/foo /tmp/helixscreen-foo
# Branch from something other than the current HEAD
./scripts/setup-worktree.sh --base feature/parent feature/child
# Set up existing worktree without creating
./scripts/setup-worktree.sh --setup-only feature/i18n
# Skip the initial build
./scripts/setup-worktree.sh --no-build feature/quick-test

A new branch is cut from the HEAD of whichever tree you run the script in, which is not necessarily main — the script prints the resolved base commit so a stale or unexpected one is visible immediately. Use --base <ref> to pick it explicitly. Passing a branch name as the second argument is rejected, as is any path inside the repo outside .worktrees/.

The script optimizes for fast builds by sharing artifacts from the main tree:

  1. Symlinks the shared lib/ submodules, copies the rewritten ones — the third-party submodules nothing edits are symlinked (no clone/configure time). The four in LIB_PRIVATE_SUBMODULES get a private per-worktree checkout instead: lib/helix-xml because it is ours and CLAUDE.md says to edit it directly rather than carry a patch, and lib/lvgl, lib/libhv and lib/lua because patches/ rewrites them and patches/ is per-branch. Sharing one checkout across branches that disagree about either is unsatisfiable — make reapply-patches in one tree redefines what every other tree compiles, and each tree’s correct action invalidates the other’s (prestonbrown/helixscreen#1471). Each private git dir lands under .git/worktrees/<name>/modules/, origin stays the public GitHub remote, and because a real checkout is what git already expects they need no --unlink/--relink.

    The checkout is copied from the main tree, not cloned fresh: a fresh checkout writes fresh mtimes, which invalidates every cloned object built against those headers and turns a warm worktree cold. cp -Rc (clonefile on APFS, --reflink=auto on btrfs/xfs, a plain copy elsewhere) keeps the mtimes; the git dir is a git clone --local, which hardlinks the object store, so no network and no second copy of ~500 MB of packs. The pin is then reconciled with a git checkout of the commit this branch names, which rewrites only the files that actually differ. Setup ends by asserting every lib/ submodule sits at this branch’s pin, and refuses to build when a private one does not.

  2. Adopts the main tree’s mtimes for every byte-identical file — without this, nothing below actually saves you anything (see next section)

  3. Clones compiled libraries — libhv.a, libwpa_client.a from main tree

  4. Symlinks tools — node_modules/, .venv/

  5. Clones build objects — copies build/obj/ and build/generated/ from the main tree (APFS clonefile on macOS; plain copy on Linux)

  6. Configures ccache for cross-worktree reuse — so the worktree builds against the same ccache the main tree populated, when ccache is installed (see below)

  7. Validates architecture — wrong-arch .o/.a files (left by a prior cross-compile) are detected and cleared so make rebuilds them correctly

  8. Configures git — .git/info/exclude + --skip-worktree keep git status clean despite the symlinks. --skip-worktree covers the symlinked submodules only: on a private checkout it would hide a real change of pinned revision from git status, git add and the revision check.

  9. Reconciles patches — make reapply-patches runs in the new worktree when this branch’s patches/ differs from the main tree’s, or when a private submodule landed somewhere the main tree’s patches do not describe. Otherwise the copy already carries them, and reapplying is not free: build/.patches-applied is a prerequisite of every object.

Trade-off: lib/lvgl, lib/libhv, lib/lua and lib/helix-xml are yours to modify in place. For any other lib/ entry, un-symlink that specific directory first (rm lib/<name> && cp -a $MAIN/lib/<name> lib/) or you are editing the main tree’s copy.

Build outputs are cloned, never symlinked. libhv.a is a build output, so make rewrites it, and cp (the tail of make libhv-build) follows a symlink, writing straight into the main tree. A worktree build that moves the main tree’s libhv.a mtime forward puts every main-tree object back on its next build. On APFS cp -c is a clonefile, so a private copy costs nothing.

A fresh git worktree add stamps every file with the checkout mtime, so make sees the whole tree as newer than the cloned objects: the .d files list include/*.h per object, and every C++ object force-includes include/lvgl_pch.h, so one fresh header invalidates all ~1970.

Measured on a fresh worktree of an up-to-date main tree: 1945 of 1967 cloned objects recompiled, 396s of wall clock, for a tree where nothing had changed.

setup-worktree.sh fixes this by walking the checkout and, for each file whose content is byte-identical to the main tree’s file at the same path, adopting that file’s mtime (scripts/sync-worktree-mtimes.py, ~1s for the 3900-file tree). The build markers build/.patches-applied and .fonts.stamp get the same treatment — they are prerequisites of every object, libhv, and all 46 font objects, so stamping them now was on its own worth ~560 recompiles and was what made every fresh worktree rebuild libhv over the main tree’s copy.

This is deliberately not blanket mtime back-dating, which is how you ship a stale build. The invariant is that for matching content the (source, object) mtime ordering in the worktree equals the ordering in the main tree, so make reaches the same up-to-date decision here that it reached there, against objects cloned from there. Anything that differs — a branch with real changes, an edit made after setup — keeps its fresh mtime and rebuilds normally:

Scenario Objects rebuilt
Worktree at the same commit, clean 0 (make test -j = 3.2s, link only)
Worktree at an older commit (1 source differs) 1 — and the binary carries the old code
Source edited after setup 1
lv_conf.h edited ~1888

What it inherits rather than fixes: if the main tree’s own build is stale, the worktree reproduces that staleness. The object clone always had that property.

If a fresh worktree still takes minutes, this is almost always inherited staleness. A worktree copies the main tree’s objects and mtimes, so it starts as up to date as the main tree is and no more. When the main tree’s build/.patches-applied is newer than its objects — which any make reapply-patches there makes true — all ~2400 objects are already out of date at the moment they are cloned, and the new worktree recompiles them. The fix is to let one tree rebuild once. Measured on one such main tree, two worktrees off the same commit both recompiled ~1280 objects, the symlinked one and the private-checkout one alike: the staleness is the main tree’s, not the worktree scheme’s.

lib/libhv and lib/lvgl no longer add to this. They are a private checkout per worktree, so rebuilding libhv in one tree regenerates only that tree’s lib/libhv/include/hv/json.hpp — a prerequisite of every object — instead of putting every tree’s objects out of date at once.

ccache is optional (it is not installed on every dev box — check with command -v ccache), and with the mtime sync in place it is no longer what makes a clean worktree fast. Where it matters is every build that genuinely has to recompile — after touching lv_conf.h, or after another tree rebuilds libhv and re-invalidates every object (see the box above). That is the difference between ~400s and a few tens of seconds.

sloppiness is not optional — without it ccache caches nothing here. Every native build compiles with -include include/lvgl_pch.h, and ccache refuses to cache any such compilation unless sloppiness permits it. Measured on a single -include compile: Uncacheable calls: 1/1 (100%) before, Cacheable calls: 1/1 (100%) after, with the repeat compile hitting. This was silently true for a long time — base_dir and hash_dir were being set while the cache stayed empty, which is why this doc used to credit ccache for a speedup it was never delivering. Both flags are required; pch_defines alone still measures 0% cacheable. If ccache has been installed on a machine for a while, check ccache --get-config sloppiness before assuming it has been doing anything.

The cost of time_macros is that ccache stops hashing __DATE__/__TIME__. Exactly one site uses them — ui_settings_about.cpp reads __DATE__ + 7 for the About screen’s copyright year — so across a New Year that screen can show the previous year until the file is next recompiled. Cosmetic, and the only such site in src/, include/, lib/helix-xml/, or the build flags.

But it only helps across worktrees if it is configured for it. The native build compiles with -g (debug info), and ccache’s default hash_dir=true folds the absolute working directory into the cache key — so an object cached while building in the main tree never matches the same source compiled under .worktrees/<name>/. Every worktree would start stone cold.

setup-worktree.sh fixes this once, by writing global ccache config (only when unset, so it never clobbers a value you chose):

ccache setting Set to Why
base_dir $HOME Rewrites absolute paths under $HOME to relative before hashing, so main-tree and worktree paths collapse to the same key
hash_dir false Stops folding the cwd (the -g debug-path component) into the key
sloppiness pch_defines,time_macros Without it ccache refuses to cache any -include include/lvgl_pch.h compile — i.e. all of them. See the box above for the __DATE__ tradeoff
max_size 25G (raised, never lowered) The default 5 GiB thrashes once several worktrees + cross-compile caches share it, re-causing cold misses

For the script’s own initial build it also exports CCACHE_BASEDIR (the longest common ancestor of the main tree and the worktree, so it works even for out-of-tree paths like /tmp/foo), CCACHE_NOHASHDIR=1, and CCACHE_SLOPPINESS.

These are global ccache settings, so they apply to normal main-tree builds too, not just worktrees — running setup-worktree.sh once is enough to fix a whole machine. Worth doing on every machine you build on, including remote build hosts: an existing ccache install with base_dir/hash_dir already set can still be caching nothing if sloppiness was never configured.

Caveat: hash_dir=false is global, so cached objects carry whichever DW_AT_comp_dir (debug source path) compiled them first. For throwaway dev worktrees this is cosmetic, but gdb inside a worktree may point at the main-tree paths. If you do serious in-worktree debugging, build that target with CCACHE_DISABLE=1.

Verify the cache is actually being shared after a build:

Terminal window
ccache -s # "Hits" should climb sharply on the 2nd+ worktree build
ccache -p | grep -E 'base_dir|hash_dir|max_size'
Terminal window
# 1. Spin up an isolated workspace for a feature (builds automatically)
./scripts/setup-worktree.sh feature/my-feature
cd .worktrees/my-feature
# 2. Iterate — XML-only changes need no rebuild (loaded at runtime + hot reload is ON by default)
./build/bin/helix-screen --test -vv
# ...C++ changes:
make -j && ./build/bin/helix-screen --test -vv
# 3. Run the relevant tests before committing
make test-run
# 4. Commit in the worktree (it's a normal checkout on its own branch)
git add -A && git commit -m "feat(scope): ..."
# 5. When done, merge/push from the worktree, then tear it down (see Cleanup)

If a worktree already exists but its symlinks/objects drifted (e.g. after a git submodule update in the main tree), re-run setup in place — it’s idempotent:

Terminal window
cd .worktrees/my-feature && ../../scripts/setup-worktree.sh --setup-only --no-build .
# (or just `../../scripts/setup-worktree.sh` with no args from inside the worktree —
# it auto-detects the branch and path)
Terminal window
# List existing worktrees
git worktree list
# Example output:
# /Users/you/code/helixscreen abc1234 [main]
# /Users/you/code/helixscreen/.worktrees/i18n def5678 [feature/i18n]
Terminal window
./scripts/teardown-worktree.sh my-feature # remove the worktree + its merged branch
./scripts/teardown-worktree.sh my-feature -n # print the plan, change nothing

git worktree remove refuses these trees because of the private submodule checkouts, so teardown is a guarded rm -rf plus a prune.

Every worktree shares the main repo’s .git/modules/<name>/, and each of those gitdirs has one core.worktree. --unlink leaves empty submodule directories that git can initialize, which aims that shared pointer into the worktree; once the worktree is gone, git status fails with “cannot chdir” in the main tree and every other worktree. --relink, setup and teardown all run scripts/lib/worktree_lib.sh#restore_shared_module_pointers, which aims any shared pointer resolving inside the worktree back at the main tree’s copy. Private checkouts keep their gitdirs under .git/worktrees/<name>/modules/ and are never touched (prestonbrown/helixscreen#1621).


The project uses GNU Make with a modular architecture:

  • Modular design: ~9,100 lines split across the top-level Makefile plus 17 mk/*.mk modules for maintainability
  • Color-coded output for easy visual parsing
  • Verbosity control to show/hide full compiler commands
  • Automatic dependency checking before builds with smart canvas detection
  • Interactive installation of missing dependencies (make install-deps)
  • Automatic code formatting for C/C++ and XML files
  • Fail-fast error handling with clear diagnostics
  • Parallel build support with output synchronization
  • Build timing for performance tracking

XML stays runtime-loaded — compile-time codegen was measured and declined. A build-time XML-to-C component compiler was designed and measured (2026-08-08; the design doc has since been deleted) and declined: runtime-loaded XML with hot reload won on build complexity and iteration speed. Revisit only if startup cost on SPI-flash platforms demands it.

The build system is organized into focused modules:

File Lines Purpose
Makefile ~1095 Configuration, variables, platform detection, module includes
mk/cross.mk ~2990 Cross-compilation, toolchain setup, display backends
mk/tests.mk ~924 All test targets (unit, integration, by-feature)
mk/deps.mk ~672 Dependency checking, installation, libhv/wpa_supplicant
mk/patches.mk ~666 LVGL patch application
mk/rules.mk ~561 Compilation rules, linking, main build targets
mk/remote.mk ~297 Remote deployment (Pi, AD5M)
mk/fonts.mk ~283 Font/icon generation, Material icons
mk/images.mk ~264 Image conversion (PNG, SVG)
mk/tools.mk ~251 Development tool targets
mk/pi-dual-link.mk ~249 Pi dual-link build (compile once, link DRM + fbdev)
mk/watchdog.mk ~198 Hardware watchdog support
mk/splash.mk ~183 Splash screen generation
mk/translations.mk ~168 Translation string generation
mk/format.mk ~110 Code and XML formatting
mk/bluetooth.mk ~86 Bluetooth support
mk/display-lib.mk ~77 Display library configuration
mk/filaments.mk ~22 Filament database generation

Each module is self-contained with GPL-3 copyright headers and clear separation of concerns.

Terminal window
# Parallel build (auto-detects CPU cores)
make -j
# Fast development build (-O0, ~2x faster compilation)
make dev
# Clean parallel build with progress/timing
make build
# Verbose mode (shows full commands)
make V=1
# Code formatting (clang-format for C/C++, xmllint for XML)
make format # Format all files
make format-staged # Format only staged files
# Dependency checking (comprehensive)
make check-deps
# Auto-install missing dependencies (interactive)
make install-deps
# Help (shows all targets and options)
make help
# Apply patches manually (usually automatic)
make apply-patches
# IDE/LSP support (auto-generated after builds, or manually)
make compile_commands # Merge existing fragments (~1-2s)

The build system supports several configuration flags to customize the build:

Verbosity Control (default: quiet)

Terminal window
# Quiet mode (default) - shows progress
make -j
# Verbose mode - shows full compiler commands
make -j V=1

The build system includes comprehensive dependency checking and automatic installation.

Terminal window
make check-deps

This checks for:

  • System tools: C/C++ compiler, cmake, make, python3, npm
  • Code formatters: clang-format (C/C++), xmllint (XML validation/formatting)
  • Libraries: pkg-config, OpenSSL, libnl, libusb (required on Linux: the build links -lusb-1.0 unconditionally; optional on macOS), ALSA (Linux, warns when the headers are missing because the sound backend is then compiled out)
  • Canvas dependencies: cairo, pango, libpng, libjpeg, librsvg (for lv_img_conv)
  • npm packages: lv_font_conv, lv_img_conv
  • Optional libraries: SDL2, spdlog, libhv (uses system if available, otherwise builds from submodules)
  • Git submodules: LVGL (always built from submodule)

The checker is platform-aware and shows the correct install commands for:

  • macOS (Homebrew)
  • Debian/Ubuntu (apt)
  • Fedora/RHEL (dnf)
Terminal window
make install-deps

This interactively installs missing dependencies:

  1. Detects your platform
  2. Lists packages to be installed
  3. Shows the command it will run
  4. Asks for confirmation before proceeding
  5. Installs system packages via brew/apt/dnf
  6. Runs npm install for lv_font_conv/lv_img_conv
  7. Initializes git submodules if needed

Smart Canvas Detection: Uses pkg-config to detect exactly which canvas libraries are missing and only installs what’s needed.

Automatic Builds: Git submodules (libhv, wpa_supplicant, spdlog) are built automatically by the main build system when missing - no manual intervention needed.

Individual clean targets are available for forcing rebuilds of specific libraries without a full make clean. This is useful when:

  • Switching between native and cross-compilation
  • Build flags have changed
  • Debugging library-specific issues
Terminal window
make libhv-clean # Clean libhv WebSocket library artifacts
make sdl2-clean # Clean SDL2 CMake build directory
make lvgl-clean # Clean LVGL compiled objects
make libs-clean # Clean all library artifacts at once

Thread stacks on musl targets: musl gives every thread a 128 KiB stack unless the binary’s PT_GNU_STACK header asks for more (musl 1.1.21 and later). The Moonraker callbacks run on such a thread, so recursion that is harmless on glibc overflows there and dies with no crash file. The unified mips target (K1, AD5X, Creator 5 Pro) and k2 link with MUSL_THREAD_STACK_LDFLAGS (-Wl,-z,stack-size=1048576, defined in mk/cross.mk), which raises the default to 1 MiB of address space; a thread commits only the pages it touches. glibc targets size thread stacks from RLIMIT_STACK and ignore the flag, so they do not carry it. Check a build with readelf -lW build/k2/bin/helix-screen | grep GNU_STACK (the memsz reads 0x100000). std::regex is kept out of app code for the same reason: libstdc++’s matcher recurses once per input character.

Cross-Compilation Note: When cross-compiling (e.g., make ad5m-docker), libhv is automatically cleaned before each build to prevent architecture mixing. This adds ~5 seconds but ensures correct builds.

helix-tests links through a response file: make’s $(file ...) function writes the object list to $(OBJ_DIR)/<binary>.objs (for the plain build, build/obj/helix-tests.objs) and the compiler reads it as @<that file>. Inline, the list passes Linux’s 128 KiB per-argument limit once object paths grow, as the ASAN build’s do, and bash refuses the recipe with “Argument list too long”.

The dependency system includes a comprehensive test suite:

Terminal window
./tests/test_deps.sh

Tests 9 scenarios with 22 assertions covering dependency detection, platform-specific commands, and auto-installation workflow.

  • V=1 - Verbose mode: shows full compiler commands instead of short [CC]/[CXX] tags
  • OPT=0|1|2 - Optimization level (default: 2). Use OPT=0 for fastest compilation, OPT=2 for release. make dev is shorthand for OPT=0 -j.
  • JOBS=N - Set parallel job count (default: scripts/helix-claim jobs, see Parallel Compilation)
  • NO_COLOR=1 - Disable colored output (useful for CI/CD)
  • -j<N> - Run N jobs; without it, plain make picks its own (see Parallel Compilation)

The build system uses color-coded tags:

  • [CC] (cyan) - Compiling C sources (LVGL)
  • [CXX] (blue) - Compiling C++ sources (app code)
  • [FONT] (green) - Compiling font assets
  • [ICON] (green) - Compiling icon assets
  • [LD] (magenta) - Linking binary
  • ✓ (green) - Success messages
  • ✗ (red) - Error messages
  • ⚠ (yellow) - Warning messages

When compilation fails, the build system:

  1. Shows the failed file with a red ✗ marker
  2. Displays the full compiler command for debugging
  3. Exits immediately (fail-fast behavior)

Example:

[CXX] src/ui_panel_home.cpp
✗ Compilation failed: src/ui_panel_home.cpp
Command: clang++ -std=c++17 -Wall -Wextra -O2 -g -I. -Iinclude ...

The build system includes automatic code formatting for C/C++ and XML files, integrated with pre-commit hooks.

  • clang-format - Formats C, C++, and Objective-C files according to .clang-format config
  • xmllint - Formats and validates XML layout files with consistent indentation

.clang-format (LLVM-based with project customizations):

  • Indentation: 4 spaces, no tabs
  • Line length: 100 characters
  • Braces: K&R style (same line)
  • Pointers: Left-aligned (int* ptr)
  • Includes: Auto-sorted with grouping (project → external → system)
Terminal window
# Format all C/C++ and XML files
make format
# Format only staged files (useful before commit)
make format-staged
# Check formatting without modifying files
./scripts/quality-checks.sh

Formatting is automatically checked by the pre-commit hook (.git/hooks/pre-commit), which calls scripts/quality-checks.sh --staged-only:

  1. Resolves the pinned formatter: the clang-format wheel pinned in requirements.txt, installed into .venv by make venv-setup (scripts/qc/phase2.sh#qc_resolve_clang_format). Nothing on PATH is consulted, and a tree without the wheel cannot commit C++ until it runs make venv-setup - one formatter everywhere is what keeps files from ping-ponging between machines
  2. Checks staged files with it and auto-formats the ones that need it
  3. Prevents commit if a formatted file could not be re-staged (partially staged hunks)
  4. Full sweeps (pre-push, CI) fail on any unformatted file outside CLANG_FORMAT_BASELINE, the list of files that predate the gate; an entry leaves the list once the file is auto-formatted on its next staging

To bypass (not recommended):

Terminal window
git commit --no-verify

.githooks/pre-push runs the full, ungated sweep - every gate, not just the ones whose inputs you staged - because a commit can pass --staged-only while breaking a repo-wide invariant that mode never ran.

It sweeps the commit being pushed, not the working tree. quality-checks.sh reads file content off disk (CI mode walks src/ and include/ with find), so sweeping a dirty tree gives the wrong answer in both directions: in-flight edits fail a push whose commits are fine, and an uncommitted fix turns the sweep green over committed code that CI will then reject. With parallel sessions and worktrees sharing one checkout, a dirty tree is the normal case.

  • Tree matches the pushed commit - the sweep runs in place, as it always did.
  • Tree differs - the commit is checked out into a throwaway worktree and swept there. .venv, node_modules and build/bin are shared by symlink, as are the lib/ submodules (patch-drift and the doc-reference gate resolve against the filesystem, so an empty lib/ reads as mass breakage).

build/ is deliberately not shared wholesale: build/helix-xml-tests holds a cmake cache recording an absolute source path, and sharing it bakes the throwaway path into the real tree’s cache, breaking that gate for every later run. The consequence is that the helix-xml submodule-test gate finds no configured build tree on the isolated path and skips - its designed behaviour, since its first configure clones LVGL over the network.

Escape hatches: HELIX_PREPUSH_IN_PLACE=1 forces the old in-place sweep, and git push --no-verify skips the hook. Expect CI to find whatever you skipped.

The scripts/quality-checks.sh script runs multiple checks:

  • Code formatting (clang-format)
  • XML formatting (xmllint)
  • XML validation (xmllint –noout)
  • Copyright headers (GPL v3 SPDX identifiers)
  • Merge conflict markers
  • Trailing whitespace
  • Build verification (pre-commit only)

Used by all three:

  • Pre-commit hook (staged files only, working tree)
  • Pre-push hook (all gates, against the commit being pushed)
  • CI/CD (all files)

The build system automatically applies patches to git submodules before compilation.

  1. Patch Storage: All submodule patches are stored in patches/ (in the repository root)
  2. Auto-Detection: Makefile checks if patches are already applied before each build
  3. Idempotent: Safe to run multiple times - patches are only applied once
  4. Transparent: No manual intervention needed for normal development

File: patches/lvgl_sdl_window_position.patch

Purpose: Adds multi-display support to LVGL 9’s SDL driver by reading environment variables.

Environment Variables:

  • HELIX_SDL_DISPLAY - Display number (0, 1, 2…) to center window on
  • HELIX_SDL_XPOS - X coordinate for exact window position
  • HELIX_SDL_YPOS - Y coordinate for exact window position

Application Logic (in mk/patches.mk):

$(Q)$(APPLY_PATCH) $(LVGL_DIR) $(PATCH_DIR)/lvgl_sdl_window.patch "LVGL SDL window patch"

$(APPLY_PATCH) is scripts/apply_submodule_patch.sh, which owns a three-way verdict for every patch: apply it, recognize it as already applied (reverse check), or report that it matches no reachable state of the submodule.

Status Messages:

  • ✓ <label> applied - Patch was applied during this build
  • ✓ <label> already applied - Reverse check recognized it; the tree keeps it
  • ✓ <label> already applied (marker present; sibling patches moved the context git compares) - Neither check passes, but the marker table confirms the patch’s effect is in the checkout; the routine case on shared files
  • ⚠ <label>: its marker is absent from the checkout - the patch's effect is missing - Neither check passes and the marker is gone; run make reapply-patches
  • ⚠ <label> is not verifiable in place - Neither check passes and the marker table has no row for the patch, so the verdict stays hedged; run make reapply-patches to judge from clean
  • ⚠ <submodule> is not pristine, so this run cannot judge patches from clean - HELIX_PATCHES_FROM_CLEAN=1 was set but the submodule already carries changes (the state make clean leaves), so the fatal verdict is not available and every patch is judged in place; run make reapply-patches to reset and judge from clean
  • ✗ <label> does not apply to a clean checkout - The patch and the submodule disagree, on a run verified to have started from pristine submodules; regenerate the patch
  • ✗ <patch> ... Its marker is missing from <file> - One of the patch’s distinctive lines (there is one per file it touches) is absent from the checkout, so the build stops before compiling against unpatched code; run make reapply-patches. The marker check is a text search that needs no git, so it also fires in docker builds rsynced from worktrees. Companion messages name a changed patch file (make regen-patch-markers) and a wired stanza with no marker row.

To add a new submodule patch:

  1. Make changes in the submodule directory
  2. Generate patch — scope the diff to the files you touched, or you will capture every other patch that is currently applied:
    Terminal window
    cd lib/lvgl
    git diff src/path/to/file.c > ../../patches/my-new-patch.patch
    If more than one patch touches that file, even a scoped diff folds the others in. Sixteen files are shared today (src/misc/lv_event.c by seven patches). Check with grep -l "diff --git a/<path>" patches/*.patch and use the pristine-file method in patches/README.md § “Regenerating a patch whose file is shared”.
  3. Update Makefile to apply the patch in the apply-patches target, as one helper stanza beside the others:
    $(Q)$(APPLY_PATCH) $(LVGL_DIR) $(PATCH_DIR)/my-new-patch.patch "My new patch" "Without it <consequence>"
    The note is optional and names the runtime consequence of building without the patch; the marker check prints it when the patch goes missing.
  4. Regenerate the marker table with make regen-patch-markers (it reapplies from clean first, then rederives mk/patch-markers.tsv). Until you do, the build fails naming the new patch — a wired stanza without a marker row is a coverage gap, not a state the check tolerates.
  5. Document in patches/README.md

The libhv DNS resolver fallback patch has three traps. The first two make the patch silently miss the binary (on the AD5M, on-machine update checks fail with “Connection failed”); the third breaks a pristine parallel build. All three are regression-tested in tests/shell/test_libhv_dns_resolver_patch.bats.

  1. Guard on the actual change, not on a side effect. A patch that adds NEW files and edits an existing one must not gate re-application on the new file’s existence. A submodule reset reverts the tracked base/hsocket.c (the wiring) but leaves an untracked dns_resolv.c orphaned, so a file-existence guard declares “already applied” and never re-wires hsocket.c — the resolver compiles but is never called. Both layers in mk/patches.mk answer this now: every stanza routes through the $(APPLY_PATCH) verdict helper (which resets nothing and never assumes), and mk/patch-markers.tsv fails the build when a distinctive line the patch adds is absent from any file it edits — on every build, docker trees included.

  2. A patched file compiled into a static .a must invalidate that .a. $(LIBHV_LIB) (build//lib/libhv.a) originally had no prerequisites → built once, never rebuilt when a patch changed the libhv source. Because the resolver call site lives only in hsocket.c (→ inside libhv.a) while dns_resolv.c is compiled separately into the app, a stale archive kept a pristine hsocket.o (pure getaddrinfo → EAI_SYSTEM / ret=-11 on static glibc) across every rebuild. This is dev-only — a fresh CI build has no libhv.a yet — but the fix is to depend on the stamp:

    $(LIBHV_LIB): $(PATCHES_STAMP)

    When in doubt, rm build/<plat>/lib/libhv.a to force a clean archive, and confirm a patch’s marker actually made it in: strings <binary> | grep <sym>.

  3. A file a patch creates needs a producer rule in every build flavour. The patch creates base/dns_resolv.c, which the cross app build and the native test build (test_dns_resolver) both compile. On a pristine tree the file does not exist when make reads the graph, so without

    $(LIBHV_DIR)/base/dns_resolv.c: $(PATCHES_STAMP)

    a -j build fails with “No rule to make target ‘lib/libhv/base/dns_resolv.c’”. The rule sits in mk/rules.mk outside any cross-only conditional; the case “dns_resolv.c depends on the patch stamp in a native build” pins it by reading the make database with CROSS_COMPILE empty (#1411).

The prototype supports multi-monitor development workflows with automatic window positioning.

Terminal window
# Display-based positioning (centered)
./build/bin/helix-screen --display 0 # Main display
./build/bin/helix-screen --display 1 # Secondary display
./build/bin/helix-screen -d 2 # Third display (short form)
# Exact pixel coordinates
./build/bin/helix-screen --x-pos 100 --y-pos 200
./build/bin/helix-screen -x 1500 -y -500 # Works with negative Y (display above)
# Combined with other options
./build/bin/helix-screen -d 1 -s small --skip-splash

Flow:

  1. main.cpp parses command line arguments
  2. Sets environment variables before LVGL initialization:
    setenv("HELIX_SDL_DISPLAY", "1", 1); // For --display 1
    // or
    setenv("HELIX_SDL_XPOS", "100", 1); // For --x-pos 100
    setenv("HELIX_SDL_YPOS", "200", 1); // For --y-pos 200
  3. LVGL SDL driver reads environment variables during window creation
  4. Uses SDL_GetDisplayBounds() to query display geometry
  5. Calculates center position: display_x + (display_w - window_w) / 2
  6. Calls SDL_SetWindowPosition() after window creation (fixes macOS quirks)

Source Files:

  • src/main.cpp - Argument parsing and environment setup (lines 218-220, 385-401)
  • lvgl/src/drivers/sdl/lv_sdl_window.c - Window positioning logic (patch)

The scripts/screenshot.sh script automatically uses display positioning:

Terminal window
# Default: opens on display 1 (keeps terminal visible on display 0)
./scripts/screenshot.sh helix-screen output-name panel
# Override display
HELIX_SCREENSHOT_DISPLAY=0 ./scripts/screenshot.sh helix-screen output panel

How it works:

Terminal window
# In screenshot.sh
HELIX_SCREENSHOT_DISPLAY=${HELIX_SCREENSHOT_DISPLAY:-1} # Default to display 1
EXTRA_ARGS="--display $HELIX_SCREENSHOT_DISPLAY $EXTRA_ARGS"

This ensures the UI window appears on a different display from the terminal, making it easier to monitor build output and screenshots simultaneously.

Plain make (or make -j) picks its own -j, so there is no need to pass one. The answer comes from scripts/helix-claim jobs:

Box -j
No jobpool (CI, a Mac, a fresh clone) the cores (nproc, or sysctl -n hw.ncpu on macOS), capped at one job per GB of MemAvailable where /proc/meminfo exists, never below 2
jobpool installed the shared pool (below); the Makefile never reads JOBS

The two-phase re-invoke that applies JOBS lives in mk/rules.mk all: and mk/tests.mk $(TEST_BIN); it is skipped whenever MAKEFLAGS already carries a jobserver. An explicit make -jN or make JOBS=N passes through untouched. scripts/helix-claim jobs -v prints the decision and what it was made from.

helix-claim jobs is make’s -j and nothing else’s. With a pool live it is the whole pool, which is right for a make (it draws its jobs from the pool) and wrong for anything else: docker run --cpus $(scripts/helix-claim jobs), ninja -j$(...) or parallel -j$(...) runs that many jobs on top of every build on the box. Work outside make sizes itself with scripts/helix-claim hold [--min M] -- CMD, which exports JOBPOOL_SLOTS, and a container goes through scripts/pool-docker.sh. Called outside a make while a pool is live, jobs says so on stderr (stdout stays the bare number), and the resource advisor names a runner sized from it.

The same number sizes the unit sweep (3 shards per slot, SHARD_CONCURRENCY overrides) and the commit hook’s build (qc_build_jobs in scripts/qc/_lib.sh, HELIX_QC_JOBS overrides). The build uses --output-sync=target, so parallel output does not interleave.

Optional: one build pool per machine (jobpool)

Section titled “Optional: one build pool per machine (jobpool)”

jobpool is a machine-wide GNU make jobserver: a small daemon owns a FIFO of tokens sized from the cores and available memory, and every build that joins draws its jobs from it. Two sessions building on one box then share the cores instead of each taking all of them, and a tree that is linking or running tests holds no tokens, so its share flows to whoever is compiling. It is not a dependency: nothing in the build requires it, and without it everything above holds as written.

This project’s own build machines (the maintainer’s desktop and its test host) have it installed. The repo is ~/Code/Tools/jobpool there; its README covers install, configuration and macOS.

Where it is installed:

  • make on PATH is jobpool’s shim. Every top-level make joins the pool, and the shim strips any -j from the command line, because GNU make leaves an inherited jobserver when it sees one.
  • jobpool status shows the target, free tokens and consumers.
  • JOBPOOL=0 make ... bypasses the pool for one run. helix-claim jobs honours the same switch.
  • helix-claim jobs reports the pool’s size while the daemon is live, so the sweep, the commit hook and the resource advisor read the machine budget. It asks jobpool target, which takes no lock and moves no tokens; only jobs -v reads jobpool status for the free count.
  • The unit sweep runs its shards three to a pool token (jobpool with-token around each batch of three), so every sweep on the box together stays within three shards per token.
  • Runners that size themselves rather than join a jobserver hold their share through scripts/helix-claim hold [-n N] -- CMD, which exports JOBPOOL_SLOTS (jobpool hold; without jobpool, -n or the cores). The helix-xml test build runs its cmake --build -j from it. make test-shell asks for --grow FILE too, which a jobpool that has it keeps at the live slot count while the suite runs; bats is handed scripts/parallel-jobs-file.sh as its parallel, which gives GNU parallel that file (re-read about once a second) in place of the number bats insists on. Without it the suite runs at the start-time JOBPOOL_SLOTS.
  • Container builds go through scripts/pool-docker.sh: a container’s make joins the pool (state dir mounted, FIFO opened inside, -j dropped, kept as the fallback when the container cannot open the FIFO). An idf.py or ninja command joins too: ninja 1.13 is a jobserver client in the FIFO form, so scripts/ninja-jobserver.sh fetches the pinned upstream release once into ~/.cache/helixscreen/ (sha256-checked; offline after that), pool-docker.sh mounts it over the image’s /usr/bin/ninja, names the FIFO in MAKEFLAGS and empties IDF_PY_BUILD_JOBS, since idf.py passes it as -j and any -j turns ninja’s jobserver off. Any other container command (the ustreamer script), or idf.py with no ninja 1.13 to be had, holds tokens and gets JOBPOOL_SLOTS and IDF_PY_BUILD_JOBS. The native cross targets (make pi and siblings) give their sub-make no -j while a pool is live.
  • helix-claim resources lists heavy runners (bats, GNU parallel, ninja, docker run) running outside the pool, and the resource advisor names one about to start.
  • On a test host with jobpool installed, scripts/test-host-run.sh joins the container to the host’s pool: the host’s jobpool conf puts the state dir under the directory the test container already mounts, the host runs docker exec under jobpool exec, and the container opens the FIFO and exports MAKEFLAGS (jobpool container-env) with no -j on make. Without a pool there it sizes -j from MemAvailable, and runs of different trees take turns. A ZFS host should keep build headroom free with the zfs_arc_sys_free tunable; the run warns when it is under 32 GiB.

Two kinds of remote machine help here, and both are optional:

  • a test host runs the expensive, non-interactive jobs (sweep, asan, tsan, mutate) in a container: scripts/test-host-run.sh. It mirrors your working tree as it is on disk, uncommitted edits included, into a per-tree directory whose build/ persists, so a warm run rebuilds only what changed. With one configured, make full-test-run runs the C++ sweep there while bats runs locally whenever the link makes that pay (TEST_HOST=1 forces it, TEST_HOST=0 or HELIX_TEST_HOST_AUTO=0 keeps both local).
  • a remote build host builds natively or for the cross targets: make remote-native, make remote-test, make remote-pi and siblings (mk/remote.mk, scripts/remote-build.sh).

Both are named in one file outside the tree, so every worktree and clone shares it:

${XDG_CONFIG_HOME:-$HOME/.config}/helixscreen/build-hosts.env

One parser reads it, scripts/lib/build_hosts.sh: the scripts source it, and make asks it for a value the first time a remote target needs one, so an ordinary make never reads the file at all. Its rules:

  • KEY=VALUE at the start of a line, no spaces around =; # starts a comment only at the start of a line.
  • Only the keys in the table below; any other key is named and skipped.
  • Values are literal: no $ expansion, nothing runs, a CRLF line ending is fine. A value cannot hold whitespace or quotes, so a trailing comment or a quoted value is named and skipped rather than half-read.
  • If a key appears twice, the last line wins.
  • A variable set in the environment (or on the make command line) wins over the file. HELIX_BUILD_HOSTS_FILE names a different file.

Nothing has a host default: a command that needs a host you have not configured stops with one line naming the variable and this file, and nothing ever connects to a host you did not name. make full-test-run with no test host simply runs both suites locally, without a word.

Variable Used by Meaning (default)
HELIX_TEST_HOST test-host-run.sh, full-test-run, helix-claim resources, teardown ssh name of the test host (none)
HELIX_TEST_CONTAINER same container name (helix-test)
HELIX_TEST_TREES_HOST same mirror root on the host, relative to the ssh user’s home unless absolute (helix-test/trees)
HELIX_TEST_TREES same the same directory inside the container (/work/trees)
HELIX_TEST_CCACHE same ccache dir inside the container (/work/ccache)
HELIX_TEST_WORKDIR test-host-run.sh --commit, mutate git checkout inside the container (/work/helixscreen)
HELIX_TEST_LOCK_DIR test-host-run.sh lock directory on the host (/tmp)
HELIX_TEST_HOST_AUTO full-test-run 0 keeps an unforced gate local even with a test host configured (1)
REMOTE_HOST, REMOTE_USER mk/remote.mk, remote-build.sh remote build host and optional user (none)
REMOTE_DIR mk/remote.mk rsync target on the remote for the cross targets (none). remote-sync rsyncs --delete into it, so give it a directory of its own, never a checkout you work in
REMOTE_BUILD_DIR remote-build.sh its own clone on the remote (~/helix-remote), which it hard-resets and cleans every run. It works only in a directory it created (marked .helix-remote-build) and refuses any other

An example file:

HELIX_TEST_HOST=buildbox.local
HELIX_TEST_CONTAINER=helix-test
HELIX_TEST_TREES_HOST=/srv/helix-test/trees
HELIX_TEST_TREES=/work/trees
REMOTE_HOST=buildbox.local
REMOTE_DIR=~/helix-remote-sync

Setting up a test host. Any Linux machine with Docker works, if you can ssh to it with a key and run sudo -n docker there. Name it in the file, then run from your own machine:

Terminal window
scripts/test-host-setup.sh # image, container, toolchain checks
scripts/test-host-setup.sh --commit-checkout # also clone the checkout --commit and mutate use
scripts/test-host-run.sh test '[netd]' # first run: syncs the tree and builds (minutes)

The setup builds the image from docker/Dockerfile.sanitizer in your tree, streamed over ssh, and starts the container with the parent of HELIX_TEST_TREES_HOST mounted at the parent of HELIX_TEST_TREES. Re-run it after the Dockerfile changes; --recreate swaps the container for one from the new image. scripts/test-host-run.sh --prune [DAYS] removes mirrors nobody has synced for DAYS (14), and teardown-worktree.sh drops a tree’s mirror with the tree.

The build system uses lv_font_conv to convert TrueType fonts into LVGL-compatible C arrays.

Material Design Icons (MDI):

  • Source: scripts/regen_mdi_fonts.sh (single source of truth)
  • Font: assets/fonts/materialdesignicons-webfont.ttf
  • Output: mdi_icons_16.c, mdi_icons_24.c, mdi_icons_32.c, mdi_icons_48.c, mdi_icons_64.c
  • Codepoint mapping: include/ui_icon_codepoints.h

Noto Sans Text Fonts:

  • Source: package.json npm scripts
  • Font: assets/fonts/NotoSans-Regular.ttf, NotoSans-Bold.ttf
  • Output: noto_sans_*.c, noto_sans_bold_*.c

MDI icon fonts are regenerated when scripts/regen_mdi_fonts.sh changes. The build system uses Make’s dependency tracking to only regenerate when needed.

Automatic regeneration:

Terminal window
make # Checks fonts and regenerates if regen script is newer

Manual regeneration:

Terminal window
make regen-fonts # Regenerate MDI icon fonts from regen script
make generate-fonts # Explicit font regeneration

To add new Material Design Icons:

  1. Find the icon at https://pictogrammers.com/library/mdi/
  2. Get the codepoint (e.g., wifi-strength-4 = 0xF0928)
  3. Edit scripts/regen_mdi_fonts.sh and add the codepoint:
    Terminal window
    MDI_ICONS+=",0xF0928" # wifi-strength-4
  4. Add to codepoints header (include/ui_icon_codepoints.h):
    {"wifi_strength_4", "\xF3\xB0\xA4\xA8"}, // F0928 wifi-strength-4
  5. Regenerate fonts:
    Terminal window
    make regen-fonts
    make -j
  • Node.js and npm - Required for font generation
    • macOS: brew install node
    • Ubuntu/Debian: sudo apt install npm
    • Fedora/RHEL: sudo dnf install npm
  • lv_font_conv - Installed automatically via npm install (see package.json devDependencies)

npm not found:

Terminal window
# macOS
brew install node
# Linux
sudo apt install npm # Debian/Ubuntu
sudo dnf install npm # Fedora/RHEL
# Verify
npm --version

Fonts not regenerating:

Terminal window
# Force regeneration by touching the regen script
touch scripts/regen_mdi_fonts.sh
make generate-fonts

Missing icons:

Terminal window
# Validate all icons in codepoints.h are in the font
make validate-fonts

Manual font generation:

Terminal window
# Generate specific size
npm run convert-noto-24
# Generate all fonts
npm run convert-noto-all

The build system includes automated icon generation with platform-specific output formats.

Terminal window
# Generate/regenerate icon from source logo
make icon

Output:

  • macOS: helix-icon.icns (multi-resolution bundle) + helix-icon.png (650x650)
  • Linux: helix-icon.png (650x650 for application use)

Required:

  • imagemagick - Image processing (magick command)
    • macOS: brew install imagemagick
    • Ubuntu/Debian: sudo apt install imagemagick
    • Fedora/RHEL: sudo dnf install ImageMagick

macOS only:

  • iconutil - macOS icon bundle creator (built-in on macOS)

The make icon target performs the following steps:

All platforms:

  1. Crops source logo (assets/../../../../assets/images/docs/helixscreen-logo.png) to just the circular helix
  2. Creates square icon at 650x650px with transparent background → helix-icon.png

macOS only (additional steps): 3. Generates 12 resolutions:

  • Standard: 16x16, 32x32, 64x64, 128x128, 256x256, 512x512
  • Retina (@2x): 32x32, 64x64, 128x128, 256x256, 512x512, 1024x1024
  1. Bundles into .icns file using iconutil → helix-icon.icns
  2. Cleans up temporary iconset directory

All platforms:

  • assets/../../../../assets/images/docs/helix-icon.png - Cropped square logo (650x650px, ~245KB)

macOS only:

  • assets/images/helix-icon.icns - macOS icon bundle (~1.3MB with all resolutions)
  • assets/images/icon.iconset/ - Temporary directory (auto-deleted after .icns creation)

Cross-platform:

  • SDL window icons (programmatically via SDL_SetWindowIcon() with PNG)
  • Linux .desktop files (Icon= field pointing to PNG)

macOS specific:

  • macOS .app bundles with Info.plist (CFBundleIconFile pointing to .icns)
  • Dock/Finder display when bundled as application

ImageMagick not found (macOS):

Terminal window
brew install imagemagick
make icon

ImageMagick not found (Linux):

Terminal window
# Ubuntu/Debian
sudo apt install imagemagick
# Fedora/RHEL
sudo dnf install ImageMagick

Linux output:

  • Linux builds generate PNG only (not .icns)
  • This is expected - .icns is macOS-specific
  • PNG icons work with SDL and Linux desktop environments

Regenerating after logo changes:

Terminal window
# Update assets/../../../../assets/images/docs/helixscreen-logo.png
make icon # Regenerates all icon files (platform-specific)

When converting SVG files to PNG for use in the project, always use rsvg-convert from the librsvg library.

ImageMagick’s SVG renderer doesn’t correctly handle certain SVG features (transforms, filters, complex paths). This produces corrupted output—often solid white/black rectangles instead of the intended graphics.

Terminal window
# Single file at specific size:
rsvg-convert logo.svg -w 64 -h 64 -o logo_64.png
# Batch convert all SVGs in a directory:
for svg in *.svg; do
name="${svg%.svg}"
rsvg-convert "$svg" -w 64 -h 64 -o "${name}_64.png"
done
# Common size options:
rsvg-convert input.svg -w 128 -h 128 -o output.png # By pixel dimensions
rsvg-convert input.svg --dpi-x 192 --dpi-y 192 -o output.png # By DPI

The librsvg package is already tracked as a dependency for lv_img_conv. Install with:

Terminal window
# macOS
brew install librsvg
# Debian/Ubuntu
sudo apt install librsvg2-bin
# Fedora/RHEL
sudo dnf install librsvg2-tools
  • AMS logos (assets/images/ams/) - Multi-material system icons converted from SVG sources

The Makefile is self-documenting — these help targets are the authoritative, always-current list (the tables below are a curated tour of the typical ones):

Command Shows
make help The common build/dependency/quality targets
make help-build Build, dependency, and patch targets
make help-test Test targets and test discovery
make help-cross Cross-compilation + per-device deployment targets and options
make help-remote Remote build system (build on a fast host, fetch binaries)
make help-images / help-splash / help-watchdog Asset/splash/watchdog targets
make help-all Everything, all topics combined
make cross-info Current cross-compile configuration (platform, backend)
Target What it does
make -j (all) Build the main binary (default), -O2, auto-parallel
make dev Fast build at -O0 (~2× faster compile; larger/slower binary)
make OPT=1 -j -O1 middle ground
make build Clean build with progress + timing
make run Build and run the UI
make clean Remove build artifacts (keeps deps)
make distclean Deep clean to fresh-checkout state
make V=1 … Verbose (show full compiler commands)
make JOBS=N … Cap parallel job count

Run flags worth knowing: ./build/bin/helix-screen --test -vv (mock printer + DEBUG logs, hot reload ON by default for live XML editing).

The build system has 30+ test targets by feature area; see TESTING.md for the tag taxonomy. Most-used:

Target What it does
make test Build tests (does not run them)
make test-run Build and run tests in parallel (recommended, ~4–8× faster)
make test-serial Run sequentially (debugging thread issues)
make test-smoke Quick smoke subset (~30s) for rapid iteration
make test-all All tests incl. [slow]
make test-asan / test-tsan Run under Address/Thread sanitizer
make test-list-tags List available tags
make test-xml Configure, build and run the helix-xml engine suite (CMake + Unity + ctest) - not part of make test
./build/bin/helix-tests "[tag]" Run a specific tag (e.g. [ams], [gcode])

make test-xml is the odd one out: it drives the standalone suite in the lib/helix-xml/ submodule rather than helix-tests. Its build tree is build/helix-xml-tests/ (outside the submodule) and LVGL comes from CMake FetchContent pinned at v9.5.0, so the first configure needs network and takes minutes; every run after that is a no-op configure plus a couple of seconds of ctest. Forward extra ctest args with make test-xml HELIX_XML_CTEST_ARGS='-R test_expr'. See TESTING.md § “helix-xml Engine Tests”.

Target What it does
make format clang-format all C/C++ + xmllint XML
make format-staged Format only staged files (pre-commit)
make quality All quality checks (formatting, headers, conflicts)
make setup-hooks Enable the git pre-commit hook
make compile_commands Merge compile_commands.json for clangd (~1–2s)
make compile_commands_full Full regen via bear/compiledb (slow; use if fragments corrupt)
Target What it does
make check-deps Verify build dependencies (see below)
make install-deps Interactively install missing deps
make venv-setup Create .venv with Python asset/telemetry deps
make libs-clean Clean all built library artifacts
make apply-patches Apply LVGL/libhv patches (idempotent; auto-run before builds)
make reapply-patches Force re-apply (repair manually-edited patched files)

Usually invoked after editing icons/images. See Font Generation and Icon Generation for the full pipeline.

Target What it does
make regen-fonts Regenerate MDI icon fonts via scripts/regen_mdi_fonts.sh
make regen-text-fonts Regenerate Noto Sans text fonts (incl. CJK)
make regen-icon-consts Regenerate icon string constants in globals.xml
make validate-fonts Verify every codepoint is present in the compiled fonts
make regen-images Regenerate pre-rendered splash images (all sizes)
make gen-printer-images Pre-render printer DB images
make translations Regenerate translation tables from YAML
make translation-coverage Show per-language translation coverage

These get their own deep section above — see Cross-Compilation. Shape of it:

  • Build: make <target>-docker (recommended, no local toolchain) or make <target> (needs host toolchain). Targets: pi, pi32, ad5m, ad5x, cc1, k1, k1-dynamic, k2, snapmaker-u1, x86.
  • Deploy + run on device: make <target>-test (build + deploy + run fg), make deploy-<target> (background), deploy-<target>-fg (foreground), deploy-<target>-bin (binaries only, fast iteration), <target>-ssh.
  • Host override: make deploy-pi PI_HOST=192.168.1.50. Defaults live in mk/cross.mk — note PI_HOST actually defaults to 192.168.1.113 (the make help-cross text saying helixpi.local is stale, and helixpi.local does not resolve). K2_HOST has no default and must be supplied.
  • Remote build: two transports, and the choice matters on a slow link.
    • make remote-native / make remote-test TAG='[ams]' send only the local delta (scripts/remote-build.sh). The build host keeps its own clone and fetches committed history from GitHub itself; your link carries one patch covering unpushed commits and uncommitted edits, plus a tar of untracked files — a few KB over one multiplexed SSH connection. Use this for every native build and test run.
    • make remote-pi / remote-ad5m / remote-all rsync the whole working tree (make remote-sync) because the Docker cross builds need a real mirror on the remote. rsync is delta-based, but it still exchanges metadata for every file under lib/ and assets/ before deciding nothing changed, and a fresh destination directory transfers ~260 MB — so reuse one REMOTE_DIR rather than a new one per branch or worktree.
    • REMOTE_HOST and REMOTE_DIR come from the build-hosts file (Using your own build/test host); make remote-status checks readiness.
Target What it does
make demo Build LVGL demo widgets (LVGL API testing)
make symbols Extract .sym + .debug (crash-backtrace resolution)
make strip Strip the binary for release
make screenshots Generate documentation screenshots
make print-cxxflags / print-ldflags Dump resolved flags (build debugging)

Before building, the system automatically checks for required dependencies:

Required:

  • clang / clang++ - C/C++ compiler with C++17 support
  • cmake - Build system for SDL2 when building from submodule (version 3.16+)
  • Git submodules: lvgl, wpa_supplicant (auto-built by build system)

Optional (uses system if available, otherwise builds from submodules):

  • sdl2, spdlog, libhv - Auto-detected and built only if not system-installed

Optional:

  • compiledb or bear - Only for compile_commands_full (normal builds auto-generate)
  • imagemagick - For screenshot conversion and icon generation
  • iconutil - For macOS .icns icon generation (macOS only, built-in)
Terminal window
make check-deps

Example output:

Checking build dependencies...
✓ clang found: Apple clang version 17.0.0
✓ clang++ found: Apple clang version 17.0.0
✓ SDL2: Using system version 2.32.10
✓ cmake found: cmake version 3.30.5
✓ libhv: Using submodule version
✓ spdlog: Using submodule version (header-only)
✓ LVGL found: lvgl
All dependencies satisfied!

If dependencies are missing, the check provides installation instructions.

The build system uses incremental compile command generation for fast IDE integration.

  1. During compilation: Each .o file generates a .ccj (compile command JSON) fragment alongside it
  2. After build: Fragments are automatically merged into compile_commands.json
  3. Adding new files: Just compile them - fragments are created automatically

This replaces the slow compiledb make -n -B approach (which did a full dry-run) with instant merges.

Terminal window
# Normal workflow - compile_commands.json is auto-updated after every build
make -j
# Manual merge (if you want to update without building)
make compile_commands # ~1-2 seconds for ~1000 files
# Full regeneration (slow, use only if fragments are corrupted)
make compile_commands_full
  • Fragments are stored as .ccj files next to .o files in build/obj/
  • They’re automatically cleaned with make clean
  • They’re gitignored (inside build/)

compile_commands.json has missing entries:

Terminal window
# Ensure all targets are built
make -j && make test-build
make compile_commands

JSON validation errors:

Terminal window
# Check if JSON is valid
python3 -m json.tool compile_commands.json > /dev/null
# If corrupted, do a full regeneration
make compile_commands_full

The project uses git submodules for external dependencies:

  • lvgl - LVGL 9.5 graphics library (with automatic patches)
  • libhv - HTTP/WebSocket client library (auto-built)
  • spdlog - Logging library
  • wpa_supplicant - WiFi control (Linux only, auto-built)

Additionally, lib/helix-xml/ is the XML engine — a permanent MIT fork taken from LVGL at a15dcbeb5 (v9.4.0-358), the last commit before v9.5 removed XML from core. It is a submodule, but ours: prestonbrown/helix-xml. That makes its workflow the opposite of every other submodule here — edit the files directly, commit and push inside lib/helix-xml/, then commit the bumped pointer in this repo. It gets no patches/*.patch entry, and it is excluded from clang-format. See HELIX_XML_FORK.md.

Being its own repo, it also carries its own tests and its own CI. lib/helix-xml/tests/ is a standalone CMake + Unity suite that builds the engine against a pinned upstream LVGL v9.5.0 rather than our patched lib/lvgl; run it with make test-xml (make test does not build it), and see TESTING.md § “helix-xml Engine Tests”. .github/workflows/ci.yml inside the submodule covers a gcc + clang matrix, ASAN/UBSAN, and a conf-guards job. scripts/quality-checks.sh runs the suite on commits that stage a lib/helix-xml change, but only once build/helix-xml-tests/ has been configured by hand - the first configure fetches LVGL and is too slow for a commit hook.

Automatic handling: Submodule dependencies are built automatically when missing. Patches are applied automatically before builds. Never commit changes directly to submodules - always create patches instead.

SDL2 is a system dependency installed via package manager:

Terminal window
# macOS
brew install sdl2
# Debian/Ubuntu
sudo apt install libsdl2-dev
# Fedora/RHEL
sudo dnf install SDL2-devel

The Makefile uses sdl2-config to auto-detect paths:

SDL2_CFLAGS := $(shell sdl2-config --cflags)
SDL2_LIBS := $(shell sdl2-config --libs)

Symptom: ✗ <label> does not apply to a clean checkout (from make reapply-patches), or ⚠ <label>: its marker is absent from the checkout / ⚠ <label> is not verifiable in place (from an incremental build)

Causes:

  1. The patch drifted: a sibling patch moved the context it needs, and it matches no reachable state of the submodule
  2. The patch was edited without regenerating it against the patched tree

Solution — let the from-clean run name every drifted patch, then regenerate each one it names (patches/README.md § “Regenerating a patch whose file is shared”):

Terminal window
make reapply-patches

Symptom: Slow compilation

The build compiles ~566 app source files. The dominant cost is template instantiation and optimization passes (not header parsing — preprocessing takes <1s even for the worst files). At -O2, individual files take 8–15s to compile.

Speed tiers (full app rebuild, 32-core machine):

Method Wall time Notes
make -j (cold, no ccache) ~4.5 min Baseline
make dev (cold, no ccache) ~2.5 min -O0 skips optimizer passes
make -j (ccache populated) ~38 sec 98% cache hit rate
make dev (ccache populated) ~20 sec -O0 + ccache
Touch one .cpp, rebuild ~7 sec Recompile + relink
Touch widely-included .h ~8 sec ccache direct hit (content unchanged)

Solutions (most impactful first):

  1. Install ccache — by far the biggest win. The Makefile auto-detects and wraps the compiler. Gives ~7x speedup for rebuilds where source content hasn’t actually changed (e.g., switching branches back and forth, touching headers without real edits).

    Terminal window
    # Ubuntu/Debian
    sudo apt install ccache
    # macOS
    brew install ccache
  2. Use make dev for daily development — builds at -O0, cutting per-file compile time roughly in half. Library code still builds at -O2 since it rarely changes. The binary is larger and slower at runtime, but compilation is ~2x faster.

    Terminal window
    make dev # -O0, auto-parallel
    make OPT=1 -j # -O1 (middle ground: some optimization, faster than -O2)
    make -j # -O2 (default, for release/CI)
  3. Use parallel builds: make -j (auto-detects all cores)

  4. Use incremental builds: make -j instead of make clean && make

Header fan-out — changing these headers triggers the most recompilation:

Header Files affected
theme_manager.h ~144
ui_update_queue.h ~144
moonraker_api.h ~122
app_globals.h ~121
printer_state.h ~104

With ccache installed, touching these headers without content changes costs ~8s (direct cache hit). Actual content changes recompile all dependents (~2 min at -O2, ~1 min at -O0).

Forced include (include/lvgl_pch.h, $(FORCED_INCLUDE) in the Makefile): covers LVGL, helix-xml, spdlog, nlohmann JSON, and common STL headers, and is passed as -include to every app and test C++ source, so many sources compile only because of it. It is an ordinary header, not precompiled: every TU parses it and tracks it through its .d file, so editing it rebuilds every C++ object. Don’t add project headers to it — only stable external libraries. LVGL’s own C++ sources (ThorVG, compiled out by lv_conf.h) do not get it.

ccache across worktrees and Docker cross-builds

Section titled “ccache across worktrees and Docker cross-builds”

ccache (~/.ccache) is shared per-user, but two things stop it from being reused as widely as you’d expect:

1. Worktree path mismatch (native builds). Because the native build compiles with -g and ccache defaults to hash_dir=true, the absolute working directory is part of the cache key — so the same source in .worktrees/foo/ misses everything the main tree cached. setup-worktree.sh configures ccache (base_dir=$HOME, hash_dir=false, max_size=25G) so worktree builds reuse the main tree’s objects. See Git Worktrees → Why worktree builds are fast for the full rationale and caveats. If you build outside $HOME (e.g. /tmp), set CCACHE_BASEDIR to a common ancestor yourself.

2. Docker cross-builds use a separate, per-target cache. Containers can’t see ~/.ccache, so each *-docker target bind-mounts its own persistent cache directory (mk/cross.mk):

DOCKER_CCACHE_BASE ?= $(HOME)/.cache/helixscreen-ccache
docker-ccache-args = -v "$(DOCKER_CCACHE_BASE)/$(1)":/ccache -e CCACHE_DIR=/ccache

So make pi-docker caches into ~/.cache/helixscreen-ccache/pi/, make ad5m-docker into .../ad5m/, etc. — one cache per architecture (they must stay separate; a Pi aarch64 object is meaningless to an AD5M armv7-a build). First cross-build of a target is cold; subsequent ones hit ~98%. Override the base location with DOCKER_CCACHE_BASE=/path make pi-docker. To wipe a single target’s cache, rm -rf ~/.cache/helixscreen-ccache/<target>.

Every Docker cross-build runs through scripts/pool-docker.sh. With jobpool installed, the container’s make joins the machine pool (its -j is dropped) and any other in-container build holds pool tokens through helix-claim hold; without jobpool the command runs exactly as written.

Clang Standard Library Issues (Arch Linux)

Section titled “Clang Standard Library Issues (Arch Linux)”

Symptom: fatal error: 'stdlib.h' file not found at #include_next <stdlib.h>

Cause: Clang can’t find GCC’s libstdc++ headers on bleeding-edge distros (Arch with GCC 15+).

Automatic Fix: The build system detects this and auto-falls back to g++. You’ll see:

Note: clang++ has stdlib issues on this system, using g++ instead

Manual Override: Force a specific compiler:

Terminal window
CXX=g++ CC=gcc make -j # Use GCC
CXX=clang++ make -j # Force Clang (may fail)

Symptom: sdl2-config: command not found

Solutions:

Terminal window
# macOS
brew install sdl2
# Debian/Ubuntu
sudo apt install libsdl2-dev
# Verify installation
which sdl2-config
sdl2-config --version

Absorbed from the helix-build skill so there is one home for it. Values verified against Makefile and mk/*.mk.

Variable Default Notes
CC clang > gcc Auto-detected, with a stdlib test for clang
CXX clang++ > g++ Auto-falls back on a broken libstdc++
OPT 2 Optimization level: 0 (dev), 1, 2 (release)
CXXFLAGS -std=c++17 -Wall -Wextra -O{OPT} -g Extended per platform
SUBMODULE_CFLAGS -std=c11 -O2 -g -D_GNU_SOURCE -w Third-party code, warnings suppressed
DEPFLAGS -MMD -MP Header dependency tracking

Read from VERSION.txt and injected as -DHELIX_VERSION="...": HELIX_VERSION, HELIX_VERSION_MAJOR / _MINOR / _PATCH.

HELIX_VERSION is the whole string, prerelease suffix included (1.1.0-beta.1). The three numeric defines come from the core triple, because they are compiled as integers: splitting the full string on . puts 0-beta in the patch field, and -DHELIX_VERSION_PATCH=0-beta fails to compile in every translation unit that includes helix_version.h. Ordering a prerelease against a release is helix::version::Version’s job, not the preprocessor’s.

The short git hash is not a global define. It changes on every commit, and VERSION_DEFINES lands on every translation unit’s command line, which ccache’s direct mode hashes — so a global -DHELIX_GIT_HASH invalidated the whole project’s cache on every push (measured: 86% direct hits on a sha matching the restored cache, 43.7% on any other, and a 12-minute build became two hours). scripts/gen-git-hash.sh writes it to build/generated/helix_git_hash.h instead, rewriting only when the hash changes, and only src/system/helix_version.cpp includes it. Read it through helix_git_hash() from helix_version.h; the macro is not visible anywhere else.

Variable Default Per-platform override
BUILD_DIR build mk/cross.mk sets per platform
BIN_DIR build/bin e.g. build/pi/bin
OBJ_DIR build/obj e.g. build/pi/obj
BUILD_SUBDIR (none) Platform name: pi, ad5m, k1
Variable Purpose
TARGET_ARCH Target architecture for the active cross target
TARGET_TRIPLE Toolchain triple (e.g. aarch64-linux-gnu)
STRIP_BINARY Whether to strip the output binary for size
FONT_TIERS Which font size tiers to embed for the target — see below

Font faces are the largest single chunk of .rodata, so each target links only the tiers it can actually display. Legal values are all (the default, mk/fonts.mk) or any subset of micro tiny small medium large xlarge xxlarge. Assignments live per target in mk/cross.mk:

PLATFORM_TARGET Tiers
pi, pi-fbdev, pi-both, pi32, pi32-fbdev, pi32-both all
x86, x86-fbdev, x86-both, native all
ad5m, ad5m-br, ad5x medium large
mips / k1, k1-dynamic small medium
k2 large xlarge
snapmaker-u1 tiny small
cc1, yocto micro tiny

HELIX_MAX_FONT_TIER is derived from this (mk/cross.mk; micro=0 … xxlarge=6). Two consumers read it: theme_manager uses it to distinguish an expected-missing font (pruned by tier) from an unexpected-missing one (a build bug), and cjk_font_manager uses it to pick its CJK face.

The consequence for layout work: a <string> token naming a face outside the target’s tiers silently fails to register on that target, and the token falls back down the ladder. If you add a font token for a large tier, check it against the tier list of the smallest device that will run it.

FONTS_XXLARGE additionally carries six faces above the authored ladder — noto_sans_48/64, noto_sans_bold_48/64, noto_sans_light_32/40 — which exist only for the high-DPI UI scale factor to step into on phone-class panels. No printer target declares the xxlarge tier, so none of them links these (~11MB of .rodata). Android does not build through this Makefile at all: android/app/jni/CMakeLists.txt globs assets/fonts/*.c wholesale, so every face including these six is linked, and its target_compile_definitions declares HELIX_MAX_FONT_TIER=6 and HELIX_HAS_HIDPI_FONTS=1 to match, since both macros default to a conservative fallback when a build path leaves them undefined (HELIX_HAS_HIDPI_FONTS to 0), which would otherwise drop these six faces’ mappings and asset registrations for a build that did link them.

Variable Default Purpose
ENABLE_SDL yes (native) SDL2 desktop display
ENABLE_OPENGLES no (all targets) Requests LVGL’s DRM EGL path; the #error in display_backend_drm.cpp fails the build if set without LV_USE_OPENGLES
ENABLE_GLES_3D yes (Linux) 3D gcode rendering
ENABLE_SCREENSAVER yes (desktop/Pi) Flying toasters
ENABLE_MOCKS yes (no on cc1/ad5m/ad5m-br) Mock backends for development
ENABLE_SSL per target OpenSSL for HTTPS/WSS
HELIX_HAS_LABEL_PRINTER 1 Label printer feature
HELIX_HAS_CFS 1 CFS feature
HELIX_HAS_IFS 1 IFS feature
HELIX_HAS_ACE 1 ACE vendor backend (0 on non-Anker cross targets)
HELIX_HAS_QIDI 1 QIDI Box vendor backend (0 on non-QIDI cross targets)
HELIX_HAS_SNAPMAKER 1 SnapSwap vendor backend (0 except snapmaker-u1)
HELIX_BACKLIGHT_FLOOR_PERCENT 0 Lowest visible raw backlight level, percent of the raw range. 20 on k2, applied only by the sysfs and Sonic Pad CLI backends (the sysfs panel on community K2 firmware renders lower levels as off, #1709); the Allwinner /dev/disp backend ignores it. /display/backlight_floor_percent in settings.json overrides it on every backend
macOS -lSDL2 -lhv -lz -lm -lpthread -liconv
-framework Foundation -framework CoreWLAN -framework CoreLocation …
Linux native -lSDL2 -lhv -lwpa_client -lusb-1.0 -lssl -lcrypto -ldl -lstdc++fs -lGLESv2
Pi (aarch64) -L/usr/lib/aarch64-linux-gnu -lhv -lwpa_client -lnl-genl-3 -lnl-3
-ldrm -linput -lEGL -lGLESv2 -lgbm -lusb-1.0 -lssl -lcrypto -lasound …
K1 static -lhv -lwpa_client -lnl-genl-3 -lnl-3 -latomic -ldl -lz -lm -lpthread
K1 dynamic -Wl,-Bstatic -lhv -lwpa_client -lnl-genl-3 -lnl-3 -lstdc++fs
-Wl,-Bdynamic -lstdc++ -lz -lm -lpthread -lrt -ldl -latomic -lgcc_s
K2 (ARM musl) -lhv -lwpa_client -lnl-genl-3 -lnl-3 -ldl -lz -lm -lpthread
Yocto preserves bitbake LDFLAGS, appends the project libs

Application sources are src/*.cpp at up to three directory levels, plus src/*.mm for the macOS Objective-C++ WiFi code. src/tools/*.cpp and src/bluetooth/*.cpp build under separate rules.

Excluded from the main binary: test_*.cpp, src/helix_splash.cpp and src/helix_watchdog.cpp (separate binaries), and src/lvgl-demo/main.cpp.

Bundled libraries: lib/lvgl/ (XML and expat sources excluded — those live in lib/helix-xml/), lib/helix-xml/, lib/lv_markdown/, lib/quirc/, lib/cpp-terminal/. LVGL’s thorvg .cpp files compile separately.

Build
all Default: main binary with dependency checks
build Clean parallel build with timing
dev OPT=0 -j fast development build
strict Build with -Werror
clean Remove all build artifacts
install Stage to DESTDIR/opt/helixscreen/
Code quality
format / format-staged clang-format + xmllint, all or staged files
quality Run scripts/quality-checks.sh
check-deps / install-deps Verify, then interactively install dependencies
Assets
regen-fonts / generate-fonts Regenerate MDI icon fonts
validate-fonts Verify icons are present in the compiled fonts
icon Generate the app icon from the logo
apply-patches / reset-patches Apply or revert submodule patches
Tools and debug
compile_commands / compile_commands_full Merge .ccj fragments (~1-2s) or fully regenerate
moonraker-inspector / tools Diagnostic tools
symbols / strip Extract .sym + .debug, or strip for size (cross-compile only)
print-ldflags / print-target-cflags / print-cxxflags / print-strip Print computed values
  1. Edit code in src/ or include/
  2. Run make dev - fast build at -O0 with auto-patching (or make -j for optimized build)
  3. Test with ./build/bin/helix-screen
  4. Screenshot with ./scripts/screenshot.sh (auto-opens on display 1)
  5. Commit with working incremental changes

For debugging build issues:

Terminal window
make clean
make V=1 # Verbose sequential build

Only use make clean && make when:

  • Switching branches with significant changes
  • Build artifacts are corrupted
  • Troubleshooting mysterious build errors

Avoid clean rebuilds for normal development (wastes time).

spdlog submodule: uses the fmt-11.2.0 branch. Initialize with git submodule update --init --recursive on a fresh clone.

Never:

  • Commit changes directly to submodules
  • Update submodule commits without testing
  • Modify submodule files without creating patches

Always:

  • Create patches for submodule changes
  • Document patches in patches/README.md
  • Test patch application on clean checkouts

Claude Code cloud sessions run on a fresh Ubuntu 24.04 VM per environment, and a cold one pays apt + submodule init + a full program and test build before it can do anything — on the order of hours. scripts/cloud/env-setup.sh and scripts/cloud/session-start.sh exist to make that a one-time cost per environment rather than a per-session one.

The setup script. The cloud platform runs scripts/cloud/env-setup.sh once, as root, before the repo is cloned — there is no checkout for it to operate on, only system and network paths — and then snapshots the whole filesystem as the starting point of every later session on that environment. It must exit 0 and finish in a few minutes, so every step is best-effort and logged to /var/log/helix-env-setup.log: apt-installing the native build’s dependencies (scripts/cloud/env-setup.sh#install_apt_packages names the single package list shared with CI), downloading and extracting a prebuilt ccache, seeding a full clone at /opt/helixscreen-seed for submodule alternates, and prebuilding a Python venv at /opt/helix-venv. /opt/helix-cloud-env/READY records what landed and gates everything downstream.

The snapshot’s lifetime. The platform reuses the snapshot until either ~7 days pass or the text pasted into the environment dialog changes. Pushing a new scripts/cloud/env-setup.sh to main does not refresh it on its own: the pasted three-liner re-fetches that file only when the snapshot is being rebuilt. To force a rebuild before the timer would, change the dialog text — a dated comment line there is a legitimate one-line edit purely for that.

The session hook. scripts/cloud/session-start.sh runs as a SessionStart hook (.claude/settings.json#SessionStart) in every session, cloud or not. On a machine that never wrote the READY marker — a laptop, thelio — it exits silently on its first line, by design: the pieces below must never run uninvited. On a warmed cloud VM, once the repo exists, it wires /opt/helixscreen-seed’s objects in as a submodule alternate (so git submodule update borrows objects instead of fetching them), runs that submodule init, and symlinks .venv to the prebuilt one before reconciling it with make venv-setup.

Nothing prefetches a ccache, and the measurement is why. Measured on a cloud box: of the calls a first build made against a published ccache snapshot, 18.75% hit; of the 955 compilations in a rebuild after 191 files moved under a new namespace, none did. The percentage a session reads from ccache -s at startup is the snapshot’s own banked history — it never grows from that number, it only dilutes as the box builds. A full make test took about 85 minutes with 98% on screen.

ccache itself is still installed and configured, and earns its keep within a session. What does not pay for itself is shipping a prebuilt one between machines.

A build-cache workflow used to publish ccache-linux-x64.tar.zst as a release asset on a build-cache tag. It is gone, and the bar for bringing anything like it back is high, because the costs are easy to miss and the benefit was measured at the numbers above:

  • No consumer. env-setup.sh never fetched it. It ran on every push to main plus daily, 12-28 minutes warm and 117 cold, to produce something only a human could use by hand.
  • A silent size ceiling. A GitHub release asset must be under 2 GiB. The snapshot grew past it and the upload began failing with a bare HTTP 422: size must be less than 2147483648, several steps after the cause. It stayed broken for weeks without anyone noticing, precisely because nothing downstream depended on it.
  • A cache pinned at its own ceiling is a decaying asset. Capping max_size under the 2 GiB limit keeps it publishable but makes it evict internally on every run, so the hit rate that was already 18.75% only falls further as the tree grows.

If someone wants to revisit this, the case has to start with a measurement on the target machine showing a hit rate that survives a refactor - not with a faster transport or a bigger size limit. R2 would sidestep the 2 GiB cap (credentials already exist for releases), so “we ran out of room” is not by itself a reason to rebuild this.

ccache-warm.yml and cache-prune.yml are a different pipeline and are unaffected: they warm and prune the Actions-cache ccache behind this repo’s cross-compile CI, restored from the previous run on the same branch rather than from a snapshot, so the staleness above does not apply to them.

The compiler_check = content setting stays in the generated ccache.conf: a cached object is reused only when the compiler that made it is byte-identical to the one asking.

Pasting the setup script into the environment dialog. The platform wants the script inline, not a path, so the pasted script re-fetches the real one and never blocks environment creation on a network hiccup:

#!/bin/bash
curl -fsSL https://raw.githubusercontent.com/prestonbrown/helixscreen/main/scripts/cloud/env-setup.sh -o /tmp/helix-env-setup.sh || exit 0
bash /tmp/helix-env-setup.sh || true

Known cache invalidation. Makefile#VERSION_DEFINES puts -DHELIX_VERSION on every translation unit’s command line, and ccache’s direct mode hashes the full command line — so a VERSION.txt bump misses the entire project’s cache exactly once, the same way it does for regular CI (see the comment above VERSION_DEFINES). HELIX_GIT_HASH deliberately avoids this by reaching only one generated header instead of every TU.

A coordinator session spawns worker sessions, hands each a scope, and merges their branches. What follows is the part of that protocol which is a property of this repo rather than of any one coordinator’s plan.

Give a worker a queue, not a ticket. On a 4-core cloud box the program binary takes about 44 minutes and the test binary another 55 before a worker can run anything — an hour and a half of machine time that a one-issue scope pays in full and then throws away. Nothing removes that cost: a prebuilt ccache measured 18.75% of calls hitting on a first build and 0.00% after a wide header moved, so a session that starts by building pays roughly the same either way (see the warm environment section). What a queue saves is the second cost, which is real: a worker that has already read a subsystem answers the next question in it far faster than a fresh session does.

So scope a worker to an area with two to four related issues in dependency order, and say which may be dropped if time runs short. Sequence anything touching the same files behind the change that moves them, and keep shared counters — the ratchet baselines in the gate files under scripts/qc/ — to one worker at a time, since two workers each ratcheting the same number is a guaranteed merge conflict over a line neither of them cares about.

A small finding in the diff’s own neighbourhood is fixed, not filed. Workers surface more than they were sent for, and an issue is the reflex — but an issue costs triage, a milestone, a label, a brief and a box, which for a twenty-minute refactor is more than the fix. If the finding is small and sits in code the worker has already read, tell it to fix it in the same branch and say so in the report. File one only when the work genuinely does not belong to that worker: it needs a decision somebody else owns, it is blocked on something external, it is large enough to want its own scope, or it lands in files another worker is holding. A queue that closes four issues and opens three has not moved as far as the count suggests.

Never leave a worker blocked on the coordinator. A worker waiting for a merge to appear on main is an idle box. When a scope depends on work still in review, say so in the brief, name what it should do meanwhile, and send the unblock as soon as it lands.

The reply channel is git. Cross-session chat does not resolve from a worker container, so a worker reports by pushing a short status file early and a full report at the end to a throwaway claude/report-<issue> branch, never onto its work branch and never as a PR. The push triggers in .github/workflows/build.yml and quality.yml exclude that branch pattern, so status pushes cost no CI.

Tell a worker the formatter rule explicitly. scripts/quality-checks.sh accepts only the pinned clang-format from .venv, so a worker runs make venv-setup before make quality and formats only the files its own diff touched. The sweep’s --auto-fix reformats every file in CLANG_FORMAT_BASELINE, which belong to whoever is retiring them, not to the worker.

A push to a work branch costs a full CI run. Build, Code Quality and XML Lint all fire on the claude/** namespace, and Build alone budgets 200 minutes. Push when the gates are green locally, never to find out whether they are — a red run on a work branch is a signal the worker skipped a check it could have run itself, and it queues behind everyone else’s work. A coordinator asking for an early push to review in parallel is accepting that cost deliberately; a worker iterating against CI is not.

One OPT flavor per tree. The pre-commit hook builds at the default optimization level, so a worker that builds with OPT=0 makes every later hook run rewrite the objects it just wrote. Default everywhere, and never two make invocations in one tree at once.