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 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!

TargetCommandArchitectureDisplayOutput Directory
Raspberry Pi (64-bit)make pi-dockeraarch64 (ARM64)DRM/fbdevbuild/pi/
Raspberry Pi (32-bit)make pi32-dockerarmv7-a (armhf)DRM/fbdevbuild/pi32/
Adventurer 5Mmake ad5m-dockerarmv7-a (hard-float)fbdevbuild/ad5m/
Centauri Carbon 1make cc1-dockerarmv7-a (hard-float)fbdevbuild/cc1/
Creality K1make mips-dockerMIPS32r2 (musl)fbdevbuild/mips/
FlashForge AD5Xmake ad5x-dockerMIPS32r5 (glibc)fbdevbuild/ad5x/
Creality K1 (dynamic)make k1-dynamic-dockerMIPS32r2 (glibc)fbdevbuild/k1-dynamic/
Creality K2make k2-dockerarmv7-a (musl)fbdevbuild/k2/
Native (SDL)makeHost architectureSDL2build/
  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.

  4. 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.

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::filesystemstd::filesystem)

Gotchas to avoid:

FeatureGCC 7 StatusWorkaroundExample
std::from_chars (integers)Not availableUse std::strtol / std::strtodsrc/util/version.cpp
std::atomic<time_point>Doesn’t compileStore as std::atomic<int64_t> (nanoseconds)include/gcode_streaming_controller.h
C++20 designated initializersNot supported ({.foo = 1})Initialize struct explicitly, then assign fieldssrc/ui/ui_fan_control_overlay.cpp
directory_entry member functions.is_regular_file(), .file_size(), .last_write_time() missingUse free functions: std::filesystem::is_regular_file(entry.path())src/print/thumbnail_cache.cpp, src/plugin/plugin_manager.cpp
-lstdc++fsRequired 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.soDisabled for k1-dynamic; uses plain ar/ranlib instead of gcc-ar/gcc-ranlibmk/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 thelio.local, 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:

PlatformDefault BackendView Logs
Linux + systemdjournaljournalctl -t helix -f
Linux (no systemd)syslogtail -f /var/log/syslog | grep helix
File fallbackrotating filetail -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:

BackendDefineLibrariesUse Case
SDLHELIX_DISPLAY_SDLSDL2Desktop development
DRMHELIX_DISPLAY_DRMlibdrm, libinputPi with KMS
fbdevHELIX_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 (GPU-accelerated) 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 -DHELIX_ENABLE_OPENGLES). 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 -lgbmbuild/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.
FilePurpose
mk/pi-dual-link.mkFbdev display lib, crash reporter variant, fbdev link rule, verify/strip targets
mk/cross.mkpi-both / pi32-both platform target definitions
mk/rules.mkConditional 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
./scripts/setup-worktree.sh feature/foo /tmp/helixscreen-foo
# 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

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

  1. Symlinks lib/ — all submodules symlinked (no clone/configure time)
  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 librarieslibhv.a, libwpa_client.a from main tree
  4. Clones the precompiled headerlvgl_pch.h.gch (27MB)
  5. Symlinks toolsnode_modules/, .venv/
  6. Clones build objects — copies build/obj/ and build/generated/ from the main tree (APFS clonefile on macOS; plain copy on Linux)
  7. Configures ccache for cross-worktree reuse — so the worktree builds against the same ccache the main tree populated, when ccache is installed (see below)
  8. Validates architecture — wrong-arch .o/.a files (left by a prior cross-compile) are detected and cleared so make rebuilds them correctly
  9. Configures git.git/info/exclude + --skip-worktree keep git status clean despite the symlinks

Trade-off: If you need to modify library code (lib/), un-symlink that specific directory first (rm lib/<name> && cp -a $MAIN/lib/<name> lib/).

