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.
Cross-Compilation (Embedded Targets)
Section titled “Cross-Compilation (Embedded Targets)”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.
Quick Start
Section titled “Quick Start”# Build for Raspberry Pi 64-bit (aarch64/ARM64)make pi-docker
# Build for Raspberry Pi 32-bit (armhf/armv7l)make pi32-docker
# Build for Flashforge Adventurer 5M (armv7-a/ARM32)make ad5m-docker
# Build for Elegoo Centauri Carbon 1 (armv7-a/ARM32)make cc1-docker
# Build for Creality K1 (MIPS32, static/musl)make mips-docker # Or: make k1-docker (alias)
# Build for FlashForge AD5X (MIPS32r5, glibc)make ad5x-docker
# Build for Creality K1 series (MIPS32, dynamic/glibc)make k1-dynamic-docker
# Build for FlashForge Creator 5 Pro (MIPS32r2, static/musl, shares the K1 image)make creator5-docker
# Build for Creality K2 series (ARM, tested on K2 Plus)make k2-docker
# Verify the binariesfile build/pi/bin/helix-screen # ELF 64-bit LSB, ARM aarch64file build/pi32/bin/helix-screen # ELF 32-bit LSB, ARM, EABI5file build/ad5m/bin/helix-screen # ELF 32-bit LSB, ARM, EABI5file build/cc1/bin/helix-screen # ELF 32-bit LSB, ARM, EABI5file 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, EABI5Docker images are automatically built on first use - no manual setup required!
Supported Targets
Section titled “Supported Targets”| Target | Command | Architecture | Display | Output Directory |
|---|---|---|---|---|
| Raspberry Pi (64-bit) | make pi-docker |
aarch64 (ARM64) | DRM/fbdev | build/pi/ |
| Raspberry Pi (32-bit) | make pi32-docker |
armv7-a (armhf) | DRM/fbdev | build/pi32/ |
| Adventurer 5M | make ad5m-docker |
armv7-a (hard-float) | fbdev | build/ad5m/ |
| Centauri Carbon 1 | make cc1-docker |
armv7-a (hard-float) | fbdev | build/cc1/ |
| Creality K1 | make mips-docker |
MIPS32r2 (musl) | fbdev | build/mips/ |
| FlashForge AD5X | make ad5x-docker |
MIPS32r5 (glibc) | fbdev | build/ad5x/ |
| Creality K1 (dynamic) | make k1-dynamic-docker |
MIPS32r2 (glibc) | fbdev | build/k1-dynamic/ |
| Creality K2 | make k2-docker |
armv7-a (musl) | fbdev | build/k2/ |
| Native (SDL) | make |
Host architecture | SDL2 | build/ |
How It Works
Section titled “How It Works”-
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. -
Auto-Build Images: When you run
make pi-docker,make pi32-docker, ormake 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
-
Volume Mounting: Your source code is mounted into the container, so compiled binaries appear directly in your
build/directory.The mount is
-v "$(CURDIR)":/src, and it must stay$(CURDIR)— never$(PWD).$(PWD)is inherited from the invoking shell;$(CURDIR)is make’s own working directory and is the one that follows-C. They agree for a plainmake, so the difference is invisible until someone runs a cross build with-C:Terminal window cd /anywheremake -C .worktrees/my-branch snapmaker-u1-docker # with $(PWD): mounts /anywhereWith
$(PWD)that bind-mounted whatever directory the shell happened to be in, compiled that tree, and left the worktree’s artifact untouched — while exiting 0 and printing “✓ Build complete!”. The binary you then deployed was built from the wrong commit, and nothing in the log said so; the failure mode is a stale artifact, so neither mtime nor size changes to give it away.tests/shell/test_build_provenance.batsnow fails the build if anydocker runmounts$(PWD).Everything else the container needs from the host rides on
$(DOCKER_HOST_CONTEXT)(mk/cross.mk), which everydocker runthat mounts the tree must pass —tests/shell/test_build_provenance.batsfails the build if one does not. It carries two things, both of which exist because a git worktree is not self-contained:DOCKER_WORKTREE_MOUNT—scripts/setup-worktree.shsymlinks the sharedlib/<submodule>entries to the main checkout by absolute path, so those links dangle inside a container that mounts only$(CURDIR). The real submodule tree is bind-mounted at its own absolute path so everylib/*link resolves identically inside and out. The detection asks everylib/entry where it really lives rather than probing one of them:lib/lvglis a private checkout under$(CURDIR), so a single-entry probe would read a worktree full of symlinks as a normal checkout and mount nothing.DOCKER_GIT_HASH_ENV— a worktree’s.gitis a file readinggitdir: $(MAIN)/.git/worktrees/<name>, a path outside the mount, so git cannot resolveHEADin the container andscripts/gen-git-hash.shused to stampHELIX_GIT_HASH "unknown"— silently, since the build still succeeded, leaving any evidence gathered on a printer unattributable. The hash is resolved on the host and passed in asHELIX_GIT_HASH, which the script prefers over its own lookup.
-
Build Features Stamp: The link rule writes
build/<platform>/bin/.build-featuresrecording which optional subsystems the binary actually contains (currentlyremote_controlanddiag_uploads).make deploy-*runs as a separate make invocation with noPLATFORM_TARGETand so cannot re-derive what the cross build chose; the stamp carries it across, and the deploy turns on the matching runtime switch on the device. Seedocs/devel/HELIXCTL.mdanddocs/devel/ENVIRONMENT_VARIABLES.md§HELIX_DIAGNOSTIC_UPLOADSfor the upload gate, which rides the same mechanism:ENABLE_DIAGNOSTIC_UPLOADSdefaults toyesonly underHELIX_PACKAGING=1, andsync-device-featuresstampsHELIX_DIAGNOSTIC_UPLOADS=1on rigs at deploy time so dev devices keep uploading. -
Display Backend Selection: Cross-compilation automatically selects the appropriate display backend:
- Pi / Pi32: DRM (preferred) with fbdev fallback
- AD5M / CC1: fbdev (framebuffer)
Build Targets
Section titled “Build Targets”# 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 Dockermake cc1-docker # Centauri Carbon 1 via Dockermake 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)
# Informationmake cross-info # Show cross-compilation helpTarget Specifications
Section titled “Target Specifications”Raspberry Pi 64-bit (Mainsail OS)
Section titled “Raspberry Pi 64-bit (Mainsail OS)”- 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)
Raspberry Pi 32-bit (Mainsail OS)
Section titled “Raspberry Pi 32-bit (Mainsail OS)”- 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
Flashforge Adventurer 5M
Section titled “Flashforge Adventurer 5M”- 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)
Elegoo Centauri Carbon 1
Section titled “Elegoo Centauri Carbon 1”- 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.
FlashForge Creator 5 Pro
Section titled “FlashForge Creator 5 Pro”- CPU: Ingenic X2000 (XBurst2, MIPS32r2 dual-core @ 1.2 GHz)
- Build: the unified
mipstarget (make creator5/make creator5-docker) — same binary and toolchain as the K1/AD5X (mipsel-k1-linux-musl-, GCC 13 + musl, NaN2008/FP64, fully static, LTO on) - Display: 480×800 portrait framebuffer (
/dev/fb0); the creator5 preset rotates 90 - Input: evdev for touch (
/dev/input/event2) - NaN encoding: the Creator 5 Pro kernel refuses legacy-NaN executables with
ENOEXEC; the unified target’s NaN2008 output is the encoding it execs - See
docs/devel/printers/FLASHFORGE_CREATOR5_PRO_SUPPORT.md
Creality K2 Series (K2, K2 Pro, K2 Plus) — Tested on K2 Plus
Section titled “Creality K2 Series (K2, K2 Pro, K2 Plus) — Tested on K2 Plus”- CPU: Allwinner sun8iw20p1 (ARM Cortex-A7, dual-core, 57 BogoMIPS)
- Toolchain: Bootlin
armv7-eabihf-musl(GCC 12, musl libc) - Display: 480x800 fbdev (all K2 models; framebuffer is double-buffered → 480x1600 virtual)
- Input: evdev for touch
- C Library: musl (static linking)
- RAM: ~488 MB
- Moonraker: Port 7125 (direct), port 4408 (nginx proxy)
- Docker Image:
helixscreen/toolchain-k2(Debian Bookworm) - OS: OpenWrt 21.02-SNAPSHOT, Linux 5.4.61 armv7l, procd init (NOT systemd)
- See
docs/devel/printers/CREALITY_K2_SUPPORT.mdfor full hardware details.
Dockerfile Architecture
Section titled “Dockerfile Architecture”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 buildThe Dockerfiles handle:
- Cross-compiler installation (
crossbuild-essential-*) - Target architecture libraries (
:arm64/:armhfpackages) - SSL/crypto libraries for Moonraker WebSocket
- Environment variables for cross-compilation
Build System Integration
Section titled “Build System Integration”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 configurationCROSS_COMPILE := arm-linux-gnueabihf- # For AD5MCC := $(CROSS_COMPILE)gccCXX := $(CROSS_COMPILE)g++
# Target-specific flagsTARGET_CFLAGS := -march=armv7-a -mfpu=neon-vfpv4 -mfloat-abi=hardTARGET_LDFLAGS := -lstdc++fs # GCC 8 requires this for std::filesystem
# Display backend selectionDISPLAY_BACKEND := fbdev # or drm, sdlGCC 7.5 Compatibility (K1 Dynamic Target)
Section titled “GCC 7.5 Compatibility (K1 Dynamic Target)”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 atinclude/compat/filesystem(aliasesstd::experimental::filesystem→std::filesystem)
Gotchas to avoid:
| Feature | GCC 7 Status | Workaround | Example |
|---|---|---|---|
std::from_chars (integers) |
Not available | Use std::strtol / std::strtod |
src/util/version.cpp |
std::atomic<time_point> |
Doesn’t compile | Store as std::atomic<int64_t> (nanoseconds) |
include/gcode_streaming_controller.h |
| C++20 designated initializers | Not supported ({.foo = 1}) |
Initialize struct explicitly, then assign fields | src/ui/ui_fan_control_overlay.cpp |
directory_entry member functions |
.is_regular_file(), .file_size(), .last_write_time() missing |
Use free functions: std::filesystem::is_regular_file(entry.path()) |
src/print/thumbnail_cache.cpp |
-lstdc++fs |
Required for <experimental/filesystem> |
Added automatically for k1-dynamic in mk/cross.mk and mk/watchdog.mk |
— |
LTO (-flto) |
GCC 7.5 static toolchain lacks liblto_plugin.so |
Disabled for k1-dynamic; uses plain ar/ranlib instead of gcc-ar/gcc-ranlib |
mk/cross.mk |
Filesystem compat shim (include/compat/filesystem):
- For GCC < 8: includes
<experimental/filesystem>and aliases it intostd::filesystem - For GCC 8+/Clang/MSVC: passes through to the real
<filesystem>via#include_next - Activated by
-isystem include/compatin 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.
Troubleshooting
Section titled “Troubleshooting”Docker not installed:
# macOS - Option 1: Docker Desktop (GUI)brew install --cask docker
# macOS - Option 2: Colima (lightweight, CLI-only, recommended)brew install colima dockercolima start --cpu 4 --memory 8 # Start VM with 4 cores, 8GB RAM
# Linuxsudo apt install docker.iosudo usermod -aG docker $USER # Logout/login after thisColima tips (macOS):
colima start # Start with defaultscolima start --cpu 4 --memory 8 # Custom resources (faster builds)colima stop # Stop VM when not neededcolima status # Check if runningDocker image build fails:
# Rebuild with no cachedocker 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:
rm -rf build/ad5m lib/wpa_supplicant/wpa_supplicant/*.amake ad5m-dockerstd::filesystem undefined references (AD5M only):
GCC 8 requires -lstdc++fs for std::filesystem. This is already configured in mk/cross.mk for AD5M target.
Deploying to Target
Section titled “Deploying to Target”Using make targets (recommended for Pi):
# Full cycle: build + deploy + run on Pimake pi-test
# Deploy only (after building)make deploy-pi # Deploy binaries + assets, restart in backgroundmake 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=piUsing make targets for AD5M:
# Full cycle: remote build on thelio + deploy + runmake ad5m-test
# Remote build only (builds on REMOTE_HOST, fetches binaries)make remote-ad5m
# Deploy only (after building)make deploy-ad5m # Deploy binaries + assets, restart in backgroundmake 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.67Note: The AD5M’s mDNS (ad5m.local) may not resolve reliably. Use the IP address directly:
# Find your AD5M's IP from your router or the printer's network settingsAD5M_HOST=192.168.1.67 make deploy-ad5mManual deployment:
# Raspberry Piscp 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/Logging on Target
Section titled “Logging on Target”HelixScreen automatically detects the best logging backend:
| Platform | Default Backend | View Logs |
|---|---|---|
| Linux + systemd | journal | journalctl -t helix -f |
| Linux (no systemd) | syslog | tail -f /var/log/syslog | grep helix |
| File fallback | rotating file | tail -f /var/log/helix-screen.log |
Override via CLI:
./helix-screen --log-dest=journal # Force systemd journal./helix-screen --log-dest=file --log-file=/tmp/debug.logsystemd service: The included config/helixscreen.service automatically logs to journal. View with:
sudo journalctl -u helixscreen -fDisplay Backend Selection
Section titled “Display Backend Selection”The build system automatically selects display backends:
| Backend | Define | Libraries | Use Case |
|---|---|---|---|
| SDL | HELIX_DISPLAY_SDL |
SDL2 | Desktop development |
| DRM | HELIX_DISPLAY_DRM |
libdrm, libinput | Pi with KMS |
| fbdev | HELIX_DISPLAY_FBDEV |
(none) | Embedded framebuffer |
Display backend is selected via DISPLAY_BACKEND in mk/cross.mk and controls:
- LVGL driver compilation (
lv_conf.hconditionals) - Display initialization in
display_backend.cpp - Input driver selection (SDL mouse, evdev touch, libinput)
Pi Dual-Link Build (Compile Once, Link Twice)
Section titled “Pi Dual-Link Build (Compile Once, Link Twice)”Pi release builds produce two binaries: DRM (vsynced page flips via dumb buffers) and fbdev (framebuffer fallback). Instead of compiling all ~900 source files twice, the dual-link build compiles everything once with DRM superset defines, then links two binaries with different display libraries and link flags.
This cuts Pi CI build time roughly in half (~40 min instead of 80+).
# 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 onlymake PLATFORM_TARGET=pi-fbdev -j # fbdev onlyHow It Works
Section titled “How It Works”-
Compile phase: All source files compile once using DRM superset defines (
-DHELIX_DISPLAY_DRM -DHELIX_DISPLAY_FBDEV). Objects go tobuild/pi/obj/. -
Variant-specific compilation (only 4 files):
display_backend.cpp,display_backend_fbdev.cpp,touch_calibration.cpp→ compiled intobuild/pi/display-fbdev/without DRM defines, archived aslibhelix-display-fbdev.acrash_reporter.cpp→ compiled intobuild/pi/fbdev-variant/with-DHELIX_BINARY_VARIANT="fbdev"
-
DRM link: All objects + LVGL DRM drivers + OpenGLES objects +
-ldrm -linput -lEGL -lGLESv2 -lgbm→build/pi/bin/helix-screen -
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 -
Verification:
verify-fbdevautomatically checks the fbdev binary has no DRM/GLES undefined symbols.
Build Output Layout
Section titled “Build Output Layout”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)Important Caveats for Developers
Section titled “Important Caveats for Developers”#ifdef HELIX_DISPLAY_DRMin non-display files: The shared objects are compiled with DRM defines enabled. If you add#ifdef HELIX_DISPLAY_DRMto a non-display source file, that code path will execute in the fbdev binary too. Onlydisplay_backend*.cppis 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 toLVGL_DRM_DRIVER_OBJSinmk/pi-dual-link.mkto exclude it from the fbdev link. Theverify-fbdevtarget will catch this if you forget. - The
piandpi-bothtargets sharebuild/pi/: Switching between them doesn’t trigger a clean rebuild — the arch-change detection maps both to the same build directory.
| File | Purpose |
|---|---|
mk/pi-dual-link.mk |
Fbdev display lib, crash reporter variant, fbdev link rule, verify/strip targets |
mk/cross.mk |
pi-both / pi32-both platform target definitions |
mk/rules.mk |
Conditional all target (uses strip-both in dual-link mode) |
Git Worktrees
Section titled “Git Worktrees”Git worktrees allow parallel development on multiple branches without switching contexts. HelixScreen uses worktrees for feature development.
Creating a Worktree
Section titled “Creating a Worktree”Use setup-worktree.sh to create and configure worktrees with fast builds:
# Create worktree with new branch (one command does everything)./scripts/setup-worktree.sh feature/my-feature
# Creates at .worktrees/my-feature, builds automaticallycd .worktrees/my-feature./build/bin/helix-screen --test -vvScript Options
Section titled “Script Options”# Create at custom path — the second argument is a PATH, not a base branch./scripts/setup-worktree.sh feature/foo /tmp/helixscreen-foo
# Branch from something other than the current HEAD./scripts/setup-worktree.sh --base feature/parent feature/child
# Set up existing worktree without creating./scripts/setup-worktree.sh --setup-only feature/i18n
# Skip the initial build./scripts/setup-worktree.sh --no-build feature/quick-testA new branch is cut from the HEAD of whichever tree you run the script in,
which is not necessarily main — the script prints the resolved base commit so
a stale or unexpected one is visible immediately. Use --base <ref> to pick it
explicitly. Passing a branch name as the second argument is rejected, as is any
path inside the repo outside .worktrees/.
What setup-worktree.sh Does
Section titled “What setup-worktree.sh Does”The script optimizes for fast builds by sharing artifacts from the main tree:
-
Symlinks the shared lib/ submodules, copies the rewritten ones — the third-party submodules nothing edits are symlinked (no clone/configure time). The four in
LIB_PRIVATE_SUBMODULESget a private per-worktree checkout instead:lib/helix-xmlbecause it is ours and CLAUDE.md says to edit it directly rather than carry a patch, andlib/lvgl,lib/libhvandlib/luabecausepatches/rewrites them andpatches/is per-branch. Sharing one checkout across branches that disagree about either is unsatisfiable —make reapply-patchesin one tree redefines what every other tree compiles, and each tree’s correct action invalidates the other’s (prestonbrown/helixscreen#1471). Each private git dir lands under.git/worktrees/<name>/modules/,originstays the public GitHub remote, and because a real checkout is what git already expects they need no--unlink/--relink.The checkout is copied from the main tree, not cloned fresh: a fresh checkout writes fresh mtimes, which invalidates every cloned object built against those headers and turns a warm worktree cold.
cp -Rc(clonefile on APFS,--reflink=autoon btrfs/xfs, a plain copy elsewhere) keeps the mtimes; the git dir is agit clone --local, which hardlinks the object store, so no network and no second copy of ~500 MB of packs. The pin is then reconciled with agit checkoutof the commit this branch names, which rewrites only the files that actually differ. Setup ends by asserting everylib/submodule sits at this branch’s pin, and refuses to build when a private one does not. -
Adopts the main tree’s mtimes for every byte-identical file — without this, nothing below actually saves you anything (see next section)
-
Clones compiled libraries —
libhv.a,libwpa_client.afrom main tree -
Symlinks tools —
node_modules/,.venv/ -
Clones build objects — copies
build/obj/andbuild/generated/from the main tree (APFS clonefile on macOS; plain copy on Linux) -
Configures ccache for cross-worktree reuse — so the worktree builds against the same ccache the main tree populated, when ccache is installed (see below)
-
Validates architecture — wrong-arch
.o/.afiles (left by a prior cross-compile) are detected and cleared somakerebuilds them correctly -
Configures git —
.git/info/exclude+--skip-worktreekeepgit statusclean despite the symlinks.--skip-worktreecovers the symlinked submodules only: on a private checkout it would hide a real change of pinned revision fromgit status,git addand the revision check. -
Reconciles patches —
make reapply-patchesruns in the new worktree when this branch’spatches/differs from the main tree’s, or when a private submodule landed somewhere the main tree’s patches do not describe. Otherwise the copy already carries them, and reapplying is not free:build/.patches-appliedis a prerequisite of every object.
Trade-off: lib/lvgl, lib/libhv, lib/lua and lib/helix-xml are yours to modify in place. For any other lib/ entry, un-symlink that specific directory first (rm lib/<name> && cp -a $MAIN/lib/<name> lib/) or you are editing the main tree’s copy.
Build outputs are cloned, never symlinked.
libhv.ais a build output, so make rewrites it, andcp(the tail ofmake libhv-build) follows a symlink, writing straight into the main tree. A worktree build that moves the main tree’slibhv.amtime forward puts every main-tree object back on its next build. On APFScp -cis a clonefile, so a private copy costs nothing.
Why worktree builds are fast
Section titled “Why worktree builds are fast”A fresh git worktree add stamps every file with the checkout mtime, so make sees the whole
tree as newer than the cloned objects: the .d files list include/*.h per object, and every
C++ object force-includes include/lvgl_pch.h, so one fresh header invalidates all ~1970.
Measured on a fresh worktree of an up-to-date main tree: 1945 of 1967 cloned objects recompiled, 396s of wall clock, for a tree where nothing had changed.
setup-worktree.sh fixes this by walking the checkout and, for each file whose content is
byte-identical to the main tree’s file at the same path, adopting that file’s mtime
(scripts/sync-worktree-mtimes.py, ~1s for the 3900-file tree). The build markers
build/.patches-applied and .fonts.stamp get the same treatment — they are prerequisites of
every object, libhv, and all 46 font objects, so stamping them now was on its own worth ~560
recompiles and was what made every fresh worktree rebuild libhv over the main tree’s copy.
This is deliberately not blanket mtime back-dating, which is how you ship a stale build. The invariant is that for matching content the (source, object) mtime ordering in the worktree equals the ordering in the main tree, so make reaches the same up-to-date decision here that it reached there, against objects cloned from there. Anything that differs — a branch with real changes, an edit made after setup — keeps its fresh mtime and rebuilds normally:
| Scenario | Objects rebuilt |
|---|---|
| Worktree at the same commit, clean | 0 (make test -j = 3.2s, link only) |
| Worktree at an older commit (1 source differs) | 1 — and the binary carries the old code |
| Source edited after setup | 1 |
lv_conf.h edited |
~1888 |
What it inherits rather than fixes: if the main tree’s own build is stale, the worktree reproduces that staleness. The object clone always had that property.
If a fresh worktree still takes minutes, this is almost always inherited staleness. A worktree copies the main tree’s objects and mtimes, so it starts as up to date as the main tree is and no more. When the main tree’s
build/.patches-appliedis newer than its objects — which anymake reapply-patchesthere makes true — all ~2400 objects are already out of date at the moment they are cloned, and the new worktree recompiles them. The fix is to let one tree rebuild once. Measured on one such main tree, two worktrees off the same commit both recompiled ~1280 objects, the symlinked one and the private-checkout one alike: the staleness is the main tree’s, not the worktree scheme’s.
lib/libhvandlib/lvglno longer add to this. They are a private checkout per worktree, so rebuilding libhv in one tree regenerates only that tree’slib/libhv/include/hv/json.hpp— a prerequisite of every object — instead of putting every tree’s objects out of date at once.
The ccache config the script sets
Section titled “The ccache config the script sets”ccache is optional (it is not installed on every dev box — check with command -v ccache), and
with the mtime sync in place it is no longer what makes a clean worktree fast. Where it matters
is every build that genuinely has to recompile — after touching lv_conf.h, or after another tree
rebuilds libhv and re-invalidates every object (see the box above). That is the difference
between ~400s and a few tens of seconds.
sloppinessis not optional — without it ccache caches nothing here. Every native build compiles with-include include/lvgl_pch.h, and ccache refuses to cache any such compilation unlesssloppinesspermits it. Measured on a single-includecompile: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_dirandhash_dirwere 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_definesalone still measures 0% cacheable. If ccache has been installed on a machine for a while, checkccache --get-config sloppinessbefore assuming it has been doing anything.The cost of
time_macrosis that ccache stops hashing__DATE__/__TIME__. Exactly one site uses them —ui_settings_about.cppreads__DATE__ + 7for 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 insrc/,include/,lib/helix-xml/, or the build flags.
But it only helps across worktrees if it is configured for it. The native build compiles with -g (debug info), and ccache’s default hash_dir=true folds the absolute working directory into the cache key — so an object cached while building in the main tree never matches the same source compiled under .worktrees/<name>/. Every worktree would start stone cold.
setup-worktree.sh fixes this once, by writing global ccache config (only when unset, so it never clobbers a value you chose):
| ccache setting | Set to | Why |
|---|---|---|
base_dir |
$HOME |
Rewrites absolute paths under $HOME to relative before hashing, so main-tree and worktree paths collapse to the same key |
hash_dir |
false |
Stops folding the cwd (the -g debug-path component) into the key |
sloppiness |
pch_defines,time_macros |
Without it ccache refuses to cache any -include include/lvgl_pch.h compile — i.e. all of them. See the box above for the __DATE__ tradeoff |
max_size |
25G (raised, never lowered) |
The default 5 GiB thrashes once several worktrees + cross-compile caches share it, re-causing cold misses |
For the script’s own initial build it also exports CCACHE_BASEDIR (the longest common ancestor of the main tree and the worktree, so it works even for out-of-tree paths like /tmp/foo), CCACHE_NOHASHDIR=1, and CCACHE_SLOPPINESS.
These are global ccache settings, so they apply to normal main-tree builds too, not just worktrees — running setup-worktree.sh once is enough to fix a whole machine. Worth doing on every machine you build on, including remote build hosts: an existing ccache install with base_dir/hash_dir already set can still be caching nothing if sloppiness was never configured.
Caveat:
hash_dir=falseis global, so cached objects carry whicheverDW_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 withCCACHE_DISABLE=1.
Verify the cache is actually being shared after a build:
ccache -s # "Hits" should climb sharply on the 2nd+ worktree buildccache -p | grep -E 'base_dir|hash_dir|max_size'Typical worktree workflow
Section titled “Typical worktree workflow”# 1. Spin up an isolated workspace for a feature (builds automatically)./scripts/setup-worktree.sh feature/my-featurecd .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 committingmake 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:
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)Managing Worktrees
Section titled “Managing Worktrees”# List existing worktreesgit worktree list
# Example output:# /Users/you/code/helixscreen abc1234 [main]# /Users/you/code/helixscreen/.worktrees/i18n def5678 [feature/i18n]Cleanup
Section titled “Cleanup”./scripts/teardown-worktree.sh my-feature # remove the worktree + its merged branch./scripts/teardown-worktree.sh my-feature -n # print the plan, change nothinggit worktree remove refuses these trees because of the private submodule checkouts, so
teardown is a guarded rm -rf plus a prune.
Every worktree shares the main repo’s .git/modules/<name>/, and each of those gitdirs has
one core.worktree. --unlink leaves empty submodule directories that git can initialize,
which aims that shared pointer into the worktree; once the worktree is gone, git status
fails with “cannot chdir” in the main tree and every other worktree. --relink, setup and
teardown all run scripts/lib/worktree_lib.sh#restore_shared_module_pointers, which aims any
shared pointer resolving inside the worktree back at the main tree’s copy. Private checkouts
keep their gitdirs under .git/worktrees/<name>/modules/ and are never touched
(prestonbrown/helixscreen#1621).
Build System Overview
Section titled “Build System Overview”The project uses GNU Make with a modular architecture:
- Modular design: ~9,100 lines split across the top-level
Makefileplus 17mk/*.mkmodules for maintainability - Color-coded output for easy visual parsing
- Verbosity control to show/hide full compiler commands
- Automatic dependency checking before builds with smart canvas detection
- Interactive installation of missing dependencies (
make install-deps) - Automatic code formatting for C/C++ and XML files
- Fail-fast error handling with clear diagnostics
- Parallel build support with output synchronization
- Build timing for performance tracking
XML stays runtime-loaded — compile-time codegen was measured and declined. A build-time XML-to-C component compiler was designed and measured (2026-08-08; the design doc has since been deleted) and declined: runtime-loaded XML with hot reload won on build complexity and iteration speed. Revisit only if startup cost on SPI-flash platforms demands it.
Modular Makefile Structure
Section titled “Modular Makefile Structure”The build system is organized into focused modules:
| File | Lines | Purpose |
|---|---|---|
Makefile |
~1095 | Configuration, variables, platform detection, module includes |
mk/cross.mk |
~2990 | Cross-compilation, toolchain setup, display backends |
mk/tests.mk |
~924 | All test targets (unit, integration, by-feature) |
mk/deps.mk |
~672 | Dependency checking, installation, libhv/wpa_supplicant |
mk/patches.mk |
~666 | LVGL patch application |
mk/rules.mk |
~561 | Compilation rules, linking, main build targets |
mk/remote.mk |
~297 | Remote deployment (Pi, AD5M) |
mk/fonts.mk |
~283 | Font/icon generation, Material icons |
mk/images.mk |
~264 | Image conversion (PNG, SVG) |
mk/tools.mk |
~251 | Development tool targets |
mk/pi-dual-link.mk |
~249 | Pi dual-link build (compile once, link DRM + fbdev) |
mk/watchdog.mk |
~198 | Hardware watchdog support |
mk/splash.mk |
~183 | Splash screen generation |
mk/translations.mk |
~168 | Translation string generation |
mk/format.mk |
~110 | Code and XML formatting |
mk/bluetooth.mk |
~86 | Bluetooth support |
mk/display-lib.mk |
~77 | Display library configuration |
mk/filaments.mk |
~22 | Filament database generation |
Each module is self-contained with GPL-3 copyright headers and clear separation of concerns.
Quick Start
Section titled “Quick Start”# Parallel build (auto-detects CPU cores)make -j
# Fast development build (-O0, ~2x faster compilation)make dev
# Clean parallel build with progress/timingmake build
# Verbose mode (shows full commands)make V=1
# Code formatting (clang-format for C/C++, xmllint for XML)make format # Format all filesmake 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)Build Configuration Options
Section titled “Build Configuration Options”The build system supports several configuration flags to customize the build:
Verbosity Control (default: quiet)
# Quiet mode (default) - shows progressmake -j
# Verbose mode - shows full compiler commandsmake -j V=1Dependency Management
Section titled “Dependency Management”The build system includes comprehensive dependency checking and automatic installation.
Checking Dependencies
Section titled “Checking Dependencies”make check-depsThis checks for:
- System tools: C/C++ compiler, cmake, make, python3, npm
- Code formatters: clang-format (C/C++), xmllint (XML validation/formatting)
- Libraries: pkg-config, OpenSSL, libnl, libusb (required on Linux: the build links
-lusb-1.0unconditionally; optional on macOS), ALSA (Linux, warns when the headers are missing because the sound backend is then compiled out) - Canvas dependencies: cairo, pango, libpng, libjpeg, librsvg (for lv_img_conv)
- npm packages: lv_font_conv, lv_img_conv
- Optional libraries: SDL2, spdlog, libhv (uses system if available, otherwise builds from submodules)
- Git submodules: LVGL (always built from submodule)
The checker is platform-aware and shows the correct install commands for:
- macOS (Homebrew)
- Debian/Ubuntu (apt)
- Fedora/RHEL (dnf)
Installing Dependencies
Section titled “Installing Dependencies”make install-depsThis interactively installs missing dependencies:
- Detects your platform
- Lists packages to be installed
- Shows the command it will run
- Asks for confirmation before proceeding
- Installs system packages via brew/apt/dnf
- Runs
npm installfor lv_font_conv/lv_img_conv - 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.
Library Clean Targets
Section titled “Library Clean Targets”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
make libhv-clean # Clean libhv WebSocket library artifactsmake sdl2-clean # Clean SDL2 CMake build directorymake lvgl-clean # Clean LVGL compiled objectsmake libs-clean # Clean all library artifacts at onceThread stacks on musl targets: musl gives every thread a 128 KiB stack unless the binary’s PT_GNU_STACK header asks for more (musl 1.1.21 and later). The Moonraker callbacks run on such a thread, so recursion that is harmless on glibc overflows there and dies with no crash file. The unified mips target (K1, AD5X, Creator 5 Pro) and k2 link with MUSL_THREAD_STACK_LDFLAGS (-Wl,-z,stack-size=1048576, defined in mk/cross.mk), which raises the default to 1 MiB of address space; a thread commits only the pages it touches. glibc targets size thread stacks from RLIMIT_STACK and ignore the flag, so they do not carry it. Check a build with readelf -lW build/k2/bin/helix-screen | grep GNU_STACK (the memsz reads 0x100000). std::regex is kept out of app code for the same reason: libstdc++’s matcher recurses once per input character.
Cross-Compilation Note: When cross-compiling (e.g., make ad5m-docker), libhv is automatically cleaned before each build to prevent architecture mixing. This adds ~5 seconds but ensures correct builds.
Test Binary Link
Section titled “Test Binary Link”helix-tests links through a response file: make’s $(file ...) function writes the object list to $(OBJ_DIR)/<binary>.objs (for the plain build, build/obj/helix-tests.objs) and the compiler reads it as @<that file>. Inline, the list passes Linux’s 128 KiB per-argument limit once object paths grow, as the ASAN build’s do, and bash refuses the recipe with “Argument list too long”.
Test Harness
Section titled “Test Harness”The dependency system includes a comprehensive test suite:
./tests/test_deps.shTests 9 scenarios with 22 assertions covering dependency detection, platform-specific commands, and auto-installation workflow.
Build Options
Section titled “Build Options”V=1- Verbose mode: shows full compiler commands instead of short[CC]/[CXX]tagsOPT=0|1|2- Optimization level (default: 2). UseOPT=0for fastest compilation,OPT=2for release.make devis shorthand forOPT=0 -j.JOBS=N- Set parallel job count (default:scripts/helix-claim jobs, see Parallel Compilation)NO_COLOR=1- Disable colored output (useful for CI/CD)-j<N>- Run N jobs; without it, plainmakepicks its own (see Parallel Compilation)
Build Output
Section titled “Build Output”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
Error Handling
Section titled “Error Handling”When compilation fails, the build system:
- Shows the failed file with a red
✗marker - Displays the full compiler command for debugging
- Exits immediately (fail-fast behavior)
Example:
[CXX] src/ui_panel_home.cpp✗ Compilation failed: src/ui_panel_home.cppCommand: clang++ -std=c++17 -Wall -Wextra -O2 -g -I. -Iinclude ...Code Formatting
Section titled “Code Formatting”The build system includes automatic code formatting for C/C++ and XML files, integrated with pre-commit hooks.
Formatters
Section titled “Formatters”- clang-format - Formats C, C++, and Objective-C files according to
.clang-formatconfig - xmllint - Formats and validates XML layout files with consistent indentation
Configuration
Section titled “Configuration”.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)
Formatting Commands
Section titled “Formatting Commands”# Format all C/C++ and XML filesmake format
# Format only staged files (useful before commit)make format-staged
# Check formatting without modifying files./scripts/quality-checks.shPre-commit Integration
Section titled “Pre-commit Integration”Formatting is automatically checked by the pre-commit hook (.git/hooks/pre-commit), which calls scripts/quality-checks.sh --staged-only:
- Resolves the pinned formatter: the
clang-formatwheel pinned inrequirements.txt, installed into.venvbymake venv-setup(scripts/qc/phase2.sh#qc_resolve_clang_format). Nothing onPATHis consulted, and a tree without the wheel cannot commit C++ until it runsmake venv-setup- one formatter everywhere is what keeps files from ping-ponging between machines - Checks staged files with it and auto-formats the ones that need it
- Prevents commit if a formatted file could not be re-staged (partially staged hunks)
- Full sweeps (pre-push, CI) fail on any unformatted file outside
CLANG_FORMAT_BASELINE, the list of files that predate the gate; an entry leaves the list once the file is auto-formatted on its next staging
To bypass (not recommended):
git commit --no-verifyPre-push Integration
Section titled “Pre-push Integration”.githooks/pre-push runs the full, ungated sweep - every gate, not just the
ones whose inputs you staged - because a commit can pass --staged-only while
breaking a repo-wide invariant that mode never ran.
It sweeps the commit being pushed, not the working tree. quality-checks.sh
reads file content off disk (CI mode walks src/ and include/ with find), so
sweeping a dirty tree gives the wrong answer in both directions: in-flight edits
fail a push whose commits are fine, and an uncommitted fix turns the sweep green
over committed code that CI will then reject. With parallel sessions and
worktrees sharing one checkout, a dirty tree is the normal case.
- Tree matches the pushed commit - the sweep runs in place, as it always did.
- Tree differs - the commit is checked out into a throwaway worktree and swept
there.
.venv,node_modulesandbuild/binare shared by symlink, as are thelib/submodules (patch-drift and the doc-reference gate resolve against the filesystem, so an emptylib/reads as mass breakage).
build/ is deliberately not shared wholesale: build/helix-xml-tests holds a
cmake cache recording an absolute source path, and sharing it bakes the throwaway
path into the real tree’s cache, breaking that gate for every later run. The
consequence is that the helix-xml submodule-test gate finds no configured build
tree on the isolated path and skips - its designed behaviour, since its first
configure clones LVGL over the network.
Escape hatches: HELIX_PREPUSH_IN_PLACE=1 forces the old in-place sweep, and
git push --no-verify skips the hook. Expect CI to find whatever you skipped.
Quality Checks
Section titled “Quality Checks”The scripts/quality-checks.sh script runs multiple checks:
- Code formatting (clang-format)
- XML formatting (xmllint)
- XML validation (xmllint –noout)
- Copyright headers (GPL v3 SPDX identifiers)
- Merge conflict markers
- Trailing whitespace
- Build verification (pre-commit only)
Used by all three:
- Pre-commit hook (staged files only, working tree)
- Pre-push hook (all gates, against the commit being pushed)
- CI/CD (all files)
Automatic Patch Application
Section titled “Automatic Patch Application”The build system automatically applies patches to git submodules before compilation.
How It Works
Section titled “How It Works”- Patch Storage: All submodule patches are stored in
patches/(in the repository root) - Auto-Detection: Makefile checks if patches are already applied before each build
- Idempotent: Safe to run multiple times - patches are only applied once
- Transparent: No manual intervention needed for normal development
Patch: LVGL SDL Window Position
Section titled “Patch: LVGL SDL Window Position”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 onHELIX_SDL_XPOS- X coordinate for exact window positionHELIX_SDL_YPOS- Y coordinate for exact window position
Application Logic (in mk/patches.mk):
$(Q)$(APPLY_PATCH) $(LVGL_DIR) $(PATCH_DIR)/lvgl_sdl_window.patch "LVGL SDL window patch"$(APPLY_PATCH) is scripts/apply_submodule_patch.sh, which owns a three-way
verdict for every patch: apply it, recognize it as already applied (reverse
check), or report that it matches no reachable state of the submodule.
Status Messages:
✓ <label> applied- Patch was applied during this build✓ <label> already applied- Reverse check recognized it; the tree keeps it✓ <label> already applied (marker present; sibling patches moved the context git compares)- Neither check passes, but the marker table confirms the patch’s effect is in the checkout; the routine case on shared files⚠ <label>: its marker is absent from the checkout - the patch's effect is missing- Neither check passes and the marker is gone; runmake reapply-patches⚠ <label> is not verifiable in place- Neither check passes and the marker table has no row for the patch, so the verdict stays hedged; runmake reapply-patchesto judge from clean⚠ <submodule> is not pristine, so this run cannot judge patches from clean-HELIX_PATCHES_FROM_CLEAN=1was set but the submodule already carries changes (the statemake cleanleaves), so the fatal verdict is not available and every patch is judged in place; runmake reapply-patchesto reset and judge from clean✗ <label> does not apply to a clean checkout- The patch and the submodule disagree, on a run verified to have started from pristine submodules; regenerate the patch✗ <patch> ... Its marker is missing from <file>- One of the patch’s distinctive lines (there is one per file it touches) is absent from the checkout, so the build stops before compiling against unpatched code; runmake reapply-patches. The marker check is a text search that needs no git, so it also fires in docker builds rsynced from worktrees. Companion messages name a changed patch file (make regen-patch-markers) and a wired stanza with no marker row.
Adding New Patches
Section titled “Adding New Patches”To add a new submodule patch:
- Make changes in the submodule directory
- Generate patch — scope the diff to the files you touched, or you will capture every
other patch that is currently applied:
If more than one patch touches that file, even a scoped diff folds the others in. Sixteen files are shared today (
Terminal window cd lib/lvglgit diff src/path/to/file.c > ../../patches/my-new-patch.patchsrc/misc/lv_event.cby seven patches). Check withgrep -l "diff --git a/<path>" patches/*.patchand use the pristine-file method inpatches/README.md§ “Regenerating a patch whose file is shared”. - Update Makefile to apply the patch in the
apply-patchestarget, as one helper stanza beside the others:The note is optional and names the runtime consequence of building without the patch; the marker check prints it when the patch goes missing.$(Q)$(APPLY_PATCH) $(LVGL_DIR) $(PATCH_DIR)/my-new-patch.patch "My new patch" "Without it <consequence>" - Regenerate the marker table with
make regen-patch-markers(it reapplies from clean first, then rederivesmk/patch-markers.tsv). Until you do, the build fails naming the new patch — a wired stanza without a marker row is a coverage gap, not a state the check tolerates. - Document in
patches/README.md
Patch Gotchas (hard-won)
Section titled “Patch Gotchas (hard-won)”The libhv DNS resolver fallback patch has three traps. The first two make the
patch silently miss the binary (on the AD5M, on-machine update checks fail with
“Connection failed”); the third breaks a pristine parallel build. All three are
regression-tested in tests/shell/test_libhv_dns_resolver_patch.bats.
-
Guard on the actual change, not on a side effect. A patch that adds NEW files and edits an existing one must not gate re-application on the new file’s existence. A submodule reset reverts the tracked
base/hsocket.c(the wiring) but leaves an untrackeddns_resolv.corphaned, so a file-existence guard declares “already applied” and never re-wireshsocket.c— the resolver compiles but is never called. Both layers inmk/patches.mkanswer this now: every stanza routes through the$(APPLY_PATCH)verdict helper (which resets nothing and never assumes), andmk/patch-markers.tsvfails the build when a distinctive line the patch adds is absent from any file it edits — on every build, docker trees included. -
A patched file compiled into a static
.amust 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(→ insidelibhv.a) whiledns_resolv.cis compiled separately into the app, a stale archive kept a pristinehsocket.o(puregetaddrinfo→EAI_SYSTEM/ret=-11on static glibc) across every rebuild. This is dev-only — a fresh CI build has nolibhv.ayet — but the fix is to depend on the stamp:$(LIBHV_LIB): $(PATCHES_STAMP)When in doubt,
rm build/<plat>/lib/libhv.ato force a clean archive, and confirm a patch’s marker actually made it in:strings <binary> | grep <sym>. -
A file a patch creates needs a producer rule in every build flavour. The patch creates
base/dns_resolv.c, which the cross app build and the native test build (test_dns_resolver) both compile. On a pristine tree the file does not exist when make reads the graph, so without$(LIBHV_DIR)/base/dns_resolv.c: $(PATCHES_STAMP)a
-jbuild fails with “No rule to make target ‘lib/libhv/base/dns_resolv.c’”. The rule sits inmk/rules.mkoutside any cross-only conditional; the case “dns_resolv.c depends on the patch stamp in a native build” pins it by reading the make database withCROSS_COMPILEempty (#1411).
Multi-Display Support (macOS)
Section titled “Multi-Display Support (macOS)”The prototype supports multi-monitor development workflows with automatic window positioning.
Command Line Arguments
Section titled “Command Line Arguments”# 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-splashImplementation Details
Section titled “Implementation Details”Flow:
main.cppparses command line arguments- Sets environment variables before LVGL initialization:
setenv("HELIX_SDL_DISPLAY", "1", 1); // For --display 1// orsetenv("HELIX_SDL_XPOS", "100", 1); // For --x-pos 100setenv("HELIX_SDL_YPOS", "200", 1); // For --y-pos 200
- LVGL SDL driver reads environment variables during window creation
- Uses
SDL_GetDisplayBounds()to query display geometry - Calculates center position:
display_x + (display_w - window_w) / 2 - 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)
Screenshot Script Integration
Section titled “Screenshot Script Integration”The scripts/screenshot.sh script automatically uses display positioning:
# Default: opens on display 1 (keeps terminal visible on display 0)./scripts/screenshot.sh helix-screen output-name panel
# Override displayHELIX_SCREENSHOT_DISPLAY=0 ./scripts/screenshot.sh helix-screen output panelHow it works:
# In screenshot.shHELIX_SCREENSHOT_DISPLAY=${HELIX_SCREENSHOT_DISPLAY:-1} # Default to display 1EXTRA_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.
Parallel Compilation
Section titled “Parallel Compilation”Plain make (or make -j) picks its own -j, so there is no need to pass one.
The answer comes from scripts/helix-claim jobs:
| Box | -j |
|---|---|
| No jobpool (CI, a Mac, a fresh clone) | the cores (nproc, or sysctl -n hw.ncpu on macOS), capped at one job per GB of MemAvailable where /proc/meminfo exists, never below 2 |
| jobpool installed | the shared pool (below); the Makefile never reads JOBS |
The two-phase re-invoke that applies JOBS lives in mk/rules.mk all: and
mk/tests.mk $(TEST_BIN); it is skipped whenever MAKEFLAGS already carries a
jobserver. An explicit make -jN or make JOBS=N passes through untouched.
scripts/helix-claim jobs -v prints the decision and what it was made from.
helix-claim jobs is make’s -j and nothing else’s. With a pool live it is
the whole pool, which is right for a make (it draws its jobs from the pool) and
wrong for anything else: docker run --cpus $(scripts/helix-claim jobs),
ninja -j$(...) or parallel -j$(...) runs that many jobs on top of every build
on the box. Work outside make sizes itself with
scripts/helix-claim hold [--min M] -- CMD, which exports JOBPOOL_SLOTS, and a
container goes through scripts/pool-docker.sh. Called outside a make while a
pool is live, jobs says so on stderr (stdout stays the bare number), and the
resource advisor names a runner sized from it.
The same number sizes the unit sweep (3 shards per slot, SHARD_CONCURRENCY
overrides) and the commit hook’s build (qc_build_jobs in scripts/qc/_lib.sh,
HELIX_QC_JOBS overrides). The build uses --output-sync=target, so parallel
output does not interleave.
Optional: one build pool per machine (jobpool)
Section titled “Optional: one build pool per machine (jobpool)”jobpool is a machine-wide GNU make jobserver: a small daemon owns a FIFO of tokens sized from the cores and available memory, and every build that joins draws its jobs from it. Two sessions building on one box then share the cores instead of each taking all of them, and a tree that is linking or running tests holds no tokens, so its share flows to whoever is compiling. It is not a dependency: nothing in the build requires it, and without it everything above holds as written.
This project’s own build machines (the maintainer’s desktop and its test host)
have it installed. The repo is ~/Code/Tools/jobpool there; its README covers
install, configuration and macOS.
Where it is installed:
makeonPATHis jobpool’s shim. Every top-level make joins the pool, and the shim strips any-jfrom the command line, because GNU make leaves an inherited jobserver when it sees one.jobpool statusshows the target, free tokens and consumers.JOBPOOL=0 make ...bypasses the pool for one run.helix-claim jobshonours the same switch.helix-claim jobsreports the pool’s size while the daemon is live, so the sweep, the commit hook and the resource advisor read the machine budget. It asksjobpool target, which takes no lock and moves no tokens; onlyjobs -vreadsjobpool statusfor the free count.- The unit sweep runs its shards three to a pool token (
jobpool with-tokenaround each batch of three), so every sweep on the box together stays within three shards per token. - Runners that size themselves rather than join a jobserver hold their share
through
scripts/helix-claim hold [-n N] -- CMD, which exportsJOBPOOL_SLOTS(jobpool hold; without jobpool,-nor the cores). The helix-xml test build runs itscmake --build -jfrom it.make test-shellasks for--grow FILEtoo, which a jobpool that has it keeps at the live slot count while the suite runs; bats is handedscripts/parallel-jobs-file.shas its parallel, which gives GNU parallel that file (re-read about once a second) in place of the number bats insists on. Without it the suite runs at the start-timeJOBPOOL_SLOTS. - Container builds go through
scripts/pool-docker.sh: a container’s make joins the pool (state dir mounted, FIFO opened inside,-jdropped, kept as the fallback when the container cannot open the FIFO). An idf.py or ninja command joins too: ninja 1.13 is a jobserver client in the FIFO form, soscripts/ninja-jobserver.shfetches the pinned upstream release once into~/.cache/helixscreen/(sha256-checked; offline after that), pool-docker.sh mounts it over the image’s/usr/bin/ninja, names the FIFO inMAKEFLAGSand emptiesIDF_PY_BUILD_JOBS, since idf.py passes it as-jand any-jturns ninja’s jobserver off. Any other container command (the ustreamer script), or idf.py with no ninja 1.13 to be had, holds tokens and getsJOBPOOL_SLOTSandIDF_PY_BUILD_JOBS. The native cross targets (make piand siblings) give their sub-make no-jwhile a pool is live. helix-claim resourceslists heavy runners (bats, GNU parallel, ninja,docker run) running outside the pool, and the resource advisor names one about to start.- On a test host with jobpool installed,
scripts/test-host-run.shjoins the container to the host’s pool: the host’s jobpool conf puts the state dir under the directory the test container already mounts, the host runsdocker execunderjobpool exec, and the container opens the FIFO and exportsMAKEFLAGS(jobpool container-env) with no-jon make. Without a pool there it sizes-jfromMemAvailable, and runs of different trees take turns. A ZFS host should keep build headroom free with thezfs_arc_sys_freetunable; the run warns when it is under 32 GiB.
Using your own build/test host
Section titled “Using your own build/test host”Two kinds of remote machine help here, and both are optional:
- a test host runs the expensive, non-interactive jobs (
sweep,asan,tsan,mutate) in a container:scripts/test-host-run.sh. It mirrors your working tree as it is on disk, uncommitted edits included, into a per-tree directory whosebuild/persists, so a warm run rebuilds only what changed. With one configured,make full-test-runruns the C++ sweep there while bats runs locally whenever the link makes that pay (TEST_HOST=1forces it,TEST_HOST=0orHELIX_TEST_HOST_AUTO=0keeps both local). - a remote build host builds natively or for the cross targets:
make remote-native,make remote-test,make remote-piand siblings (mk/remote.mk,scripts/remote-build.sh).
Both are named in one file outside the tree, so every worktree and clone shares it:
${XDG_CONFIG_HOME:-$HOME/.config}/helixscreen/build-hosts.envOne parser reads it, scripts/lib/build_hosts.sh: the scripts source it, and
make asks it for a value the first time a remote target needs one, so an
ordinary make never reads the file at all. Its rules:
KEY=VALUEat the start of a line, no spaces around=;#starts a comment only at the start of a line.- Only the keys in the table below; any other key is named and skipped.
- Values are literal: no
$expansion, nothing runs, a CRLF line ending is fine. A value cannot hold whitespace or quotes, so a trailing comment or a quoted value is named and skipped rather than half-read. - If a key appears twice, the last line wins.
- A variable set in the environment (or on the make command line) wins over
the file.
HELIX_BUILD_HOSTS_FILEnames a different file.
Nothing has a host default: a command that needs a host you have not
configured stops with one line naming the variable and this file, and nothing
ever connects to a host you did not name. make full-test-run with no test
host simply runs both suites locally, without a word.
| Variable | Used by | Meaning (default) |
|---|---|---|
HELIX_TEST_HOST |
test-host-run.sh, full-test-run, helix-claim resources, teardown |
ssh name of the test host (none) |
HELIX_TEST_CONTAINER |
same | container name (helix-test) |
HELIX_TEST_TREES_HOST |
same | mirror root on the host, relative to the ssh user’s home unless absolute (helix-test/trees) |
HELIX_TEST_TREES |
same | the same directory inside the container (/work/trees) |
HELIX_TEST_CCACHE |
same | ccache dir inside the container (/work/ccache) |
HELIX_TEST_WORKDIR |
test-host-run.sh --commit, mutate |
git checkout inside the container (/work/helixscreen) |
HELIX_TEST_LOCK_DIR |
test-host-run.sh |
lock directory on the host (/tmp) |
HELIX_TEST_HOST_AUTO |
full-test-run |
0 keeps an unforced gate local even with a test host configured (1) |
REMOTE_HOST, REMOTE_USER |
mk/remote.mk, remote-build.sh |
remote build host and optional user (none) |
REMOTE_DIR |
mk/remote.mk |
rsync target on the remote for the cross targets (none). remote-sync rsyncs --delete into it, so give it a directory of its own, never a checkout you work in |
REMOTE_BUILD_DIR |
remote-build.sh |
its own clone on the remote (~/helix-remote), which it hard-resets and cleans every run. It works only in a directory it created (marked .helix-remote-build) and refuses any other |
An example file:
HELIX_TEST_HOST=buildbox.localHELIX_TEST_CONTAINER=helix-testHELIX_TEST_TREES_HOST=/srv/helix-test/treesHELIX_TEST_TREES=/work/treesREMOTE_HOST=buildbox.localREMOTE_DIR=~/helix-remote-syncSetting up a test host. Any Linux machine with Docker works, if you can ssh
to it with a key and run sudo -n docker there. Name it in the file, then run
from your own machine:
scripts/test-host-setup.sh # image, container, toolchain checksscripts/test-host-setup.sh --commit-checkout # also clone the checkout --commit and mutate usescripts/test-host-run.sh test '[netd]' # first run: syncs the tree and builds (minutes)The setup builds the image from docker/Dockerfile.sanitizer in your tree,
streamed over ssh, and starts the container with the parent of
HELIX_TEST_TREES_HOST mounted at the parent of HELIX_TEST_TREES. Re-run it
after the Dockerfile changes; --recreate swaps the container for one from the
new image. scripts/test-host-run.sh --prune [DAYS] removes mirrors nobody has
synced for DAYS (14), and teardown-worktree.sh drops a tree’s mirror with the
tree.
Font Generation
Section titled “Font Generation”The build system uses lv_font_conv to convert TrueType fonts into LVGL-compatible C arrays.
Font Types
Section titled “Font Types”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.jsonnpm scripts - Font:
assets/fonts/NotoSans-Regular.ttf,NotoSans-Bold.ttf - Output:
noto_sans_*.c,noto_sans_bold_*.c
How It Works
Section titled “How It Works”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:
make # Checks fonts and regenerates if regen script is newerManual regeneration:
make regen-fonts # Regenerate MDI icon fonts from regen scriptmake generate-fonts # Explicit font regenerationAdding New Icon Glyphs
Section titled “Adding New Icon Glyphs”To add new Material Design Icons:
- Find the icon at https://pictogrammers.com/library/mdi/
- Get the codepoint (e.g.,
wifi-strength-4=0xF0928) - Edit
scripts/regen_mdi_fonts.shand add the codepoint:Terminal window MDI_ICONS+=",0xF0928" # wifi-strength-4 - Add to codepoints header (
include/ui_icon_codepoints.h):{"wifi_strength_4", "\xF3\xB0\xA4\xA8"}, // F0928 wifi-strength-4 - Regenerate fonts:
Terminal window make regen-fontsmake -j
Requirements
Section titled “Requirements”- Node.js and npm - Required for font generation
- macOS:
brew install node - Ubuntu/Debian:
sudo apt install npm - Fedora/RHEL:
sudo dnf install npm
- macOS:
- lv_font_conv - Installed automatically via
npm install(seepackage.jsondevDependencies)
Troubleshooting
Section titled “Troubleshooting”npm not found:
# macOSbrew install node
# Linuxsudo apt install npm # Debian/Ubuntusudo dnf install npm # Fedora/RHEL
# Verifynpm --versionFonts not regenerating:
# Force regeneration by touching the regen scripttouch scripts/regen_mdi_fonts.shmake generate-fontsMissing icons:
# Validate all icons in codepoints.h are in the fontmake validate-fontsManual font generation:
# Generate specific sizenpm run convert-noto-24
# Generate all fontsnpm run convert-noto-allIcon Generation
Section titled “Icon Generation”The build system includes automated icon generation with platform-specific output formats.
Quick Start
Section titled “Quick Start”# Generate/regenerate icon from source logomake iconOutput:
- macOS:
helix-icon.icns(multi-resolution bundle) +helix-icon.png(650x650) - Linux:
helix-icon.png(650x650 for application use)
Requirements
Section titled “Requirements”Required:
imagemagick- Image processing (magickcommand)- macOS:
brew install imagemagick - Ubuntu/Debian:
sudo apt install imagemagick - Fedora/RHEL:
sudo dnf install ImageMagick
- macOS:
macOS only:
iconutil- macOS icon bundle creator (built-in on macOS)
What It Does
Section titled “What It Does”The make icon target performs the following steps:
All platforms:
- Crops source logo (
assets/../../../../assets/images/docs/helixscreen-logo.png) to just the circular helix - 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
- Bundles into .icns file using
iconutil→helix-icon.icns - Cleans up temporary iconset directory
Generated Files
Section titled “Generated Files”All platforms:
assets/../../../../assets/images/docs/helix-icon.png- Cropped square logo (650x650px, ~245KB)
macOS only:
assets/images/helix-icon.icns- macOS icon bundle (~1.3MB with all resolutions)assets/images/icon.iconset/- Temporary directory (auto-deleted after .icns creation)
Cross-platform:
- SDL window icons (programmatically via
SDL_SetWindowIcon()with PNG) - Linux
.desktopfiles (Icon= field pointing to PNG)
macOS specific:
- macOS
.appbundles withInfo.plist(CFBundleIconFile pointing to .icns) - Dock/Finder display when bundled as application
Troubleshooting
Section titled “Troubleshooting”ImageMagick not found (macOS):
brew install imagemagickmake iconImageMagick not found (Linux):
# Ubuntu/Debiansudo apt install imagemagick
# Fedora/RHELsudo dnf install ImageMagickLinux output:
- Linux builds generate PNG only (not .icns)
- This is expected -
.icnsis macOS-specific - PNG icons work with SDL and Linux desktop environments
Regenerating after logo changes:
# Update assets/../../../../assets/images/docs/helixscreen-logo.pngmake icon # Regenerates all icon files (platform-specific)SVG to PNG Conversion
Section titled “SVG to PNG Conversion”When converting SVG files to PNG for use in the project, always use rsvg-convert from the librsvg library.
Why Not ImageMagick?
Section titled “Why Not ImageMagick?”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.
Using rsvg-convert
Section titled “Using rsvg-convert”# 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 dimensionsrsvg-convert input.svg --dpi-x 192 --dpi-y 192 -o output.png # By DPIInstallation
Section titled “Installation”The librsvg package is already tracked as a dependency for lv_img_conv. Install with:
# macOSbrew install librsvg
# Debian/Ubuntusudo apt install librsvg2-bin
# Fedora/RHELsudo dnf install librsvg2-toolsCurrent Usage
Section titled “Current Usage”- AMS logos (
assets/images/ams/) - Multi-material system icons converted from SVG sources
Make Target Reference
Section titled “Make Target Reference”The Makefile is self-documenting — these help targets are the authoritative, always-current list (the tables below are a curated tour of the typical ones):
| Command | Shows |
|---|---|
make help |
The common build/dependency/quality targets |
make help-build |
Build, dependency, and patch targets |
make help-test |
Test targets and test discovery |
make help-cross |
Cross-compilation + per-device deployment targets and options |
make help-remote |
Remote build system (build on a fast host, fetch binaries) |
make help-images / help-splash / help-watchdog |
Asset/splash/watchdog targets |
make help-all |
Everything, all topics combined |
make cross-info |
Current cross-compile configuration (platform, backend) |
Native build & run
Section titled “Native build & run”| Target | What it does |
|---|---|
make -j (all) |
Build the main binary (default), -O2, auto-parallel |
make dev |
Fast build at -O0 (~2× faster compile; larger/slower binary) |
make OPT=1 -j |
-O1 middle ground |
make build |
Clean build with progress + timing |
make run |
Build and run the UI |
make clean |
Remove build artifacts (keeps deps) |
make distclean |
Deep clean to fresh-checkout state |
make V=1 … |
Verbose (show full compiler commands) |
make JOBS=N … |
Cap parallel job count |
Run flags worth knowing: ./build/bin/helix-screen --test -vv (mock printer + DEBUG logs, hot reload ON by default for live XML editing).
The build system has 30+ test targets by feature area; see TESTING.md for the tag taxonomy. Most-used:
| Target | What it does |
|---|---|
make test |
Build tests (does not run them) |
make test-run |
Build and run tests in parallel (recommended, ~4–8× faster) |
make test-serial |
Run sequentially (debugging thread issues) |
make test-smoke |
Quick smoke subset (~30s) for rapid iteration |
make test-all |
All tests incl. [slow] |
make test-asan / test-tsan |
Run under Address/Thread sanitizer |
make test-list-tags |
List available tags |
make test-xml |
Configure, build and run the helix-xml engine suite (CMake + Unity + ctest) - not part of make test |
./build/bin/helix-tests "[tag]" |
Run a specific tag (e.g. [ams], [gcode]) |
make test-xml is the odd one out: it drives the standalone suite in the lib/helix-xml/
submodule rather than helix-tests. Its build tree is build/helix-xml-tests/ (outside the
submodule) and LVGL comes from CMake FetchContent pinned at v9.5.0, so the first
configure needs network and takes minutes; every run after that is a no-op configure plus a
couple of seconds of ctest. Forward extra ctest args with
make test-xml HELIX_XML_CTEST_ARGS='-R test_expr'. See
TESTING.md § “helix-xml Engine Tests”.
Code quality & IDE
Section titled “Code quality & IDE”| Target | What it does |
|---|---|
make format |
clang-format all C/C++ + xmllint XML |
make format-staged |
Format only staged files (pre-commit) |
make quality |
All quality checks (formatting, headers, conflicts) |
make setup-hooks |
Enable the git pre-commit hook |
make compile_commands |
Merge compile_commands.json for clangd (~1–2s) |
make compile_commands_full |
Full regen via bear/compiledb (slow; use if fragments corrupt) |
Dependencies & patches
Section titled “Dependencies & patches”| Target | What it does |
|---|---|
make check-deps |
Verify build dependencies (see below) |
make install-deps |
Interactively install missing deps |
make venv-setup |
Create .venv with Python asset/telemetry deps |
make libs-clean |
Clean all built library artifacts |
make apply-patches |
Apply LVGL/libhv patches (idempotent; auto-run before builds) |
make reapply-patches |
Force re-apply (repair manually-edited patched files) |
Asset regeneration
Section titled “Asset regeneration”Usually invoked after editing icons/images. See Font Generation and Icon Generation for the full pipeline.
| Target | What it does |
|---|---|
make regen-fonts |
Regenerate MDI icon fonts via scripts/regen_mdi_fonts.sh |
make regen-text-fonts |
Regenerate Noto Sans text fonts (incl. CJK) |
make regen-icon-consts |
Regenerate icon string constants in globals.xml |
make validate-fonts |
Verify every codepoint is present in the compiled fonts |
make regen-images |
Regenerate pre-rendered splash images (all sizes) |
make gen-printer-images |
Pre-render printer DB images |
make translations |
Regenerate translation tables from YAML |
make translation-coverage |
Show per-language translation coverage |
Cross-compile, deploy & remote build
Section titled “Cross-compile, deploy & remote build”These get their own deep section above — see Cross-Compilation. Shape of it:
- Build:
make <target>-docker(recommended, no local toolchain) ormake <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 inmk/cross.mk— notePI_HOSTactually defaults to192.168.1.113(themake help-crosstext sayinghelixpi.localis stale, andhelixpi.localdoes not resolve).K2_HOSThas no default and must be supplied. - Remote build: two transports, and the choice matters on a slow link.
make remote-native/make remote-test TAG='[ams]'send only the local delta (scripts/remote-build.sh). The build host keeps its own clone and fetches committed history from GitHub itself; your link carries one patch covering unpushed commits and uncommitted edits, plus a tar of untracked files — a few KB over one multiplexed SSH connection. Use this for every native build and test run.make remote-pi/remote-ad5m/remote-allrsync the whole working tree (make remote-sync) because the Docker cross builds need a real mirror on the remote. rsync is delta-based, but it still exchanges metadata for every file underlib/andassets/before deciding nothing changed, and a fresh destination directory transfers ~260 MB — so reuse oneREMOTE_DIRrather than a new one per branch or worktree.REMOTE_HOSTandREMOTE_DIRcome from the build-hosts file (Using your own build/test host);make remote-statuschecks readiness.
Utilities
Section titled “Utilities”| Target | What it does |
|---|---|
make demo |
Build LVGL demo widgets (LVGL API testing) |
make symbols |
Extract .sym + .debug (crash-backtrace resolution) |
make strip |
Strip the binary for release |
make screenshots |
Generate documentation screenshots |
make print-cxxflags / print-ldflags |
Dump resolved flags (build debugging) |
Dependency Checking
Section titled “Dependency Checking”Before building, the system automatically checks for required dependencies:
Required:
clang/clang++- C/C++ compiler with C++17 supportcmake- 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:
compiledborbear- Only forcompile_commands_full(normal builds auto-generate)imagemagick- For screenshot conversion and icon generationiconutil- For macOS .icns icon generation (macOS only, built-in)
Manual Dependency Check
Section titled “Manual Dependency Check”make check-depsExample 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.
IDE/LSP Support (compile_commands.json)
Section titled “IDE/LSP Support (compile_commands.json)”The build system uses incremental compile command generation for fast IDE integration.
How It Works
Section titled “How It Works”- During compilation: Each
.ofile generates a.ccj(compile command JSON) fragment alongside it - After build: Fragments are automatically merged into
compile_commands.json - 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.
# Normal workflow - compile_commands.json is auto-updated after every buildmake -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_fullFragment Storage
Section titled “Fragment Storage”- Fragments are stored as
.ccjfiles next to.ofiles inbuild/obj/ - They’re automatically cleaned with
make clean - They’re gitignored (inside
build/)
Troubleshooting
Section titled “Troubleshooting”compile_commands.json has missing entries:
# Ensure all targets are builtmake -j && make test-buildmake compile_commandsJSON validation errors:
# Check if JSON is validpython3 -m json.tool compile_commands.json > /dev/null
# If corrupted, do a full regenerationmake compile_commands_fullDependency Management
Section titled “Dependency Management”Git Submodules
Section titled “Git Submodules”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 librarywpa_supplicant- WiFi control (Linux only, auto-built)
Additionally, lib/helix-xml/ is the XML engine — a permanent MIT fork taken from LVGL at a15dcbeb5 (v9.4.0-358), the last commit before v9.5 removed XML from core. It is a submodule, but ours: prestonbrown/helix-xml. That makes its workflow the opposite of every other submodule here — edit the files directly, commit and push inside lib/helix-xml/, then commit the bumped pointer in this repo. It gets no patches/*.patch entry, and it is excluded from clang-format. See HELIX_XML_FORK.md.
Being its own repo, it also carries its own tests and its own CI. lib/helix-xml/tests/ is a standalone CMake + Unity suite that builds the engine against a pinned upstream LVGL v9.5.0 rather than our patched lib/lvgl; run it with make test-xml (make test does not build it), and see TESTING.md § “helix-xml Engine Tests”. .github/workflows/ci.yml inside the submodule covers a gcc + clang matrix, ASAN/UBSAN, and a conf-guards job. scripts/quality-checks.sh runs the suite on commits that stage a lib/helix-xml change, but only once build/helix-xml-tests/ has been configured by hand - the first configure fetches LVGL and is too slow for a commit hook.
Automatic handling: Submodule dependencies are built automatically when missing. Patches are applied automatically before builds. Never commit changes directly to submodules - always create patches instead.
SDL2 is a system dependency installed via package manager:
# macOSbrew install sdl2
# Debian/Ubuntusudo apt install libsdl2-dev
# Fedora/RHELsudo dnf install SDL2-develThe Makefile uses sdl2-config to auto-detect paths:
SDL2_CFLAGS := $(shell sdl2-config --cflags)SDL2_LIBS := $(shell sdl2-config --libs)Troubleshooting
Section titled “Troubleshooting”Patch Application Fails
Section titled “Patch Application Fails”Symptom: ✗ <label> does not apply to a clean checkout (from make reapply-patches),
or ⚠ <label>: its marker is absent from the checkout / ⚠ <label> is not verifiable in place (from an incremental build)
Causes:
- The patch drifted: a sibling patch moved the context it needs, and it matches no reachable state of the submodule
- The patch was edited without regenerating it against the patched tree
Solution — let the from-clean run name every drifted patch, then
regenerate each one it names (patches/README.md § “Regenerating a patch
whose file is shared”):
make reapply-patchesBuild Performance
Section titled “Build Performance”Symptom: Slow compilation
The build compiles ~566 app source files. The dominant cost is template instantiation and optimization passes (not header parsing — preprocessing takes <1s even for the worst files). At -O2, individual files take 8–15s to compile.
Speed tiers (full app rebuild, 32-core machine):
| Method | Wall time | Notes |
|---|---|---|
make -j (cold, no ccache) |
~4.5 min | Baseline |
make dev (cold, no ccache) |
~2.5 min | -O0 skips optimizer passes |
make -j (ccache populated) |
~38 sec | 98% cache hit rate |
make dev (ccache populated) |
~20 sec | -O0 + ccache |
Touch one .cpp, rebuild |
~7 sec | Recompile + relink |
Touch widely-included .h |
~8 sec | ccache direct hit (content unchanged) |
Solutions (most impactful first):
-
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/Debiansudo apt install ccache# macOSbrew install ccache -
Use
make devfor daily development — builds at-O0, cutting per-file compile time roughly in half. Library code still builds at-O2since it rarely changes. The binary is larger and slower at runtime, but compilation is ~2x faster.Terminal window make dev # -O0, auto-parallelmake OPT=1 -j # -O1 (middle ground: some optimization, faster than -O2)make -j # -O2 (default, for release/CI) -
Use parallel builds:
make -j(auto-detects all cores) -
Use incremental builds:
make -jinstead ofmake clean && make
Header fan-out — changing these headers triggers the most recompilation:
| Header | Files affected |
|---|---|
theme_manager.h |
~144 |
ui_update_queue.h |
~144 |
moonraker_api.h |
~122 |
app_globals.h |
~121 |
printer_state.h |
~104 |
With ccache installed, touching these headers without content changes costs ~8s (direct cache hit). Actual content changes recompile all dependents (~2 min at -O2, ~1 min at -O0).
Forced include (include/lvgl_pch.h, $(FORCED_INCLUDE) in the Makefile): covers LVGL, helix-xml, spdlog, nlohmann JSON, and common STL headers, and is passed as -include to every app and test C++ source, so many sources compile only because of it. It is an ordinary header, not precompiled: every TU parses it and tracks it through its .d file, so editing it rebuilds every C++ object. Don’t add project headers to it — only stable external libraries. LVGL’s own C++ sources (ThorVG, compiled out by lv_conf.h) do not get it.
ccache across worktrees and Docker cross-builds
Section titled “ccache across worktrees and Docker cross-builds”ccache (~/.ccache) is shared per-user, but two things stop it from being reused as widely as you’d expect:
1. Worktree path mismatch (native builds). Because the native build compiles with -g and ccache defaults to hash_dir=true, the absolute working directory is part of the cache key — so the same source in .worktrees/foo/ misses everything the main tree cached. setup-worktree.sh configures ccache (base_dir=$HOME, hash_dir=false, max_size=25G) so worktree builds reuse the main tree’s objects. See Git Worktrees → Why worktree builds are fast for the full rationale and caveats. If you build outside $HOME (e.g. /tmp), set CCACHE_BASEDIR to a common ancestor yourself.
2. Docker cross-builds use a separate, per-target cache. Containers can’t see ~/.ccache, so each *-docker target bind-mounts its own persistent cache directory (mk/cross.mk):
DOCKER_CCACHE_BASE ?= $(HOME)/.cache/helixscreen-ccachedocker-ccache-args = -v "$(DOCKER_CCACHE_BASE)/$(1)":/ccache -e CCACHE_DIR=/ccacheSo make pi-docker caches into ~/.cache/helixscreen-ccache/pi/, make ad5m-docker into .../ad5m/, etc. — one cache per architecture (they must stay separate; a Pi aarch64 object is meaningless to an AD5M armv7-a build). First cross-build of a target is cold; subsequent ones hit ~98%. Override the base location with DOCKER_CCACHE_BASE=/path make pi-docker. To wipe a single target’s cache, rm -rf ~/.cache/helixscreen-ccache/<target>.
Every Docker cross-build runs through scripts/pool-docker.sh. With jobpool installed, the container’s make joins the machine pool (its -j is dropped) and any other in-container build holds pool tokens through helix-claim hold; without jobpool the command runs exactly as written.
Clang Standard Library Issues (Arch Linux)
Section titled “Clang Standard Library Issues (Arch Linux)”Symptom: fatal error: 'stdlib.h' file not found at #include_next <stdlib.h>
Cause: Clang can’t find GCC’s libstdc++ headers on bleeding-edge distros (Arch with GCC 15+).
Automatic Fix: The build system detects this and auto-falls back to g++. You’ll see:
Note: clang++ has stdlib issues on this system, using g++ insteadManual Override: Force a specific compiler:
CXX=g++ CC=gcc make -j # Use GCCCXX=clang++ make -j # Force Clang (may fail)SDL2 Not Found
Section titled “SDL2 Not Found”Symptom: sdl2-config: command not found
Solutions:
# macOSbrew install sdl2
# Debian/Ubuntusudo apt install libsdl2-dev
# Verify installationwhich sdl2-configsdl2-config --versionMakefile Variable & Target Reference
Section titled “Makefile Variable & Target Reference”Absorbed from the helix-build skill so there is one home for it. Values verified against
Makefile and mk/*.mk.
Compiler and flags
Section titled “Compiler and flags”| Variable | Default | Notes |
|---|---|---|
CC |
clang > gcc | Auto-detected, with a stdlib test for clang |
CXX |
clang++ > g++ | Auto-falls back on a broken libstdc++ |
OPT |
2 |
Optimization level: 0 (dev), 1, 2 (release) |
CXXFLAGS |
-std=c++17 -Wall -Wextra -O{OPT} -g |
Extended per platform |
SUBMODULE_CFLAGS |
-std=c11 -O2 -g -D_GNU_SOURCE -w |
Third-party code, warnings suppressed |
DEPFLAGS |
-MMD -MP |
Header dependency tracking |
Version
Section titled “Version”Read from VERSION.txt and injected as -DHELIX_VERSION="...": HELIX_VERSION,
HELIX_VERSION_MAJOR / _MINOR / _PATCH.
HELIX_VERSION is the whole string, prerelease suffix included
(1.1.0-beta.1). The three numeric defines come from the core triple, because they
are compiled as integers: splitting the full string on . puts 0-beta in the
patch field, and -DHELIX_VERSION_PATCH=0-beta fails to compile in every
translation unit that includes helix_version.h. Ordering a prerelease against a
release is helix::version::Version’s job, not the preprocessor’s.
The short git hash is not a global define. It changes on every commit, and
VERSION_DEFINES lands on every translation unit’s command line, which ccache’s
direct mode hashes — so a global -DHELIX_GIT_HASH invalidated the whole
project’s cache on every push (measured: 86% direct hits on a sha matching the
restored cache, 43.7% on any other, and a 12-minute build became two hours).
scripts/gen-git-hash.sh writes it to build/generated/helix_git_hash.h
instead, rewriting only when the hash changes, and only
src/system/helix_version.cpp includes it. Read it through helix_git_hash()
from helix_version.h; the macro is not visible anywhere else.
Build directories
Section titled “Build directories”| Variable | Default | Per-platform override |
|---|---|---|
BUILD_DIR |
build |
mk/cross.mk sets per platform |
BIN_DIR |
build/bin |
e.g. build/pi/bin |
OBJ_DIR |
build/obj |
e.g. build/pi/obj |
BUILD_SUBDIR |
(none) | Platform name: pi, ad5m, k1 |
Cross-compile variables
Section titled “Cross-compile variables”| Variable | Purpose |
|---|---|
TARGET_ARCH |
Target architecture for the active cross target |
TARGET_TRIPLE |
Toolchain triple (e.g. aarch64-linux-gnu) |
STRIP_BINARY |
Whether to strip the output binary for size |
FONT_TIERS |
Which font size tiers to embed for the target — see below |
FONT_TIERS
Section titled “FONT_TIERS”Font faces are the largest single chunk of .rodata, so each target links only the
tiers it can actually display. Legal values are all (the default, mk/fonts.mk)
or any subset of micro tiny small medium large xlarge xxlarge. Assignments live per
target in mk/cross.mk:
PLATFORM_TARGET |
Tiers |
|---|---|
pi, pi-fbdev, pi-both, pi32, pi32-fbdev, pi32-both |
all |
x86, x86-fbdev, x86-both, native |
all |
ad5m, ad5m-br, ad5x |
medium large |
mips / k1, k1-dynamic |
small medium |
k2 |
large xlarge |
snapmaker-u1 |
tiny small |
cc1, yocto |
micro tiny |
HELIX_MAX_FONT_TIER is derived from this (mk/cross.mk; micro=0 …
xxlarge=6). Two consumers read it: theme_manager uses it to distinguish an
expected-missing font (pruned by tier) from an unexpected-missing one (a build bug),
and cjk_font_manager uses it to pick its CJK face.
The consequence for layout work: a <string> token naming a face outside the
target’s tiers silently fails to register on that target, and the token falls back
down the ladder. If you add a font token for a large tier, check it against the
tier list of the smallest device that will run it.
FONTS_XXLARGE additionally carries six faces above the authored ladder —
noto_sans_48/64, noto_sans_bold_48/64, noto_sans_light_32/40 — which exist only
for the high-DPI UI scale factor to step into on phone-class panels. No printer target
declares the xxlarge tier, so none of them links these (~11MB of .rodata). Android
does not build through this Makefile at all: android/app/jni/CMakeLists.txt globs
assets/fonts/*.c wholesale, so every face including these six is linked, and its
target_compile_definitions declares HELIX_MAX_FONT_TIER=6 and
HELIX_HAS_HIDPI_FONTS=1 to match, since both macros default to a conservative fallback
when a build path leaves them undefined (HELIX_HAS_HIDPI_FONTS to 0), which would
otherwise drop these six faces’ mappings and asset registrations for a build that
did link them.
Feature gates
Section titled “Feature gates”| Variable | Default | Purpose |
|---|---|---|
ENABLE_SDL |
yes (native) | SDL2 desktop display |
ENABLE_OPENGLES |
no (all targets) | Requests LVGL’s DRM EGL path; the #error in display_backend_drm.cpp fails the build if set without LV_USE_OPENGLES |
ENABLE_GLES_3D |
yes (Linux) | 3D gcode rendering |
ENABLE_SCREENSAVER |
yes (desktop/Pi) | Flying toasters |
ENABLE_MOCKS |
yes (no on cc1/ad5m/ad5m-br) |
Mock backends for development |
ENABLE_SSL |
per target | OpenSSL for HTTPS/WSS |
HELIX_HAS_LABEL_PRINTER |
1 | Label printer feature |
HELIX_HAS_CFS |
1 | CFS feature |
HELIX_HAS_IFS |
1 | IFS feature |
HELIX_HAS_ACE |
1 | ACE vendor backend (0 on non-Anker cross targets) |
HELIX_HAS_QIDI |
1 | QIDI Box vendor backend (0 on non-QIDI cross targets) |
HELIX_HAS_SNAPMAKER |
1 | SnapSwap vendor backend (0 except snapmaker-u1) |
HELIX_BACKLIGHT_FLOOR_PERCENT |
0 | Lowest visible raw backlight level, percent of the raw range. 20 on k2, applied only by the sysfs and Sonic Pad CLI backends (the sysfs panel on community K2 firmware renders lower levels as off, #1709); the Allwinner /dev/disp backend ignores it. /display/backlight_floor_percent in settings.json overrides it on every backend |
Linker flags by platform
Section titled “Linker flags by platform”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 -lGLESv2Pi (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 -lpthreadK1 dynamic -Wl,-Bstatic -lhv -lwpa_client -lnl-genl-3 -lnl-3 -lstdc++fs -Wl,-Bdynamic -lstdc++ -lz -lm -lpthread -lrt -ldl -latomic -lgcc_sK2 (ARM musl) -lhv -lwpa_client -lnl-genl-3 -lnl-3 -ldl -lz -lm -lpthreadYocto preserves bitbake LDFLAGS, appends the project libsSource organization
Section titled “Source organization”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.
Targets
Section titled “Targets”| Build | |
|---|---|
all |
Default: main binary with dependency checks |
build |
Clean parallel build with timing |
dev |
OPT=0 -j fast development build |
strict |
Build with -Werror |
clean |
Remove all build artifacts |
install |
Stage to DESTDIR/opt/helixscreen/ |
| Code quality | |
|---|---|
format / format-staged |
clang-format + xmllint, all or staged files |
quality |
Run scripts/quality-checks.sh |
check-deps / install-deps |
Verify, then interactively install dependencies |
| Assets | |
|---|---|
regen-fonts / generate-fonts |
Regenerate MDI icon fonts |
validate-fonts |
Verify icons are present in the compiled fonts |
icon |
Generate the app icon from the logo |
apply-patches / reset-patches |
Apply or revert submodule patches |
| Tools and debug | |
|---|---|
compile_commands / compile_commands_full |
Merge .ccj fragments (~1-2s) or fully regenerate |
moonraker-inspector / tools |
Diagnostic tools |
symbols / strip |
Extract .sym + .debug, or strip for size (cross-compile only) |
print-ldflags / print-target-cflags / print-cxxflags / print-strip |
Print computed values |
Best Practices
Section titled “Best Practices”Development Workflow
Section titled “Development Workflow”- Edit code in
src/orinclude/ - Run
make dev- fast build at-O0with auto-patching (ormake -jfor optimized build) - Test with
./build/bin/helix-screen - Screenshot with
./scripts/screenshot.sh(auto-opens on display 1) - Commit with working incremental changes
For debugging build issues:
make cleanmake V=1 # Verbose sequential buildClean Builds
Section titled “Clean Builds”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).
Submodule Management
Section titled “Submodule Management”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
Cloud sessions: the warm environment
Section titled “Cloud sessions: the warm environment”Claude Code cloud sessions run on a fresh Ubuntu 24.04 VM per environment, and a cold one pays
apt + submodule init + a full program and test build before it can do anything — on the order of
hours. scripts/cloud/env-setup.sh and scripts/cloud/session-start.sh exist to make that a
one-time cost per environment rather than a per-session one.
The setup script. The cloud platform runs scripts/cloud/env-setup.sh once, as root, before
the repo is cloned — there is no checkout for it to operate on, only system and network paths — and
then snapshots the whole filesystem as the starting point of every later session on that
environment. It must exit 0 and finish in a few minutes, so every step is best-effort and logged
to /var/log/helix-env-setup.log: apt-installing the native build’s dependencies
(scripts/cloud/env-setup.sh#install_apt_packages names the single package list shared with CI),
downloading and extracting a prebuilt ccache, seeding a full clone at /opt/helixscreen-seed for
submodule alternates, and prebuilding a Python venv at /opt/helix-venv.
/opt/helix-cloud-env/READY records what landed and gates everything downstream.
The snapshot’s lifetime. The platform reuses the snapshot until either ~7 days pass or the
text pasted into the environment dialog changes. Pushing a new scripts/cloud/env-setup.sh to
main does not refresh it on its own: the pasted three-liner re-fetches that file only when the
snapshot is being rebuilt. To force a rebuild before the timer would, change the dialog text — a
dated comment line there is a legitimate one-line edit purely for that.
The session hook. scripts/cloud/session-start.sh runs as a SessionStart hook
(.claude/settings.json#SessionStart) in every session, cloud or not. On a machine that never
wrote the READY marker — a laptop, thelio — it exits silently on its first line, by design: the
pieces below must never run uninvited. On a warmed cloud VM, once the repo exists, it wires
/opt/helixscreen-seed’s objects in as a submodule alternate (so git submodule update borrows
objects instead of fetching them), runs that submodule init, and symlinks .venv to the prebuilt
one before reconciling it with make venv-setup.
Nothing prefetches a ccache, and the measurement is why. Measured on a cloud box: of the calls
a first build made against a published ccache snapshot, 18.75% hit; of the 955 compilations in a
rebuild after 191 files moved under a new namespace, none did. The percentage a session reads
from ccache -s at startup is the snapshot’s own banked history — it never grows from that number,
it only dilutes as the box builds. A full make test took about 85 minutes with 98% on screen.
ccache itself is still installed and configured, and earns its keep within a session. What does not pay for itself is shipping a prebuilt one between machines.
Do not re-add a prebuilt ccache snapshot
Section titled “Do not re-add a prebuilt ccache snapshot”A build-cache workflow used to publish ccache-linux-x64.tar.zst as a release asset on a
build-cache tag. It is gone, and the bar for bringing anything like it back is high, because the
costs are easy to miss and the benefit was measured at the numbers above:
- No consumer.
env-setup.shnever fetched it. It ran on every push tomainplus daily, 12-28 minutes warm and 117 cold, to produce something only a human could use by hand. - A silent size ceiling. A GitHub release asset must be under 2 GiB. The snapshot grew past it
and the upload began failing with a bare
HTTP 422: size must be less than 2147483648, several steps after the cause. It stayed broken for weeks without anyone noticing, precisely because nothing downstream depended on it. - A cache pinned at its own ceiling is a decaying asset. Capping
max_sizeunder the 2 GiB limit keeps it publishable but makes it evict internally on every run, so the hit rate that was already 18.75% only falls further as the tree grows.
If someone wants to revisit this, the case has to start with a measurement on the target machine showing a hit rate that survives a refactor - not with a faster transport or a bigger size limit. R2 would sidestep the 2 GiB cap (credentials already exist for releases), so “we ran out of room” is not by itself a reason to rebuild this.
ccache-warm.yml and cache-prune.yml are a different pipeline and are unaffected: they warm and
prune the Actions-cache ccache behind this repo’s cross-compile CI, restored from the previous run
on the same branch rather than from a snapshot, so the staleness above does not apply to them.
The compiler_check = content setting stays in the generated ccache.conf: a cached object is
reused only when the compiler that made it is byte-identical to the one asking.
Pasting the setup script into the environment dialog. The platform wants the script inline, not a path, so the pasted script re-fetches the real one and never blocks environment creation on a network hiccup:
#!/bin/bashcurl -fsSL https://raw.githubusercontent.com/prestonbrown/helixscreen/main/scripts/cloud/env-setup.sh -o /tmp/helix-env-setup.sh || exit 0bash /tmp/helix-env-setup.sh || trueKnown cache invalidation. Makefile#VERSION_DEFINES puts -DHELIX_VERSION on every
translation unit’s command line, and ccache’s direct mode hashes the full command line — so a
VERSION.txt bump misses the entire project’s cache exactly once, the same way it does for
regular CI (see the comment above VERSION_DEFINES). HELIX_GIT_HASH deliberately avoids this by
reaching only one generated header instead of every TU.
Briefing a cloud worker
Section titled “Briefing a cloud worker”A coordinator session spawns worker sessions, hands each a scope, and merges their branches. What follows is the part of that protocol which is a property of this repo rather than of any one coordinator’s plan.
Give a worker a queue, not a ticket. On a 4-core cloud box the program binary takes about 44 minutes and the test binary another 55 before a worker can run anything — an hour and a half of machine time that a one-issue scope pays in full and then throws away. Nothing removes that cost: a prebuilt ccache measured 18.75% of calls hitting on a first build and 0.00% after a wide header moved, so a session that starts by building pays roughly the same either way (see the warm environment section). What a queue saves is the second cost, which is real: a worker that has already read a subsystem answers the next question in it far faster than a fresh session does.
So scope a worker to an area with two to four related issues in dependency order, and say which
may be dropped if time runs short. Sequence anything touching the same files behind the change that
moves them, and keep shared counters — the ratchet baselines in the gate files under scripts/qc/ — to one
worker at a time, since two workers each ratcheting the same number is a guaranteed merge conflict
over a line neither of them cares about.
A small finding in the diff’s own neighbourhood is fixed, not filed. Workers surface more than they were sent for, and an issue is the reflex — but an issue costs triage, a milestone, a label, a brief and a box, which for a twenty-minute refactor is more than the fix. If the finding is small and sits in code the worker has already read, tell it to fix it in the same branch and say so in the report. File one only when the work genuinely does not belong to that worker: it needs a decision somebody else owns, it is blocked on something external, it is large enough to want its own scope, or it lands in files another worker is holding. A queue that closes four issues and opens three has not moved as far as the count suggests.
Never leave a worker blocked on the coordinator. A worker waiting for a merge to appear on
main is an idle box. When a scope depends on work still in review, say so in the brief, name what
it should do meanwhile, and send the unblock as soon as it lands.
The reply channel is git. Cross-session chat does not resolve from a worker container, so a
worker reports by pushing a short status file early and a full report at the end to a throwaway
claude/report-<issue> branch, never onto its work branch and never as a PR. The push
triggers in .github/workflows/build.yml and quality.yml exclude that branch pattern, so status
pushes cost no CI.
Tell a worker the formatter rule explicitly. scripts/quality-checks.sh accepts only the
pinned clang-format from .venv, so a worker runs make venv-setup before make quality and
formats only the files its own diff touched. The sweep’s --auto-fix reformats every file in
CLANG_FORMAT_BASELINE, which belong to whoever is retiring them, not to the worker.
A push to a work branch costs a full CI run. Build, Code Quality and XML Lint all fire on the
claude/** namespace, and Build alone budgets 200 minutes. Push when the gates are green locally,
never to find out whether they are — a red run on a work branch is a signal the worker skipped a
check it could have run itself, and it queues behind everyone else’s work. A coordinator asking for
an early push to review in parallel is accepting that cost deliberately; a worker iterating against
CI is not.
One OPT flavor per tree. The pre-commit hook builds at the default optimization level, so a
worker that builds with OPT=0 makes every later hook run rewrite the objects it just wrote.
Default everywhere, and never two make invocations in one tree at once.
See Also
Section titled “See Also”- README.md - Project overview and quick start
- DEVELOPMENT.md - Development environment, workflow, and contributing
- ARCHITECTURE.md - The 15-minute whole-app model + chapter-series routing
- CLAUDE.md - Development context and AI assistant guidelines
- patches/README.md - Patch documentation