2026-05-06 21:59:33 +12:00
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 citron Emulator Project
# SPDX-License-Identifier: GPL-3.0-or-later
# =============================================================================
# build-citron-linux.sh — Local Linux build script with PGO + LTO + BOLT
#
# Lives in the citron repo root. Assumes the repo has already been cloned
# (with or without submodules — CPM handles all library dependencies and
# FFmpeg source at cmake configure time; no submodule init is required).
#
# PGO FLAG MANAGEMENT:
# This script manages all PGO and LTO compiler/linker flags directly, using
# CITRON_PGO_FLAGS_MANAGED_BY_SCRIPT=ON to suppress PGO.cmake's built-in
# flag injection.
#
# Why: PGO.cmake's default Clang path uses -fprofile-instr-generate (FE PGO
# only), has no path control, and never passes -fprofile-use to the linker
# command line. Without -fprofile-use on the linker, the LTO backend runs
# without profile guidance even when full LTO is active — negating most of
# the combined PGO+LTO benefit.
#
# This script passes explicit flags to CMAKE_C_FLAGS_<BT>,
# CMAKE_CXX_FLAGS_<BT>, and CMAKE_EXE_LINKER_FLAGS_<BT> so:
# - IR PGO uses -fprofile-generate / -fprofile-use (not -fprofile-instr-*)
# - Profraw files land in a controlled directory with %p PID expansion
# - The LTO linker backend receives -fprofile-use for LTCG with profile data
# - Profile runtime symbols are force-kept on the linker command line
#
2026-05-08 17:14:34 +12:00
# LTO + PGO LINKER FLAGS (use/csgenerate/bolt stages):
2026-05-06 21:59:33 +12:00
# For full LTO (-flto), the -fprofile-use flag must appear on
# CMAKE_EXE_LINKER_FLAGS as well as the compile flags, so the LTO backend
# applies the profile during link-time code generation.
#
# CS-IRPGO (csgenerate stage):
# IR PGO only. Layers context-sensitive counters on top of a stage1-optimized
# binary. Provides per-call-site counter data vs per-function-definition.
# CRITICAL INVARIANT: csgenerate always uses default.profdata (stage1 only)
# as -fprofile-use input, never merged.profdata. If merged.profdata (which
# already contains CS records) were used, the inlining decisions would shift
# relative to the plain stage1 baseline, restructuring the IR that the new CS
# counters are keyed to — yielding profile hash mismatches at the use stage.
#
# STAGES:
# setup Detect your distro and install required build tools (run once).
# generate Stage 1: PGO instrumented build.
# csgenerate Stage 1b: [IR PGO only] CS-IRPGO instrumented build.
# merge Merge collected .profraw files into .profdata.
# summary Show profile statistics.
# use Stage 2: PGO + LTO optimized build (or baseline if --pgo none).
2026-05-08 17:14:34 +12:00
# bolt Stage 3: BOLT binary layout optimization.
2026-05-06 21:59:33 +12:00
# clean Remove build directories (profile data preserved).
#
# USAGE:
# ./build-citron-linux.sh <stage> [options]
#
# OPTIONS:
# --build DIR Build root directory (default: ./build)
# --jobs N Parallel compile jobs (default: nproc)
# --pgo ir|fe|none PGO instrumentation mode (default: ir)
# ir = LLVM IR PGO (-fprofile-generate/-fprofile-use)
# LTO mode MUST match between generate,
# csgenerate, and use stages.
# fe = Frontend PGO (-fprofile-instr-generate/-use)
# CS-IRPGO not available with fe.
# none = No PGO. Baseline build.
# --lto thin|full|none LTO mode (default: full)
# --lite-lto Alias for --lto thin
# --no-lto Alias for --lto none
# --arch x86_64|v3|aarch64|auto
# Optimization target (default: auto)
# v3 = x86-64-v3 (AVX2, BMI, FMA — Haswell+)
# --unity Enable unity (jumbo) builds (~30-90% faster compile)
# --relwithdebinfo Include debug symbols alongside optimizations
# --clang-version N Clang version to use (default: 21)
# --help, -h Show this message
#
# TYPICAL WORKFLOWS:
#
# Baseline (no PGO):
# ./build-citron-linux.sh setup
# ./build-citron-linux.sh use --pgo none --lto full
# # Binary: ./build/use-nopgo/bin/citron
#
# IR PGO (recommended):
# ./build-citron-linux.sh setup
# ./build-citron-linux.sh generate --pgo ir --lto full
# # Run ./build/generate/bin/citron, play 2-3 games for 5-10 min, exit cleanly.
# ./build-citron-linux.sh use --pgo ir --lto full
# # Binary: ./build/use/bin/citron
#
# CS-IRPGO (two profiling sessions, better inlining data):
# ./build-citron-linux.sh generate --pgo ir --lto full
# # Run ./build/generate/bin/citron, play games, exit cleanly.
# ./build-citron-linux.sh use --pgo ir --lto full
# # (produces default.profdata — also a usable binary)
# ./build-citron-linux.sh csgenerate --pgo ir --lto full
# # Run ./build/cs-generate/bin/citron, play the same games, exit cleanly.
# # cs-default-*.profraw lands next to the binary — copy to build/pgo-profiles/cs/
# ./build-citron-linux.sh use --pgo ir --lto full
# # (auto-merges CS layer → merged.profdata, rebuilds)
# # Binary: ./build/use/bin/citron
#
# IR PGO + BOLT:
# (after completing 'use' above)
# ./build-citron-linux.sh bolt --pgo ir --lto full
# # BOLT pauses — run instrumented binary, exit, press Enter.
# # Binary: ./build/bolt/citron
#
# DEPENDENCIES MANAGED BY CMAKE (not this script):
# All C++ library dependencies are fetched from source by CPM at cmake
# configure time. No system library packages are required.
# Qt is downloaded via aqt by CMakeModules/qt_download.cmake.
# FFmpeg source is downloaded via CPM; built by externals/ffmpeg/CMakeLists.txt.
# OpenSSL is built from source by CMakeModules/openssl_build.cmake.
#
# REQUIRED HOST TOOLS (installed by the setup stage):
# clang / clang++ / lld / llvm-profdata compiler toolchain
# cmake + ninja build system
# git CPM source fetches
# nasm FFmpeg assembly optimisations
# perl OpenSSL Configure script
# python3 + aqtinstall Qt binary download (invoked by cmake)
# autoconf + automake + make FFmpeg autotools build
# glslang (glslc) Vulkan shader compilation
2026-05-07 16:41:59 +12:00
# patchelf bundle RPATH normalization
2026-07-01 02:12:16 +10:00
# gamemode bundled into AppImage if present (package stage)
2026-05-06 21:59:33 +12:00
# =============================================================================
set -euo pipefail
# =============================================================================
# Locate repo root (script lives in repo root)
# =============================================================================
SCRIPT_DIR = " $( cd " $( dirname " ${ BASH_SOURCE [0] } " ) " && pwd ) "
[[ -f " ${ SCRIPT_DIR } /CMakeLists.txt" ]] \
|| { echo "[ERROR] CMakeLists.txt not found next to this script." >& 2; exit 1; }
# =============================================================================
# Configuration defaults
# =============================================================================
CLANG_VERSION = " ${ CLANG_VERSION :- 21 } "
BUILD_ROOT = " ${ BUILD_ROOT :- ${ SCRIPT_DIR } /build } "
JOBS = " ${ JOBS :-$( nproc 2>/dev/null || echo 4) } "
LTO_MODE = " ${ LTO_MODE :- full } "
PGO_MODE = " ${ PGO_MODE :- ir } "
UNITY_BUILD = " ${ UNITY_BUILD :- OFF } "
BUILD_TYPE = " ${ BUILD_TYPE :- Release } "
2026-07-01 02:12:16 +10:00
NO_PACKAGE = " ${ NO_PACKAGE :- false } "
2026-05-06 21:59:33 +12:00
CPM_SOURCE_CACHE = " ${ CPM_SOURCE_CACHE :- ${ HOME } /.cache/cpm } "
CPM_SOURCE_CACHE = " ${ CPM_SOURCE_CACHE /# \~ / $HOME } "
2026-05-14 09:49:59 +10:00
# Uncomment to optimize for this machine's CPU — produces a non-portable binary
#MARCH_NATIVE="-march=native"
MARCH_NATIVE = " ${ MARCH_NATIVE :- } "
2026-05-06 21:59:33 +12:00
_ARCH_ARG = " ${ _ARCH_ARG :- auto } "
# =============================================================================
# Derived paths (updated when --build is passed)
# =============================================================================
_set_derived_paths() {
BUILD_GENERATE = " ${ BUILD_ROOT } /generate"
BUILD_CSGENERATE = " ${ BUILD_ROOT } /cs-generate"
BUILD_USE = " ${ BUILD_ROOT } /use"
BUILD_BOLT = " ${ BUILD_ROOT } /bolt"
PROFILE_DIR = " ${ BUILD_ROOT } /pgo-profiles"
BOLT_PROFILE_DIR = " ${ BUILD_ROOT } /bolt-profiles"
}
_set_derived_paths
_set_clang_tools() {
CLANG = "clang- ${ CLANG_VERSION } "
CLANGPP = "clang++- ${ CLANG_VERSION } "
LLVM_PROFDATA = "llvm-profdata- ${ CLANG_VERSION } "
LLVM_BOLT = "llvm-bolt- ${ CLANG_VERSION } "
MERGE_FDATA = "merge-fdata- ${ CLANG_VERSION } "
LLD = "lld- ${ CLANG_VERSION } "
}
_set_clang_tools
# =============================================================================
# Colour helpers
# =============================================================================
RED = '\033[0;31m' ; GREEN = '\033[0;32m' ; YELLOW = '\033[1;33m'
CYAN = '\033[0;36m' ; BOLD = '\033[1m' ; RESET = '\033[0m'
2026-05-08 17:14:34 +12:00
info() { echo -e " ${ CYAN } [INFO] ${ RESET } $* " >& 2; }
success() { echo -e " ${ GREEN } [OK] ${ RESET } $* " >& 2; }
warn() { echo -e " ${ YELLOW } [WARN] ${ RESET } $* " >& 2; }
2026-05-06 21:59:33 +12:00
error() { echo -e " ${ RED } [ERROR] ${ RESET } $* " >& 2; exit 1; }
header() {
2026-05-08 17:14:34 +12:00
echo -e "\n ${ BOLD }${ GREEN } ================================================================= ${ RESET } " >& 2
echo -e " ${ BOLD }${ GREEN } $* ${ RESET } " >& 2
echo -e " ${ BOLD }${ GREEN } ================================================================= ${ RESET } " >& 2
2026-05-06 21:59:33 +12:00
}
2026-05-14 16:01:51 +10:00
# =============================================================================
# Validate CPM_SOURCE_CACHE
# =============================================================================
if [[ " ${ CPM_SOURCE_CACHE } " == *" " * ]] ; then
error "CPM_SOURCE_CACHE (' ${ CPM_SOURCE_CACHE } ') contains spaces.\n" \
" CPM and some build tools do not support paths with spaces.\n" \
" Please set CPM_SOURCE_CACHE to a path without spaces, e.g.:\n" \
" export CPM_SOURCE_CACHE=\"/tmp/cpm-cache\""
fi
2026-05-06 21:59:33 +12:00
# =============================================================================
# Architecture flags
# =============================================================================
resolve_arch_flags() {
2026-05-14 09:49:59 +10:00
if [[ -n " ${ MARCH_NATIVE :- } " ]] ; then
ARCH_FLAGS = " ${ MARCH_NATIVE } "
else
local host_arch; host_arch = " $( uname -m) "
case " ${ _ARCH_ARG } " in
v3)
[[ " ${ host_arch } " == "x86_64" ]] \
|| error "--arch v3 requires an x86_64 host (this machine is ${ host_arch } )"
ARCH_FLAGS = "-march=x86-64-v3 -mtune=generic" ;;
x86_64) ARCH_FLAGS = "-march=x86-64 -mtune=generic" ;;
aarch64) ARCH_FLAGS = "-march=armv8-a -mtune=generic" ;;
auto| *)
case " ${ host_arch } " in
x86_64) ARCH_FLAGS = "-march=x86-64 -mtune=generic" ;;
aarch64) ARCH_FLAGS = "-march=armv8-a -mtune=generic" ;;
*) ARCH_FLAGS = "" ;;
esac ;;
esac
fi
2026-05-06 21:59:33 +12:00
info "Architecture flags: ${ ARCH_FLAGS :- (none) } "
}
# =============================================================================
# LTO helpers
# =============================================================================
lto_cmake_flag() { case " ${ LTO_MODE } " in full| thin) echo "ON" ;; *) echo "OFF" ;; esac ; }
lto_clang_flag() { case " ${ LTO_MODE } " in full) echo "-flto" ;; thin) echo "-flto=thin" ;; *) echo "" ;; esac ; }
# =============================================================================
# PGO flag helpers
# =============================================================================
pgo_gen_compile_flag() {
# $1 = output directory for profraw files
local output_dir = " $1 "
[[ " ${ PGO_MODE } " == "ir" ]] \
2026-05-14 09:49:59 +10:00
&& echo "-fprofile-generate=\" ${ output_dir } /default-%p.profraw\"" \
|| echo "-fprofile-instr-generate=\" ${ output_dir } /default-%p.profraw\""
2026-05-06 21:59:33 +12:00
}
pgo_use_compile_flag() {
# $1 = path to .profdata file
local profdata = " $1 "
[[ " ${ PGO_MODE } " == "ir" ]] \
2026-05-14 09:49:59 +10:00
&& echo "-fprofile-use=\" ${ profdata } \"" \
|| echo "-fprofile-instr-use=\" ${ profdata } \" -Wno-profile-instr-unprofiled -Wno-profile-instr-out-of-date"
2026-05-06 21:59:33 +12:00
}
# build_compile_flags: assemble CMAKE_C/CXX_FLAGS_<BT> for a stage.
# Args: stage (generate|csgenerate|use|nopgo) [extra]
# generate: extra = profile output directory
# csgenerate: extra = "stage1_profdata:cs_output_dir"
# use: extra = path to .profdata
# nopgo: extra = (unused)
build_compile_flags() {
local stage = " $1 " ; local extra = " ${ 2 :- } "
local debug_flag = "" ; [[ " ${ BUILD_TYPE } " == "RelWithDebInfo" ]] && debug_flag = "-g"
local lto_flag; lto_flag = " $( lto_clang_flag) "
local base = "-O3 -DNDEBUG ${ debug_flag } -USuccess -UNone ${ ARCH_FLAGS :- } -Wno-error -w"
case " ${ stage } " in
generate)
echo " ${ base } $( pgo_gen_compile_flag " ${ extra } " ) ${ lto_flag :+ ${ lto_flag }} "
;;
csgenerate)
# Use stage1 profile to guide inlining; layer CS counters on top.
local pd = " ${ extra %%:* } " ; local cs_dir = " ${ extra ##*: } "
2026-05-14 09:49:59 +10:00
echo " ${ base } $( pgo_use_compile_flag " ${ pd } " ) -fcs-profile-generate=\" ${ cs_dir } /cs-default-%p.profraw\" ${ lto_flag :+ ${ lto_flag }} "
2026-05-06 21:59:33 +12:00
;;
use)
# LTO flag before pgo_use so profile flag reaches the LTO backend.
echo " ${ base } ${ lto_flag :+ ${ lto_flag } } $( pgo_use_compile_flag " ${ extra } " ) "
;;
nopgo)
echo " ${ base }${ lto_flag :+ ${ lto_flag }} "
;;
esac
}
# build_linker_flags: assemble CMAKE_EXE_LINKER_FLAGS_<BT> for a stage.
#
# Critical for IR PGO + LTO:
# -fprofile-use MUST appear on the linker command line when LTO is active.
# Without it the LTO backend's link-time code generation pass runs without
# profile guidance — negating inlining and hot-path layout decisions that
# require profile data to be meaningful at LTCG time.
#
# For the generate/csgenerate stages, we also force-keep profile runtime
# entry points that the linker might otherwise dead-strip:
# -u __llvm_profile_write_file pulls InstrProfilingFile.o (write logic)
# -u __llvm_profile_runtime pulls InstrProfilingRuntime.o whose
# constructor registers the flush-on-exit hook
build_linker_flags() {
local stage = " $1 " ; local extra = " ${ 2 :- } "
local debug_flag = "" ; [[ " ${ BUILD_TYPE } " == "RelWithDebInfo" ]] && debug_flag = "-g"
local lto_flag; lto_flag = " $( lto_clang_flag) "
2026-05-09 14:09:00 +12:00
local base = "-fuse-ld= ${ LLD } -Wl,-z,nopack-relative-relocs"
2026-05-06 21:59:33 +12:00
case " ${ stage } " in
generate| csgenerate)
# Include the full compile flags so the profiling runtime is linked
# correctly, then add the force-keep symbols.
echo " ${ base } $( build_compile_flags " ${ stage } " " ${ extra } " ) -Wl,-u,__llvm_profile_write_file,-u,__llvm_profile_runtime"
;;
use)
# Pass pgo_use flag to linker so LTO backend gets profile guidance.
echo " ${ base } ${ debug_flag :+ ${ debug_flag } }${ lto_flag :+ ${ lto_flag } } $( pgo_use_compile_flag " ${ extra } " ) "
;;
nopgo)
echo " ${ base }${ lto_flag :+ ${ lto_flag }} "
;;
esac
}
# =============================================================================
# Distro / package manager detection
# =============================================================================
detect_pkg_manager() {
if command -v apt-get & >/dev/null; then echo "apt"
elif command -v pacman & >/dev/null; then echo "pacman"
elif command -v dnf & >/dev/null; then echo "dnf"
elif command -v yum & >/dev/null; then echo "yum"
elif command -v zypper & >/dev/null; then echo "zypper"
elif command -v emerge & >/dev/null; then echo "emerge"
else echo "unknown"
fi
}
# =============================================================================
# Stage: setup
# =============================================================================
stage_setup() {
header "Setting Up Build Environment"
local pkg_mgr; pkg_mgr = " $( detect_pkg_manager) "
info "Package manager: ${ pkg_mgr } "
case " ${ pkg_mgr } " in
apt) _setup_apt ;;
pacman) _setup_pacman ;;
dnf) _setup_dnf ;;
yum) _setup_yum ;;
zypper) _setup_zypper ;;
emerge) _setup_emerge ;;
*)
warn "Unrecognised package manager. Install manually:"
warn " clang ( ${ CLANG_VERSION } +), lld, llvm-profdata, cmake, ninja, git,"
2026-05-07 16:41:59 +12:00
warn " nasm, perl, python3, python3-pip, autoconf, automake, make, glslang-tools, patchelf"
2026-05-06 21:59:33 +12:00
;;
esac
_install_llvm_clang
_install_aqt
_check_bolt
_verify_tools
}
_setup_apt() {
info "Updating apt package lists..."
sudo apt-get update -qq
info "Installing core build tools via apt..."
sudo apt-get install -y \
build-essential cmake ninja-build git pkg-config \
python3 python3-pip curl wget xz-utils \
nasm yasm perl \
autoconf automake make \
glslang-tools \
2026-05-07 16:41:59 +12:00
patchelf \
2026-05-06 21:59:33 +12:00
lsb-release software-properties-common gnupg \
2026-07-02 13:23:53 +10:00
libelf-dev libssl-dev libzstd-dev libudev-dev zstd \
2026-07-01 02:12:16 +10:00
libgl-dev libopengl-dev \
2026-07-02 13:23:53 +10:00
libxkbcommon-dev
# linux-tools-common/generic are best-effort: the versioned
# linux-tools-$(uname -r) package frequently doesn't exist on the runner's
# kernel and is intentionally optional. Keeping them in a separate call
# (not grouped with the mandatory packages above) ensures a missing
# perf/tools package never masks a failure in the required deps.
sudo apt-get install -y linux-tools-common linux-tools-generic 2>/dev/null || true
2026-05-06 21:59:33 +12:00
sudo apt-get install -y "linux-tools- $( uname -r) " 2>/dev/null || true
2026-07-01 02:12:16 +10:00
# NOTE: libgl-dev and libopengl-dev are MANDATORY even though citron is
# Vulkan-only and never calls any OpenGL API at runtime. The aqt-downloaded
# Qt6 (linux_gcc_64) was built against the GLVND OpenGL interface, so its
# Qt6GuiConfig.cmake requires cmake's WrapOpenGL module to find
# libOpenGL.so (from libopengl-dev) and libGL.so / GL headers (libgl-dev)
# at configure time. Without them find_package(Qt6 ... Widgets) fails:
# Could NOT find WrapOpenGL → Qt6Gui NOT FOUND → Qt6Widgets NOT FOUND
# Both packages are tiny (~200 KB combined) and must not be in the optional
# block below, which can fail atomically and be silently skipped.
# ── Hardware video acceleration for bundled FFmpeg ──────────────────────
# externals/ffmpeg/CMakeLists.txt uses pkg_check_modules to detect VAAPI,
# VDPAU, and NVDEC, then conditionally enables them in FFmpeg's configure
# script. Missing packages cause graceful fallback (--disable-vaapi etc.),
# EXCEPT for the packages that are REQUIRED inside the LIBVA_FOUND block:
#
# if(LIBVA_FOUND)
# pkg_check_modules(LIBDRM libdrm REQUIRED) ← hard-fail if absent
# find_package(X11 REQUIRED) ← hard-fail if absent
# pkg_check_modules(LIBVA-DRM libva-drm REQUIRED)
# pkg_check_modules(LIBVA-X11 libva-x11 REQUIRED)
#
# Additionally, SDL2's cmake (also CPM-sourced) runs CheckX11() whenever
# libX11.so.6 is findable on the system. Inside CheckX11(), SDL2 2.x
# unconditionally hard-fails if Xext.h is absent:
#
# message_error("*** ERROR: Missing Xext.h, maybe you need to
# install the libxext-dev package?")
#
# This means libxext-dev MUST always be installed alongside libx11-dev.
# Installing libx11-dev without libxext-dev triggers the SDL2 hard-fail.
#
# These cascading REQUIRED constraints make libva-dev, libdrm-dev,
# libx11-dev, and libxext-dev an all-or-nothing group: if libva-dev
# installs but any of its sibling REQUIRED packages don't, cmake
# hard-fails the build. APT's atomic install behaviour (the whole command
# either succeeds or is rolled back) is what keeps this safe.
# DO NOT use --ignore-missing on this group, and DO NOT split it.
# ── VAAPI + X11 core (all-or-nothing) ───────────────────────────────────
# libva-dev → libva.pc, libva-drm.pc, libva-x11.pc
# libva-drm2 → runtime libva-drm.so (linked by FFmpeg)
# libva-x11-2 → runtime libva-x11.so
# libdrm-dev → libdrm.pc (REQUIRED by FFmpeg cmake when LIBVA_FOUND)
# libx11-dev → X11 headers (REQUIRED by FFmpeg cmake when LIBVA_FOUND)
# libxext-dev → Xext.h (REQUIRED by SDL2 cmake whenever libX11.so.6 is
# findable, regardless of VAAPI; must travel with libx11-dev)
info "Installing VAAPI + X11 core packages (required together)..."
sudo apt-get install -y \
libva-dev libva-drm2 libva-x11-2 \
libdrm-dev \
libx11-dev libxext-dev \
|| warn "VAAPI+X11 group install failed — FFmpeg will build with --disable-vaapi and SDL2 without X11"
# ── VDPAU (NVIDIA legacy — independent of VAAPI) ─────────────────────────
# libvdpau-dev → vdpau.pc; cmake detects independently of libva
info "Installing VDPAU hw-accel packages..."
sudo apt-get install -y libvdpau-dev \
|| warn "libvdpau-dev unavailable — FFmpeg will build with --disable-vdpau"
2026-07-02 10:09:43 +10:00
# ── Linux audio output (ALSA + PulseAudio) — REQUIRED, not optional ─────
# SDL2's CheckALSA()/CheckPulseAudio() configure-time checks look for
# alsa/asoundlib.h and pulse/pulseaudio.h. If either header is missing,
# SDL2 silently disables that backend (SDL_ALSA / SDL_PULSEAUDIO → OFF)
# rather than hard-failing the build — the same "quiet fallback" pattern
# as the VAAPI/VDPAU checks above, except here the fallback is SDL2's
# SDL_DUMMYAUDIO/SDL_DISKAUDIO drivers, neither of which produces any
# actual sound. A machine missing both dev packages therefore still
# builds and packages successfully, but ships an AppImage with no
# functioning Linux audio output at all — a failure mode with no build
# warning to catch it, which is why this group is REQUIRED (unlike the
# --ignore-missing X11/XCB extras below).
#
# This also solves packaging determinism: package-citron-linux.sh finds
# libasound.so.2 and libpulse.so.0 to bundle into the AppImage via
# `ldconfig -p` against whatever happens to be installed on the machine
# running the packaging step (see comments there). Installing the -dev
# packages here pulls in their runtime libs (libasound2, libpulse0) as
# apt dependencies, so that probe now succeeds identically on every
# machine that runs this script — CI included — instead of depending on
# whether that machine happens to already have a desktop audio stack.
info "Installing ALSA + PulseAudio dev packages (required for audio output)..."
sudo apt-get install -y libasound2-dev libpulse-dev \
|| error "ALSA/PulseAudio dev packages failed to install — Linux builds would have no audio output"
2026-07-01 02:12:16 +10:00
# ── X11 / XCB optional extras (SDL2 Xi/XSS/XCB, Qt XCB platform plugin) ─
# These extend the X11 install above with input, screensaver, and XCB
# extension headers. All are optional — their cmake checks produce soft
# warnings, not hard errors. --ignore-missing lets a single unavailable
# package name (which can vary across distro versions) skip cleanly.
info "Installing optional X11/XCB extension packages..."
sudo apt-get install -y --ignore-missing \
libxi-dev \
libxkbcommon-x11-dev libxss-dev \
libxcb1-dev libxcb-cursor-dev libxcb-image0-dev \
libxcb-render-util0-dev libxinerama-dev \
libgles-dev \
|| warn "Some optional X11/XCB extension packages unavailable — optional display features may be limited"
# Optional: bundled into the AppImage by package-citron-linux.sh if present.
sudo apt-get install -y gamemode 2>/dev/null || true
2026-05-06 21:59:33 +12:00
}
_setup_pacman() {
info "Installing build tools via pacman..."
sudo pacman -Syu --needed --noconfirm \
base-devel cmake ninja git \
python python-pip curl wget \
nasm yasm perl \
autoconf automake make \
2026-07-02 13:23:53 +10:00
glslang clang lld llvm zstd \
2026-05-07 16:41:59 +12:00
patchelf perf 2>/dev/null || true
2026-07-01 02:12:16 +10:00
# Hardware video acceleration for bundled FFmpeg (VAAPI / VDPAU)
# X11/XCB libraries required by SDL2 and Qt
sudo pacman -S --needed --noconfirm \
libva \
libva-utils \
libdrm \
libvdpau \
libx11 \
libxext \
libxi \
libxkbcommon-x11 \
libxss \
libxcb \
libxcb-cursor \
libxcb-image \
libxcb-render-util \
libxinerama 2>/dev/null || warn "Hardware acceleration libraries unavailable — FFmpeg will be software-decode only"
# Optional: bundled into the AppImage by package-citron-linux.sh if present.
sudo pacman -S --needed --noconfirm gamemode 2>/dev/null || true
2026-07-02 10:09:43 +10:00
# ── Linux audio output (ALSA + PulseAudio) —
sudo pacman -S --needed --noconfirm alsa-lib libpulse \
|| error "ALSA/PulseAudio packages failed to install — Linux builds would have no audio output"
2026-05-06 21:59:33 +12:00
# Arch ships unversioned tools — symlink to versioned names
for tool in clang clang++ lld llvm-profdata llvm-bolt merge-fdata; do
local versioned = "/usr/local/bin/ ${ tool } - ${ CLANG_VERSION } "
if command -v " ${ tool } " & >/dev/null && [[ ! -e " ${ versioned } " ]] ; then
sudo ln -sf " $( command -v " ${ tool } " ) " " ${ versioned } " 2>/dev/null || true
fi
done
success "Pacman packages installed."
}
_setup_dnf() {
info "Installing build tools via dnf..."
sudo dnf install -y \
gcc gcc-c++ cmake ninja-build git pkg-config \
python3 python3-pip curl wget xz \
nasm yasm perl \
autoconf automake make \
2026-05-07 16:41:59 +12:00
glslang clang lld patchelf \
2026-07-02 13:23:53 +10:00
elfutils-libelf-devel openssl-devel libudev-devel zstd \
2026-05-06 21:59:33 +12:00
perf 2>/dev/null || true
sudo dnf install -y "clang ${ CLANG_VERSION } " "llvm ${ CLANG_VERSION } " 2>/dev/null \
|| warn "Versioned LLVM ${ CLANG_VERSION } not in repos — using default clang."
2026-07-01 02:12:16 +10:00
# Hardware video acceleration for bundled FFmpeg (VAAPI / VDPAU)
# X11/XCB libraries required by SDL2 and Qt
sudo dnf install -y \
libva-devel \
libdrm-devel \
libvdpau-devel \
libX11-devel \
libXext-devel \
libXi-devel \
libxkbcommon-x11-devel \
libXScrnSaver-devel \
libxcb-devel \
libxcb-cursor-devel \
libxcb-image-devel \
libxcb-render-util-devel \
libXinerama-devel 2>/dev/null || warn "Hardware acceleration libraries unavailable — FFmpeg will be software-decode only"
# Optional: bundled into the AppImage by package-citron-linux.sh if present.
sudo dnf install -y gamemode 2>/dev/null || true
2026-07-02 10:09:43 +10:00
# ── Linux audio output (ALSA + PulseAudio) —
sudo dnf install -y alsa-lib-devel pulseaudio-libs-devel \
|| error "ALSA/PulseAudio dev packages failed to install — Linux builds would have no audio output"
2026-05-06 21:59:33 +12:00
}
_setup_yum() {
info "Installing build tools via yum..."
sudo yum install -y epel-release 2>/dev/null || true
sudo yum install -y \
gcc gcc-c++ cmake ninja-build git pkg-config \
python3 python3-pip curl wget xz \
nasm yasm perl \
autoconf automake make \
2026-05-07 16:41:59 +12:00
clang lld patchelf \
2026-07-02 13:23:53 +10:00
elfutils-libelf-devel openssl-devel libudev-devel zstd \
2026-05-06 21:59:33 +12:00
perf 2>/dev/null || true
warn "yum/CentOS: LLVM ${ CLANG_VERSION } may not be in repos. Check SCL or llvm.org."
2026-07-01 02:12:16 +10:00
# Optional: bundled into the AppImage by package-citron-linux.sh if present.
# May require EPEL or an RPM Fusion-style repo on RHEL-based distros.
sudo yum install -y gamemode 2>/dev/null || true
2026-07-02 10:09:43 +10:00
# ── Linux audio output (ALSA + PulseAudio) —
sudo yum install -y alsa-lib-devel pulseaudio-libs-devel \
|| error "ALSA/PulseAudio dev packages failed to install — Linux builds would have no audio output"
2026-05-06 21:59:33 +12:00
}
_setup_zypper() {
info "Installing build tools via zypper..."
sudo zypper install -y --no-recommends \
gcc gcc-c++ cmake ninja git pkg-config \
python3 python3-pip curl wget xz \
nasm yasm perl \
autoconf automake make \
2026-05-07 16:41:59 +12:00
glslang clang lld llvm patchelf \
2026-07-02 13:23:53 +10:00
libelf-devel libopenssl-devel libudev-devel zstd \
2026-05-06 21:59:33 +12:00
perf 2>/dev/null || true
2026-07-01 02:12:16 +10:00
# Hardware video acceleration for bundled FFmpeg (VAAPI / VDPAU)
sudo zypper install -y --no-recommends \
libva-devel \
libdrm-devel \
libvdpau-devel \
2026-07-02 13:23:53 +10:00
libX11-devel \
libXext-devel 2>/dev/null || warn "Hardware acceleration libraries unavailable — FFmpeg will be software-decode only"
2026-07-01 02:12:16 +10:00
# Optional: bundled into the AppImage by package-citron-linux.sh if present.
sudo zypper install -y --no-recommends gamemode 2>/dev/null || true
2026-07-02 10:09:43 +10:00
# ── Linux audio output (ALSA + PulseAudio) —
sudo zypper install -y --no-recommends alsa-devel libpulse-devel \
|| error "ALSA/PulseAudio dev packages failed to install — Linux builds would have no audio output"
2026-05-06 21:59:33 +12:00
}
_setup_emerge() {
info "Installing build tools via emerge..."
sudo emerge --ask= n \
dev-build/cmake dev-build/ninja dev-vcs/git \
dev-lang/python dev-lang/perl \
dev-lang/nasm dev-lang/yasm \
sys-devel/clang sys-devel/lld \
dev-build/autoconf dev-build/automake \
media-libs/glslang \
2026-05-07 16:41:59 +12:00
dev-util/patchelf \
2026-07-02 13:23:53 +10:00
dev-libs/elfutils dev-libs/openssl sys-apps/util-linux app-arch/zstd 2>/dev/null || true
2026-07-01 02:12:16 +10:00
# Optional: bundled into the AppImage by package-citron-linux.sh if present.
sudo emerge --ask= n games-util/gamemode 2>/dev/null || true
2026-07-02 10:09:43 +10:00
# ── Linux audio output (ALSA + PulseAudio) —
sudo emerge --ask= n media-libs/alsa-lib media-libs/libpulse \
|| error "ALSA/PulseAudio packages failed to install — Linux builds would have no audio output"
2026-05-06 21:59:33 +12:00
}
_install_llvm_clang() {
if command -v " ${ CLANG } " & >/dev/null; then
success " ${ CLANG } already available: $( command -v " ${ CLANG } " ) "
return 0
fi
local pkg_mgr; pkg_mgr = " $( detect_pkg_manager) "
if [[ " ${ pkg_mgr } " != "apt" ]] ; then
warn " ${ CLANG } not found. Install LLVM ${ CLANG_VERSION } manually for your distro."
return 0
fi
info "Installing LLVM/Clang ${ CLANG_VERSION } from apt.llvm.org..."
wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh
chmod +x /tmp/llvm.sh
sudo /tmp/llvm.sh " ${ CLANG_VERSION } " all \
|| sudo /tmp/llvm.sh " ${ CLANG_VERSION } " \
|| error "LLVM ${ CLANG_VERSION } install failed — see https://apt.llvm.org"
sudo apt-get install -y \
"clang- ${ CLANG_VERSION } " "clang++- ${ CLANG_VERSION } " \
"lld- ${ CLANG_VERSION } " "llvm- ${ CLANG_VERSION } " \
"llvm- ${ CLANG_VERSION } -dev" "libclang-rt- ${ CLANG_VERSION } -dev" \
2>/dev/null || warn "Some LLVM ${ CLANG_VERSION } packages unavailable."
success " ${ CLANG } installed."
}
_install_aqt() {
# aqt is used by CMakeModules/qt_download.cmake to fetch Qt binaries at
# cmake configure time. It must be installed before the first cmake run.
if command -v aqt & >/dev/null || python3 -m aqt --version & >/dev/null 2>& 1; then
success "aqt already available"
return 0
fi
info "Installing aqtinstall (Qt binary downloader — invoked by cmake)..."
python3 -m pip install aqtinstall --break-system-packages --quiet \
|| python3 -m pip install aqtinstall --user --quiet \
|| error "aqtinstall install failed — run: pip3 install aqtinstall"
success "aqtinstall installed."
}
_check_bolt() {
if command -v " ${ LLVM_BOLT } " & >/dev/null; then
success " ${ LLVM_BOLT } available"
return 0
fi
if command -v llvm-bolt & >/dev/null; then
sudo ln -sf " $( command -v llvm-bolt) " "/usr/local/bin/ ${ LLVM_BOLT } " 2>/dev/null || true
sudo ln -sf " $( command -v merge-fdata) " "/usr/local/bin/ ${ MERGE_FDATA } " 2>/dev/null || true
return 0
fi
warn " ${ LLVM_BOLT } not found. The bolt stage will build it from source (~15-20 min)."
}
_verify_tools() {
echo ""
info "Verifying installation..."
local ok = 1
for tool in " ${ CLANG } " " ${ CLANGPP } " " ${ LLD } " " ${ LLVM_PROFDATA } " \
2026-05-07 16:41:59 +12:00
cmake ninja git nasm perl python3 patchelf; do
2026-05-06 21:59:33 +12:00
if command -v " ${ tool } " & >/dev/null; then
success " ${ tool } -> $( command -v " ${ tool } " ) "
else
warn " ${ tool } -> NOT FOUND" ; ok = 0
fi
done
if command -v aqt & >/dev/null || python3 -m aqt --version & >/dev/null 2>& 1; then
success " aqt -> available"
else
warn " aqt -> NOT FOUND (Qt download by cmake will fail)" ; ok = 0
fi
2026-07-01 02:12:16 +10:00
if ls /usr/lib*/libgamemode.so* & >/dev/null 2>& 1 || ldconfig -p 2>/dev/null | grep -q libgamemode; then
success " gamemode -> available (will be bundled into AppImage)"
else
warn " gamemode -> not found (optional — AppImage will be built without it)"
fi
2026-05-06 21:59:33 +12:00
[[ ${ ok } -eq 1 ]] && success "All required tools available." \
|| warn "Some tools missing — check output above."
echo ""
info "Setup complete. Typical next steps:"
echo " ./build-citron-linux.sh use --pgo none --lto full # quick baseline"
echo " ./build-citron-linux.sh generate --pgo ir --lto full # start IR PGO"
}
# =============================================================================
# Source patches (run against the repo this script lives in)
# =============================================================================
apply_source_patches() {
info "Applying source compatibility patches..."
# Boost.Asio API: io_service → io_context (Boost 1.74+)
find " ${ SCRIPT_DIR } /src" -type f \( -name '*.cpp' -o -name '*.h' \) \
-exec sed -i \
-e 's/\bboost::asio::io_service\b/boost::asio::io_context/g' \
-e 's/\bboost::asio::io_service::strand\b/boost::asio::strand<boost::asio::io_context::executor_type>/g' \
{} + 2>/dev/null || true
# Boost.Process v1 (async_pipe moved in Boost 1.86+)
find " ${ SCRIPT_DIR } /src" -type f \( -name '*.cpp' -o -name '*.h' \) \
-exec sed -i \
-e 's|#include *<boost/process/async_pipe\.hpp>|#include <boost/process/v1/async_pipe.hpp>|g' \
-e 's/\bboost::process::async_pipe\b/boost::process::v1::async_pipe/g' \
{} + 2>/dev/null || true
# sse2neon reference (AArch64 only — breaks x86_64 video_core builds)
[[ -f " ${ SCRIPT_DIR } /src/video_core/CMakeLists.txt" ]] && \
sed -i '/sse2neon/d' " ${ SCRIPT_DIR } /src/video_core/CMakeLists.txt" \
2>/dev/null || true
# xbyak cmake_minimum_required floor (some forks still carry 2.8)
[[ -f " ${ SCRIPT_DIR } /externals/xbyak/CMakeLists.txt" ]] && \
sed -i 's/cmake_minimum_required(VERSION 2\.8)/cmake_minimum_required(VERSION 3.5)/' \
" ${ SCRIPT_DIR } /externals/xbyak/CMakeLists.txt" 2>/dev/null || true
success "Source patches applied."
}
# =============================================================================
# Profile helpers
# =============================================================================
normalize_profraw_dirs() {
# IR PGO on Linux writes a directory named default-<pid>.profraw/ containing
# numbered chunk files rather than a flat .profraw file. Flatten them so
# llvm-profdata can glob *.profraw directly.
local base_dir = " $1 "
[[ -d " ${ base_dir } " ]] || return 0
local entry
while IFS = read -r -d '' entry; do
[[ -d " ${ entry } " ]] || continue
local dir_name = " ${ entry ##*/ } "
local prefix = " ${ dir_name %.profraw } "
local idx = 0 file
while IFS = read -r -d '' file; do
[[ -f " ${ file } " ]] || continue
local target = " ${ base_dir } / ${ prefix }${ idx :+- ${ idx }} .profraw"
while [[ -e " ${ target } " ]] ; do
idx = $(( idx + 1 ))
target = " ${ base_dir } / ${ prefix } - ${ idx } .profraw"
done
mv " ${ file } " " ${ target } " ; idx = $(( idx + 1 ))
done < <( find " ${ entry } " -maxdepth 1 -type f -name '*.profraw' -print0)
rm -rf " ${ entry } "
info "Flattened profraw directory: ${ dir_name } "
done < <( find " ${ base_dir } " -maxdepth 1 -type d -name '*.profraw' -print0)
}
2026-05-08 17:14:34 +12:00
_collect_appimage_profiles() {
2026-07-01 02:12:16 +10:00
# Profiles generated by running the AppImage are written next to the
# AppImage file itself via AppDir/.env (LLVM_PROFILE_FILE=$(dirname
# "$APPIMAGE")/default-%p.profraw, set by package-citron-linux.sh). pkgforge's
# quick-sharun places the finished .AppImage in build_dir/AppImage/AppImage/,
# so that is where profraw lands. We collect it back to the main profile
# directory so it can be merged and used by the build script.
#
# Also check the legacy linuxdeploy location (build_dir/AppImage/) for
# compatibility with profiles collected before the pkgforge switch.
2026-05-08 17:14:34 +12:00
# Standard PGO profiles
2026-07-01 02:12:16 +10:00
for profile_src in " ${ BUILD_GENERATE } /AppImage/AppImage" " ${ BUILD_GENERATE } /AppImage" ; do
if [[ -d " ${ profile_src } " ]] ; then
local count; count = " $( find " ${ profile_src } " -maxdepth 1 -name "*.profraw" 2>/dev/null | wc -l) "
if [[ " ${ count } " -gt 0 ]] ; then
info "Collecting ${ count } profile(s) from ${ profile_src } ..."
mv " ${ profile_src } " /*.profraw " ${ PROFILE_DIR } /" 2>/dev/null || true
fi
2026-05-08 17:14:34 +12:00
fi
2026-07-01 02:12:16 +10:00
done
2026-05-08 17:14:34 +12:00
# CS-PGO profiles
2026-07-01 02:12:16 +10:00
for profile_src in " ${ BUILD_CSGENERATE } /AppImage/AppImage" " ${ BUILD_CSGENERATE } /AppImage" ; do
if [[ -d " ${ profile_src } " ]] ; then
local count; count = " $( find " ${ profile_src } " -maxdepth 1 -name "*.profraw" 2>/dev/null | wc -l) "
if [[ " ${ count } " -gt 0 ]] ; then
info "Collecting ${ count } CS profile(s) from ${ profile_src } ..."
mkdir -p " ${ PROFILE_DIR } /cs"
mv " ${ profile_src } " /*.profraw " ${ PROFILE_DIR } /cs/" 2>/dev/null || true
fi
2026-05-08 17:14:34 +12:00
fi
2026-07-01 02:12:16 +10:00
done
2026-05-08 17:14:34 +12:00
}
2026-05-06 21:59:33 +12:00
_merge_profraw_to_profdata() {
local profile_dir = " $1 " ; local output_file = " $2 "
normalize_profraw_dirs " ${ profile_dir } "
local count; count = " $( find " ${ profile_dir } " -maxdepth 1 -name "*.profraw" 2>/dev/null | wc -l) "
[[ " ${ count } " -gt 0 ]] || { info "No .profraw files in ${ profile_dir } ." ; return 1; }
info "Merging ${ count } .profraw file(s) → ${ output_file ##*/ } ..."
local cmd = " ${ LLVM_PROFDATA } "
command -v " ${ cmd } " & >/dev/null || cmd = "llvm-profdata"
command -v " ${ cmd } " & >/dev/null || error "llvm-profdata not found. Run setup first."
" ${ cmd } " merge --sparse --output= " ${ output_file } " " ${ profile_dir } " /*.profraw
success "Merged: ${ output_file } "
}
resolve_use_profdata() {
2026-05-08 17:14:34 +12:00
_collect_appimage_profiles
2026-05-06 21:59:33 +12:00
# Determine which profdata to hand to the use stage.
# Preference order: merged.profdata (stage1+CS) > default.profdata (stage1) > merge from profraw.
local merged = " ${ PROFILE_DIR } /merged.profdata"
local stage1 = " ${ PROFILE_DIR } /default.profdata"
# Invalidate merged if there are newer unmerged CS profraw files
if [[ -f " ${ merged } " && -d " ${ PROFILE_DIR } /cs" ]] ; then
normalize_profraw_dirs " ${ PROFILE_DIR } /cs" 2>/dev/null || true
local cs_pending
cs_pending = " $( find " ${ PROFILE_DIR } /cs" -maxdepth 1 -name "*.profraw" 2>/dev/null | wc -l) "
if [[ " ${ cs_pending } " -gt 0 ]] ; then
warn "merged.profdata stale — ${ cs_pending } unmerged CS file(s) pending. Rebuilding..."
rm -f " ${ merged } "
fi
fi
# Auto-merge CS profraw if present and merged doesn't exist yet
if [[ ! -f " ${ merged } " && -d " ${ PROFILE_DIR } /cs" ]] ; then
normalize_profraw_dirs " ${ PROFILE_DIR } /cs" 2>/dev/null || true
local cs_count
cs_count = " $( find " ${ PROFILE_DIR } /cs" -maxdepth 1 -name "*.profraw" 2>/dev/null | wc -l) "
if [[ " ${ cs_count } " -gt 0 ]] ; then
[[ -f " ${ stage1 } " ]] \
|| error "default.profdata missing — run generate + use first before using CS profiles."
info "CS profraw detected ( ${ cs_count } files) — merging with stage1..."
local cs_tmp = " ${ PROFILE_DIR } /cs-only.profdata"
local cmd = " ${ LLVM_PROFDATA } "
command -v " ${ cmd } " & >/dev/null || cmd = "llvm-profdata"
" ${ cmd } " merge --sparse --output= " ${ cs_tmp } " " ${ PROFILE_DIR } /cs" /*.profraw
" ${ cmd } " merge --sparse --output= " ${ merged } " " ${ stage1 } " " ${ cs_tmp } "
rm -f " ${ cs_tmp } "
success "CS-IRPGO merged profile: ${ merged } "
fi
fi
if [[ -f " ${ merged } " ]] ; then echo " ${ merged } " ; return 0
elif [[ -f " ${ stage1 } " ]] ; then echo " ${ stage1 } " ; return 0
fi
# Last resort: try merging from profraw
_merge_profraw_to_profdata " ${ PROFILE_DIR } " " ${ stage1 } " \
|| error "No profile data found in ${ PROFILE_DIR } .\nRun generate, collect profraw by playing games, then re-run use."
echo " ${ stage1 } "
}
# =============================================================================
# Common cmake arguments (compile/linker flags are per-stage, not here)
# =============================================================================
2026-05-14 09:49:59 +10:00
build_common_cmake_args() {
# Populates the global _CMAKE_ARGS array. Using an array rather than echo +
# $() command substitution avoids bash word-splitting on paths that contain
# spaces (e.g. a username or project folder with a space in it).
_CMAKE_ARGS =(
"-G" "Ninja"
"-DCMAKE_BUILD_TYPE= ${ BUILD_TYPE } "
"-DCMAKE_C_COMPILER= ${ CLANG } "
"-DCMAKE_CXX_COMPILER= ${ CLANGPP } "
"-DCITRON_USE_CPM=ON"
"-DCPM_SOURCE_CACHE= ${ CPM_SOURCE_CACHE } "
"-DCITRON_USE_BUNDLED_VCPKG=OFF"
"-DCITRON_USE_BUNDLED_QT=ON"
"-DUSE_SYSTEM_QT=OFF"
"-DENABLE_QT6=ON"
"-DCITRON_USE_BUNDLED_FFMPEG=ON"
"-DBUILD_TESTING=OFF"
"-DCITRON_TESTS=OFF"
"-DCITRON_DOWNLOAD_TIME_ZONE_DATA=ON"
"-DCITRON_CHECK_SUBMODULES=OFF"
"-DCITRON_USE_LLVM_DEMANGLE=OFF"
"-DCITRON_USE_QT_WEB_ENGINE=OFF"
2026-07-01 02:12:16 +10:00
"-DCITRON_USE_QT_MULTIMEDIA=OFF"
"-DQT_NO_PRIVATE_MODULE_WARNING=ON"
2026-05-14 09:49:59 +10:00
"-DENABLE_QT_TRANSLATION=ON"
"-DUSE_DISCORD_PRESENCE=ON"
"-DENABLE_WEB_SERVICE=ON"
"-DENABLE_OPENSSL=ON"
"-DBUNDLE_SPEEX=ON"
"-DCITRON_USE_FASTER_LD=OFF"
"-DCITRON_USE_EXTERNAL_Vulkan_HEADERS=ON"
"-DCITRON_USE_EXTERNAL_VULKAN_UTILITY_LIBRARIES=ON"
"-DCITRON_USE_AUTO_UPDATER=ON"
"-DCITRON_BUILD_TYPE=Release"
"-DCMAKE_POLICY_VERSION_MINIMUM=3.5"
2026-05-06 21:59:33 +12:00
"-Wno-dev"
2026-05-14 09:49:59 +10:00
)
2026-07-01 02:12:16 +10:00
# -mtls-dialect=gnu2 is x86_64-specific; skip for aarch64
local host_arch; host_arch = " $( uname -m) "
local use_tls_dialect = false
case " ${ _ARCH_ARG } " in
aarch64) use_tls_dialect = false ;;
auto)
[[ " ${ host_arch } " == "x86_64" ]] && use_tls_dialect = true || use_tls_dialect = false
;;
*)
# Explicit x86_64 or v3
[[ " ${ host_arch } " == "x86_64" ]] && use_tls_dialect = true || use_tls_dialect = false
;;
esac
if [[ " ${ use_tls_dialect } " == "true" ]] ; then
_CMAKE_ARGS +=( "-DCMAKE_C_FLAGS=-mtls-dialect=gnu2" )
_CMAKE_ARGS +=( "-DCMAKE_CXX_FLAGS=-mtls-dialect=gnu2" )
fi
2026-05-14 09:49:59 +10:00
[[ " ${ UNITY_BUILD } " == "ON" ]] && _CMAKE_ARGS +=( "-DENABLE_UNITY_BUILD=ON" )
# Ensure the function always returns 0: a trailing [[ ]] that evaluates false
# would otherwise return exit code 1, triggering set -e in the caller.
:
2026-05-06 21:59:33 +12:00
}
print_profiling_instructions() {
local binary = " $1 "
echo ""
echo -e " ${ YELLOW } ════════════════════════════════════════════════════════════════ ${ RESET } "
echo -e " ${ YELLOW } Training guide for best PGO results ${ RESET } "
echo -e " ${ YELLOW } ════════════════════════════════════════════════════════════════ ${ RESET } "
echo ""
echo -e " ${ BOLD } Instrumented binary: ${ RESET } ${ binary } "
echo -e " ${ BOLD } Profile output: ${ RESET } ${ PROFILE_DIR } /"
echo ""
echo " 1. Run the instrumented binary and play 2-3 games for 5-10 min each."
echo " Navigate menus and the game list (profiles UI code too)."
echo " Exit cleanly via File > Exit or Ctrl+Q."
if [[ " ${ PGO_MODE } " == "ir" ]] ; then
echo ""
echo " NOTE (IR PGO): LLVM writes a directory named default-<pid>.profraw/"
echo " containing numbered chunks. This is normal — the script flattens"
echo " them automatically before merging."
fi
echo ""
echo " 2. Build the optimized binary:"
echo " ./build-citron-linux.sh use --pgo ${ PGO_MODE } --lto ${ LTO_MODE } "
echo ""
if [[ " ${ PGO_MODE } " == "ir" ]] ; then
echo " Optional: add CS-IRPGO (second profiling session, better inlining data):"
echo " ./build-citron-linux.sh use --pgo ir --lto ${ LTO_MODE } "
echo " ./build-citron-linux.sh csgenerate --pgo ir --lto ${ LTO_MODE } "
echo " # Run csgenerate binary, exit cleanly, copy cs-default-*.profraw"
echo " # to build/pgo-profiles/cs/"
echo " ./build-citron-linux.sh use --pgo ir --lto ${ LTO_MODE } "
echo ""
fi
echo -e " ${ YELLOW } ════════════════════════════════════════════════════════════════ ${ RESET } "
echo ""
}
# =============================================================================
# Sentinel helpers
# =============================================================================
write_gen_sentinel() {
printf "LTO=%s\nPGO=%s\n" " ${ LTO_MODE } " " ${ PGO_MODE } " \
> " ${ BUILD_ROOT } /.citron-gen-config"
}
check_stage_compatibility() {
local gen_cfg = " ${ BUILD_ROOT } /.citron-gen-config"
[[ -f " ${ gen_cfg } " ]] || return 0
local gen_lto gen_pgo
gen_lto = " $( grep -oP '(?<=LTO=)\S+' " ${ gen_cfg } " 2>/dev/null || true ) "
gen_pgo = " $( grep -oP '(?<=PGO=)\S+' " ${ gen_cfg } " 2>/dev/null || true ) "
if [[ -n " ${ gen_lto } " && " ${ gen_lto } " != " ${ LTO_MODE } " ]] ; then
error "LTO mismatch: generate used LTO= ${ gen_lto } , this stage has LTO= ${ LTO_MODE } .\n" \
" For IR PGO, LTO must match across all stages. Use --lto ${ gen_lto } "
fi
if [[ -n " ${ gen_pgo } " && " ${ gen_pgo } " != " ${ PGO_MODE } " ]] ; then
error "PGO mode mismatch: generate used PGO= ${ gen_pgo } . Use --pgo ${ gen_pgo } "
fi
}
2026-05-14 09:49:59 +10:00
# Internal: fix RPATH of binaries to find custom libs (Qt, ICU, XCB)
# =============================================================================
_patch_binary_rpaths() {
local bin_dir = " $1 "
local build_dir; build_dir = " $( dirname " ${ bin_dir } " ) "
local config_file = " ${ build_dir } /AppImageBuilder/config.sh"
if [[ ! -f " ${ config_file } " ]] ; then
warn "config.sh not found in ${ build_dir } , skipping RPATH patching."
return
fi
# Extract paths from the generated config.sh
local qt_path; qt_path = " $( grep -oP '(?<=export CITRON_QT_PATH=")[^"]+' " ${ config_file } " || true ) "
local icu_path; icu_path = " $( grep -oP '(?<=export CITRON_ICU_PATH=")[^"]+' " ${ config_file } " || true ) "
local xcb_path; xcb_path = " $( grep -oP '(?<=export CITRON_XCB_PATH=")[^"]+' " ${ config_file } " || true ) "
2026-07-01 02:12:16 +10:00
# Construct RPATH (Qt lib, ICU lib, XCB lib).
#
# Variable semantics from config.sh (set by CMake configure_file):
# CITRON_QT_PATH = QT_TARGET_PATH = Qt6 prefix (append /lib)
# CITRON_ICU_PATH = ICU_BINARY_DIR = ICU lib dir itself (do NOT append /lib)
# CITRON_XCB_PATH = XCB_BUILD_ROOT = XCB prefix (append /lib)
#
# ICU_BINARY_DIR is set as "${ICU_BUILD_DIR}/lib" in icu_build.cmake, so
# it already ends in /lib. Appending /lib again produces a wrong path like
# "icu-build/lib/lib" which does not exist on disk.
2026-05-14 09:49:59 +10:00
local rpath = ""
[[ -n " ${ qt_path } " ]] && rpath = " ${ qt_path } /lib"
2026-07-01 02:12:16 +10:00
[[ -n " ${ icu_path } " ]] && rpath = " ${ rpath }${ rpath :+: }${ icu_path } "
2026-05-14 09:49:59 +10:00
[[ -n " ${ xcb_path } " ]] && rpath = " ${ rpath }${ rpath :+: }${ xcb_path } /lib"
if [[ -z " ${ rpath } " ]] ; then
warn "No custom library paths found in config.sh, skipping RPATH patching."
return
fi
info "Patching RPATH for binaries in ${ bin_dir } ..."
2026-07-01 02:12:16 +10:00
# --force-rpath writes the old-style DT_RPATH tag instead of the default
# DT_RUNPATH. DT_RPATH is searched BEFORE LD_LIBRARY_PATH, whereas
# DT_RUNPATH is searched AFTER it. Without this, a dev's desktop session
# with LD_LIBRARY_PATH pointing at a distro/KDE Qt6 install can cause
# `ldd` (and the dynamic linker) to resolve libQt6Core.so.6 etc. to that
# OLDER system Qt instead of this CPM-built Qt — even though the binary
# was linked against the newer one — producing errors like:
# libQt6Core.so.6: version `Qt_6.9' not found
# DT_RPATH ensures our CPM-built Qt/ICU/XCB win regardless of the host's
# LD_LIBRARY_PATH.
2026-05-14 09:49:59 +10:00
for bin in "citron" "citron-cmd" "citron-room" ; do
if [[ -f " ${ bin_dir } / ${ bin } " ]] ; then
2026-07-02 13:23:53 +10:00
# Always re-apply --force-rpath to convert any DT_RUNPATH tag to
# DT_RPATH. Even when existing_rpath already contains our path, a
# DT_RUNPATH tag would still be searched AFTER LD_LIBRARY_PATH,
# letting a system Qt shadow the CPM-built one. --force-rpath
# ensures we always end up with DT_RPATH regardless of the linker's
# default tag choice.
2026-05-14 09:49:59 +10:00
local existing_rpath; existing_rpath = " $( patchelf --print-rpath " ${ bin_dir } / ${ bin } " 2>/dev/null || echo "" ) "
2026-07-02 13:23:53 +10:00
if [[ -z " ${ existing_rpath } " ]] || [[ " ${ existing_rpath } " == *" ${ rpath } " * ]] ; then
patchelf --force-rpath --set-rpath " ${ rpath }${ existing_rpath :+: ${ existing_rpath }} " " ${ bin_dir } / ${ bin } "
else
2026-07-01 02:12:16 +10:00
patchelf --force-rpath --set-rpath " ${ rpath } : ${ existing_rpath } " " ${ bin_dir } / ${ bin } "
2026-05-14 09:49:59 +10:00
fi
fi
done
}
2026-05-06 21:59:33 +12:00
# =============================================================================
# Internal: configure + build in a given directory
# =============================================================================
_build_with_flags() {
local build_dir = " $1 " ; local stage = " $2 "
shift 2; local extra_args =( " $@ " )
local bt_upper; bt_upper = " $( echo " ${ BUILD_TYPE } " | tr '[:lower:]' '[:upper:]' ) "
# nopgo is a convenience shorthand — fill in the extra_args here
if [[ " ${ stage } " == "nopgo" && ${# extra_args [@] } -eq 0 ]] ; then
extra_args =(
"-DCITRON_ENABLE_PGO_GENERATE=OFF"
"-DCITRON_ENABLE_PGO_USE=OFF"
"-DCITRON_PGO_FLAGS_MANAGED_BY_SCRIPT=ON"
"-DCITRON_ENABLE_LTO= $( lto_cmake_flag) "
"-DCMAKE_C_FLAGS_ ${ bt_upper } = $( build_compile_flags nopgo) "
"-DCMAKE_CXX_FLAGS_ ${ bt_upper } = $( build_compile_flags nopgo) "
"-DCMAKE_EXE_LINKER_FLAGS_ ${ bt_upper } = $( build_linker_flags nopgo) "
)
fi
mkdir -p " ${ build_dir } " ; cd " ${ build_dir } "
rm -f CMakeCache.txt; rm -rf CMakeFiles
2026-05-14 09:49:59 +10:00
build_common_cmake_args
2026-05-06 21:59:33 +12:00
cmake " ${ SCRIPT_DIR } " \
2026-05-14 09:49:59 +10:00
" ${ _CMAKE_ARGS [@] } " \
" ${ extra_args [@] } " \
|| error "CMake configure failed"
2026-05-06 21:59:33 +12:00
info "Building citron ( ${ BUILD_TYPE } , ${ JOBS } jobs)..."
2026-05-14 09:49:59 +10:00
cmake --build . --config " ${ BUILD_TYPE } " -j " ${ JOBS } " \
|| error "cmake --build failed"
# Patch binaries to find our custom libs (Qt via aqt, ICU/XCB via /tmp)
_patch_binary_rpaths " ${ PWD } /bin"
2026-05-06 21:59:33 +12:00
}
2026-05-08 17:14:34 +12:00
# =============================================================================
# AppImage building
# =============================================================================
build_appimage_stage() {
local build_dir = " $1 "
local stage_name = " $2 "
local binary_override = " ${ 3 :- } "
2026-07-01 02:12:16 +10:00
if [[ " ${ NO_PACKAGE :- false } " == "true" ]] ; then
info "Skipping AppImage packaging for ${ stage_name } (--nopackage)."
return
2026-05-08 17:14:34 +12:00
fi
2026-07-01 02:12:16 +10:00
header "Building AppImage for ${ stage_name } (pkgforge)"
2026-05-08 17:14:34 +12:00
2026-07-01 02:12:16 +10:00
# build_dir is the cmake build tree this binary was built in. For
# bolt/propeller, the caller passes the relocs-enabled build tree as
# build_dir, and the post-link-rewritten binary separately as
# binary_override (substituted into <install_root>/usr/bin/citron below).
2026-05-08 17:14:34 +12:00
2026-07-01 02:12:16 +10:00
# ── Stage install tree manually (not cmake --install) ─────────────────────
# pkgforge's quick-sharun expects a standard /usr layout (binary, desktop
# file, icon, and metadata under one prefix) so it can discover and bundle
# them. We deliberately do NOT run `cmake --install <build_dir>`:
#
# 1. It installs the ENTIRE configured project, including every
# CPM-fetched dependency (zlib, zstd, openal, opus, cubeb, ...) —
# gigabytes of headers/cmake-configs/pkgconfig files this AppImage
# never needs.
# 2. cubeb's cmake_install.cmake has a broken install(... include/cubeb)
# rule that resolves against citron's top-level source dir instead
# of cubeb's own source dir, and hard-fails `cmake --install` for
# the whole tree:
# CMake Error: file INSTALL cannot find ".../include/cubeb"
#
# citron's own install surface (src/citron/CMakeLists.txt +
# CMakeLists.txt) is exactly: the citron binary, plus four static files
# from dist/ (desktop entry, icon, mime type, appstream metadata). We
# replicate that small, known set directly — no sudo, no system changes,
# no CPM dependency install rules involved.
local install_root = " ${ build_dir } /install-root"
rm -rf " ${ install_root } "
mkdir -p " ${ install_root } /usr/bin" \
" ${ install_root } /usr/share/applications" \
" ${ install_root } /usr/share/icons/hicolor/scalable/apps" \
" ${ install_root } /usr/share/icons/hicolor/256x256/apps" \
" ${ install_root } /usr/share/mime/packages" \
" ${ install_root } /usr/share/metainfo"
info "Staging install tree for ${ stage_name } (no cmake --install, no sudo)..."
info " Staging root: ${ install_root } "
[[ -f " ${ build_dir } /bin/citron" ]] \
|| error "citron binary not found at ${ build_dir } /bin/citron"
cp " ${ build_dir } /bin/citron" " ${ install_root } /usr/bin/citron"
cp " ${ SCRIPT_DIR } /dist/org.citron_emu.citron.desktop" \
" ${ install_root } /usr/share/applications/"
cp " ${ SCRIPT_DIR } /dist/citron.svg" \
" ${ install_root } /usr/share/icons/hicolor/scalable/apps/org.citron_emu.citron.svg"
if [ -f " ${ SCRIPT_DIR } /dist/org.citron_emu.citron.png" ] ; then
cp " ${ SCRIPT_DIR } /dist/org.citron_emu.citron.png" \
" ${ install_root } /usr/share/icons/hicolor/256x256/apps/"
fi
cp " ${ SCRIPT_DIR } /dist/org.citron_emu.citron.xml" \
" ${ install_root } /usr/share/mime/packages/" 2>/dev/null || true
cp " ${ SCRIPT_DIR } /dist/org.citron_emu.citron.metainfo.xml" \
" ${ install_root } /usr/share/metainfo/" 2>/dev/null || true
# Qt translations: citron's own cmake does not install these. Pull them
# directly from the aqt-downloaded Qt under CPM_SOURCE_CACHE/qt-bin
# (see CMakeModules/qt_download.cmake) if present.
local qt_translations
2026-07-02 13:23:53 +10:00
qt_translations = " $( find " ${ CPM_SOURCE_CACHE } /qt-bin" -maxdepth 4 -type d \
-name 'translations' 2>/dev/null | head -1) "
2026-07-01 02:12:16 +10:00
if [[ -n " ${ qt_translations } " ]] ; then
mkdir -p " ${ install_root } /usr/share/qt6"
cp -r " ${ qt_translations } " " ${ install_root } /usr/share/qt6/translations"
fi
if [[ -n " ${ binary_override } " ]] ; then
# The bolt/propeller-rewritten binary replaces the cmake-installed one.
info "Substituting BOLT/Propeller-optimized binary..."
cp " ${ binary_override } " " ${ install_root } /usr/bin/citron"
fi
# ── Package via pkgforge (quick-sharun) ──────────────────────────────────
local pkg_work = " ${ build_dir } /AppImage"
rm -rf " ${ pkg_work } "
mkdir -p " ${ pkg_work } /user"
cd " ${ pkg_work } "
local version = " ${ APP_VERSION :- } "
if [[ -z " ${ version } " ]] ; then
version = " $( git -C " ${ SCRIPT_DIR } " rev-parse --short HEAD 2>/dev/null || echo " ${ stage_name } " ) "
fi
local arch_suffix = " ${ ARCH_SUFFIX :- "- ${ stage_name } " } "
# Extract CITRON_QT_PATH from config.sh so package-citron-linux.sh can
# derive QT_LOCATION and point quick-sharun at CPM's Qt plugins rather
# than the system Qt's plugins (system plugins depend on the system
# libQt6Core.so.6 which may be older than the CPM-built Qt 6.9.3 citron
# was compiled against, causing "libQt6Core.so.6: version 'Qt_6.9' not
# found" at AppImage runtime).
local config_file = " ${ build_dir } /AppImageBuilder/config.sh"
local qt_path = ""
if [[ -f " ${ config_file } " ]] ; then
qt_path = " $( grep -oP '(?<=export CITRON_QT_PATH=")[^"]+' " ${ config_file } " || true ) "
fi
2026-07-02 13:23:53 +10:00
if [[ -z " ${ qt_path } " ]] ; then
error "CITRON_QT_PATH not found in ${ config_file } — cannot package AppImage with correct Qt plugins. Ensure the build completed successfully and config.sh was written."
fi
2026-07-01 02:12:16 +10:00
APP_VERSION = " ${ version } " \
ARCH_SUFFIX = " ${ arch_suffix } " \
DEVEL = " ${ DEVEL :- false } " \
OUTPATH = " ${ pkg_work } " \
DESTDIR = " ${ install_root } " \
CITRON_QT_PATH = " ${ qt_path } " \
bash " ${ SCRIPT_DIR } /AppImageBuilder/package-citron-linux.sh" \
|| error "package-citron-linux.sh failed for ${ stage_name } "
cd " ${ build_dir } "
local appimage
appimage = " $( find " ${ pkg_work } " -maxdepth 1 -name '*.AppImage' | head -1) "
if [[ -z " ${ appimage } " ]] ; then
error "package-citron-linux.sh did not produce an AppImage in ${ pkg_work } "
fi
success "AppImage for ${ stage_name } placed in ${ appimage } "
2026-05-08 17:14:34 +12:00
}
2026-05-06 21:59:33 +12:00
# =============================================================================
# Stage: generate
# =============================================================================
stage_generate() {
header "Stage 1: PGO Instrumented Build ( ${ PGO_MODE } PGO, LTO= ${ LTO_MODE } )"
command -v " ${ CLANG } " & >/dev/null || error " ${ CLANG } not found. Run setup first."
command -v cmake & >/dev/null || error "cmake not found."
command -v ninja & >/dev/null || error "ninja not found."
apply_source_patches
mkdir -p " ${ BUILD_GENERATE } " " ${ PROFILE_DIR } "
# Remove stale profraw from a previous generate run so they don't
# contaminate a fresh profdata merge.
local stale
stale = " $( find " ${ PROFILE_DIR } " -maxdepth 1 \
\( -name "*.profraw" -o \( -type d -name "*.profraw" \) \) \
2>/dev/null | wc -l) "
if [[ " ${ stale } " -gt 0 ]] ; then
info "Removing ${ stale } stale profraw entries from previous generate run..."
find " ${ PROFILE_DIR } " -maxdepth 1 -name "*.profraw" -delete
find " ${ PROFILE_DIR } " -maxdepth 1 -type d -name "*.profraw" \
-exec rm -rf {} + 2>/dev/null || true
fi
local bt_upper; bt_upper = " $( echo " ${ BUILD_TYPE } " | tr '[:lower:]' '[:upper:]' ) "
local compile_flags; compile_flags = " $( build_compile_flags generate " ${ PROFILE_DIR } " ) "
local linker_flags; linker_flags = " $( build_linker_flags generate " ${ PROFILE_DIR } " ) "
info "Compile flags: ${ compile_flags } "
info "Linker flags: ${ linker_flags } "
_build_with_flags " ${ BUILD_GENERATE } " generate \
"-DCITRON_ENABLE_PGO_GENERATE=ON" \
"-DCITRON_ENABLE_PGO_USE=OFF" \
"-DCITRON_PGO_FLAGS_MANAGED_BY_SCRIPT=ON" \
"-DCITRON_ENABLE_LTO= $( lto_cmake_flag) " \
"-DCITRON_PGO_PROFILE_DIR= ${ PROFILE_DIR } " \
"-DCMAKE_C_FLAGS_ ${ bt_upper } = ${ compile_flags } " \
"-DCMAKE_CXX_FLAGS_ ${ bt_upper } = ${ compile_flags } " \
"-DCMAKE_EXE_LINKER_FLAGS_ ${ bt_upper } = ${ linker_flags } "
write_gen_sentinel
2026-05-07 16:41:59 +12:00
mkdir -p " ${ BUILD_GENERATE } /bin/user"
2026-05-06 21:59:33 +12:00
success "Instrumented build: ${ BUILD_GENERATE } /bin/citron"
2026-05-08 17:14:34 +12:00
build_appimage_stage " ${ BUILD_GENERATE } " "generate"
2026-05-06 21:59:33 +12:00
print_profiling_instructions " ${ BUILD_GENERATE } /bin/citron"
}
# =============================================================================
# Stage: csgenerate
#
# CRITICAL INVARIANT:
# Uses default.profdata (stage1 only) as -fprofile-use input.
# NEVER uses merged.profdata. If merged.profdata (which contains CS records
# from a prior CS cycle) were used, the inlining decisions at this stage
# would diverge from the plain stage1 baseline that the use-stage binary is
# built from — causing CS counter hash mismatches at the use stage and
# producing a worse binary than plain IR PGO.
# =============================================================================
stage_csgenerate() {
header "Stage 1b: CS-IRPGO Instrumented Build"
[[ " ${ PGO_MODE } " == "ir" ]] \
|| error "csgenerate requires --pgo ir. CS-IRPGO is not available for FE PGO."
command -v " ${ CLANG } " & >/dev/null || error " ${ CLANG } not found. Run setup first."
local gen_cfg = " ${ BUILD_ROOT } /.citron-gen-config"
[[ -f " ${ gen_cfg } " ]] \
|| error "Generate sentinel not found at ${ BUILD_ROOT } /.citron-gen-config.\nRun the generate stage first."
check_stage_compatibility
# Locate stage1 profdata — MUST be default.profdata, never merged.profdata
local stage1_pd = " ${ PROFILE_DIR } /default.profdata"
if [[ ! -f " ${ stage1_pd } " ]] ; then
normalize_profraw_dirs " ${ PROFILE_DIR } "
local count
count = " $( find " ${ PROFILE_DIR } " -maxdepth 1 -name "*.profraw" 2>/dev/null | wc -l) "
if [[ " ${ count } " -gt 0 ]] ; then
_merge_profraw_to_profdata " ${ PROFILE_DIR } " " ${ stage1_pd } "
elif [[ -f " ${ PROFILE_DIR } /merged.profdata" ]] ; then
error "default.profdata missing but merged.profdata exists.\n" \
" merged.profdata contains CS records and CANNOT be used as the\n" \
" csgenerate baseline — see script header for explanation.\n" \
" Fix: re-run 'use --pgo ir --lto ${ LTO_MODE } ' to produce a\n" \
" fresh default.profdata, then re-run csgenerate."
else
error "No stage1 profdata or profraw found in ${ PROFILE_DIR } /.\n" \
" Run generate, collect profraw by playing games, then:\n" \
" ./build-citron-linux.sh use --pgo ir --lto ${ LTO_MODE } \n" \
" (produces default.profdata), then re-run csgenerate."
fi
fi
info "Stage1 profdata (plain IR, no CS): ${ stage1_pd } "
local cs_dir = " ${ PROFILE_DIR } /cs"
mkdir -p " ${ BUILD_CSGENERATE } " " ${ cs_dir } "
local bt_upper; bt_upper = " $( echo " ${ BUILD_TYPE } " | tr '[:lower:]' '[:upper:]' ) "
local compile_flags; compile_flags = " $( build_compile_flags csgenerate " ${ stage1_pd } : ${ cs_dir } " ) "
local linker_flags; linker_flags = " $( build_linker_flags csgenerate " ${ stage1_pd } : ${ cs_dir } " ) "
info "Compile flags: ${ compile_flags } "
info "Linker flags: ${ linker_flags } "
apply_source_patches
_build_with_flags " ${ BUILD_CSGENERATE } " csgenerate \
"-DCITRON_ENABLE_PGO_GENERATE=ON" \
"-DCITRON_ENABLE_PGO_USE=OFF" \
"-DCITRON_PGO_FLAGS_MANAGED_BY_SCRIPT=ON" \
"-DCITRON_ENABLE_LTO= $( lto_cmake_flag) " \
"-DCITRON_PGO_PROFILE_DIR= ${ cs_dir } " \
"-DCMAKE_C_FLAGS_ ${ bt_upper } = ${ compile_flags } " \
"-DCMAKE_CXX_FLAGS_ ${ bt_upper } = ${ compile_flags } " \
"-DCMAKE_EXE_LINKER_FLAGS_ ${ bt_upper } = ${ linker_flags } "
2026-05-07 16:41:59 +12:00
mkdir -p " ${ BUILD_CSGENERATE } /bin/user"
2026-05-06 21:59:33 +12:00
success "CS-IRPGO instrumented build: ${ BUILD_CSGENERATE } /bin/citron"
2026-05-08 17:14:34 +12:00
build_appimage_stage " ${ BUILD_CSGENERATE } " "csgenerate"
2026-05-06 21:59:33 +12:00
echo ""
echo -e " ${ YELLOW } ════════════════════════════════════════════════════════════════ ${ RESET } "
echo -e " ${ YELLOW } NEXT STEP: Collect CS Profile Data (Session 2) ${ RESET } "
echo -e " ${ YELLOW } ════════════════════════════════════════════════════════════════ ${ RESET } "
echo ""
echo " Run: ${ BUILD_CSGENERATE } /bin/citron"
echo " Play the same games as session 1 for 15-30 min. Exit cleanly."
echo " cs-default-<pid>.profraw (or directory) lands next to the binary."
echo " Copy it to: ${ cs_dir } /"
echo ""
echo " Then rebuild — the use stage merges stage1 + CS automatically:"
echo " ./build-citron-linux.sh use --pgo ir --lto ${ LTO_MODE } "
echo ""
echo -e " ${ YELLOW } ════════════════════════════════════════════════════════════════ ${ RESET } "
echo ""
}
# =============================================================================
# Stage: merge
# =============================================================================
stage_merge() {
header "Merging PGO Profiles"
2026-05-08 17:14:34 +12:00
_collect_appimage_profiles
2026-05-06 21:59:33 +12:00
[[ -d " ${ PROFILE_DIR } " ]] || error "Profile directory not found: ${ PROFILE_DIR } "
_merge_profraw_to_profdata " ${ PROFILE_DIR } " " ${ PROFILE_DIR } /default.profdata"
find " ${ PROFILE_DIR } " -maxdepth 1 -name "*.profraw" -delete
info "Run 'summary' to check coverage, or 'use' to build the optimized binary."
}
# =============================================================================
# Stage: summary
# =============================================================================
stage_summary() {
header "Profile Summary"
[[ -d " ${ PROFILE_DIR } " ]] || { warn "Profile directory not found: ${ PROFILE_DIR } " ; return 0; }
local cmd = " ${ LLVM_PROFDATA } "
command -v " ${ cmd } " & >/dev/null || cmd = "llvm-profdata"
for pd in " ${ PROFILE_DIR } /merged.profdata" " ${ PROFILE_DIR } /default.profdata" ; do
if [[ -f " ${ pd } " ]] ; then
local sz; sz = " $( du -h " ${ pd } " | cut -f1) "
success "Profile: ${ pd } ( ${ sz } )"
command -v " ${ cmd } " & >/dev/null && \
" ${ cmd } " show --counts --all-functions " ${ pd } " 2>/dev/null | tail -6 || true
fi
done
local pr; pr = " $( find " ${ PROFILE_DIR } " -maxdepth 1 -name "*.profraw" 2>/dev/null | wc -l) "
[[ " ${ pr } " -gt 0 ]] && warn " ${ pr } unmerged .profraw file(s). Run: merge"
local cs; cs = " $( find " ${ PROFILE_DIR } /cs" -name "*.profraw" 2>/dev/null | wc -l) "
[[ " ${ cs } " -gt 0 ]] && \
info " ${ cs } CS profraw file(s) in pgo-profiles/cs/ (auto-merged on next 'use')"
[[ ! -f " ${ PROFILE_DIR } /default.profdata" && " ${ pr } " -eq 0 ]] && \
warn "No profile data found in ${ PROFILE_DIR } "
}
# =============================================================================
# Stage: use
# =============================================================================
stage_use() {
if [[ " ${ PGO_MODE } " == "none" ]] ; then
header "Build: Baseline Release (no PGO, LTO= ${ LTO_MODE } )"
apply_source_patches
_build_with_flags " ${ BUILD_ROOT } /use-nopgo" nopgo
2026-05-07 16:41:59 +12:00
mkdir -p " ${ BUILD_ROOT } /use-nopgo/bin/user"
2026-05-06 21:59:33 +12:00
success "Baseline build: ${ BUILD_ROOT } /use-nopgo/bin/citron"
2026-05-08 17:14:34 +12:00
build_appimage_stage " ${ BUILD_ROOT } /use-nopgo" "use-nopgo"
2026-05-06 21:59:33 +12:00
return 0
fi
header "Stage 2: PGO + LTO Optimized Build"
command -v " ${ CLANG } " & >/dev/null || error " ${ CLANG } not found. Run setup first."
check_stage_compatibility
local profdata; profdata = " $( resolve_use_profdata) "
[[ " ${ profdata } " == " ${ PROFILE_DIR } /merged.profdata" ]] \
&& info "Using CS-IRPGO merged profile: ${ profdata } " \
|| info "Using stage1 profile: ${ profdata } "
apply_source_patches
local bt_upper; bt_upper = " $( echo " ${ BUILD_TYPE } " | tr '[:lower:]' '[:upper:]' ) "
local compile_flags; compile_flags = " $( build_compile_flags use " ${ profdata } " ) "
local linker_flags; linker_flags = " $( build_linker_flags use " ${ profdata } " ) "
info "Compile flags: ${ compile_flags } "
info "Linker flags: ${ linker_flags } "
info " ↳ -fprofile-use on linker enables LTO backend LTCG with profile guidance"
_build_with_flags " ${ BUILD_USE } " use \
"-DCITRON_ENABLE_PGO_USE=ON" \
"-DCITRON_ENABLE_PGO_GENERATE=OFF" \
"-DCITRON_PGO_FLAGS_MANAGED_BY_SCRIPT=ON" \
"-DCITRON_ENABLE_LTO= $( lto_cmake_flag) " \
"-DCITRON_PGO_PROFILE_DIR= ${ PROFILE_DIR } " \
"-DCMAKE_C_FLAGS_ ${ bt_upper } = ${ compile_flags } " \
"-DCMAKE_CXX_FLAGS_ ${ bt_upper } = ${ compile_flags } " \
"-DCMAKE_EXE_LINKER_FLAGS_ ${ bt_upper } = ${ linker_flags } "
local pgo_label
[[ " ${ profdata } " == " ${ PROFILE_DIR } /merged.profdata" ]] \
&& pgo_label = "CS-IRPGO (stage1 IR + CS layer)" \
|| pgo_label = "IR PGO (stage1 only)"
echo ""
success "════════════════════════════════════════════════════════════════"
success " Stage use complete"
2026-05-07 16:41:59 +12:00
mkdir -p " ${ BUILD_USE } /bin/user"
2026-05-06 21:59:33 +12:00
success " Binary: ${ BUILD_USE } /bin/citron"
success " PGO: ${ pgo_label } "
success " LTO: ${ LTO_MODE }${ LTO_MODE :+ ( $( lto_clang_flag) ) } "
success "════════════════════════════════════════════════════════════════"
2026-05-08 17:14:34 +12:00
build_appimage_stage " ${ BUILD_USE } " "use"
2026-05-06 21:59:33 +12:00
echo ""
info "Next steps (optional):"
echo " BOLT: ./build-citron-linux.sh bolt --pgo ${ PGO_MODE } --lto ${ LTO_MODE } "
if [[ " ${ profdata } " != " ${ PROFILE_DIR } /merged.profdata" && " ${ PGO_MODE } " == "ir" ]] ; then
echo ""
echo " CS-IRPGO (add a second profiling layer):"
echo " ./build-citron-linux.sh csgenerate --pgo ir --lto ${ LTO_MODE } "
fi
echo ""
}
# =============================================================================
# BOLT: build from source if needed
# =============================================================================
_ensure_bolt() {
if command -v " ${ LLVM_BOLT } " & >/dev/null && command -v " ${ MERGE_FDATA } " & >/dev/null; then
success " ${ LLVM_BOLT } available" ; return 0
fi
if command -v llvm-bolt & >/dev/null; then
sudo ln -sf " $( command -v llvm-bolt) " "/usr/local/bin/ ${ LLVM_BOLT } " 2>/dev/null || true
sudo ln -sf " $( command -v merge-fdata) " "/usr/local/bin/ ${ MERGE_FDATA } " 2>/dev/null || true
return 0
fi
header "Building LLVM BOLT ${ CLANG_VERSION } from Source (~15-20 min)"
local bolt_src = "/tmp/llvm-bolt- ${ CLANG_VERSION } -src"
local bolt_build = "/tmp/llvm-bolt- ${ CLANG_VERSION } -build"
local found_tag = ""
for minor in 0.0 1.0 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9; do
local candidate = "llvmorg- ${ CLANG_VERSION } . ${ minor } "
if git ls-remote --tags https://github.com/llvm/llvm-project.git \
" ${ candidate } " 2>/dev/null | grep -q " ${ candidate } " ; then
found_tag = " ${ candidate } "
fi
done
[[ -n " ${ found_tag } " ]] || error "No LLVM ${ CLANG_VERSION } release tag found on GitHub."
info "Using LLVM tag: ${ found_tag } "
if [[ ! -d " ${ bolt_src } /.git" ]] ; then
git clone --depth= 1 --branch " ${ found_tag } " \
--filter= blob:none --sparse \
https://github.com/llvm/llvm-project.git " ${ bolt_src } "
pushd " ${ bolt_src } " > /dev/null
git sparse-checkout set llvm bolt cmake third-party
popd > /dev/null
fi
cmake -S " ${ bolt_src } /llvm" -B " ${ bolt_build } " -G Ninja \
-DCMAKE_BUILD_TYPE= Release \
-DLLVM_ENABLE_PROJECTS= "bolt" \
-DLLVM_TARGETS_TO_BUILD= "X86;AArch64" \
-DLLVM_INCLUDE_TESTS= OFF -DLLVM_INCLUDE_EXAMPLES= OFF \
-DCMAKE_C_COMPILER= " ${ CLANG } " -DCMAKE_CXX_COMPILER= " ${ CLANGPP } "
cmake --build " ${ bolt_build } " --target llvm-bolt merge-fdata bolt_rt -j " ${ JOBS } "
sudo cp " ${ bolt_build } /bin/llvm-bolt" "/usr/local/bin/ ${ LLVM_BOLT } "
sudo cp " ${ bolt_build } /bin/merge-fdata" "/usr/local/bin/ ${ MERGE_FDATA } "
sudo chmod +x "/usr/local/bin/ ${ LLVM_BOLT } " "/usr/local/bin/ ${ MERGE_FDATA } "
sudo cp " ${ bolt_build } /lib/libbolt_rt_instr.a" /usr/local/lib/ 2>/dev/null || true
sudo cp " ${ bolt_build } /lib/libbolt_rt_hugify.a" /usr/local/lib/ 2>/dev/null || true
success " ${ LLVM_BOLT } installed"
}
# =============================================================================
# Stage: bolt
# =============================================================================
stage_bolt() {
header "Stage 3A: BOLT Binary Layout Optimization"
_ensure_bolt
# BOLT requires the ELF to have been linked with --emit-relocs.
# We build a dedicated relocs-enabled binary rather than relying on the
# use-stage binary (which typically is not built with --emit-relocs).
local relocs_dir = " ${ BUILD_ROOT } /use-relocs"
if [[ ! -f " ${ relocs_dir } /bin/citron" ]] ; then
info "Building use-stage binary with --emit-relocs for BOLT..."
apply_source_patches
local bt_upper; bt_upper = " $( echo " ${ BUILD_TYPE } " | tr '[:lower:]' '[:upper:]' ) "
if [[ " ${ PGO_MODE } " != "none" ]] ; then
check_stage_compatibility
local profdata; profdata = " $( resolve_use_profdata) "
local compile_flags; compile_flags = " $( build_compile_flags use " ${ profdata } " ) "
# Append --emit-relocs to the use-stage linker flags
local linker_flags; linker_flags = " $( build_linker_flags use " ${ profdata } " ) -Wl,--emit-relocs"
_build_with_flags " ${ relocs_dir } " use \
"-DCITRON_ENABLE_PGO_USE=ON" \
"-DCITRON_ENABLE_PGO_GENERATE=OFF" \
"-DCITRON_PGO_FLAGS_MANAGED_BY_SCRIPT=ON" \
"-DCITRON_ENABLE_LTO= $( lto_cmake_flag) " \
"-DCITRON_PGO_PROFILE_DIR= ${ PROFILE_DIR } " \
"-DCMAKE_C_FLAGS_ ${ bt_upper } = ${ compile_flags } " \
"-DCMAKE_CXX_FLAGS_ ${ bt_upper } = ${ compile_flags } " \
"-DCMAKE_EXE_LINKER_FLAGS_ ${ bt_upper } = ${ linker_flags } "
else
local compile_flags; compile_flags = " $( build_compile_flags nopgo) "
local linker_flags; linker_flags = " $( build_linker_flags nopgo) -Wl,--emit-relocs"
_build_with_flags " ${ relocs_dir } " use \
"-DCITRON_ENABLE_PGO_USE=OFF" \
"-DCITRON_ENABLE_PGO_GENERATE=OFF" \
"-DCITRON_PGO_FLAGS_MANAGED_BY_SCRIPT=ON" \
"-DCITRON_ENABLE_LTO= $( lto_cmake_flag) " \
"-DCMAKE_C_FLAGS_ ${ bt_upper } = ${ compile_flags } " \
"-DCMAKE_CXX_FLAGS_ ${ bt_upper } = ${ compile_flags } " \
"-DCMAKE_EXE_LINKER_FLAGS_ ${ bt_upper } = ${ linker_flags } "
fi
success "use-with-relocs build: ${ relocs_dir } /bin/citron"
fi
local elf_binary = " ${ relocs_dir } /bin/citron"
2026-05-07 16:41:59 +12:00
mkdir -p " ${ BOLT_PROFILE_DIR } " " ${ BUILD_BOLT } /bin"
local instrumented = " ${ BUILD_BOLT } /bin/citron-bolt-instrumented"
2026-05-06 21:59:33 +12:00
local fdata_pattern = " ${ BOLT_PROFILE_DIR } /citron-%p.fdata"
local merged_fdata = " ${ BOLT_PROFILE_DIR } /citron-merged.fdata"
2026-05-08 17:14:34 +12:00
local skipped_funcs = "RC4_options"
2026-05-06 21:59:33 +12:00
info "Instrumenting ELF with BOLT..."
" ${ LLVM_BOLT } " " ${ elf_binary } " \
--instrument \
2026-05-08 17:14:34 +12:00
--skip-funcs= " ${ skipped_funcs } " \
2026-05-06 21:59:33 +12:00
--instrumentation-file= " ${ fdata_pattern } " \
--instrumentation-file-append-pid \
-o " ${ instrumented } "
success "Instrumented: ${ instrumented } "
2026-05-08 17:14:34 +12:00
# Load custom library paths from config.sh for the run instructions
local lp_env = ""
local config_file = " ${ relocs_dir } /AppImageBuilder/config.sh"
if [[ -f " ${ config_file } " ]] ; then
# Use a subshell to avoid polluting our current environment
lp_env = " $( ( source " ${ config_file } " ; echo "LD_LIBRARY_PATH=\" ${ CITRON_QT_PATH } /lib: ${ CITRON_ICU_PATH } : ${ CITRON_XCB_PATH } /lib\" " ) )"
fi
2026-05-06 21:59:33 +12:00
echo ""
echo -e " ${ YELLOW } ════════════════════════════════════════════════════════════════ ${ RESET } "
echo -e " ${ YELLOW } Run the BOLT-instrumented binary ${ RESET } "
echo -e " ${ YELLOW } ════════════════════════════════════════════════════════════════ ${ RESET } "
echo ""
2026-05-08 17:14:34 +12:00
echo " Run the instrumented binary with custom library paths:"
echo " ${ lp_env }${ instrumented } "
2026-05-06 21:59:33 +12:00
echo ""
echo " Play for 15-30 min. Exit cleanly."
echo " fdata files will appear in: ${ BOLT_PROFILE_DIR } /"
echo ""
echo -e " ${ YELLOW } ════════════════════════════════════════════════════════════════ ${ RESET } "
read -rp " Press Enter once you have exited the instrumented binary... "
echo ""
local fdata_count
fdata_count = " $( find " ${ BOLT_PROFILE_DIR } " -name "*.fdata" 2>/dev/null | wc -l) "
[[ " ${ fdata_count } " -gt 0 ]] \
|| error "No .fdata files found in ${ BOLT_PROFILE_DIR } . Run the instrumented binary first."
info "Merging ${ fdata_count } .fdata file(s)..."
" ${ MERGE_FDATA } " " ${ BOLT_PROFILE_DIR } " /*.fdata -o " ${ merged_fdata } "
success "Merged: ${ merged_fdata } "
info "Optimizing ELF with BOLT..."
" ${ LLVM_BOLT } " " ${ elf_binary } " \
-p " ${ merged_fdata } " \
2026-05-08 17:14:34 +12:00
--skip-funcs= " ${ skipped_funcs } " \
2026-05-06 21:59:33 +12:00
--reorder-blocks= ext-tsp \
--reorder-functions= cdsort \
--split-functions \
--split-all-cold \
--split-eh \
--dyno-stats \
2026-05-07 16:41:59 +12:00
-o " ${ BUILD_BOLT } /bin/citron" \
2026-05-06 21:59:33 +12:00
|| error "BOLT optimization failed."
echo ""
success "════════════════════════════════════════════════════════════════"
success " Stage bolt complete"
2026-05-07 16:41:59 +12:00
mkdir -p " ${ BUILD_BOLT } /user"
success " Binary: ${ BUILD_BOLT } /bin/citron"
2026-05-06 21:59:33 +12:00
success " Optimizations: PGO + LTO + BOLT basic-block reordering"
success "════════════════════════════════════════════════════════════════"
2026-05-14 09:49:59 +10:00
# Patch the final BOLT binary too
_patch_binary_rpaths " ${ BUILD_BOLT } /bin"
2026-05-08 17:14:34 +12:00
build_appimage_stage " ${ relocs_dir } " "bolt" " ${ BUILD_BOLT } /bin/citron"
2026-05-07 16:41:59 +12:00
echo ""
2026-05-06 21:59:33 +12:00
}
# =============================================================================
# Stage: clean
# =============================================================================
stage_clean() {
header "Cleaning Build Directories"
local backup = "/tmp/citron-pgo-backup- $$ "
[[ -d " ${ PROFILE_DIR } " ]] && { info "Backing up profile data..." ; cp -r " ${ PROFILE_DIR } " " ${ backup } " ; }
[[ -d " ${ BUILD_ROOT } " ]] && { info "Removing ${ BUILD_ROOT } ..." ; rm -rf " ${ BUILD_ROOT } " ; }
if [[ -d " ${ backup } " ]] ; then
mkdir -p " ${ PROFILE_DIR } "
cp -r " ${ backup } /." " ${ PROFILE_DIR } /"
rm -rf " ${ backup } "
success "Profile data preserved: ${ PROFILE_DIR } "
fi
success "Clean complete."
}
# =============================================================================
# Argument parsing
# =============================================================================
STAGE = ""
while [[ $# -gt 0 ]] ; do
case " $1 " in
2026-05-08 17:14:34 +12:00
setup| generate| csgenerate| merge| summary| use| bolt| clean)
2026-05-06 21:59:33 +12:00
STAGE = " $1 " ; shift ;;
--build)
BUILD_ROOT = " $2 " ; _set_derived_paths; shift 2 ;;
--jobs| -j)
JOBS = " $2 " ; shift 2 ;;
--pgo-type| --pgo)
case " $2 " in
ir| fe| none) PGO_MODE = " $2 " ; shift 2 ;;
*) error "--pgo requires: ir, fe, or none" ;;
esac ;;
--lto)
case " $2 " in
thin| full| none) LTO_MODE = " $2 " ; shift 2 ;;
*) error "--lto requires: thin, full, or none" ;;
esac ;;
--lite-lto) LTO_MODE = "thin" ; shift ;;
--no-lto) LTO_MODE = "none" ; shift ;;
--arch)
case " $2 " in
x86_64| v3| aarch64| auto) _ARCH_ARG = " $2 " ; shift 2 ;;
*) error "--arch requires: x86_64, v3, aarch64, or auto" ;;
esac ;;
--unity) UNITY_BUILD = "ON" ; shift ;;
--no-unity) UNITY_BUILD = "OFF" ; shift ;;
--relwithdebinfo) BUILD_TYPE = "RelWithDebInfo" ; shift ;;
--clang-version)
CLANG_VERSION = " $2 " ; _set_clang_tools; shift 2 ;;
2026-07-01 02:12:16 +10:00
--nopackage| --no-package)
NO_PACKAGE = true; shift ;;
2026-05-06 21:59:33 +12:00
--help| -h)
sed -n '/^# build-citron-linux/,/^# ===/p' " $0 " | head -130
exit 0 ;;
*) error "Unknown argument: $1 \nRun with --help for usage." ;;
esac
done
[[ -n " ${ STAGE } " ]] \
2026-05-08 17:14:34 +12:00
|| error "No stage specified.\nUsage: $0 <stage> [options]\nStages: setup generate csgenerate merge summary use bolt clean"
2026-05-06 21:59:33 +12:00
resolve_arch_flags
case " ${ STAGE } " in
setup) stage_setup ;;
generate) stage_generate ;;
csgenerate) stage_csgenerate ;;
merge) stage_merge ;;
summary) stage_summary ;;
use) stage_use ;;
bolt) stage_bolt ;;
clean) stage_clean ;;
esac