Build outputs are cloned, never symlinked. libhv.a and lvgl_pch.h.gch used to be symlinks into the main tree. They are build outputs, so make rewrites them — and both cp (the tail of make libhv-build) and clang -o follow a symlink, writing straight into the main tree. Measured: one fresh-worktree build moved the main tree’s libhv.a mtime forward five days, which left the main tree’s own PCH older than it and put ~1900 objects back on the main tree’s 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. It bites twice over:

  • $(PCH) lists include/lvgl_pch.h and lv_conf.h as prerequisites, and every C++ object lists $(PCH) — one fresh header invalidates all ~1970 objects;
  • the .d files list include/*.h per object, and those are fresh too — so fixing only the PCH would still leave every object out of date.

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 the PCH, 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:

ScenarioObjects rebuilt
Worktree at the same commit, clean0 (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 setup1
lv_conf.h editedPCH + 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 why. lib/ is symlinked from the main tree, so the libhv submodule is shared by every tree. Rebuilding libhv anywhere regenerates lib/libhv/include/hv/json.hpp, which is a $(PCH) prerequisite — so the PCH, and with it all ~1970 objects, goes out of date in the main tree and in every worktree at once. The mtime sync cannot repair it: the script deliberately never follows the lib/ symlinks, and git ls-files does not reach inside a submodule. Observed during this work: a worktree that should have built in 5s took 462s (680 objects + PCH) purely because an unrelated tree had rebuilt libhv twenty minutes earlier. The fix is to let one tree rebuild once — after that every tree is fast again. Un-sharing lib/ would remove the coupling entirely, but that is the core of the current worktree design.

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 the shared PCH (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 $(PCH), and ccache refuses to cache any compilation that uses a precompiled header 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 settingSet toWhy
base_dir$HOMERewrites absolute paths under $HOME to relative before hashing, so main-tree and worktree paths collapse to the same key
hash_dirfalseStops folding the cwd (the -g debug-path component) into the key
sloppinesspch_defines,time_macrosWithout it ccache refuses to cache any -include $(PCH) compile — i.e. all of them. See the box above for the __DATE__ tradeoff
max_size25G (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
# Remove a worktree
git worktree remove .worktrees/my-feature
# Or force remove if dirty
git worktree remove --force .worktrees/my-feature

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

The build system is organized into focused modules:

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

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.

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: auto-detects CPU cores)
  • NO_COLOR=1 - Disable colored output (useful for CI/CD)
  • -j<N> - Enable parallel builds with N jobs (NOT auto-enabled by default)

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. Checks staged files for formatting issues
  2. Reports files that need formatting
  3. Prevents commit if formatting issues are found
  4. Suggests fix: Run make format-staged or clang-format -i <file>

To bypass (not recommended):

Terminal window
git commit --no-verify

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 both:

  • Pre-commit hook (staged files only)
  • 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 Makefile):

apply-patches:
@echo "Checking LVGL patches..."
@if git -C $(LVGL_DIR) diff --quiet src/drivers/sdl/lv_sdl_window.c; then \
# File is clean, apply patch
git -C $(LVGL_DIR) apply ../patches/lvgl_sdl_window_position.patch
else \
# File already modified (patch applied)
echo "✓ LVGL SDL window position patch already applied"
fi

Status Messages:

  • ✓ Patch applied successfully - Patch was applied during this build
  • ✓ LVGL SDL window position patch already applied - Patch was already present
  • ⚠ Cannot apply patch (already applied or conflicts) - Manual intervention needed

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. Use git -C $(LVGL_DIR) apply --check <patch> as the apply condition rather than a “is file X dirty?” test, which breaks as soon as another patch touches X.
  4. Document in patches/README.md

Two traps cost a full debugging session on 2026-06-14 when the libhv DNS resolver fallback patch silently stopped reaching the binary on the AD5M (on-machine update checks failed with “Connection failed”). Both are now 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. The old guard used [ ! -f base/dns_resolv.c ]; a submodule reset reverted the tracked base/hsocket.c (the wiring) but left the untracked dns_resolv.c orphaned, so the guard declared “already applied” and never re-wired hsocket.c. Result: resolver compiled but never called. Guard on a marker string inside the edited file and self-heal:

    if ! grep -q "dns_resolv_resolve" "$(LIBHV_DIR)/base/hsocket.c"; then
    rm -f .../base/dns_resolv.c .../base/dns_resolv.h; # drop orphans
    git -C $(LIBHV_DIR) checkout -- base/hsocket.c; # pristine
    git -C $(LIBHV_DIR) apply .../libhv-dns-resolver-fallback.patch
    fi
  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 getaddrinfoEAI_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>.

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.

Important: Parallel builds are NOT enabled by default. Use -j flag explicitly.

UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Darwin)
# macOS
NPROC := $(shell sysctl -n hw.ncpu 2>/dev/null || echo 4)
PLATFORM := macOS
else
# Linux
NPROC := $(shell nproc 2>/dev/null || echo 4)
PLATFORM := Linux
endif
Terminal window
make -j # Auto-detect CPU cores and parallelize (recommended)
make -j16 # Explicit job count (current system has 16 cores)
make JOBS=16 # Set job count via variable
make build # Clean parallel build (auto-detects cores)

The build system uses --output-sync=target to prevent interleaved output during parallel builds.

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/images/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 iconutilhelix-icon.icns
  2. Cleans up temporary iconset directory

All platforms:

  • assets/images/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/images/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):

CommandShows
make helpThe common build/dependency/quality targets
make help-buildBuild, dependency, and patch targets
make help-testTest targets and test discovery
make help-crossCross-compilation + per-device deployment targets and options
make help-remoteRemote build system (build on a fast host, fetch binaries)
make help-images / help-splash / help-watchdogAsset/splash/watchdog targets
make help-allEverything, all topics combined
make cross-infoCurrent cross-compile configuration (platform, backend)
TargetWhat it does
make -j (all)Build the main binary (default), -O2, auto-parallel
make devFast build at -O0 (~2× faster compile; larger/slower binary)
make OPT=1 -j-O1 middle ground
make buildClean build with progress + timing
make runBuild and run the UI
make cleanRemove build artifacts (keeps deps)
make distcleanDeep 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:

TargetWhat it does
make testBuild tests (does not run them)
make test-runBuild and run tests in parallel (recommended, ~4–8× faster)
make test-serialRun sequentially (debugging thread issues)
make test-smokeQuick smoke subset (~30s) for rapid iteration
make test-allAll tests incl. [slow]
make test-asan / test-tsanRun under Address/Thread sanitizer
make test-list-tagsList available tags
make test-xmlConfigure, 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”.

TargetWhat it does
make formatclang-format all C/C++ + xmllint XML
make format-stagedFormat only staged files (pre-commit)
make qualityAll quality checks (formatting, headers, conflicts)
make setup-hooksEnable the git pre-commit hook
make compile_commandsMerge compile_commands.json for clangd (~1–2s)
make compile_commands_fullFull regen via bear/compiledb (slow; use if fragments corrupt)
TargetWhat it does
make check-depsVerify build dependencies (see below)
make install-depsInteractively install missing deps
make venv-setupCreate .venv with Python asset/telemetry deps
make libs-cleanClean all built library artifacts
make apply-patchesApply LVGL/libhv patches (idempotent; auto-run before builds)
make reapply-patchesForce re-apply (repair manually-edited patched files)

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

TargetWhat it does
make regen-fontsRegenerate MDI icon fonts via scripts/regen_mdi_fonts.sh
make regen-text-fontsRegenerate Noto Sans text fonts (incl. CJK)
make regen-icon-constsRegenerate icon string constants in globals.xml
make validate-fontsVerify every codepoint is present in the compiled fonts
make regen-imagesRegenerate pre-rendered splash images (all sizes)
make gen-printer-imagesPre-render printer DB images
make translationsRegenerate translation tables from YAML
make translation-coverageShow 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: make remote-pi / remote-ad5m / remote-native build on a fast Linux host (REMOTE_HOST, default thelio.local) and fetch the binaries back. make remote-status checks readiness.
TargetWhat it does
make demoBuild LVGL demo widgets (LVGL API testing)
make symbolsExtract .sym + .debug (crash-backtrace resolution)
make stripStrip the binary for release
make screenshotsGenerate documentation screenshots
make print-cxxflags / print-ldflagsDump 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 LVGL_XML_SITUATION.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: ⚠ Cannot apply patch (already applied or conflicts)

Causes:

  1. Submodule was manually modified (expected if patch is working)
  2. Patch conflicts with newer LVGL version
  3. Patch file is corrupted

Solutions:

Terminal window
# Check if file is modified (expected)
git -C lvgl diff src/drivers/sdl/lv_sdl_window.c
# Revert to original (re-applies patch on next build)
git -C lvgl checkout src/drivers/sdl/lv_sdl_window.c
make apply-patches
# Force re-apply
git -C lvgl checkout src/drivers/sdl/lv_sdl_window.c
git -C lvgl apply ../patches/lvgl_sdl_window_position.patch

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):

MethodWall timeNotes
make -j (cold, no ccache)~4.5 minBaseline
make dev (cold, no ccache)~2.5 min-O0 skips optimizer passes
make -j (ccache populated)~38 sec98% cache hit rate
make dev (ccache populated)~20 sec-O0 + ccache
Touch one .cpp, rebuild~7 secRecompile + relink
Touch widely-included .h~8 secccache 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:

HeaderFiles 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).

Precompiled header (include/lvgl_pch.h): Covers LVGL, helix-xml, spdlog, nlohmann JSON, and common STL headers. These are precompiled once and reused across all translation units. Don’t add project headers to the PCH — only stable external libraries.

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>.

Concurrent Docker cross-builds are serialized by scripts/cross-compile-lock.sh to avoid thrashing the machine — this is automatic.

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.

VariableDefaultNotes
CCclang > gccAuto-detected, with a stdlib test for clang
CXXclang++ > g++Auto-falls back on a broken libstdc++
OPT2Optimization level: 0 (dev), 1, 2 (release)
CXXFLAGS-std=c++17 -Wall -Wextra -O{OPT} -gExtended per platform
SUBMODULE_CFLAGS-std=c11 -O2 -g -D_GNU_SOURCE -wThird-party code, warnings suppressed
DEPFLAGS-MMD -MPHeader dependency tracking

Read from VERSION.txt (MAJOR.MINOR.PATCH) and injected as -DHELIX_VERSION="...": HELIX_VERSION, HELIX_VERSION_MAJOR / _MINOR / _PATCH.

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.

VariableDefaultPer-platform override
BUILD_DIRbuildmk/cross.mk sets per platform
BIN_DIRbuild/bine.g. build/pi/bin
OBJ_DIRbuild/obje.g. build/pi/obj
BUILD_SUBDIR(none)Platform name: pi, ad5m, k1
VariablePurpose
TARGET_ARCHTarget architecture for the active cross target
TARGET_TRIPLEToolchain triple (e.g. aarch64-linux-gnu)
STRIP_BINARYWhether to strip the output binary for size
FONT_TIERSWhich 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:107) or any subset of micro tiny small medium large xlarge xxlarge. Assignments live per target in mk/cross.mk:

PLATFORM_TARGETTiers
pi, pi-fbdev, pi-both, pi32, pi32-fbdev, pi32-bothall
x86, x86-fbdev, x86-both, nativeall
ad5m, ad5m-br, ad5xmedium large
mips / k1, k1-dynamicsmall medium
k2large xlarge
snapmaker-u1tiny small
cc1, yoctomicro tiny

HELIX_MAX_FONT_TIER is derived from this (mk/cross.mk:728-750; micro=0xxlarge=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.

VariableDefaultPurpose
ENABLE_SDLyes (native)SDL2 desktop display
ENABLE_OPENGLESper targetEGL/GLES GPU rendering
ENABLE_GLES_3Dyes (Linux)3D gcode rendering
ENABLE_SCREENSAVERyes (desktop/Pi)Flying toasters
ENABLE_MOCKSyesMock backends for development
ENABLE_SSLper targetOpenSSL for HTTPS/WSS
HELIX_HAS_LABEL_PRINTER1Label printer feature
HELIX_HAS_CFS1CFS feature
HELIX_HAS_IFS1IFS feature
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
allDefault: main binary with dependency checks
buildClean parallel build with timing
devOPT=0 -j fast development build
strictBuild with -Werror
cleanRemove all build artifacts
installStage to DESTDIR/opt/helixscreen/
Code quality
format / format-stagedclang-format + xmllint, all or staged files
qualityRun scripts/quality-checks.sh
check-deps / install-depsVerify, then interactively install dependencies
Assets
regen-fonts / generate-fontsRegenerate MDI icon fonts
validate-fontsVerify icons are present in the compiled fonts
iconGenerate the app icon from the logo
apply-patches / reset-patchesApply or revert submodule patches
Tools and debug
compile_commands / compile_commands_fullMerge .ccj fragments (~1-2s) or fully regenerate
moonraker-inspector / toolsDiagnostic tools
symbols / stripExtract .sym + .debug, or strip for size (cross-compile only)
print-ldflags / print-target-cflags / print-cxxflags / print-stripPrint 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