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 Max)
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 Max)
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, K2 Max) — Tested on K2 Max

Section titled “Creality K2 Series (K2, K2 Pro, K2 Plus, K2 Max) — Tested on K2 Max”
  • CPU: Allwinner sun8iw20p1 (ARM Cortex-A7, dual-core, 57 BogoMIPS)
  • Toolchain: Bootlin armv7-eabihf-musl (GCC 12, musl libc)
  • Display: 480x1600 fbdev on K2 Max (480x800 on other K2 models)
  • 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)

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. Symlinks compiled librarieslibhv.a, libwpa_client.a from main tree
  3. Symlinks precompiled headerlvgl_pch.h.gch (22MB saved)
  4. Symlinks toolsnode_modules/, .venv/
  5. Clones build objects — copies build/obj/ 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 (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

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

Why worktree builds are fast (and the ccache config the script sets)

Section titled “Why worktree builds are fast (and the ccache config the script sets)”

A fresh git worktree add stamps every source file with the current mtime, so make sees all sources as newer than the cloned objects and wants to recompile the whole tree. The cloned build/obj/ therefore does not, by itself, save you on Linux — the real speedup comes from ccache: those “recompiles” become near-instant cache hits instead of cold compiles.

But ccache only helps across worktrees if it’s 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
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) and CCACHE_NOHASHDIR=1.

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)
HELIX_HOT_RELOAD=1 ./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: ~4,300 lines split across 14 files 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~630Configuration, variables, platform detection, module includes
mk/tests.mk~880All test targets (unit, integration, by-feature)
mk/cross.mk~750Cross-compilation, toolchain setup, display backends
mk/deps.mk~500Dependency checking, installation, libhv/wpa_supplicant
mk/rules.mk~340Compilation rules, linking, main build targets
mk/remote.mk~280Remote deployment (Pi, AD5M)
mk/images.mk~200Image conversion (PNG, SVG)
mk/patches.mk~130LVGL patch application
mk/fonts.mk~120Font/icon generation, Material icons
mk/watchdog.mk~120Hardware watchdog support
mk/format.mk~110Code and XML formatting
mk/splash.mk~110Splash screen generation
mk/tools.mk~110Development tool targets
mk/display-lib.mk~60Display library configuration
mk/pi-dual-link.mk~200Pi dual-link build (compile once, link DRM + fbdev)

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:
    Terminal window
    cd lib/lvgl
    git diff > ../../patches/my-new-patch.patch
  3. Update Makefile to apply the patch in the apply-patches target
  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 --panel home

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-font-24
# Generate all fonts
npm run convert-all-fonts

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), HELIX_HOT_RELOAD=1 … (live XML reload).

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
./build/bin/helix-tests "[tag]"Run a specific tag (e.g. [ams], [gcode])
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 from codepoints.h
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/ contains the extracted XML engine (originally from LVGL 9.4, MIT licensed). This is not a submodule — it lives directly in the repository with XML patches baked in permanently.

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

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