Files
emulator/build-clangtron-windows.sh

4678 lines
226 KiB
Bash
Executable File

#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 citron Emulator Project
# SPDX-License-Identifier: GPL-3.0-or-later
# =============================================================================
# build-clangtron-windows.sh — PGO + LTO + PLO optimized cross-compilation build script
#
# Builds Citron (Nintendo Switch emulator) for Windows (x86_64-w64-mingw32)
# from a Linux host, using a multi-stage compiler optimization pipeline:
#
# Stage 1 (generate): Build with Clang PGO instrumentation (FE or IR).
# Stage 1b (csgenerate): [IR PGO only] Build with context-sensitive IR
# instrumentation layered on stage1 profile data.
# Requires a second Windows profiling session.
# Stage 2 (use): Rebuild using collected profile(s) + LTO.
# Auto-merges CS profraw if pgo-profiles/cs/ is
# populated. Builds the Windows PE only.
# Stage 2b (build-elf): Build native Linux ELF with BBAddrMap sections
# for BOLT/Propeller profiling. Invoked on-demand
# by bolt/propeller if not already present.
# Stage 3 (choose one):
# bolt BOLT ELF-proxy: instruments the Linux ELF, profiles it natively,
# extracts hot function order, re-links the Windows PE with /order:@
# propeller Propeller: collects perf LBR data from the Linux ELF, generates
# a BB+function layout profile, rebuilds the Windows PE with /order:@
#
# PGO MODES (--pgo-type):
#
# fe — Frontend PGO (-fprofile-instr-generate / -fprofile-instr-use):
# Counters are inserted before LLVM optimization passes, at the AST/frontend
# level. More robust to flag changes between generate and use. CS-IRPGO is
# not available with fe.
#
# ir — LLVM IR PGO (-fprofile-generate / -fprofile-use): [DEFAULT]
# Counters are inserted at the LLVM IR level, after early optimization passes
# (SRO, SROA, etc.). The profile reflects the code structure that the
# optimizer actually sees, making inlining and branch decisions more accurate.
# For an emulator with complex JIT/dispatch paths, IR PGO typically yields
# 2-5% better runtime performance than FE PGO.
#
# CRITICAL: IR PGO profiles are tied to the LLVM IR produced at generate
# time. The --lto value and optimization flags MUST be identical between
# generate, csgenerate, and use. Only the propeller/bolt final relink
# may use a different --lto value.
#
# CS-IRPGO (ir + csgenerate stage):
# Context-Sensitive IR PGO adds a second instrumentation pass on top of an
# already-PGO-optimized binary. The CS layer captures per-call-site counter
# data rather than per-function-definition data, giving the compiler separate
# profiles for each inlined instance of a function. For an emulator where
# the same JIT/memory/GPU functions are called from both inner loops and cold
# init paths, this provides substantially better inlining decisions.
#
# CS-IRPGO requires two Windows profiling sessions:
# Session 1: run the generate binary (stage1, standard IR instrumentation)
# Session 2: run the csgenerate binary (CS instrumentation built on top of
# the stage1 profile — the binary is already PGO-optimized at
# stage1 quality before the CS counters are applied)
# The use stage auto-detects pgo-profiles/cs/ and merges both profiles.
#
# CRITICAL INVARIANT — csgenerate must always use default.profdata (stage1
# only) as the input to -fprofile-use, never merged.profdata (which contains
# CS records from a prior CS cycle). If merged.profdata were used:
# - The compiler applies stale CS data (keyed to the previous csgenerate
# binary's IR) during the new csgenerate build
# - Inlining decisions change relative to the plain stage1 baseline,
# restructuring the IR the new CS counters are keyed to
# - The new CS profraw then hash-mismatches during the use stage because
# the use stage builds on the plain stage1 IR, not the doubly-CS-
# influenced one
# This invariant is enforced by the script: csgenerate always requires
# default.profdata and refuses to run if only merged.profdata is present.
#
# PROFILE RUNTIME (applies to ALL PGO modes — FE, IR, and CS-IRPGO):
# All three instrumentation modes write .profraw files using the same LLVM
# InstrProfiling runtime library (libclang_rt.profile.a). On a Windows PE
# cross-compiled with llvm-mingw, this runtime must be present and must include
# POSIX stubs for symbols (mmap, flock, etc.) that the MinGW runtime does not
# provide. The ensure_profile_runtime_mingw() function verifies and rebuilds
# this library if needed. The -u,__llvm_profile_write_file and
# -u,__llvm_profile_runtime linker flags prevent lld from dead-stripping the
# runtime entry points. These mechanisms apply equally to FE, IR, and
# CS-IRPGO generate/csgenerate binaries.
#
# LTO + PGO LINKER FLAGS (use stage):
# For full LTO (-flto), Clang/LLD re-runs the optimization backend at link
# time across all merged bitcode modules. The -fprofile-use=... flag must be
# present on the LINKER command line (CMAKE_EXE_LINKER_FLAGS_RELEASE) as well
# as the compile flags, so the LTO backend can apply the profile during link-
# time code generation. Without this, cross-TU inlining and hot-path layout
# decisions made during LTO run without profile guidance, negating the main
# benefit of full LTO. The use stage sets CMAKE_EXE_LINKER_FLAGS_RELEASE to
# include both the LTO flag and -fprofile-use.
#
# HOW BOLT WORKS HERE:
# BOLT operates on ELF binaries natively — PE/COFF support does not exist.
# Instead, a native Linux ELF is built alongside the PE, profiled under BOLT
# instrumentation, and the resulting hot function order is fed to lld's /order:@
# flag when re-linking the final Windows PE. Agreement rate ~38-64%: many ELF
# hot functions are inlined away by full LTO in the PE.
#
# HOW PROPELLER WORKS HERE:
# Propeller uses Linux perf with LBR (Last Branch Record) to collect a
# branch-trace profile of the native Linux ELF, then runs
# generate_propeller_profiles (google/llvm-propeller) to produce:
# - propeller_cc.prof Basic-block layout profile (ELF-only, not used for PE)
# - propeller_symorder.txt Hot function order (fed to lld /order:@ for PE)
# The PE rebuild uses PGO + LTO + /order:@ function ordering. Basic-block
# layout (the CC profile) cannot currently be applied to COFF/PE targets
# because -fbasic-block-sections=list is ELF-only.
#
# FUTURE: COFF/Windows BBAddrMap support is being added to LLVM. When merged
# into llvm-mingw, the CC profile can be applied to the PE build as well,
# recovering the intra-function BB layout benefit currently limited to ELF.
# Track progress at:
# PR: https://github.com/llvm/llvm-project/pull/187268
# RFC: https://discourse.llvm.org/t/rfc-extend-bbaddrmap-support-to-coff-windows/90232
#
# TOOLCHAIN:
# Cross-compilation uses llvm-mingw — a self-contained Clang/LLD/libc++/
# compiler-rt MinGW-w64 toolchain. Both the toolchain and project dependencies
# are cached globally in CPM_SOURCE_CACHE (default: ~/.cache/cpm) to speed up
# builds across repository clones. The host LLVM install (clang-21,
# llvm-profdata, llvm-bolt) is used for PGO merging, BOLT, and the Linux ELF.
#
# USAGE:
# ./build-clangtron-windows.sh [stage] [options]
#
# Stages:
# setup Install all dependencies (run once on a new machine)
# generate Stage 1: Build PGO-instrumented Windows PE (FE or IR PGO)
# csgenerate Stage 1b: [IR PGO only] Build CS-instrumented Windows PE.
# Requires default.profdata from a prior generate run.
# Produces build/cs-generate/bin/citron.exe; CS profraw
# goes to pgo-profiles/cs/ after the Windows session.
# use Stage 2: Build PGO+LTO Windows PE only.
# Auto-merges CS profraw in pgo-profiles/cs/ if present.
# build-elf Stage 2b: Build native Linux ELF with BBAddrMap sections
# (-fbasic-block-address-map) for BOLT/Propeller profiling.
# Built on-demand by bolt/propeller if not already present.
# Use --pgo none to build a baseline ELF without PGO:
# ./build-clangtron-windows.sh build-elf --pgo none
# bolt Stage 3A: BOLT function-order optimization (ELF-proxy → PE)
# propeller Stage 3B: Propeller BB+function layout (perf LBR → PE)
# clean Remove build directory
#
# Options:
# --source DIR Path to citron source tree (default: cwd)
# --build DIR Path to build directory (default: ./build)
# --jobs N Parallel jobs (default: nproc)
# --lto thin|full|none LTO mode (default: full)
# MUST match between generate, csgenerate, and use.
# Only propeller/bolt (final relink) may differ.
# --lite-lto Alias for --lto thin
# --no-lto Alias for --lto none
# --pgo-type ir|fe|none PGO instrumentation mode (default: ir)
# ir = LLVM IR PGO (-fprofile-generate / -fprofile-use).
# Counters at the optimized-IR level. More accurate
# for inlining decisions. Required for CS-IRPGO.
# LTO and flag set MUST match across all stages.
# fe = Frontend PGO (-fprofile-instr-generate / -use).
# Counters before optimizations. More robust to
# flag changes. CS-IRPGO not available with fe.
# none = No PGO. Baseline build (use and build-elf stages).
# use: outputs to build/use-nopgo/.
# build-elf: outputs to build/use-nopgo-elf/.
# LTO still applies for use; build-elf always
# disables LTO (required for BBAddrMap sections).
# Use --lto none for a fully unoptimized PE:
# ./build-clangtron-windows.sh use --pgo none --lto none
# MUST match between generate, csgenerate, and use
# (except none, which skips profdata entirely).
# --unity Enable unity builds (passes ENABLE_UNITY_BUILD=ON)
# ~30-90% faster compilation; no runtime effect.
# --clang-version N Host Clang version (default: 21)
# --relwithdebinfo Enable RelWithDebInfo build (Release with debug symbols).
# Injects -g into all build stages while keeping O3/LTO/PGO.
# --llvm-mingw-version VER llvm-mingw release tag (default: 20260224)
#
# LTO mode details:
# full → Full LTO (-flto). Best runtime performance; most aggressive inlining
# reduces BOLT/Propeller agreement rates (~38-44%).
# thin → ThinLTO (-flto=thin). Faster builds, slightly higher agreement rates.
# none → No LTO. Not recommended; significantly reduced performance.
#
# Stage 1 (generate/csgenerate): instruments the Windows PE.
# IR PGO: LTO mode affects which IR is instrumented — must match use.
# FE PGO: LTO does not affect counter placement — more forgiving.
# Stage 2 (use): builds PGO+LTO Windows PE only.
# Stage 2b (build-elf): builds the native Linux ELF for BOLT/Propeller.
# The Linux ELF always omits LTO to allow -fbasic-block-address-map
# to emit BBAddrMap sections (LTO prevents this at the TU level).
# Stage 3A (bolt): re-links Windows PE with BOLT function order.
# Stage 3B (propeller): rebuilds Windows PE with Propeller function order.
# Both stage 3 variants may use a different --lto than stages 1-2.
#
# REQUIREMENTS (installed by the setup stage):
# - clang/clang++ 21+ Host compiler (PGO merge, BOLT/Propeller ELF build)
# - lld Linker (LTO)
# - llvm-profdata Merges .profraw -> .profdata
# - llvm-bolt Binary optimization tool (ELF only)
# - perf Linux perf with LBR support (for Propeller)
# - llvm-mingw Self-contained Clang+libc++/compiler-rt MinGW toolchain
# - cmake + ninja-build Build system
#
# EXAMPLE FULL PIPELINE — IR PGO (recommended):
# ./build-clangtron-windows.sh setup
# ./build-clangtron-windows.sh generate --pgo-type ir --lto full
# # Copy build/generate/bin/ to Windows, run citron.exe for 15-30 min.
# # default-<pid>.profraw appears next to citron.exe on exit.
# # Copy the .profraw file(s) to build/pgo-profiles/
# ./build-clangtron-windows.sh use --pgo-type ir --lto full
# # Then propeller or bolt (may use a different --lto for the relink):
# ./build-clangtron-windows.sh propeller --pgo-type ir --lto full
# # Final binary: build/propeller/bin/citron.exe
#
# EXAMPLE FULL PIPELINE — CS-IRPGO (two Windows sessions, best quality):
# ./build-clangtron-windows.sh setup
#
# # --- Session 1: Standard IR instrumentation ---
# ./build-clangtron-windows.sh generate --pgo-type ir --lto full
# # Copy build/generate/bin/ to Windows. Run citron.exe for 15-30 min.
# # Copy default-<pid>.profraw back to build/pgo-profiles/
#
# # Merge stage1 profraw → default.profdata (required before csgenerate).
# # This also gives you a usable PGO+LTO binary without the CS layer.
# ./build-clangtron-windows.sh use --pgo-type ir --lto full
#
# # --- Session 2: Context-sensitive instrumentation ---
# # csgenerate builds a NEW binary using ONLY default.profdata for -fprofile-use
# # (never merged.profdata — see CRITICAL INVARIANT above) and adds CS counters.
# ./build-clangtron-windows.sh csgenerate --pgo-type ir --lto full
# # Copy build/cs-generate/bin/ to Windows. Run citron.exe for 15-30 min.
# # cs-default-<pid>.profraw is written next to citron.exe on exit.
# # Copy cs-default-*.profraw to build/pgo-profiles/cs/
#
# # --- Final build: use auto-detects CS profiles and merges them ---
# # With pgo-profiles/cs/ populated, use:
# # 1. Merges cs-default-*.profraw → cs-only.profdata
# # 2. Merges default.profdata + cs-only.profdata → merged.profdata
# # 3. Rebuilds the PE with -fprofile-use=merged.profdata + LTO
# # (linker also gets -fprofile-use for LTO backend LTCG)
# ./build-clangtron-windows.sh use --pgo-type ir --lto full
#
# # Propeller or BOLT (final relink; --lto here may differ):
# ./build-clangtron-windows.sh propeller --pgo-type ir --lto full
# # Final binary: build/propeller/bin/citron.exe
#
# EXAMPLE FULL PIPELINE — Frontend PGO (simpler, flag-change tolerant):
# ./build-clangtron-windows.sh setup
# ./build-clangtron-windows.sh generate --pgo-type fe --lto full
# # Copy build/generate/bin/ to Windows, collect default-*.profraw
# # Copy profraw to build/pgo-profiles/
# ./build-clangtron-windows.sh use --pgo-type fe --lto full
# ./build-clangtron-windows.sh propeller --pgo-type fe --lto full
# # Final binary: build/propeller/bin/citron.exe
#
# Option A — BOLT ELF-proxy (function-level reordering):
# ./build-clangtron-windows.sh bolt --pgo-type ir --lto full
# # bolt pauses: run build/use-elf/bin/citron-bolt-instrumented on Linux
# # Play for 15-30 minutes, exit cleanly, then press Enter
# # Final binary: build/bolt/bin/citron.exe
# =============================================================================
set -euo pipefail
# =============================================================================
# Configuration
# =============================================================================
CLANG_VERSION="${CLANG_VERSION:-21}"
# llvm-mingw release tag — cross-compilation toolchain (Clang+libc++/compiler-rt)
# https://github.com/mstorsjo/llvm-mingw/releases
LLVM_MINGW_VERSION="${LLVM_MINGW_VERSION:-20260224}"
SOURCE_DIR="${SOURCE_DIR:-$(pwd)}"
BUILD_ROOT="${BUILD_ROOT:-$(pwd)/build}"
JOBS="${JOBS:-$(nproc)}"
LTO_MODE="${LTO_MODE:-full}"
PGO_MODE="${PGO_MODE:-ir}" # ir = LLVM IR PGO (-fprofile-generate/-fprofile-use)
# fe = Frontend PGO (-fprofile-instr-generate/-fprofile-instr-use)
UNITY_BUILD="${UNITY_BUILD:-OFF}" # ENABLE_UNITY_BUILD: batch TUs to speed up compilation
BUILD_TYPE="${BUILD_TYPE:-Release}" # Release, RelWithDebInfo
CPM_SOURCE_CACHE="${CPM_SOURCE_CACHE:-${HOME}/.cache/cpm}"
# Expand ~ to $HOME if present
CPM_SOURCE_CACHE="${CPM_SOURCE_CACHE/#\~/$HOME}"
# Uncomment to optimize for this machine's CPU — produces a non-portable binary
# MARCH_NATIVE="-march=native"
MARCH_NATIVE="${MARCH_NATIVE:-}"
# =============================================================================
# Host OS detection
# =============================================================================
_HOST_OS="linux"
case "$(uname -s 2>/dev/null)" in
MINGW*|MSYS*|CYGWIN*) _HOST_OS="windows" ;;
Darwin*) _HOST_OS="macos" ;;
esac
# MSYS2 clang64 toolchain prefix. On Windows/MSYS2 this replaces llvm-mingw:
# the clang64 directory layout mirrors llvm-mingw exactly —
# bin/ x86_64-w64-mingw32-clang, -clang++, llvm-dlltool, -windres, llvm-ar …
# x86_64-w64-mingw32/{include,lib,bin}/ (sysroot headers + runtime DLLs)
# Override with MSYS2_PREFIX env var when using ucrt64/clang32/etc.
MSYS2_PREFIX="${MSYS2_PREFIX:-/clang64}"
# =============================================================================
# Derived paths
# =============================================================================
BUILD_GENERATE="${BUILD_ROOT}/generate"
BUILD_CSGENERATE="${BUILD_ROOT}/cs-generate"
BUILD_USE="${BUILD_ROOT}/use"
BUILD_USE_ELF="${BUILD_ROOT}/use-elf"
BUILD_BOLT="${BUILD_ROOT}/bolt"
BUILD_PROPELLER="${BUILD_ROOT}/propeller"
PROFILE_DIR="${BUILD_ROOT}/pgo-profiles"
BOLT_PROFILE_DIR="${BUILD_ROOT}/bolt-profiles"
PROPELLER_PROFILE_DIR="${BUILD_ROOT}/propeller-profiles"
# On MSYS2/Windows the clang64 prefix IS the llvm-mingw equivalent.
# On Linux we download a pre-built llvm-mingw release into the build root.
if [[ "${_HOST_OS}" == "windows" ]]; then
LLVM_MINGW_DIR="${MSYS2_PREFIX}"
else
LLVM_MINGW_DIR="${CPM_SOURCE_CACHE}/llvm-mingw"
fi
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}"
# On MSYS2/Windows, LLVM tools are unversioned; BOLT/Propeller are Linux-only.
if [[ "${_HOST_OS}" == "windows" ]]; then
CLANG="clang"
CLANGPP="clang++"
LLVM_PROFDATA="llvm-profdata"
LLVM_BOLT=""
MERGE_FDATA=""
fi
MINGW_TRIPLE="x86_64-w64-mingw32"
MINGW_CLANG=""
MINGW_CLANGPP=""
SPIRV_HEADERS_INSTALL="${BUILD_ROOT}/spirv-headers-install"
VULKAN_HEADERS_INSTALL="${BUILD_ROOT}/vulkan-headers-install"
# =============================================================================
# Helpers
# =============================================================================
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
RESET='\033[0m'
info() { echo -e "${CYAN}[INFO]${RESET} $*"; }
success() { echo -e "${GREEN}[OK]${RESET} $*"; }
warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; }
error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; exit 1; }
header() { echo -e "\n${BOLD}${GREEN}=================================================================${RESET}"; \
echo -e "${BOLD}${GREEN} $*${RESET}"; \
echo -e "${BOLD}${GREEN}=================================================================${RESET}"; }
# =============================================================================
# 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
# download_with_retry URL OUTPUT_FILE [MAX_RETRIES]
# Downloads URL to OUTPUT_FILE using wget, retrying on failure with
# exponential back-off (5 s → 10 s → 20 s …).
# Returns 0 on success, 1 after all attempts are exhausted.
download_with_retry() {
local url="$1"
local output="$2"
local max_retries="${3:-3}"
local attempt=1
local delay=5
while [[ "${attempt}" -le "${max_retries}" ]]; do
if [[ "${attempt}" -gt 1 ]]; then
warn "Download retry ${attempt}/${max_retries} (waiting ${delay}s): $(basename "${output}")"
sleep "${delay}"
delay=$(( delay * 2 ))
fi
if wget -q --show-progress --timeout=60 --tries=1 \
-O "${output}" "${url}" 2>&1; then
return 0
fi
rm -f "${output}"
attempt=$(( attempt + 1 ))
done
return 1
}
# _sudo — portable sudo wrapper.
# On Windows/MSYS2, sudo is unavailable; run privileged commands directly.
# On Linux, delegate to the real sudo as usual.
_sudo() {
if [[ "${_HOST_OS}" == "windows" ]]; then
"$@"
else
sudo "$@"
fi
}
# require_llvm_mingw — validate and activate the MinGW cross/native toolchain.
#
# On Linux : verifies the downloaded llvm-mingw toolchain is present
# (downloading it if the sentinel is missing via ensure_llvm_mingw),
# then prepends its bin/ to PATH and sets MINGW_CLANG/MINGW_CLANGPP.
# Replaces the "ensure_llvm_mingw; setup_llvm_mingw_path" pair that
# formerly appeared verbatim at the top of every build stage.
#
# On Windows: resolves MINGW_CLANG/MINGW_CLANGPP from the MSYS2 clang64
# environment. PATH is already configured by the MSYS2 shell.
require_llvm_mingw() {
if [[ "${_HOST_OS}" == "windows" ]]; then
export CC=clang
export CXX=clang++
if command -v clang &>/dev/null; then
MINGW_CLANG="$(cygpath -m "$(command -v clang)")"
MINGW_CLANGPP="$(cygpath -m "$(command -v clang++)")"
info "MSYS2: using clang from PATH: ${MINGW_CLANG}"
else
error "MSYS2 clang not found at ${LLVM_MINGW_DIR}/bin/ or in PATH.\n" \
" Run: ./build-clangtron-windows.sh setup"
fi
return 0
fi
# Linux: download if needed, then activate.
ensure_llvm_mingw
setup_llvm_mingw_path
}
check_tool() {
if ! command -v "$1" &>/dev/null; then
error "Required tool not found: $1\n Run: ./build-clangtron-windows.sh setup"
fi
}
resolve_bolt_binaries() {
if command -v "llvm-bolt-${CLANG_VERSION}" &>/dev/null; then
LLVM_BOLT="llvm-bolt-${CLANG_VERSION}"
MERGE_FDATA="merge-fdata-${CLANG_VERSION}"
elif command -v llvm-bolt &>/dev/null; then
LLVM_BOLT="llvm-bolt"
MERGE_FDATA="merge-fdata"
else
LLVM_BOLT=""
MERGE_FDATA=""
fi
}
lto_cmake_flag() {
case "$LTO_MODE" in
full|thin) echo "ON" ;;
none) echo "OFF" ;;
esac
}
lto_clang_flag() {
case "$LTO_MODE" in
full) echo "-flto" ;;
thin) echo "-flto=thin" ;;
none) echo "" ;;
esac
}
# =============================================================================
# llvm-mingw toolchain
#
# Downloads and extracts the llvm-mingw pre-built cross-compilation toolchain.
# Provides Clang+LLD+libc++/compiler-rt for Windows targets.
#
# Eliminates all GCC runtime workarounds:
# - No std::__once_callable TLS/non-TLS ABI mismatch (libc++ doesn't use these)
# - No dual GCC variant detection (posix vs win32 threading model)
# - No --whole-archive libstdc++ hackery
# - No --gcc-toolchain flag or manual GCC include/lib paths
# =============================================================================
ensure_llvm_mingw() {
local tarball="llvm-mingw-${LLVM_MINGW_VERSION}-ucrt-ubuntu-22.04-x86_64.tar.xz"
local url="https://github.com/mstorsjo/llvm-mingw/releases/download/${LLVM_MINGW_VERSION}/${tarball}"
local sentinel="${LLVM_MINGW_DIR}/bin/${MINGW_TRIPLE}-clang"
if [[ -x "${sentinel}" ]]; then
info "llvm-mingw already present: ${LLVM_MINGW_DIR}"
MINGW_CLANG="${LLVM_MINGW_DIR}/bin/${MINGW_TRIPLE}-clang"
MINGW_CLANGPP="${LLVM_MINGW_DIR}/bin/${MINGW_TRIPLE}-clang++"
return 0
fi
mkdir -p "${CPM_SOURCE_CACHE}"
info "Downloading llvm-mingw ${LLVM_MINGW_VERSION}..."
info " URL: ${url}"
wget --quiet --show-progress -O "${CPM_SOURCE_CACHE}/${tarball}" "${url}" \
|| error "Failed to download llvm-mingw — check network or LLVM_MINGW_VERSION"
info "Extracting llvm-mingw..."
tar -xf "${CPM_SOURCE_CACHE}/${tarball}" -C "${CPM_SOURCE_CACHE}"
rm -f "${CPM_SOURCE_CACHE}/${tarball}"
# Find the extracted directory (name includes version and platform)
local extract_dir
extract_dir="$(find "${CPM_SOURCE_CACHE}" -maxdepth 1 -type d -name "llvm-mingw-${LLVM_MINGW_VERSION}*" | head -1)"
[[ -n "${extract_dir}" ]] || error "Could not find extracted llvm-mingw directory"
# Move to a version-independent path for stable toolchain file references
if [[ "${extract_dir}" != "${LLVM_MINGW_DIR}" ]]; then
rm -rf "${LLVM_MINGW_DIR}"
mv "${extract_dir}" "${LLVM_MINGW_DIR}"
fi
[[ -x "${sentinel}" ]] || error "llvm-mingw extraction failed — ${sentinel} not found"
MINGW_CLANG="${LLVM_MINGW_DIR}/bin/${MINGW_TRIPLE}-clang"
MINGW_CLANGPP="${LLVM_MINGW_DIR}/bin/${MINGW_TRIPLE}-clang++"
success "llvm-mingw ${LLVM_MINGW_VERSION} installed: ${LLVM_MINGW_DIR}"
local clang_ver
clang_ver=$("${MINGW_CLANG}" --version 2>&1 | head -1 || true)
info " ${clang_ver}"
}
# Prepend llvm-mingw/bin to PATH so cmake and tools find the wrappers.
setup_llvm_mingw_path() {
export PATH="${LLVM_MINGW_DIR}/bin:${PATH}"
# Ensure ~/.local/bin is in PATH for aqt
if [[ -d "${HOME}/.local/bin" && ":${PATH}:" != *":${HOME}/.local/bin:"* ]]; then
export PATH="${HOME}/.local/bin:${PATH}"
fi
MINGW_CLANG="${LLVM_MINGW_DIR}/bin/${MINGW_TRIPLE}-clang"
MINGW_CLANGPP="${LLVM_MINGW_DIR}/bin/${MINGW_TRIPLE}-clang++"
}
ensure_aqt() {
if command -v aqt &>/dev/null || "${HOME}/.local/bin/aqt" --version &>/dev/null 2>&1; then
return 0
fi
local python_mm=""
python_mm="$(python3 -c 'import sys; print(f"{sys.version_info[0]}.{sys.version_info[1]}")' 2>/dev/null || true)"
if [[ "${_HOST_OS}" == "windows" && "${python_mm}" == "3.11" ]]; then
error "aqt is not installed, and auto-install is disabled on MSYS2 CLANG64 Python 3.11.\n" \
" Reason: pip may try to build backports.zstd from source, which commonly fails\n" \
" in this environment with: unknown file type '.s'.\n" \
" Recommended next steps:\n" \
" 1. Update MSYS2 fully (pacman -Syu, restart shell, then pacman -Su)\n" \
" 2. Re-check python3 --version in the CLANG64 shell\n" \
" 3. If Python is still 3.11, install aqt via pacman if available, or use a\n" \
" newer Python environment before re-running this script."
fi
python3 -m pip install aqtinstall --break-system-packages --quiet
}
# =============================================================================
# Build BOLT from source (LLVM subproject)
# BOLT for current LLVM versions is not in the LLVM apt repo for noble
# (only older stable versions ship the full bolt package) — must build from source.
# =============================================================================
build_bolt_from_source() {
header "Building BOLT ${CLANG_VERSION} from Source"
local bolt_src="/tmp/llvm-bolt-${CLANG_VERSION}-src"
local bolt_build="/tmp/llvm-bolt-${CLANG_VERSION}-build"
local bolt_tag=""
local install_dir="/usr/local/bin"
# Probe for the latest point-release tag with a single ls-remote call.
# Fetches all refs/tags/llvmorg-<VER>.* in one round-trip, then picks
# the highest version with sort -V.
local found_tag=""
local _all_tags
_all_tags="$(git ls-remote --tags https://github.com/llvm/llvm-project.git \
"refs/tags/llvmorg-${CLANG_VERSION}.*" 2>/dev/null || true)"
found_tag="$(printf '%s\n' "${_all_tags}" \
| grep -o "llvmorg-${CLANG_VERSION}\.[0-9][0-9.]*" \
| sort -V | tail -1 || true)"
if [[ -z "${found_tag}" ]]; then
error "Could not find any LLVM ${CLANG_VERSION} release tag on GitHub.\n" \
" Check that CLANG_VERSION=${CLANG_VERSION} matches an actual LLVM release."
fi
bolt_tag="${found_tag}"
info "Using LLVM tag: ${bolt_tag}"
if [[ ! -d "${bolt_src}/.git" ]]; then
info "Cloning LLVM source (sparse, shallow)..."
git clone \
--depth=1 \
--branch "${bolt_tag}" \
--filter=blob:none \
--sparse \
https://github.com/llvm/llvm-project.git \
"${bolt_src}" || error "Failed to clone llvm-project at tag ${bolt_tag}"
pushd "${bolt_src}" > /dev/null
git sparse-checkout set llvm bolt cmake third-party
popd > /dev/null
else
info "Cached clone found at ${bolt_src}, skipping re-clone."
fi
info "Configuring BOLT build..."
cmake \
-S "${bolt_src}/llvm" \
-B "${bolt_build}" \
-G Ninja \
-DCMAKE_BUILD_TYPE=${BUILD_TYPE} \
-DLLVM_ENABLE_PROJECTS="bolt" \
-DLLVM_TARGETS_TO_BUILD="X86" \
-DLLVM_INCLUDE_TESTS=OFF \
-DLLVM_INCLUDE_EXAMPLES=OFF \
-DLLVM_INCLUDE_BENCHMARKS=OFF \
-DCMAKE_C_COMPILER="clang-${CLANG_VERSION}" \
-DCMAKE_CXX_COMPILER="clang++-${CLANG_VERSION}" \
|| error "BOLT cmake configure failed"
info "Building llvm-bolt, merge-fdata, and BOLT runtime (approx 15-20 min)..."
cmake --build "${bolt_build}" --target llvm-bolt merge-fdata bolt_rt -j "${JOBS}" \
|| error "BOLT build failed"
_sudo cp "${bolt_build}/bin/llvm-bolt" "${install_dir}/llvm-bolt-${CLANG_VERSION}"
_sudo cp "${bolt_build}/bin/merge-fdata" "${install_dir}/merge-fdata-${CLANG_VERSION}"
_sudo chmod +x "${install_dir}/llvm-bolt-${CLANG_VERSION}"
_sudo chmod +x "${install_dir}/merge-fdata-${CLANG_VERSION}"
# Install the BOLT instrumentation runtime library where llvm-bolt expects it
_sudo cp "${bolt_build}/lib/libbolt_rt_instr.a" /usr/local/lib/libbolt_rt_instr.a
_sudo cp "${bolt_build}/lib/libbolt_rt_hugify.a" /usr/local/lib/libbolt_rt_hugify.a 2>/dev/null || true
command -v "llvm-bolt-${CLANG_VERSION}" &>/dev/null \
|| error "Installation failed — llvm-bolt-${CLANG_VERSION} not found in PATH"
success "llvm-bolt-${CLANG_VERSION} installed"
success "merge-fdata-${CLANG_VERSION} installed"
}
# =============================================================================
# Stage: setup
# =============================================================================
stage_setup() {
header "Setting Up Build Environment"
# ── MSYS2/Windows path ────────────────────────────────────────────────────
if [[ "${_HOST_OS}" == "windows" ]]; then
info "Detected MSYS2/Windows host (MSYS2_PREFIX=${MSYS2_PREFIX})."
if ! command -v pacman &>/dev/null; then
error "pacman not found. Windows setup requires MSYS2 (clang64 environment).\n" \
" Launch the 'MSYS2 CLANG64' terminal from the Start Menu and re-run."
fi
info "Installing toolchain and build tools via pacman..."
pacman -S --needed --noconfirm \
base-devel git curl wget \
mingw-w64-clang-x86_64-python-pip \
mingw-w64-clang-x86_64-python-psutil \
mingw-w64-clang-x86_64-toolchain \
mingw-w64-clang-x86_64-cmake \
mingw-w64-clang-x86_64-ninja \
mingw-w64-clang-x86_64-python \
mingw-w64-clang-x86_64-boost \
mingw-w64-clang-x86_64-SDL2 \
mingw-w64-clang-x86_64-nasm \
mingw-w64-clang-x86_64-yasm \
mingw-w64-clang-x86_64-glslang \
2>/dev/null || warn "Some pacman packages failed — check output above."
info "MSYS2: llvm-mingw is the system clang64 environment."
info " LLVM_MINGW_DIR → ${LLVM_MINGW_DIR}"
# Activate toolchain so shared setup steps below have MINGW_CLANG set.
require_llvm_mingw
# ── Shared toolchain-dependent artifacts ─────────────────────────────
mkdir -p "${BUILD_ROOT}"
compile_comsupp_stubs
setup_case_fixup_headers
# Verify
echo ""
info "Verifying MSYS2 installation..."
local _ok=1
for _tool in clang "clang++" lld cmake ninja llvm-profdata; do
if command -v "${_tool}" &>/dev/null; then
success " ${_tool} -> $(command -v "${_tool}")"
else
warn " ${_tool} -> NOT FOUND"
_ok=0
fi
done
[[ ${_ok} -eq 1 ]] && success "All required tools available." \
|| warn "Some tools missing — check output above."
echo ""
warn "ELF build, BOLT, and Propeller stages require a Linux host."
echo ""
info "Setup complete. Clone citron source if needed:"
echo " git clone --recursive https://github.com/citron-neo/emulator.git"
echo ""
info "Then run: ./build-clangtron-windows.sh generate"
return 0
fi
# ── Linux path ───────────────────────────────────────────────────────────
info "Updating package lists..."
_sudo apt-get update -qq
info "Installing core build tools..."
_sudo apt-get install -y \
build-essential cmake ninja-build git pkg-config \
python3 python3-pip curl wget xz-utils \
lsb-release software-properties-common gnupg
# Ensure aqt is available
ensure_aqt
# ── Host LLVM (for PGO merging, BOLT, native ELF build) ─────────────────
# Cross-compilation uses llvm-mingw; these host tools are for profdata
# merging, BOLT instrumentation, and the Linux ELF build (build-elf stage).
info "Installing host LLVM ${CLANG_VERSION}..."
if ! command -v "clang-${CLANG_VERSION}" &>/dev/null; then
wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh
chmod +x /tmp/llvm.sh
_sudo /tmp/llvm.sh "${CLANG_VERSION}"
else
info "clang-${CLANG_VERSION} already installed, skipping."
fi
_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" \
|| warn "Some LLVM packages failed to install."
# BOLT: not in the LLVM apt repo for noble on current versions, build from source
if command -v "llvm-bolt-${CLANG_VERSION}" &>/dev/null; then
info "llvm-bolt-${CLANG_VERSION} already installed, skipping."
else
build_bolt_from_source
fi
# ── llvm-mingw cross-compilation toolchain ───────────────────────────────
# Clang + LLD + libc++ + compiler-rt for Windows x86_64.
# Replaces GCC MinGW packages for cross-compilation entirely.
info "Setting up llvm-mingw cross-compilation toolchain..."
mkdir -p "${BUILD_ROOT}"
ensure_llvm_mingw
# ── Citron build dependencies ─────────────────────────────────────────────
info "Installing citron build dependencies..."
_sudo apt-get install -y \
nasm yasm glslang-tools
# ── Toolchain-dependent artifacts ────────────────────────────────────────
# Idempotent (sentinel-guarded) — fast no-ops on re-run.
# Running them here lets subsequent stages (csgenerate, use, bolt, propeller)
# skip redundant calls on a properly set-up machine.
mkdir -p "${BUILD_ROOT}"
compile_comsupp_stubs
setup_case_fixup_headers
# ── Verify ────────────────────────────────────────────────────────────────
echo ""
info "Verifying installation..."
local ok=1
for tool in "clang-${CLANG_VERSION}" "clang++-${CLANG_VERSION}" \
"lld-${CLANG_VERSION}" "llvm-profdata-${CLANG_VERSION}" \
cmake ninja; do
if command -v "$tool" &>/dev/null; then
success " $tool -> $(command -v "$tool")"
else
warn " $tool -> NOT FOUND"
ok=0
fi
done
local mingw_clang="${LLVM_MINGW_DIR}/bin/${MINGW_TRIPLE}-clang"
if [[ -x "${mingw_clang}" ]]; then
local ver
ver=$("${mingw_clang}" --version 2>&1 | head -1 || true)
success " ${MINGW_TRIPLE}-clang -> ${mingw_clang}"
success " (${ver})"
else
warn " ${MINGW_TRIPLE}-clang -> NOT FOUND (${mingw_clang})"
ok=0
fi
if command -v "llvm-bolt-${CLANG_VERSION}" &>/dev/null; then
success " llvm-bolt-${CLANG_VERSION} -> $(command -v "llvm-bolt-${CLANG_VERSION}")"
else
warn " llvm-bolt-${CLANG_VERSION} -> NOT FOUND (generate/use stages still work)"
fi
[[ $ok -eq 1 ]] && success "All required tools available." \
|| warn "Some tools missing — check output above."
echo ""
info "Setup complete. Clone citron source if needed:"
echo " git clone --recursive https://github.com/citron-neo/emulator.git"
echo ""
info "Then run: ./build-clangtron-windows.sh generate"
}
# =============================================================================
# PGO profile runtime for Windows
#
# llvm-mingw ships libclang_rt.profile.a for Windows targets.
# This function verifies it exists; if not, builds from LLVM sources as fallback.
# =============================================================================
ensure_profile_runtime_mingw() {
[[ -x "${MINGW_CLANG}" ]] || error "MINGW_CLANG not set — call ensure_llvm_mingw first"
local resource_dir
resource_dir=$("${MINGW_CLANG}" --print-resource-dir 2>/dev/null || true)
if [[ -z "${resource_dir}" ]]; then
warn "Could not determine llvm-mingw resource dir — skipping profile runtime check"
return 0
fi
# The clang MinGW driver (lib/Driver/ToolChains/MinGW.cpp) resolves the
# profile runtime using ToolChain.getTriple().str() which for llvm-mingw is
# "x86_64-w64-mingw32", NOT "x86_64-w64-windows-gnu".
# We install to the mingw32 directory. A windows-gnu symlink is also created
# as a fallback for older clang versions that used that name.
local target_triple="${MINGW_TRIPLE}" # x86_64-w64-mingw32
local runtime_dir="${resource_dir}/lib/${target_triple}"
local runtime_lib="${runtime_dir}/libclang_rt.profile.a"
# Also accept the old "windows" layout: libclang_rt.profile-x86_64.a
local windows_dir="${resource_dir}/lib/windows"
local windows_lib="${windows_dir}/libclang_rt.profile-x86_64.a"
_profile_rt_valid() {
local lib="$1"
[[ -f "${lib}" ]] || return 1
local nm_tool="llvm-nm-${CLANG_VERSION}"
command -v "${nm_tool}" >/dev/null 2>&1 || nm_tool="llvm-nm"
command -v "${nm_tool}" >/dev/null 2>&1 || nm_tool="nm"
local nm_out
nm_out=$("${nm_tool}" --defined-only "${lib}" 2>/dev/null || true)
# LLVM 17+ may internalize some runtime entry points, but the archive
# still must provide the Windows mmap/flock helpers used by
# InstrProfilingFile/Util. The llvm-mingw 20260224 x86_64 archive can
# expose __llvm_profile_raw_version while still missing those helpers,
# which causes the exact undefined symbols seen during PE linking.
echo "${nm_out}" | grep -q '__llvm_profile_raw_version' || return 1
if [[ "${lib}" == *profile-x86_64.a || "${lib}" == *x86_64-w64-mingw32/libclang_rt.profile.a ]]; then
local required=(
' lprofProfileDumped$'
' __llvm_profile_mmap$'
' __llvm_profile_flock$'
' __llvm_profile_munmap$'
' __llvm_profile_madvise$'
)
local sym
for sym in "${required[@]}"; do
echo "${nm_out}" | grep -Eq "[[:xdigit:]]+[[:space:]]+[TDBR][[:space:]]+${sym}" || return 1
done
fi
return 0
}
if _profile_rt_valid "${runtime_lib}"; then
info "Profile runtime OK: ${runtime_lib}"
export PROFILE_RUNTIME_LIB="${runtime_lib}"
return 0
fi
if _profile_rt_valid "${windows_lib}"; then
info "Profile runtime OK (windows layout): ${windows_lib}"
mkdir -p "${runtime_dir}"
if cp -f "${windows_lib}" "${runtime_lib}" 2>/dev/null; then
info "Installed MinGW-layout profile runtime from existing windows-layout archive"
export PROFILE_RUNTIME_LIB="${runtime_lib}"
else
warn "Could not copy profile runtime into ${runtime_dir}; using windows-layout archive directly"
export PROFILE_RUNTIME_LIB="${windows_lib}"
fi
return 0
fi
[[ -f "${runtime_lib}" ]] \
&& warn "Profile runtime exists but missing required symbols — rebuilding." \
|| warn "Profile runtime not found at ${runtime_lib} — building from source."
# Fallback: build from LLVM compiler-rt sources
local clang_version
clang_version=$("${MINGW_CLANG}" --version 2>&1 | grep -oP '\d+\.\d+\.\d+' | head -1)
[[ -n "${clang_version}" ]] \
|| { warn "Cannot determine Clang version — skipping profile runtime build"; return 0; }
local llvm_tag="llvmorg-${clang_version}"
local build_dir="${BUILD_ROOT}/compiler-rt-profile"
local src_dir="${build_dir}/src"
local inc_dir="${build_dir}/include"
local obj_dir="${build_dir}/obj"
mkdir -p "${src_dir}" "${inc_dir}" "${obj_dir}"
info "Building profile runtime from ${llvm_tag}..."
local raw_base="https://raw.githubusercontent.com/llvm/llvm-project/${llvm_tag}"
# InstrProfilingRuntime was renamed from .c to .cpp in LLVM 16.
# Build the source list dynamically, probing for the correct extension.
local profile_c_srcs=(
InstrProfiling.c InstrProfilingBuffer.c InstrProfilingFile.c
InstrProfilingMerge.c InstrProfilingMergeFile.c InstrProfilingNameVar.c
InstrProfilingPlatformWindows.c InstrProfilingUtil.c
InstrProfilingValue.c InstrProfilingVersionVar.c InstrProfilingWriter.c
)
# Probe for InstrProfilingRuntime — .cpp since LLVM 16, .c before that.
# LLVM 16+ always uses .cpp; since we require Clang >= 19 we can skip the .c
# fallback entirely. We still do a two-step probe but avoid relying on network
# HEAD requests (which can be blocked or return unreliable results in CI), and
# we purge any stale zero-byte files from previous failed attempts.
local runtime_src=""
local major_ver
major_ver=$(echo "${clang_version}" | cut -d. -f1)
if (( major_ver >= 16 )); then
# .cpp is canonical for LLVM 16+
local stale="${src_dir}/InstrProfilingRuntime.c"
[[ -f "${stale}" ]] && rm -f "${stale}" # remove stale fallback if it exists
runtime_src="InstrProfilingRuntime.cpp"
else
# Legacy: probe for correct extension
for ext in c cpp; do
local candidate="InstrProfilingRuntime.${ext}"
# If a non-empty local copy exists, reuse it
if [[ -s "${src_dir}/${candidate}" ]]; then
runtime_src="${candidate}"; break
fi
done
[[ -n "${runtime_src}" ]] || runtime_src="InstrProfilingRuntime.c"
fi
[[ -n "${runtime_src}" ]] \
|| { warn "Cannot determine InstrProfilingRuntime source for ${llvm_tag}"; return 1; }
profile_c_srcs+=("${runtime_src}")
# Remove any zero-byte or partial files from previous failed attempts so
# the download loop doesn't skip them and silently use corrupt stubs.
find "${src_dir}" "${inc_dir}" -maxdepth 1 -type f -empty -delete 2>/dev/null || true
_populate_profile_sources_from_git() {
local git_src="${build_dir}/llvm-project-src-${llvm_tag}"
local repo_profile="${git_src}/compiler-rt/lib/profile"
local repo_include="${git_src}/compiler-rt/include/profile"
if [[ ! -d "${git_src}/.git" ]]; then
command -v git >/dev/null 2>&1 \
|| { warn "git not available for LLVM source fallback"; return 1; }
info " Falling back to sparse llvm-project checkout..."
git clone \
--depth=1 \
--branch "${llvm_tag}" \
--filter=blob:none \
--sparse \
https://github.com/llvm/llvm-project.git \
"${git_src}" \
|| return 1
pushd "${git_src}" >/dev/null
git sparse-checkout set compiler-rt/lib/profile compiler-rt/include/profile \
|| { popd >/dev/null; return 1; }
popd >/dev/null
elif [[ ! -d "${repo_profile}" || ! -d "${repo_include}" ]]; then
pushd "${git_src}" >/dev/null
git sparse-checkout set compiler-rt/lib/profile compiler-rt/include/profile \
|| { popd >/dev/null; return 1; }
popd >/dev/null
fi
[[ -d "${repo_profile}" ]] || return 1
[[ -d "${repo_include}" ]] || return 1
for f in "${profile_c_srcs[@]}"; do
cp -f "${repo_profile}/${f}" "${src_dir}/${f}" || return 1
done
for f in InstrProfiling.h InstrProfilingInternal.h InstrProfilingPort.h \
InstrProfilingUtil.h WindowsMMap.h; do
cp -f "${repo_profile}/${f}" "${src_dir}/${f}" || return 1
done
cp -f "${repo_include}/InstrProfData.inc" "${inc_dir}/InstrProfData.inc" \
|| return 1
return 0
}
# curl_retry: download $1 → $2 with exponential backoff.
# GitHub's raw content CDN returns HTTP 429 (Too Many Requests) when multiple
# files are fetched in rapid succession from the same IP. We retry up to 4
# times (delays: 0 s, 2 s, 8 s, 32 s) before giving up.
curl_retry() {
local url="$1" dest="$2" fatal="${3:-1}"
local delay=0 attempt
for attempt in 1 2 3 4; do
[[ ${delay} -gt 0 ]] && { info " (rate-limited, retrying in ${delay}s…)"; sleep "${delay}"; }
if curl -fsSL --retry 0 -o "${dest}" "${url}" 2>/dev/null; then
return 0
fi
delay=$(( delay == 0 ? 2 : delay * 4 ))
done
rm -f "${dest}" 2>/dev/null || true
[[ "${fatal}" == 1 ]] \
&& { warn "Failed to download $(basename "${url}")"; return 1; } \
|| return 1
}
for f in "${profile_c_srcs[@]}"; do
[[ -f "${src_dir}/${f}" ]] && continue
info " Downloading ${f}..."
curl_retry "${raw_base}/compiler-rt/lib/profile/${f}" "${src_dir}/${f}" 1 \
|| { warn "Raw source fetch failed; using sparse llvm-project checkout."; _populate_profile_sources_from_git || return 1; break; }
done
for f in InstrProfiling.h InstrProfilingInternal.h InstrProfilingPort.h \
InstrProfilingUtil.h WindowsMMap.h; do
[[ -f "${src_dir}/${f}" ]] && continue
curl_retry "${raw_base}/compiler-rt/lib/profile/${f}" "${src_dir}/${f}" 0 || true
done
[[ -f "${inc_dir}/InstrProfData.inc" ]] || \
curl_retry "${raw_base}/compiler-rt/include/profile/InstrProfData.inc" \
"${inc_dir}/InstrProfData.inc" 1 \
|| { warn "Raw include fetch failed; using sparse llvm-project checkout."; _populate_profile_sources_from_git || return 1; }
mkdir -p "${inc_dir}/sys"
[[ -f "${inc_dir}/sys/utsname.h" ]] || cat > "${inc_dir}/sys/utsname.h" <<'EOF'
#pragma once
struct utsname { char sysname[256]; char nodename[256]; char release[256];
char version[256]; char machine[256]; };
static inline int uname(struct utsname *buf) { (void)buf; return -1; }
EOF
local stubs_file="${src_dir}/InstrProfilingWindowsStubs.c"
cat > "${stubs_file}" <<'STUBS_EOF'
#include <windows.h>
#include <errno.h>
#include <io.h>
#include <stddef.h>
#include <stdint.h>
#include <sys/types.h>
static int profile_dumped_flag = 0;
unsigned lprofProfileDumped(void) {
return (unsigned)profile_dumped_flag;
}
void lprofSetProfileDumped(int value) {
profile_dumped_flag = value;
}
void* __llvm_profile_mmap(void* start, size_t length, int prot, int flags, int fd, off_t offset) {
(void)prot;
(void)flags;
HANDLE file = (HANDLE)_get_osfhandle(fd);
if (file == INVALID_HANDLE_VALUE) {
errno = EBADF;
return (void*)-1;
}
DWORD protect = PAGE_READONLY;
if (prot & 0x2) {
protect = PAGE_READWRITE;
}
ULARGE_INTEGER map_size;
map_size.QuadPart = (unsigned long long)offset + (unsigned long long)length;
HANDLE mapping = CreateFileMappingW(file, NULL, protect, map_size.HighPart, map_size.LowPart, NULL);
if (!mapping) {
errno = EINVAL;
return (void*)-1;
}
DWORD access = FILE_MAP_READ;
if (prot & 0x2) {
access |= FILE_MAP_WRITE;
}
ULARGE_INTEGER view_offset;
view_offset.QuadPart = (unsigned long long)offset;
void* view = MapViewOfFileEx(mapping, access, view_offset.HighPart, view_offset.LowPart, length, start);
CloseHandle(mapping);
if (!view) {
errno = EINVAL;
return (void*)-1;
}
return view;
}
void __llvm_profile_munmap(void* addr, size_t length) {
(void)length;
if (addr && addr != (void*)-1) {
UnmapViewOfFile(addr);
}
}
int __llvm_profile_madvise(void* addr, size_t length, int advice) {
(void)addr;
(void)length;
(void)advice;
return 0;
}
int __llvm_profile_flock(int fd, int operation) {
HANDLE file = (HANDLE)_get_osfhandle(fd);
if (file == INVALID_HANDLE_VALUE) {
errno = EBADF;
return -1;
}
OVERLAPPED ov = {0};
DWORD flags = 0;
if (operation & 0x8) {
if (!UnlockFileEx(file, 0, MAXDWORD, MAXDWORD, &ov)) {
errno = EINVAL;
return -1;
}
return 0;
}
if (operation & 0x4) {
flags |= LOCKFILE_FAIL_IMMEDIATELY;
}
if (operation & 0x2) {
flags |= LOCKFILE_EXCLUSIVE_LOCK;
}
if (!LockFileEx(file, flags, 0, MAXDWORD, MAXDWORD, &ov)) {
errno = EWOULDBLOCK;
return -1;
}
return 0;
}
STUBS_EOF
local cflags=(
"-I${src_dir}" "-I${inc_dir}" "-O2"
"-fno-stack-protector" "-fno-exceptions"
"-D_WIN32" "-D__MINGW32__"
"-UCOMPILER_RT_HAS_FCNTL_LCK" "-UCOMPILER_RT_HAS_UNAME"
"-DCOMPILER_RT_HAS_ATOMICS=1"
"-fvisibility=default"
)
local objs=()
for src in "${profile_c_srcs[@]}"; do
local obj="${obj_dir}/${src%.c}.o"
info " Compiling ${src}..."
"${MINGW_CLANG}" "${cflags[@]}" -c "${src_dir}/${src}" -o "${obj}" \
|| { warn "Failed to compile ${src}"; rm -f "${obj}"; return 1; }
objs+=("${obj}")
done
local stubs_obj="${obj_dir}/InstrProfilingWindowsStubs.o"
"${MINGW_CLANG}" "${cflags[@]}" -c "${stubs_file}" -o "${stubs_obj}" \
|| { warn "Failed to compile stubs"; return 1; }
objs+=("${stubs_obj}")
local ar="${LLVM_MINGW_DIR}/bin/llvm-ar"
[[ -x "${ar}" ]] || ar="llvm-ar-${CLANG_VERSION}"
command -v "${ar}" >/dev/null 2>&1 || ar="ar"
local tmp_lib="${obj_dir}/libclang_rt.profile.a"
mkdir -p "${runtime_dir}"
"${ar}" rcs "${tmp_lib}" "${objs[@]}" \
&& cp "${tmp_lib}" "${runtime_lib}" \
|| { warn "Failed to create profile runtime"; return 1; }
# Also install to the windows-layout directory so older clang versions find it
mkdir -p "${windows_dir}"
cp "${tmp_lib}" "${windows_dir}/libclang_rt.profile-x86_64.a" 2>/dev/null || true
export PROFILE_RUNTIME_LIB="${runtime_lib}"
success "Profile runtime built: ${runtime_lib}"
}
# =============================================================================
# comsupp stub
# MSVC provides _com_util::ConvertStringToBSTR via comsuppw.lib.
# llvm-mingw (and all MinGW toolchains) do not ship it.
# =============================================================================
compile_comsupp_stubs() {
local stub_src="${BUILD_ROOT}/comsupp_stubs.cpp"
local stub_obj="${BUILD_ROOT}/comsupp_stubs.o"
[[ -x "${MINGW_CLANGPP}" ]] || error "MINGW_CLANGPP not set — call ensure_llvm_mingw first"
if [[ -f "${stub_obj}" ]]; then
info "comsupp_stubs.o already compiled: ${stub_obj}"
else
info "Compiling _com_util::ConvertStringToBSTR stub..."
cat > "${stub_src}" << 'COMSUPP_CPP_EOF'
// Stub for _com_util::ConvertStringToBSTR (MSVC comsuppw.lib).
// performance_overlay.cpp uses it for WMI BSTR strings.
// Uses LocalAlloc (no oleaut32 dep at compile time; SysFreeString uses
// LocalFree internally so BSTRs are safe to free with SysFreeString).
#include <windows.h>
namespace _com_util {
BSTR __stdcall ConvertStringToBSTR(const char* pSrc) {
if (!pSrc) return nullptr;
int nWide = MultiByteToWideChar(CP_ACP, 0, pSrc, -1, nullptr, 0);
if (nWide <= 0) nWide = 1;
UINT byteLen = (UINT)(nWide - 1) * sizeof(WCHAR);
BYTE* raw = (BYTE*)LocalAlloc(LMEM_FIXED, sizeof(UINT) + nWide * sizeof(WCHAR));
if (!raw) return nullptr;
*((UINT*)raw) = byteLen;
WCHAR* bstr = (WCHAR*)(raw + sizeof(UINT));
if (nWide > 1)
MultiByteToWideChar(CP_ACP, 0, pSrc, -1, bstr, nWide);
else
bstr[0] = L'\0';
return bstr;
}
}
COMSUPP_CPP_EOF
# llvm-mingw wrapper sets --target, --sysroot, -stdlib=libc++ automatically
"${MINGW_CLANGPP}" -O2 -c "${stub_src}" -o "${stub_obj}" \
|| error "Failed to compile comsupp_stubs.o"
success "comsupp_stubs.o compiled: ${stub_obj}"
fi
# CMAKE_CXX_STANDARD_LIBRARIES embeds this path directly into the toolchain
# file as a raw linker flag string. CMake splits that string on spaces when
# building the link command, so a path like "C:/Users/Gaming PC/.../comsupp_stubs.o"
# gets torn in two. When BUILD_ROOT contains spaces (common on Windows when
# the username has a space), copy the object to MSYS2's /tmp which is always
# space-free (C:\msys64\tmp), and record the safe path for write_toolchain_file.
if [[ "${stub_obj}" == *' '* ]]; then
local _safe_obj="/tmp/citron-comsupp_stubs.o"
cp "${stub_obj}" "${_safe_obj}" \
|| error "Failed to copy comsupp_stubs.o to space-free path ${_safe_obj}"
# Export as a Windows mixed-path so the toolchain file sees a native path
if [[ "${_HOST_OS}" == "windows" ]]; then
_COMSUPP_TC_PATH="$(cygpath -m "${_safe_obj}")"
else
_COMSUPP_TC_PATH="${_safe_obj}"
fi
info "comsupp_stubs.o staged to space-free path: ${_COMSUPP_TC_PATH}"
else
# Path is already space-free; convert to Windows mixed-path for the toolchain
if [[ "${_HOST_OS}" == "windows" ]]; then
_COMSUPP_TC_PATH="$(cygpath -m "${stub_obj}")"
else
_COMSUPP_TC_PATH="${stub_obj}"
fi
fi
}
# =============================================================================
# Windows header case-fixup directory
# =============================================================================
setup_case_fixup_headers() {
local fixup_dir="${BUILD_ROOT}/mingw-case-fixups"
info "Creating Windows header case-fixup directory..."
mkdir -p "${fixup_dir}"
local -a pairs=(
"Windows.h:windows.h" "Winsock2.h:winsock2.h"
"Ws2tcpip.h:ws2tcpip.h" "Winerror.h:winerror.h"
"Winnt.h:winnt.h" "Windef.h:windef.h"
"Winbase.h:winbase.h" "Wingdi.h:wingdi.h"
"Winuser.h:winuser.h" "Objbase.h:objbase.h"
"Ole2.h:ole2.h" "Shlobj.h:shlobj.h"
"Shellapi.h:shellapi.h" "Commctrl.h:commctrl.h"
"Psapi.h:psapi.h" "Tlhelp32.h:tlhelp32.h"
"Dbghelp.h:dbghelp.h" "Mmsystem.h:mmsystem.h"
"Iphlpapi.h:iphlpapi.h"
"WbemIdl.h:wbemidl.h" "WbemCli.h:wbemcli.h"
"WbemDisp.h:wbemdisp.h" "WbemProv.h:wbemprov.h"
"WbemTran.h:wbemtran.h" "ObjBase.h:objbase.h"
"ObjIdl.h:objidl.h" "PropIdl.h:propidl.h"
"ComDef.h:comdef.h" "ComDefSP.h:comdefsp.h"
"ComUtil.h:comutil.h"
)
# Search llvm-mingw's sysroot first, then fall back to system MinGW.
# On MSYS2 clang64, headers are directly in ${LLVM_MINGW_DIR}/include.
local mingw_inc="${LLVM_MINGW_DIR}/${MINGW_TRIPLE}/include"
if [[ "${_HOST_OS}" == "windows" ]] && [[ ! -d "${mingw_inc}" ]]; then
mingw_inc="${LLVM_MINGW_DIR}/include"
fi
local sys_mingw_inc="/usr/${MINGW_TRIPLE}/include"
local created=0
for pair in "${pairs[@]}"; do
local upper="${pair%%:*}" lower="${pair##*:}"
if [[ -f "${mingw_inc}/${lower}" ]] || [[ -f "${sys_mingw_inc}/${lower}" ]]; then
printf '#include <%s>\n' "${lower}" > "${fixup_dir}/${upper}"
(( created++ )) || true
fi
done
success "Case fixup headers: ${created} wrappers in ${fixup_dir}"
}
# =============================================================================
# Patch: silence MSVC-only #pragma comment(lib, ...)
# =============================================================================
# patch_vfs_stat
#
# vfs_real.cpp has:
# #ifdef _MSC_VER
# #define stat _stat64
# #endif
#
# MinGW defines _WIN32 but NOT _MSC_VER, so the guard is wrong: _wstat64
# expects a struct _stat64* but gets a POSIX struct stat*. We broaden the
# guard to also cover __MINGW32__.
# =============================================================================
# patch_bfd_linker
#
# New citron (post-3.1.2) sets target_link_options(... -fuse-ld=bfd) for all
# non-MSVC non-Apple builds. GNU ld.bfd cannot process llvm-mingw COFF objects.
# This wraps -fuse-ld=bfd calls in a CMAKE_CXX_COMPILER_ID STREQUAL "GNU" check.
# =============================================================================
# Patch: suppress CS-IRPGO instrumentation on hot functions with high discard rates
#
# CS-IRPGO inserts per-call-site counters at the LLVM IR level AFTER the
# stage1 PGO optimisation pass. For functions that are extremely hot and
# heavily inlined by full LTO (particularly inner-loop dispatch functions),
# the post-optimisation IR seen by CS instrumentation differs substantially
# from the IR the use-stage produces from the same stage1 profile — causing
# pervasive hash mismatches and large profile discard counts.
#
# Two functions account for >86M discarded counts in the CSIR use stage:
#
# Service::HID::NPad::OnUpdate ~76M discarded (src/hid_core/)
# Common::Log::FmtLogMessageImpl ~10.8M discarded (src/common/)
#
# The correct fix is __attribute__((no_profile_instrument_function)), a
# Clang/GCC attribute (also spelable as [[clang::no_profile_instrument_function]]
# in C++11) that tells the compiler NOT to insert PGO counter code into that
# specific function, while leaving -fprofile-use optimisation fully intact.
# The function still gets profile-guided inlining/branch decisions; it simply
# does not COLLECT new counters during the CS profiling run.
#
# This is applied ONLY during the csgenerate stage, before cmake configure.
# It is idempotent (guarded by a marker comment) and reversible (plain text).
#
# Note: this cannot be done via target_compile_options() because the script
# places -fcs-profile-generate in CMAKE_CXX_FLAGS_RELEASE globally; CMake
# has no mechanism to subtract a flag from that variable per-target.
# =============================================================================
# Patch: make CMakeModules/PGO.cmake defer PGO flags to this script
#
# Fresh upstream clones may not yet contain the CITRON_PGO_FLAGS_MANAGED_BY_SCRIPT
# guard in PGO.cmake. Without that guard, Clang builds can receive both the
# script-managed IR/FE PGO flags and CMake's own frontend PGO flags, producing
# invalid combinations such as:
# -fprofile-generate=... with -fprofile-instr-generate
# This patch is idempotent and safe to run before any PGO configure stage.
# =============================================================================
# Normalize profraw directories produced by LLVM instrumentation.
# Default IR/FE instrumentation produces:
# default-<pid>.profraw/
# default_<hash>_0.profraw
# (same file-name inside every directory, collisions prevented by unique directories).
# This helper flattens those directories into standalone .profraw files in the
# same folder so later steps can glob "*.profraw" without walking directories.
# =============================================================================
normalize_profraw_dirs() {
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
local file
while IFS= read -r -d '' file; do
[[ -f "${file}" ]] || continue
local target_suffix=""
[[ "${idx}" -gt 0 ]] && target_suffix="-${idx}"
local target="${base_dir}/${prefix}${target_suffix}.profraw"
while [[ -e "${target}" ]]; do
idx=$((idx + 1))
target_suffix="-${idx}"
target="${base_dir}/${prefix}${target_suffix}.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)
}
# =============================================================================
# Vulkan import library
#
# cmake's FindVulkan needs a libvulkan-1.a import library at configure time.
# We generate it from the bundled Vulkan-Headers submodule — the same headers
# citron is compiled against — so the symbol set is always correct and no
# network access or hardcoded version string is required.
#
# WHY NOT gendef / downloading vulkan-1.dll:
# gendef extracts symbols from a pre-built Windows DLL. That DLL would be a
# specific Vulkan Loader release, potentially older than the Vulkan-Headers
# submodule citron uses, and the download URL breaks whenever a new loader
# version is released. Parsing the vendored headers directly is strictly
# more correct: it matches exactly the API surface citron is built against,
# requires no network, and stays in sync with submodule updates automatically.
#
# WHY NOT --kill-at:
# On x86_64, the Windows ABI uses cdecl for all functions (including those
# declared WINAPI/VKAPI_CALL). There is no @N stack-size decoration in
# 64-bit PE exports. --kill-at is only meaningful for 32-bit stdcall.
# =============================================================================
ensure_vulkan_import_lib() {
local out_dir="${BUILD_ROOT}/vulkan-stub"
local def_file="${out_dir}/vulkan-1.def"
local lib_file="${out_dir}/libvulkan-1.a"
if [[ -f "${lib_file}" ]]; then
info "Vulkan import lib already exists: ${lib_file}"
return 0
fi
mkdir -p "${out_dir}"
info "Building vulkan-1 MinGW import library from vendored headers..."
# Use the checked-in stub definition.
local stub_def="${SOURCE_DIR}/externals/vulkan-stub/vulkan-1.def"
if [[ -f "${stub_def}" ]]; then
info " Using pre-generated Vulkan module definition from stub..."
cp -f "${stub_def}" "${def_file}"
else
error "vulkan-1.def stub not found at ${stub_def}"
fi
# Use llvm-mingw's llvm-dlltool. It is always present in the llvm-mingw
# distribution and is the correct tool for the llvm-mingw toolchain.
# Fall back to system binutils dlltool only if llvm-mingw is not yet
# extracted (e.g. running ensure_vulkan_import_lib standalone).
local dlltool="${LLVM_MINGW_DIR}/bin/llvm-dlltool"
if [[ ! -x "${dlltool}" ]]; then
warn "llvm-mingw dlltool not found at ${dlltool}, trying system fallback"
dlltool="x86_64-w64-mingw32-dlltool"
command -v "${dlltool}" &>/dev/null \
|| error "No dlltool available. Run setup or ensure llvm-mingw is extracted."
fi
info " Running ${dlltool##*/} to generate libvulkan-1.a..."
# -m i386:x86-64 — target machine (x86_64 PE)
# No --kill-at — not needed for x86_64 cdecl exports (see function comment)
"${dlltool}" \
-m i386:x86-64 \
--input-def "${def_file}" \
--output-lib "${lib_file}" \
|| error "dlltool failed to generate ${lib_file}"
local sym_count
sym_count=$(grep -c '^ vk' "${def_file}" 2>/dev/null || echo "?")
success "Vulkan import lib built: ${lib_file} (${sym_count} entry points)"
}
# =============================================================================
# detect_ffmpeg_version — Read FFmpeg version from the submodule RELEASE file
# and export FFMPEG_VERSION plus per-library soname variables.
#
# Sets globals:
# FFMPEG_VERSION e.g. "7.1.3"
# FFMPEG_AVCODEC_VER e.g. "61"
# FFMPEG_AVFORMAT_VER e.g. "61"
# FFMPEG_AVFILTER_VER e.g. "10"
# FFMPEG_AVUTIL_VER e.g. "59"
# FFMPEG_SWSCALE_VER e.g. "8"
# FFMPEG_SWRESAMPLE_VER e.g. "5"
#
# The soname major numbers are NOT mechanically derivable from the package
# version (7.1.3 → 61 is an upstream decision), so they live in a lookup table
# keyed by FFmpeg major version. Only one new entry is needed per major release.
# =============================================================================
detect_ffmpeg_version() {
local release_file="${SOURCE_DIR}/externals/ffmpeg/ffmpeg/RELEASE"
if [[ -f "${release_file}" ]]; then
# Strip whitespace/newlines from the RELEASE file content
FFMPEG_VERSION="$(tr -d '[:space:]' < "${release_file}")"
else
# No submodule — use the version pinned in CMakeModules/dependencies.cmake (CPM tag n8.0)
FFMPEG_VERSION="8.0"
info "[detect_ffmpeg_version] RELEASE file not found — using pinned version ${FFMPEG_VERSION}"
fi
if [[ -z "${FFMPEG_VERSION}" ]]; then
error "[detect_ffmpeg_version] RELEASE file is empty: ${release_file}"
fi
# Extract the upstream major version number (first component of X.Y.Z)
local _major
_major="$(echo "${FFMPEG_VERSION}" | cut -d. -f1)"
# Soname lookup table — one entry per FFmpeg major release.
# Library sonames are set independently by upstream FFmpeg and do not follow
# the package version number. Update this table when a new major is released.
case "${_major}" in
8)
FFMPEG_AVCODEC_VER=62
FFMPEG_AVFORMAT_VER=62
FFMPEG_AVFILTER_VER=11
FFMPEG_AVUTIL_VER=60
FFMPEG_SWSCALE_VER=9
FFMPEG_SWRESAMPLE_VER=6
;;
7)
FFMPEG_AVCODEC_VER=61
FFMPEG_AVFORMAT_VER=61
FFMPEG_AVFILTER_VER=10
FFMPEG_AVUTIL_VER=59
FFMPEG_SWSCALE_VER=8
FFMPEG_SWRESAMPLE_VER=5
;;
6)
FFMPEG_AVCODEC_VER=60
FFMPEG_AVFORMAT_VER=60
FFMPEG_AVFILTER_VER=9
FFMPEG_AVUTIL_VER=58
FFMPEG_SWSCALE_VER=7
FFMPEG_SWRESAMPLE_VER=4
;;
5)
FFMPEG_AVCODEC_VER=59
FFMPEG_AVFORMAT_VER=59
FFMPEG_AVFILTER_VER=8
FFMPEG_AVUTIL_VER=57
FFMPEG_SWSCALE_VER=6
FFMPEG_SWRESAMPLE_VER=4
;;
*)
error "[detect_ffmpeg_version] Unknown FFmpeg major version '${_major}' (from ${FFMPEG_VERSION}).
Add a soname entry for this major version in detect_ffmpeg_version()."
;;
esac
info "[ffmpeg] Detected FFmpeg ${FFMPEG_VERSION} (avcodec-${FFMPEG_AVCODEC_VER}, avutil-${FFMPEG_AVUTIL_VER}, swscale-${FFMPEG_SWSCALE_VER})"
}
# Pre-build FFmpeg for Windows (pthread-free, llvm-mingw, shared DLLs).
#
# WHY THIS EXISTS:
# The citron WIN32 cmake path calls download_bundled_external() which fetches
# a pre-built FFmpeg archive from yuzu-mirror/ext-windows-bin. That repo is
# a frozen mirror of the original yuzu binaries and only carries FFmpeg 6.0
# (and earlier) archives; newer versions produce an HTTP 404 at cmake configure
# time, aborting the build.
#
# Additionally, the pre-built GCC DLLs import libwinpthread-1.dll, whose TLS
# initialiser races with llvm-mingw's libc++ at game boot and triggers an
# interval_map.hpp assertion crash.
#
# This function solves both problems by:
# 1. Running BEFORE cmake configure so the externals/ffmpeg-VERSION/ directory
# tree already exists and cmake's download_bundled_external() is skipped.
# 2. Building FFmpeg with llvm-mingw using --disable-pthreads --enable-w32threads,
# eliminating the libwinpthread dependency entirely.
#
# PREREQUISITES:
# - detect_ffmpeg_version() has been called (sets FFMPEG_VERSION and soname vars)
# - require_llvm_mingw() has been called (sets LLVM_MINGW_DIR, MINGW_CLANG, etc.)
#
# ARGS:
# $1 build_dir — e.g. BUILD_GENERATE, BUILD_USE, BUILD_BOLT, BUILD_PROPELLER
# =============================================================================
rebuild_ffmpeg_pthread_free() {
local build_dir="$1"
# detect_ffmpeg_version must have been called by the caller.
[[ -n "${FFMPEG_VERSION:-}" ]] \
|| error "[ffmpeg-rebuild] FFMPEG_VERSION not set — call detect_ffmpeg_version() first"
local ffmpeg_ext_dir="${BUILD_ROOT}/externals/ffmpeg-${FFMPEG_VERSION}-static"
local ffmpeg_lib="${ffmpeg_ext_dir}/lib"
local ffmpeg_hdr="${ffmpeg_ext_dir}/include"
local ffmpeg_bld="${BUILD_ROOT}/externals/ffmpeg-${FFMPEG_VERSION}-llvm-bld"
local ffmpeg_src_dir="${CPM_SOURCE_CACHE}/ffmpeg-src/${FFMPEG_VERSION}"
# FFmpeg's configure script does bare `cd` calls and cannot handle spaces in
# either the source or build directory paths. On Windows the username may
# contain spaces (e.g. "Gaming PC"), which propagates into CPM_SOURCE_CACHE
# and BUILD_ROOT. Detect this and redirect both dirs to MSYS2's /tmp
# (C:\msys64\tmp — guaranteed to be space-free) so configure succeeds.
# Only the temporary source/build dirs move; ffmpeg_ext_dir (the installed
# .a + headers consumed by CMake) stays under BUILD_ROOT as before.
if [[ "${ffmpeg_src_dir}" == *' '* || "${ffmpeg_bld}" == *' '* ]]; then
local _ffmpeg_safe_root="/tmp/citron-ffmpeg/${FFMPEG_VERSION}"
warn "[ffmpeg-rebuild] Path contains spaces — redirecting FFmpeg source/build to ${_ffmpeg_safe_root}"
ffmpeg_src_dir="${_ffmpeg_safe_root}/src"
ffmpeg_bld="${_ffmpeg_safe_root}/bld"
# Ensure clean directories in /tmp to avoid stale or recursive Makefiles
rm -rf "${ffmpeg_src_dir}" "${ffmpeg_bld}"
fi
# Define global cache location
local ffmpeg_global_cache="${CPM_SOURCE_CACHE}/citron-ffmpeg-static/${FFMPEG_VERSION}-llvm-mingw"
local sentinel="${ffmpeg_lib}/.llvm_static_built"
# 1. Check if it's already in the local build dir
if [[ -f "${sentinel}" ]]; then
info "[ffmpeg-rebuild] Static FFmpeg libs already in place locally — skipping"
return 0
fi
# 2. Check if it's in the global cache
if [[ -f "${ffmpeg_global_cache}/lib/.llvm_static_built" ]]; then
info "[ffmpeg-rebuild] Found pre-built FFmpeg in global cache: ${ffmpeg_global_cache}"
info "[ffmpeg-rebuild] Copying cached libs to ${ffmpeg_ext_dir}..."
mkdir -p "${ffmpeg_ext_dir}"
cp -r "${ffmpeg_global_cache}/." "${ffmpeg_ext_dir}/"
return 0
fi
# ── Locate or download FFmpeg source matching FFMPEG_VERSION ─────────────
#
# _ffmpeg_abi_matches: verify that a source tree's library sonames match
# the version declared by detect_ffmpeg_version(). Uses the env vars set
# by detect_ffmpeg_version() so no version strings are hardcoded here.
_ffmpeg_abi_matches() {
local dir="$1"
[[ -f "${dir}/configure" ]] || return 1
[[ -f "${dir}/libavcodec/version_major.h" ]] || return 1
[[ -f "${dir}/libavformat/version_major.h" ]] || return 1
[[ -f "${dir}/libswscale/version_major.h" ]] || return 1
[[ -f "${dir}/libswresample/version_major.h" ]] || return 1
# Use awk instead of grep -oP lookbehind — variable-length lookbehinds
# are not supported by the grep shipped in MSYS2/clang64 (and many other
# non-GNU environments), causing silent empty matches and false negatives.
local _codec _fmt _scale _resample
_codec=$(awk '/^#define LIBAVCODEC_VERSION_MAJOR/{print $NF; exit}' \
"${dir}/libavcodec/version_major.h")
_fmt=$(awk '/^#define LIBAVFORMAT_VERSION_MAJOR/{print $NF; exit}' \
"${dir}/libavformat/version_major.h")
_scale=$(awk '/^#define LIBSWSCALE_VERSION_MAJOR/{print $NF; exit}' \
"${dir}/libswscale/version_major.h")
_resample=$(awk '/^#define LIBSWRESAMPLE_VERSION_MAJOR/{print $NF; exit}' \
"${dir}/libswresample/version_major.h")
[[ "${_codec}" == "${FFMPEG_AVCODEC_VER}" ]] &&
[[ "${_fmt}" == "${FFMPEG_AVFORMAT_VER}" ]] &&
[[ "${_scale}" == "${FFMPEG_SWSCALE_VER}" ]] &&
[[ "${_resample}" == "${FFMPEG_SWRESAMPLE_VER}" ]]
}
local ffmpeg_src=""
# Priority 1: previously downloaded/extracted source tree.
# Requires the .ffmpeg_src_ready sentinel written after a clean extraction —
# a directory with configure but no sentinel means a partial/interrupted
# extraction from a prior run and must not be reused.
if [[ -f "${ffmpeg_src_dir}/.ffmpeg_src_ready" && -f "${ffmpeg_src_dir}/configure" ]]; then
if _ffmpeg_abi_matches "${ffmpeg_src_dir}"; then
ffmpeg_src="${ffmpeg_src_dir}"
info "[ffmpeg-rebuild] Using cached FFmpeg ${FFMPEG_VERSION} source"
else
warn "[ffmpeg-rebuild] Cached source ABI does not match FFmpeg ${FFMPEG_VERSION} — ignoring"
fi
elif [[ -d "${ffmpeg_src_dir}" ]]; then
warn "[ffmpeg-rebuild] Cached source dir exists but lacks .ffmpeg_src_ready sentinel — likely a partial extraction. Wiping and re-downloading."
rm -rf "${ffmpeg_src_dir}"
fi
# Priority 2: vendored submodule (only if sonames match)
local submodule="${SOURCE_DIR}/externals/ffmpeg/ffmpeg"
if [[ -z "${ffmpeg_src}" && -f "${submodule}/configure" ]]; then
if _ffmpeg_abi_matches "${submodule}"; then
ffmpeg_src="${submodule}"
info "[ffmpeg-rebuild] Using vendored FFmpeg submodule (ABI matches ${FFMPEG_VERSION})"
else
warn "[ffmpeg-rebuild] Vendored submodule ABI does not match FFmpeg ${FFMPEG_VERSION} — ignoring"
fi
fi
# Priority 3: download tarball from ffmpeg.org
if [[ -z "${ffmpeg_src}" ]]; then
# Git snapshot versions (e.g. "8.0.git") have no release tarball on
# ffmpeg.org. If the submodule ABI check failed for a git version it
# means the soname table in detect_ffmpeg_version() is out of date —
# not that we should construct a bogus URL.
if [[ "${FFMPEG_VERSION}" == *git* || "${FFMPEG_VERSION}" == *dev* ]]; then
error "[ffmpeg-rebuild] FFmpeg version '${FFMPEG_VERSION}' is a git snapshot with no release tarball.
The vendored submodule ABI check failed — the soname table in
detect_ffmpeg_version() may be wrong for this development version.
Check libavcodec/version_major.h in the submodule and update the
soname table, or check out a tagged FFmpeg release."
fi
local tarball="${BUILD_ROOT}/ffmpeg-${FFMPEG_VERSION}.tar.bz2"
local ffmpeg_url="https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.bz2"
info "[ffmpeg-rebuild] Downloading FFmpeg ${FFMPEG_VERSION} source from ffmpeg.org..."
mkdir -p "${BUILD_ROOT}"
download_with_retry "${ffmpeg_url}" "${tarball}" 3 \
|| error "[ffmpeg-rebuild] Failed to download FFmpeg ${FFMPEG_VERSION} after 3 attempts.
URL: ${ffmpeg_url}
Check network connectivity or set CPM_SOURCE_CACHE to a pre-populated directory."
info "[ffmpeg-rebuild] Extracting FFmpeg ${FFMPEG_VERSION}..."
mkdir -p "${ffmpeg_src_dir}"
info "[ffmpeg-rebuild] Verifying tarball integrity..."
tar -tjf "${tarball}" > /dev/null 2>&1 \
|| error "[ffmpeg-rebuild] Tarball integrity check failed — download is corrupt.
Delete ${tarball} and retry."
tar -xjf "${tarball}" -C "${ffmpeg_src_dir}" --strip-components=1 \
|| error "[ffmpeg-rebuild] Extraction failed — tarball may be corrupt. Delete ${tarball} and retry."
touch "${ffmpeg_src_dir}/.ffmpeg_src_ready"
ffmpeg_src="${ffmpeg_src_dir}"
success "[ffmpeg-rebuild] FFmpeg ${FFMPEG_VERSION} source ready"
fi
# Final safety check: if the source path still has spaces, we MUST copy it
# to a safe path for FFmpeg's configure to work (even if it's the submodule).
if [[ "${ffmpeg_src}" == *' '* ]]; then
local safe_src="/tmp/citron-ffmpeg/${FFMPEG_VERSION}/src"
if [[ "${ffmpeg_src}" != "${safe_src}" ]]; then
info "[ffmpeg-rebuild] Copying source to space-free path: ${safe_src}..."
mkdir -p "$(dirname "${safe_src}")"
rm -rf "${safe_src}"
cp -r "${ffmpeg_src}" "${safe_src}"
ffmpeg_src="${safe_src}"
fi
fi
info "[ffmpeg-rebuild] Building static FFmpeg ${FFMPEG_VERSION} with llvm-mingw..."
mkdir -p "${ffmpeg_bld}" "${ffmpeg_lib}" "${ffmpeg_hdr}"
local cross_prefix="${LLVM_MINGW_DIR}/bin/${MINGW_TRIPLE}-"
local cc="${LLVM_MINGW_DIR}/bin/${MINGW_TRIPLE}-clang"
local ar="${LLVM_MINGW_DIR}/bin/llvm-ar"
local nm_bin="${LLVM_MINGW_DIR}/bin/llvm-nm"
local strip_tool="${LLVM_MINGW_DIR}/bin/llvm-strip"
local ranlib="${LLVM_MINGW_DIR}/bin/llvm-ranlib"
local windres="${LLVM_MINGW_DIR}/bin/${MINGW_TRIPLE}-windres"
# On MSYS2/Windows the host and target are the same machine (both
# x86_64-w64-mingw32), so --enable-cross-compile must NOT be used.
local _ffmpeg_cross_flags=()
if [[ "${_HOST_OS}" == "linux" || "${_HOST_OS}" == "macos" ]]; then
_ffmpeg_cross_flags=(
--enable-cross-compile
"--cross-prefix=${cross_prefix}"
)
else
local _host_clang
_host_clang="$(command -v clang 2>/dev/null \
|| echo "${MSYS2_PREFIX}/bin/clang")"
_ffmpeg_cross_flags=("--host-cc=${_host_clang}")
fi
info "[ffmpeg-rebuild] Configuring FFmpeg (static, no pthreads, dxva2+d3d11va)..."
(
cd "${ffmpeg_bld}"
# Use relative path to configure to avoid absolute path inclusion bugs in Makefile
local _rel_cfg="../src/configure"
if [[ ! -f "${_rel_cfg}" ]]; then
_rel_cfg="${ffmpeg_src}/configure"
fi
bash "${_rel_cfg}" \
--arch=x86_64 \
--target-os=mingw32 \
"${_ffmpeg_cross_flags[@]}" \
"--cc=${cc}" \
"--ar=${ar}" \
"--nm=${nm_bin}" \
"--strip=${strip_tool}" \
"--ranlib=${ranlib}" \
"--windres=${windres}" \
--disable-pthreads \
--enable-w32threads \
--enable-static \
--disable-shared \
--disable-doc \
--disable-programs \
--disable-avdevice \
--disable-network \
--disable-everything \
--disable-vaapi \
--disable-vdpau \
--enable-decoder=h264,vp8,vp9,aac,mp3,opus,flac \
--enable-demuxer=mp4,matroska,ogg \
--enable-filter=yadif,scale,aresample \
--enable-protocol=file \
--enable-dxva2 \
--enable-d3d11va
) || {
error "[ffmpeg-rebuild] FFmpeg configure failed"
}
info "[ffmpeg-rebuild] Compiling (this takes a few minutes)..."
# Ensure no stale config.h exists in source if doing out-of-tree build
rm -f "${ffmpeg_src}/config.h"
make -C "${ffmpeg_bld}" -j"${JOBS}" || {
error "[ffmpeg-rebuild] FFmpeg make failed"
}
# ── Install static libraries (.a) ──────────────────────────────────────────
local installed=0
info "[ffmpeg-rebuild] Installing static libs to ${ffmpeg_lib}/..."
for lib in avutil avcodec avfilter swscale swresample avformat; do
local static_lib
static_lib="$(find "${ffmpeg_bld}" -maxdepth 2 -name "lib${lib}.a" 2>/dev/null | head -1)"
if [[ -n "${static_lib}" ]]; then
cp -f "${static_lib}" "${ffmpeg_lib}/lib${lib}.a"
info " [ffmpeg-rebuild] lib${lib}.a"
(( installed++ )) || true
else
warn " [ffmpeg-rebuild] lib${lib}.a NOT FOUND in build tree"
fi
done
# ── Install public headers (needed by cmake at configure time) ────────────
info "[ffmpeg-rebuild] Installing headers to ${ffmpeg_hdr}/..."
for lib in libavcodec libavfilter libavformat libavutil libswresample libswscale; do
local inc_dst="${ffmpeg_hdr}/${lib}"
mkdir -p "${inc_dst}"
# Source-tree public headers
if [[ -d "${ffmpeg_src}/${lib}" ]]; then
find "${ffmpeg_src}/${lib}" -maxdepth 1 -name "*.h" \
-exec cp -f {} "${inc_dst}/" \; 2>/dev/null || true
fi
# Build-generated headers (version.h, config.h, etc.)
if [[ -d "${ffmpeg_bld}/${lib}" ]]; then
find "${ffmpeg_bld}/${lib}" -maxdepth 1 -name "*.h" \
-exec cp -f {} "${inc_dst}/" \; 2>/dev/null || true
fi
done
if [[ "${installed}" -eq 0 ]]; then
error "[ffmpeg-rebuild] No static libs were installed after make — FFmpeg build silently produced nothing.
Check make output above for configuration errors.
Try removing ${ffmpeg_bld} and re-running."
fi
# ── Verify: static libs must NOT depend on libwinpthread ─────────────────
local nm_tool="${LLVM_MINGW_DIR}/bin/llvm-nm"
[[ -x "${nm_tool}" ]] || \
nm_tool="$(command -v llvm-nm 2>/dev/null || command -v nm 2>/dev/null || true)"
if [[ -n "${nm_tool}" ]]; then
local pthread_refs=0
for afile in "${ffmpeg_lib}"/lib*.a; do
if "${nm_tool}" "${afile}" 2>/dev/null | grep -qiE ' U .*pthread_'; then
warn "[ffmpeg-rebuild] ${afile##*/} imports external pthread symbols!"
pthread_refs=1
fi
done
if [[ "${pthread_refs}" -eq 0 ]]; then
success "[ffmpeg-rebuild] Verified: all static libs are pthread-free"
else
warn "[ffmpeg-rebuild] Some static libs reference pthread — check configure output"
fi
fi
touch "${sentinel}"
if [[ -n "${ffmpeg_global_cache}" ]]; then
info "[ffmpeg-rebuild] Populating global cache: ${ffmpeg_global_cache}..."
mkdir -p "$(dirname "${ffmpeg_global_cache}")"
rm -rf "${ffmpeg_global_cache}"
cp -r "${ffmpeg_ext_dir}" "${ffmpeg_global_cache}"
fi
success "[ffmpeg-rebuild] Static FFmpeg ${FFMPEG_VERSION} installed (${installed} libs)"
}
# =============================================================================
# Runtime DLL deployment
# =============================================================================
deploy_runtime_dlls() {
local bin_dir="$1"
# This step is now redundant as CMake (CopyMinGWDeps.cmake) handles
# synchronized, recursive DLL and plugin deployment during the build.
success "All runtime DLLs deployed to ${bin_dir} (synchronized via CMake)"
}
print_profiling_instructions() {
local binary="$1"
local bin_dir="${binary%/*}"
local unity_flag=""
[[ "${UNITY_BUILD}" == "ON" ]] && unity_flag=" --unity"
echo ""
echo -e "${YELLOW}================================================================${RESET}"
echo -e "${YELLOW} NEXT STEP: Collect Profile Data on Windows (Session 1)${RESET}"
echo -e "${YELLOW}================================================================${RESET}"
echo ""
echo -e " ${BOLD}Instrumented binary :${RESET} ${binary}"
echo -e " ${BOLD}Profile output dir :${RESET} ${PROFILE_DIR}/"
echo ""
echo " 1. Copy the entire bin/ folder to your Windows machine:"
echo " ${bin_dir}/"
echo ""
echo " 2. Run citron.exe directly (do NOT run from a terminal — the profraw"
echo " is written next to citron.exe on a clean exit, not to the terminal"
echo " working directory)."
echo ""
echo " 3. Play games / navigate menus for 15-30 minutes of representative"
echo " gameplay. Exit cleanly via File > Exit or Ctrl+Q (do NOT kill"
echo " the process — the profraw is only written on clean exit)."
echo ""
echo " 4. After exiting, look next to citron.exe for:"
echo " default-<pid>.profraw"
echo ""
echo -e " ${BOLD}NOTE (IR PGO):${RESET} For IR PGO (-fprofile-generate), Clang writes a"
echo " DIRECTORY named default-<pid>.profraw/ containing numbered chunk"
echo " files inside it — NOT a single flat file. Copy the entire directory."
echo " Copy it (and any others from the same run) here:"
echo " ${PROFILE_DIR}/"
echo ""
echo " 5. Build the optimized binary:"
echo " ./build-clangtron-windows.sh use --pgo-type ${PGO_MODE} --lto ${LTO_MODE}${unity_flag}"
echo " (auto-normalizes profraw directories, merges → default.profdata,"
echo " then builds citron.exe with -fprofile-use applied to compile + LTO link)"
echo ""
if [[ "${PGO_MODE}" == "ir" ]]; then
echo " Optional: add a CS-IRPGO layer (second Windows session, higher quality):"
echo " ./build-clangtron-windows.sh csgenerate --pgo-type ir --lto ${LTO_MODE}${unity_flag}"
echo " Run that binary on Windows → copy cs-default-*.profraw (or folder) to"
echo " ${PROFILE_DIR}/cs/ → re-run use."
echo ""
fi
echo -e "${YELLOW}================================================================${RESET}"
echo ""
}
# =============================================================================
# CMake toolchain file for llvm-mingw cross-compilation
#
# Uses llvm-mingw wrapper scripts which automatically set --target, --sysroot,
# -stdlib=libc++, -rtlib=compiler-rt, and -fuse-ld=lld. No extra cross flags
# are needed beyond pointing CMAKE_C/CXX_COMPILER at the wrappers.
# =============================================================================
write_toolchain_file() {
local path="$1"
mkdir -p "$(dirname "$path")"
local CMAKE_BUILD_ROOT="${BUILD_ROOT}"
if [[ "${_HOST_OS}" == "windows" ]]; then
CMAKE_BUILD_ROOT="$(cygpath -m "${BUILD_ROOT}")"
# MSYS2/Windows: native compilation — CMAKE_SYSTEM_NAME is auto-detected
# as Windows; no cross-compile sysroot is needed. The MSYS2 clang64
# toolchain targets Windows natively with the same libc++/compiler-rt ABI
# as llvm-mingw. On a case-insensitive Windows filesystem the
# mingw-case-fixups include dir is unnecessary.
cat > "$path" <<MSYS2_TC_EOF
# CMake toolchain: native Windows x64 with MSYS2 clang64
# Generated by build-clangtron-windows.sh — do not edit manually
set(CMAKE_SYSTEM_NAME Windows)
set(CMAKE_SYSTEM_PROCESSOR x86_64)
set(CMAKE_C_COMPILER "${MINGW_CLANG}")
set(CMAKE_CXX_COMPILER "${MINGW_CLANGPP}")
set(CMAKE_RC_COMPILER "windres.exe")
set(CMAKE_C_FLAGS_INIT "-D__INTRINSIC_DEFINED___cpuidex -D__USE_MINGW_STAT64 -Wno-unknown-pragmas")
set(CMAKE_CXX_FLAGS_INIT "-D_WIN32_WINNT=0x0A00 -DWINVER=0x0A00 -D__INTRINSIC_DEFINED___cpuidex -D__USE_MINGW_STAT64 -U__GLIBCXX__ -Wno-unknown-pragmas")
set(CMAKE_EXE_LINKER_FLAGS_INIT "-fuse-ld=lld -Wl,--allow-multiple-definition")
set(CMAKE_SHARED_LINKER_FLAGS_INIT "-fuse-ld=lld -Wl,--allow-multiple-definition")
set(CMAKE_MODULE_LINKER_FLAGS_INIT "-fuse-ld=lld -Wl,--allow-multiple-definition")
set(CMAKE_CXX_STANDARD_LIBRARIES "${_COMSUPP_TC_PATH} -loleaut32")
set(CMAKE_AUTORCC_OPTIONS "--compress-algo;zlib")
MSYS2_TC_EOF
return
fi
cat > "$path" <<EOF
# CMake toolchain: cross-compile for Windows x86_64 with llvm-mingw
# (Clang + LLD + libc++ + compiler-rt — no GCC runtime dependency)
# Generated by build-clangtron-windows.sh — do not edit manually
set(CMAKE_SYSTEM_NAME Windows)
set(CMAKE_SYSTEM_PROCESSOR x86_64)
# llvm-mingw wrapper scripts handle: --target, --sysroot, -stdlib=libc++,
# -rtlib=compiler-rt, -fuse-ld=lld. No additional cross flags needed.
set(CMAKE_C_COMPILER "${LLVM_MINGW_DIR}/bin/${MINGW_TRIPLE}-clang")
set(CMAKE_CXX_COMPILER "${LLVM_MINGW_DIR}/bin/${MINGW_TRIPLE}-clang++")
set(CMAKE_RC_COMPILER "${LLVM_MINGW_DIR}/bin/${MINGW_TRIPLE}-windres")
# Sysroot for cmake find_library / find_file / find_path
set(CMAKE_FIND_ROOT_PATH "${LLVM_MINGW_DIR}/${MINGW_TRIPLE}")
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE BOTH)
# -D__INTRINSIC_DEFINED___cpuidex: prevents MinGW intrin-impl.h from defining
# __cpuidex with external linkage. Clang's cpuid.h has a static __inline
# definition; the guard macro ensures it is the sole definer, eliminating
# duplicate-symbol link errors from SDL2 and other libraries.
# Applied to both C and C++ (SDL2 is compiled as C and triggers the duplicate).
set(CMAKE_C_FLAGS_INIT "-D__INTRINSIC_DEFINED___cpuidex -D__USE_MINGW_STAT64 -isystem \"${CMAKE_BUILD_ROOT}/mingw-case-fixups\" -Wno-unknown-pragmas")
set(CMAKE_CXX_FLAGS_INIT "-D_WIN32_WINNT=0x0A00 -DWINVER=0x0A00 -D__INTRINSIC_DEFINED___cpuidex -D__USE_MINGW_STAT64 -U__GLIBCXX__ -isystem \"${CMAKE_BUILD_ROOT}/mingw-case-fixups\" -Wno-unknown-pragmas")
# --allow-multiple-definition: belt-and-suspenders for any residual __cpuidex
# duplicates inside libSDL2.a (SDL_dynapi.c include-all mechanism).
set(CMAKE_EXE_LINKER_FLAGS_INIT "-Wl,--allow-multiple-definition")
set(CMAKE_SHARED_LINKER_FLAGS_INIT "-Wl,--allow-multiple-definition")
set(CMAKE_MODULE_LINKER_FLAGS_INIT "-Wl,--allow-multiple-definition")
# Standard libraries (appended after all user archives by CMake):
# comsupp_stubs.o: _com_util::ConvertStringToBSTR (MSVC-specific, no MinGW equivalent)
# -loleaut32: COM/OLE Automation symbols (SysAllocString etc.) for WMI code
# libc++ and libunwind are linked automatically by the llvm-mingw wrappers.
set(CMAKE_CXX_STANDARD_LIBRARIES "${_COMSUPP_TC_PATH} -loleaut32")
# Force Qt rcc to use zlib resource compression instead of zstd.
# aqt's llvm_mingw Qt6Core lacks zstd resource support; default zstd calls
# qResourceFeatureZstd() which is missing from Qt6Core.a.
set(CMAKE_AUTORCC_OPTIONS "--compress-algo;zlib")
EOF
}
# =============================================================================
# Common CMake arguments for cross-compilation to Windows
# =============================================================================
# build_common_cmake_args — populate the global _CMAKE_ARGS array with the
# flags that every cmake configure invocation needs. Callers append their
# own stage-specific flags and then pass the whole array as:
#
# cmake "${SOURCE_DIR}" "${_CMAKE_ARGS[@]}" [extra flags…]
#
# Using an array (rather than the old `echo`-based helper consumed via
# $(common_cmake_args)) avoids bash word-splitting on paths that contain
# spaces — e.g. when the Windows username or project folder has a space.
build_common_cmake_args() {
local lto_flag; lto_flag="$(lto_cmake_flag)"
local toolchain_file="${BUILD_ROOT}/mingw-clang-toolchain.cmake"
write_toolchain_file "$toolchain_file"
local CMAKE_BUILD_ROOT="${BUILD_ROOT}"
local CMAKE_SOURCE_DIR="${SOURCE_DIR}"
local CMAKE_SPIRV_HEADERS_INSTALL="${SPIRV_HEADERS_INSTALL}"
local CMAKE_VULKAN_HEADERS_INSTALL="${VULKAN_HEADERS_INSTALL}"
local CMAKE_TOOLCHAIN_FILE_PATH="${toolchain_file}"
if [[ "${_HOST_OS}" == "windows" ]]; then
CMAKE_BUILD_ROOT="$(cygpath -m "${BUILD_ROOT}")"
CMAKE_SOURCE_DIR="$(cygpath -m "${SOURCE_DIR}")"
CMAKE_SPIRV_HEADERS_INSTALL="$(cygpath -m "${SPIRV_HEADERS_INSTALL}")"
CMAKE_VULKAN_HEADERS_INSTALL="$(cygpath -m "${VULKAN_HEADERS_INSTALL}")"
CMAKE_TOOLCHAIN_FILE_PATH="$(cygpath -m "${toolchain_file}")"
fi
local CMAKE_CPM_CACHE="${CPM_SOURCE_CACHE}"
if [[ "${_HOST_OS}" == "windows" ]]; then
CMAKE_CPM_CACHE="$(cygpath -m "${CMAKE_CPM_CACHE}")"
fi
# Set Vulkan include dir using the checked-in stub (avoiding submodule/CPM download)
local VULKAN_HEADERS_STUB_DIR=""
if [[ -d "${SOURCE_DIR}/externals/vulkan-stub/include" ]]; then
VULKAN_HEADERS_STUB_DIR="${CMAKE_SOURCE_DIR}/externals/vulkan-stub/include"
fi
# Populate the global array — each element is a single cmake argument,
# so paths containing spaces are passed correctly.
_CMAKE_ARGS=(
"-G" "Ninja"
"-DCMAKE_BUILD_TYPE=${BUILD_TYPE}"
"-DCMAKE_TOOLCHAIN_FILE=${CMAKE_TOOLCHAIN_FILE_PATH}"
"-DCMAKE_DISABLE_FIND_PACKAGE_LLVM=ON"
"-DCITRON_ENABLE_LTO=${lto_flag}"
"-DBUILD_TESTING=OFF"
"-DCITRON_TESTS=OFF"
"-DCITRON_USE_BUNDLED_FFMPEG=ON"
"-DCITRON_USE_EXTERNAL_SDL2=ON"
"-DCITRON_USE_EXTERNAL_VULKAN_HEADERS=ON"
"-DCITRON_USE_EXTERNAL_VULKAN_UTILITY_LIBRARIES=ON"
"-DSPIRV-Headers_DIR=${CMAKE_SPIRV_HEADERS_INSTALL}/share/cmake/SPIRV-Headers"
"-DVulkanHeaders_DIR=${CMAKE_VULKAN_HEADERS_INSTALL}/share/cmake/VulkanHeaders"
"-DCMAKE_PREFIX_PATH=${CMAKE_VULKAN_HEADERS_INSTALL};${CMAKE_SPIRV_HEADERS_INSTALL}"
"-DVulkanMemoryAllocator_FOUND=TRUE"
"-Ddynarmic_FOUND=TRUE"
"-Dxbyak_FOUND=TRUE"
"-Dcubeb_FOUND=TRUE"
"-DENABLE_LIBUSB=OFF"
"-DVulkan_LIBRARY=${CMAKE_BUILD_ROOT}/vulkan-stub/libvulkan-1.a"
"-DCITRON_USE_PRECOMPILED_HEADERS=OFF"
"-DCITRON_USE_CPM=ON"
"-DCITRON_CHECK_SUBMODULES=OFF"
"-DCPM_SOURCE_CACHE=${CMAKE_CPM_CACHE}"
"-Wno-dev"
)
[[ -n "${VULKAN_HEADERS_STUB_DIR}" ]] && _CMAKE_ARGS+=(
"-DVulkan_INCLUDE_DIR=${VULKAN_HEADERS_STUB_DIR}"
"-DVulkan_INCLUDE_DIRS=${VULKAN_HEADERS_STUB_DIR}"
)
[[ -n "${GLSLC_PATH:-}" ]] && _CMAKE_ARGS+=(
"-DVulkan_GLSLC_EXECUTABLE=${GLSLC_PATH}"
"-DVulkan_GLSLANG_VALIDATOR_EXECUTABLE=${GLSLC_PATH}"
)
# Static FFmpeg dir — only passed when the sentinel confirms rebuild_ffmpeg_pthread_free
# completed successfully. Passing a nonexistent dir causes CMake to fall through to
# the legacy RELEASE-file path and then immediately fail with a fatal error.
if [[ -n "${FFMPEG_VERSION:-}" ]]; then
local _ffmpeg_ext_dir="${BUILD_ROOT}/externals/ffmpeg-${FFMPEG_VERSION}-static"
local _ffmpeg_sentinel="${_ffmpeg_ext_dir}/lib/.llvm_static_built"
if [[ -f "${_ffmpeg_sentinel}" ]]; then
local _ffmpeg_static="${_ffmpeg_ext_dir}"
if [[ "${_HOST_OS}" == "windows" ]]; then
_ffmpeg_static="$(cygpath -m "${_ffmpeg_ext_dir}")"
fi
_CMAKE_ARGS+=("-DCITRON_FFMPEG_STATIC_DIR=${_ffmpeg_static}")
else
error "FFmpeg static libs not ready — sentinel missing: ${_ffmpeg_sentinel}
rebuild_ffmpeg_pthread_free() must complete successfully before cmake is invoked.
If this is unexpected, delete ${_ffmpeg_ext_dir} and re-run."
fi
fi
[[ -n "${CITRON_BUILD_TYPE:-}" ]] && _CMAKE_ARGS+=("-DCITRON_BUILD_TYPE=${CITRON_BUILD_TYPE}")
[[ "${UNITY_BUILD}" == "ON" ]] && _CMAKE_ARGS+=("-DENABLE_UNITY_BUILD=ON")
[[ -n "${MARCH_NATIVE:-}" ]] && _CMAKE_ARGS+=(
"-DCMAKE_C_FLAGS=${MARCH_NATIVE}"
"-DCMAKE_CXX_FLAGS=${MARCH_NATIVE}"
)
# Ensure the function always returns 0: a trailing [[ ]] that evaluates false
# would otherwise return exit code 1, triggering set -e in the caller.
:
}
# =============================================================================
# Stage 1: generate
# =============================================================================
stage_generate() {
header "Stage 1: PGO Instrumented Build"
check_tool "${CLANG}"; check_tool "${CLANGPP}"
check_tool "ninja"; check_tool "cmake"
[[ -d "$SOURCE_DIR" ]] \
|| error "Source directory not found: ${SOURCE_DIR}\nClone citron first or use --source."
require_llvm_mingw
mkdir -p "${BUILD_GENERATE}" "${PROFILE_DIR}"
local lto_generate_flag=""
local generate_lto_cmake="OFF"
case "${LTO_MODE}" in
full)
lto_generate_flag="-flto"
generate_lto_cmake="ON"
info "Generate stage: Full LTO enabled."
;;
thin)
lto_generate_flag="-flto=thin"
generate_lto_cmake="ON"
info "Generate stage: ThinLTO enabled."
;;
none)
info "Generate stage: LTO disabled."
;;
esac
# PGO instrumentation flag: IR PGO (-fprofile-generate) or Frontend PGO
# (-fprofile-instr-generate). Both write default-%p.profraw relative to
# the binary's working directory on clean exit. %p = PID.
# IR PGO inserts counters at the LLVM IR level after early optimizations,
# so the profile accurately reflects what the optimizer will see — but
# IR PGO inserts counters at the LLVM IR level after early optimizations.
# CRITICAL: generate and use must use the same -O level. IR PGO hashes
# are computed from the post-optimization IR — if generate uses -O2 and
# use uses -O3, the additional O3 passes (loop unrolling, vectorisation,
# extra inlining) restructure basic blocks and every affected function's
# hash mismatches, discarding its profile data entirely. With full LTO
# this affects nearly the entire program. Use -O3 here to match use stage.
local pgo_gen_flag
if [[ "${PGO_MODE}" == "ir" ]]; then
pgo_gen_flag="-fprofile-generate=default-%p.profraw"
else
pgo_gen_flag="-fprofile-instr-generate=default-%p.profraw"
fi
local debug_flag=""
# -gcodeview: see stage_use for why plain -g isn't enough on this MinGW target.
[[ "${BUILD_TYPE}" == "RelWithDebInfo" ]] && debug_flag="-g -gcodeview"
local c_flags="-O3 -DNDEBUG ${debug_flag} ${pgo_gen_flag}${lto_generate_flag:+ ${lto_generate_flag}}"
local cxx_flags="${c_flags}"
# Force-keep the profile runtime symbols so lld does not dead-strip them.
# -u,__llvm_profile_write_file: pulls InstrProfilingFile.o (write logic)
# -u,__llvm_profile_runtime: pulls InstrProfilingRuntime.o whose constructor
# initializes __llvm_profile_write_file_internal.
# --pdb= (not -DEBUG/-Wl,-DEBUG, not /DEBUG): the MinGW driver invoked by
# -fuse-ld=lld on this target doesn't recognize -DEBUG at all (separate
# option table from lld-link) -- see stage_use's no-PGO block for the
# full explanation. --pdb is its actual recognized flag; empty value
# means auto-name the PDB per binary (this flag is shared across
# several executables built in this stage).
# --threads=1: combining --pdb= with -flto enables a known LLD COFF
# deadlock between PDB type/symbol-record merge threads and parallel LTO
# backend codegen threads (manifests as three ld.lld processes stuck at
# low CPU % indefinitely). --threads=N is the MinGW driver's own spelling
# of this option (per LLVM D76885); /threads:N is the COFF/lld-link form.
# Unlike order/ignore, "threads" IS in the MinGW driver's option table, so
# it does NOT need -Xlink= wrapping. Slower link, but only RelWithDebInfo.
local linker_debug_flag=""
[[ "${BUILD_TYPE}" == "RelWithDebInfo" ]] && linker_debug_flag="-Wl,--pdb= -Wl,--threads=1"
local extra_link_flags="-Wl,-u,__llvm_profile_write_file,-u,__llvm_profile_runtime"
# Qt via aqt
# Qt via aqt - use global cache
local qt_base_dir="${CPM_SOURCE_CACHE}/qt-bin"
local qt_install_dir="${qt_base_dir}/6.9.3/llvm-mingw_64"
local qt_host_dir="${BUILD_GENERATE}/externals/qt-host/6.9.3/gcc_64"
if [[ "${_HOST_OS}" != "windows" ]]; then
qt_host_dir="${CPM_SOURCE_CACHE}/qt-bin-host/6.9.3/gcc_64"
fi
local qt6_cmake_dir="${qt_install_dir}/lib/cmake/Qt6"
ensure_aqt
local aqt_bin
aqt_bin="$(command -v aqt 2>/dev/null || echo "${HOME}/.local/bin/aqt")"
if [[ ! -f "${qt_install_dir}/lib/cmake/Qt6/Qt6Config.cmake" ]]; then
info "Downloading Qt 6.9.3 Windows/MinGW target (base + multimedia) via aqt..."
mkdir -p "${qt_base_dir}"
"${aqt_bin}" install-qt windows desktop 6.9.3 win64_llvm_mingw \
--outputdir "${qt_base_dir}" \
--modules qtmultimedia qtimageformats \
|| error "Qt download failed."
fi
# Qt host tools (Linux gcc_64) are only needed on Linux for cross-compilation.
# On Windows we are doing a native build — the target Qt IS the host Qt, and
# CMake must NOT receive QT_HOST_PATH (it would switch to cross-compile mode).
# The Linux Qt package contains Unix symlinks that Windows cannot create without
# Developer Mode enabled (OSError WinError 1314), so skip this entirely here.
if [[ "${_HOST_OS}" != "windows" ]]; then
if [[ ! -f "${qt_host_dir}/lib/cmake/Qt6/Qt6Config.cmake" ]]; then
local _host_outdir="${CPM_SOURCE_CACHE}/qt-bin-host"
mkdir -p "${_host_outdir}"
"${aqt_bin}" install-qt linux desktop 6.9.3 linux_gcc_64 \
--outputdir "${_host_outdir}" \
--modules qtsvg qtmultimedia \
|| warn "aqt Qt 6.9.3 linux download failed"
fi
else
info "Windows native build: skipping Linux host Qt download (not needed for native builds)."
qt_host_dir="" # Ensure QT_HOST_PATH is NOT passed to CMake on Windows
fi
info "Qt6 cmake dir: ${qt6_cmake_dir}"
ensure_profile_runtime_mingw
compile_comsupp_stubs
rm -f "${BUILD_ROOT}/vulkan-stub/libvulkan-1.a" 2>/dev/null || true
ensure_vulkan_import_lib
setup_case_fixup_headers
GLSLC_PATH="$(command -v glslc 2>/dev/null || true)"
if [[ -n "${GLSLC_PATH}" ]]; then
info "Found glslc: ${GLSLC_PATH}"
else
GLSLC_PATH="$(command -v glslangValidator 2>/dev/null || true)"
[[ -n "${GLSLC_PATH}" ]] \
&& info "Using glslangValidator: ${GLSLC_PATH}" \
|| warn "No Vulkan shader compiler found — install glslang-tools"
fi
# Detect FFmpeg version from the submodule and pre-build pthread-free DLLs
# BEFORE cmake configure so download_bundled_external() finds them already
# present and skips the network download (yuzu-mirror lacks FFmpeg >= 7.x).
detect_ffmpeg_version
rebuild_ffmpeg_pthread_free "${BUILD_GENERATE}"
info "Configuring CMake (instrumented build)..."
cd "${BUILD_GENERATE}"
rm -f CMakeCache.txt; rm -rf CMakeFiles
[[ -d "src/citron/citron_autogen" ]] && rm -rf src/citron/citron_autogen
local bt_upper; bt_upper=$(echo "${BUILD_TYPE}" | tr '[:lower:]' '[:upper:]')
build_common_cmake_args
[[ -n "${qt6_cmake_dir}" ]] && _CMAKE_ARGS+=("-DQt6_DIR=${qt6_cmake_dir}")
[[ -n "${qt_host_dir}" ]] && _CMAKE_ARGS+=("-DQT_HOST_PATH=${qt_host_dir}")
_CMAKE_ARGS+=(
"-DCITRON_ENABLE_PGO_GENERATE=ON"
"-DCITRON_PGO_FLAGS_MANAGED_BY_SCRIPT=ON"
"-DCITRON_ENABLE_LTO=${generate_lto_cmake}"
"-DCMAKE_C_FLAGS_${bt_upper}=${c_flags}"
"-DCMAKE_CXX_FLAGS_${bt_upper}=${cxx_flags}"
"-DCMAKE_EXE_LINKER_FLAGS_${bt_upper}=${c_flags} ${PROFILE_RUNTIME_LIB:+${PROFILE_RUNTIME_LIB}} ${extra_link_flags} ${linker_debug_flag}"
"-DCITRON_PGO_PROFILE_DIR=${PROFILE_DIR}"
)
cmake "${SOURCE_DIR}" "${_CMAKE_ARGS[@]}" \
|| error "CMake configure failed"
info "Building instrumented citron (${BUILD_TYPE})..."
cmake --build . --config "${BUILD_TYPE}" -j "${JOBS}"
success "Instrumented build complete: ${BUILD_GENERATE}/bin/citron.exe"
# ── Verify instrumentation was actually linked into the binary ────────────
# A PGO-instrumented binary must export __llvm_profile_raw_version (marks
# the counters segment) and __llvm_profile_runtime (the atexit hook that
# flushes profile data on clean exit). If either is absent the binary will
# run fine but produce no .profraw — exactly the silent failure mode we
# want to catch before the user spends 30 minutes profiling a bad build.
local citron_exe="${BUILD_GENERATE}/bin/citron.exe"
local nm_tool
nm_tool="$(command -v "llvm-nm-${CLANG_VERSION}" 2>/dev/null || command -v llvm-nm 2>/dev/null || command -v nm 2>/dev/null || true)"
if [[ -n "${nm_tool}" && -f "${citron_exe}" ]]; then
local nm_out
nm_out=$("${nm_tool}" --defined-only "${citron_exe}" 2>/dev/null || true)
local has_raw_version has_runtime has_write_file
has_raw_version=$(echo "${nm_out}" | grep -c '__llvm_profile_raw_version' || true)
has_runtime=$(echo "${nm_out}" | grep -c '__llvm_profile_runtime' || true)
has_write_file=$(echo "${nm_out}" | grep -c '__llvm_profile_write_file' || true)
if [[ "${has_raw_version}" -gt 0 && "${has_runtime}" -gt 0 && "${has_write_file}" -gt 0 ]]; then
success "Instrumentation check: OK"
success " __llvm_profile_raw_version ✓"
success " __llvm_profile_runtime ✓"
success " __llvm_profile_write_file ✓"
else
echo ""
error_no_exit() { echo -e "${RED}[ERROR]${RESET} $*" >&2; }
warn "════════════════════════════════════════════════════════════════"
warn " INSTRUMENTATION CHECK FAILED — binary will NOT produce profraw"
warn "════════════════════════════════════════════════════════════════"
warn " __llvm_profile_raw_version $([ "${has_raw_version}" -gt 0 ] && echo|| echo)"
warn " __llvm_profile_runtime $([ "${has_runtime}" -gt 0 ] && echo|| echo)"
warn " __llvm_profile_write_file $([ "${has_write_file}" -gt 0 ] && echo|| echo '✗ ← flush function stripped by linker')"
warn ""
warn " Likely causes:"
warn " 1. Profile runtime not linked: PROFILE_RUNTIME_LIB=${PROFILE_RUNTIME_LIB:-<unset>}"
warn " 2. PGO generate flag not passed to the linker (PGO_MODE=${PGO_MODE})"
warn " 3. citron cmake config overrode CMAKE_EXE_LINKER_FLAGS_RELEASE"
warn ""
warn " The binary will still run, but no .profraw will be written."
warn " Re-run the generate stage or check the cmake flags above."
warn "════════════════════════════════════════════════════════════════"
echo ""
fi
else
warn "Instrumentation check skipped: nm tool or citron.exe not found"
fi
deploy_runtime_dlls \
"${BUILD_GENERATE}/bin" \
"${BUILD_GENERATE}/externals/qt/6.9.3/llvm-mingw_64" \
"${BUILD_GENERATE}"
# Write sentinel recording this generate config.
# stage_use and stage_csgenerate verify LTO + PGO match to catch
# profile mismatches before a long build wastes time.
printf "LTO=%s\nPGO=%s\n" "${LTO_MODE}" "${PGO_MODE}" \
> "${BUILD_ROOT}/.citron-gen-config"
print_profiling_instructions "${BUILD_GENERATE}/bin/citron.exe"
}
# =============================================================================
# Stage 1b: csgenerate — Context-Sensitive IR PGO instrumented build
#
# CS-IRPGO layers a context-sensitive instrumentation pass on top of a binary
# that has already been optimized with stage1 IR PGO profiles. The resulting
# binary collects per-call-site counter data rather than per-function-definition
# data, letting the compiler make inlining and branch decisions with full context
# for each inlined copy of a function.
#
# Requirements:
# - --pgo-type ir must be set (CS-IRPGO requires IR PGO; not available for FE)
# - --lto and --pgo-type must match the prior generate run (enforced by sentinel)
# - default.profdata must exist in pgo-profiles/ (produced by 'use' or by
# merging the stage1 profraw from generate). merged.profdata is NOT accepted
# as a substitute — see CRITICAL INVARIANT in the script header.
#
# Compile flags for the CS binary:
# -fprofile-use=default.profdata Apply stage1 IR profile (optimizes this build)
# -fcs-profile-generate=... Layer CS counters on top of the optimized IR
# (Both flags are passed together to C, C++, and linker command lines.)
#
# The CS-instrumented binary writes cs-default-<pid>.profraw next to itself on
# exit (same mechanism as stage1). The user copies these to pgo-profiles/cs/,
# then re-runs 'use' — which auto-detects pgo-profiles/cs/ and merges both
# profiles into merged.profdata (stage1 + CS combined) before building.
#
# Profdata merging (performed by stage_use, not here):
# Step 1: llvm-profdata merge --sparse cs-default-*.profraw → cs-only.profdata
# Step 2: llvm-profdata merge --sparse default.profdata cs-only.profdata
# → merged.profdata
# Step 3: use builds with -fprofile-use=merged.profdata (compile + linker)
#
# Profile runtime:
# CS-IRPGO uses the same LLVM InstrProfiling runtime as standard IR/FE PGO.
# ensure_profile_runtime_mingw() and extra_link_flags apply identically here.
# =============================================================================
stage_csgenerate() {
header "Stage 1b: CS-IRPGO Instrumented Build"
# CS-IRPGO is only valid with IR PGO — it layers a CS pass on IR counters.
if [[ "${PGO_MODE}" != "ir" ]]; then
error "csgenerate requires --pgo-type ir.\n" \
" Context-Sensitive PGO is not available for frontend PGO (fe).\n" \
" Re-run with: ./build-clangtron-windows.sh csgenerate --pgo-type ir --lto ${LTO_MODE}"
fi
check_tool "${CLANG}"; check_tool "${CLANGPP}"
check_tool "ninja"; check_tool "cmake"
[[ -d "$SOURCE_DIR" ]] \
|| error "Source directory not found: ${SOURCE_DIR}\nClone citron first or use --source."
require_llvm_mingw
# ── Sentinel check: LTO and PGO must match the prior generate run ─────────
local _gen_cfg="${BUILD_ROOT}/.citron-gen-config"
if [[ -f "${_gen_cfg}" ]]; then
local _gen_lto _gen_pgo
_gen_lto=$(awk -F= '/^LTO=/{print $2; exit}' "${_gen_cfg}" 2>/dev/null || true)
_gen_pgo=$(awk -F= '/^PGO=/{print $2; exit}' "${_gen_cfg}" 2>/dev/null || true)
if [[ -n "${_gen_lto}" && "${_gen_lto}" != "${LTO_MODE}" ]]; then
error "LTO mismatch: generate used LTO=${_gen_lto}, csgenerate has LTO=${LTO_MODE}.\n"\
" IR PGO profiles are tied to the IR produced at generate time.\n"\
" Re-run csgenerate with --lto ${_gen_lto}."
fi
if [[ -n "${_gen_pgo}" && "${_gen_pgo}" != "${PGO_MODE}" ]]; then
error "PGO mode mismatch: generate used PGO=${_gen_pgo}, csgenerate has PGO=${PGO_MODE}.\n"\
" Re-run csgenerate with --pgo-type ${_gen_pgo}."
fi
else
# The sentinel is written by stage_generate and records the LTO+PGO mode
# that produced the IR which the stage1 profdata is keyed to. Without it
# we cannot verify that csgenerate is building on a compatible baseline —
# a mismatch silently produces a CS binary whose counters are keyed to a
# different IR shape, making the resulting profraw unloadable in stage_use.
# bench.sh copies the sentinel from the IR config dir before invoking
# csgenerate; if it is still absent something went wrong in that copy step.
error "Generate sentinel not found at ${_gen_cfg}.\n" \
" This file is written by stage_generate and records the LTO+PGO\n" \
" mode used to produce the stage1 profdata. Without it, csgenerate\n" \
" cannot verify IR compatibility and may produce an unusable CS binary.\n" \
" If running via bench.sh, re-run build-generate for the matching IR\n" \
" config first. If running build-clangtron-windows.sh directly, run generate\n" \
" before csgenerate, or manually create the sentinel:\n" \
" printf 'LTO=${LTO_MODE}\\nPGO=ir\\n' > ${_gen_cfg}"
fi
# ── Locate stage1 profdata (MUST be default.profdata, never merged.profdata) ─
#
# CRITICAL: csgenerate must use ONLY the plain stage1 default.profdata for
# -fprofile-use. merged.profdata (if it exists) contains CS records from a
# prior CS cycle keyed to the previous csgenerate binary's IR. Feeding those
# CS records through -fprofile-use during a new csgenerate changes inlining
# decisions relative to the plain stage1 baseline, restructuring the IR that
# the new CS counters are keyed to. When the use stage then compiles from the
# plain stage1 baseline (as it must), every function reshaped by the stale CS
# influence hash-mismatches — producing a CS binary that is worse than plain
# IR PGO rather than better. Always start the CS layer from the clean stage1
# profile only.
local stage1_pd="${PROFILE_DIR}/default.profdata"
if [[ ! -f "${stage1_pd}" ]]; then
# default.profdata is absent — try building it from profraw files.
# normalize_profraw_dirs must run first: LLVM IR PGO writes profraw
# *directories* named default-<pid>.profraw/ containing numbered chunk
# files; without normalization the glob below passes directory paths to
# llvm-profdata, which may silently skip or error on them.
normalize_profraw_dirs "${PROFILE_DIR}"
local profraw_count
profraw_count=$(find "${PROFILE_DIR}" -maxdepth 1 -name "*.profraw" 2>/dev/null | wc -l)
if [[ "${profraw_count}" -gt 0 ]]; then
info "Merging ${profraw_count} stage1 .profraw file(s) → default.profdata..."
"${LLVM_PROFDATA}" merge --sparse \
--output="${stage1_pd}" "${PROFILE_DIR}"/*.profraw
success "Stage1 profdata merged: ${stage1_pd}"
else
local merged_pd="${PROFILE_DIR}/merged.profdata"
if [[ -f "${merged_pd}" ]]; then
error "default.profdata not found, but merged.profdata exists.\n" \
" merged.profdata contains CS records from a previous cycle and\n" \
" cannot be used as the stage1 base for csgenerate (see script header).\n" \
" To rebuild default.profdata:\n" \
" 1. Copy the original stage1 default-<pid>.profraw files to\n" \
" ${PROFILE_DIR}/\n" \
" 2. Re-run: ./build-clangtron-windows.sh use --pgo-type ir --lto ${LTO_MODE}\n" \
" (this produces default.profdata from the stage1 profraw)"
else
error "No stage1 profdata or profraw found in ${PROFILE_DIR}/\n" \
" Run generate, collect default-<pid>.profraw on Windows,\n" \
" copy to ${PROFILE_DIR}/, then run:\n" \
" ./build-clangtron-windows.sh use --pgo-type ir --lto ${LTO_MODE}\n" \
" (produces default.profdata), then re-run csgenerate."
fi
fi
fi
info "Stage1 profdata (plain IR, no CS): ${stage1_pd}"
mkdir -p "${BUILD_CSGENERATE}" "${PROFILE_DIR}/cs"
local lto_generate_flag=""
local generate_lto_cmake="OFF"
case "${LTO_MODE}" in
full) lto_generate_flag="-flto"; generate_lto_cmake="ON"
info "csgenerate: Full LTO" ;;
thin) lto_generate_flag="-flto=thin"; generate_lto_cmake="ON"
info "csgenerate: ThinLTO" ;;
none) info "csgenerate: LTO disabled" ;;
esac
# CS-IRPGO compile flags:
# -fprofile-use=<stage1> Apply stage1 IR profile (optimizes this build).
# -fcs-profile-generate=... Layer CS counters on top.
# Both flags are passed together. The compiler applies stage1 PGO optimizations
# first, then inserts CS counters into the optimized IR.
# cs-default-%p.profraw — %p expands to PID so parallel runs don't collide.
# The output is relative (no directory prefix) so it writes next to the .exe
# on Windows, where the Linux absolute path would be meaningless.
local stage1_pd_compiler="${stage1_pd}"
[[ "${_HOST_OS}" == "windows" ]] && stage1_pd_compiler="$(cygpath -m "${stage1_pd}")"
local cs_gen_flag="-fcs-profile-generate=cs-default-%p.profraw"
local pgo_use_flag="-fprofile-use=\"${stage1_pd_compiler}\""
local debug_flag=""
# -gcodeview: see stage_use for why plain -g isn't enough on this MinGW target.
[[ "${BUILD_TYPE}" == "RelWithDebInfo" ]] && debug_flag="-g -gcodeview"
local c_flags="-O3 -DNDEBUG ${debug_flag} ${pgo_use_flag} ${cs_gen_flag}${lto_generate_flag:+ ${lto_generate_flag}}"
local cxx_flags="${c_flags}"
local bt_upper; bt_upper=$(echo "${BUILD_TYPE}" | tr '[:lower:]' '[:upper:]')
# Force-keep profile runtime entry points.
# CS-IRPGO uses the same LLVM InstrProfiling runtime as standard IR/FE PGO.
# lld may dead-strip __llvm_profile_write_file when instrumented counter code
# lives in archived libraries that are not directly referenced from main().
# -u,__llvm_profile_runtime ensures InstrProfilingRuntime.o's constructor
# fires on startup, initializing the write-file machinery.
# --pdb= (not -DEBUG/-Wl,-DEBUG, not /DEBUG): the MinGW driver invoked by
# -fuse-ld=lld on this target doesn't recognize -DEBUG at all (separate
# option table from lld-link) -- see stage_use's no-PGO block for the
# full explanation. --pdb is its actual recognized flag; empty value
# means auto-name the PDB per binary (this flag is shared across
# several executables built in this stage).
# --threads=1: combining --pdb= with -flto enables a known LLD COFF
# deadlock between PDB type/symbol-record merge threads and parallel LTO
# backend codegen threads (manifests as three ld.lld processes stuck at
# low CPU % indefinitely). --threads=N is the MinGW driver's own spelling
# of this option (per LLVM D76885); /threads:N is the COFF/lld-link form.
# Unlike order/ignore, "threads" IS in the MinGW driver's option table, so
# it does NOT need -Xlink= wrapping. Slower link, but only RelWithDebInfo.
local linker_debug_flag=""
[[ "${BUILD_TYPE}" == "RelWithDebInfo" ]] && linker_debug_flag="-Wl,--pdb= -Wl,--threads=1"
local extra_link_flags="-Wl,-u,__llvm_profile_write_file,-u,__llvm_profile_runtime"
ensure_profile_runtime_mingw
ensure_vulkan_import_lib
local qt_install_dir="${BUILD_GENERATE}/externals/qt/6.9.3/llvm-mingw_64"
local qt_host_dir="${BUILD_GENERATE}/externals/qt-host/6.9.3/gcc_64"
if [[ "${_HOST_OS}" == "windows" ]]; then
qt_host_dir=""
fi
local qt6_cmake_dir="${qt_install_dir}/lib/cmake/Qt6"
GLSLC_PATH="$(command -v glslc 2>/dev/null || true)"
[[ -z "${GLSLC_PATH}" ]] && GLSLC_PATH="$(command -v glslangValidator 2>/dev/null || true)"
# Pre-build FFmpeg (sentinel makes this a fast no-op if already built for this dir)
detect_ffmpeg_version
rebuild_ffmpeg_pthread_free "${BUILD_CSGENERATE}"
info "Configuring CMake (CS-IRPGO instrumented build)..."
cd "${BUILD_CSGENERATE}"
rm -f CMakeCache.txt; rm -rf CMakeFiles
[[ -d "src/citron/citron_autogen" ]] && rm -rf src/citron/citron_autogen
# shellcheck disable=SC2034 # _CMAKE_ARGS used via array expansion below
build_common_cmake_args
[[ -n "${qt6_cmake_dir}" ]] && _CMAKE_ARGS+=("-DQt6_DIR=${qt6_cmake_dir}")
[[ -n "${qt_host_dir}" ]] && _CMAKE_ARGS+=("-DQT_HOST_PATH=${qt_host_dir}")
_CMAKE_ARGS+=(
"-DCITRON_ENABLE_PGO_GENERATE=ON"
"-DCITRON_PGO_FLAGS_MANAGED_BY_SCRIPT=ON"
"-DCITRON_ENABLE_LTO=${generate_lto_cmake}"
"-DCMAKE_C_FLAGS_RELEASE=${c_flags}"
"-DCMAKE_CXX_FLAGS_RELEASE=${cxx_flags}"
"-DCMAKE_EXE_LINKER_FLAGS_RELEASE=${c_flags} ${PROFILE_RUNTIME_LIB:+${PROFILE_RUNTIME_LIB}} ${extra_link_flags} ${linker_debug_flag}"
"-DCITRON_PGO_PROFILE_DIR=${PROFILE_DIR}"
)
cmake "${SOURCE_DIR}" "${_CMAKE_ARGS[@]}" \
|| error "CMake configure failed"
info "Building CS-IRPGO instrumented citron..."
cmake --build . --config Release -j "${JOBS}"
success "CS-IRPGO instrumented build complete: ${BUILD_CSGENERATE}/bin/citron.exe"
# ── Verify CS instrumentation symbols are present ─────────────────────────
# The CS binary must have the same profile runtime symbols as a standard
# generate binary. If any are missing lld dead-stripped them and the binary
# will run but produce no .profraw.
local citron_exe="${BUILD_CSGENERATE}/bin/citron.exe"
local nm_tool
nm_tool="$(command -v "llvm-nm-${CLANG_VERSION}" 2>/dev/null \
|| command -v llvm-nm 2>/dev/null \
|| command -v nm 2>/dev/null || true)"
if [[ -n "${nm_tool}" && -f "${citron_exe}" ]]; then
local nm_out
nm_out=$("${nm_tool}" --defined-only "${citron_exe}" 2>/dev/null || true)
local has_raw_version has_runtime has_write_file
has_raw_version=$(echo "${nm_out}" | grep -c '__llvm_profile_raw_version' || true)
has_runtime=$(echo "${nm_out}" | grep -c '__llvm_profile_runtime' || true)
has_write_file=$(echo "${nm_out}" | grep -c '__llvm_profile_write_file' || true)
if [[ "${has_raw_version}" -gt 0 && "${has_runtime}" -gt 0 && "${has_write_file}" -gt 0 ]]; then
success "CS instrumentation check: OK"
success " __llvm_profile_raw_version ✓"
success " __llvm_profile_runtime ✓"
success " __llvm_profile_write_file ✓"
else
warn "════════════════════════════════════════════════════════════════"
warn " CS INSTRUMENTATION CHECK FAILED — binary will NOT produce profraw"
warn "════════════════════════════════════════════════════════════════"
warn " __llvm_profile_raw_version $([ "${has_raw_version}" -gt 0 ] && echo|| echo)"
warn " __llvm_profile_runtime $([ "${has_runtime}" -gt 0 ] && echo|| echo)"
warn " __llvm_profile_write_file $([ "${has_write_file}" -gt 0 ] && echo|| echo '✗ ← stripped by linker')"
warn " The binary will run but produce no cs-default-*.profraw."
warn "════════════════════════════════════════════════════════════════"
fi
fi
deploy_runtime_dlls \
"${BUILD_CSGENERATE}/bin" \
"${BUILD_GENERATE}/externals/qt/6.9.3/llvm-mingw_64" \
"${BUILD_CSGENERATE}"
local unity_flag=""
[[ "${UNITY_BUILD}" == "ON" ]] && unity_flag=" --unity"
echo ""
echo -e "${YELLOW}================================================================${RESET}"
echo -e "${YELLOW} NEXT STEP: Collect CS Profile Data on Windows (Session 2)${RESET}"
echo -e "${YELLOW}================================================================${RESET}"
echo ""
echo -e " ${BOLD}CS binary :${RESET} ${citron_exe}"
echo -e " ${BOLD}CS profdata :${RESET} ${stage1_pd} (stage1 base, correct)"
echo -e " ${BOLD}CS output dir:${RESET} ${PROFILE_DIR}/cs/"
echo ""
echo " 1. Copy the entire bin/ folder to your Windows machine:"
echo " ${BUILD_CSGENERATE}/bin/"
echo ""
echo " 2. Run citron.exe directly (do NOT run from a terminal — the profraw"
echo " is written next to citron.exe on a clean exit, not to the terminal"
echo " working directory)."
echo ""
echo " 3. Play through the same games / scenarios as session 1."
echo " Aim for 15-30 minutes of representative gameplay."
echo " Exit cleanly via File > Exit or Ctrl+Q (do NOT kill the process)."
echo ""
echo " 4. After exiting, look next to citron.exe for:"
echo " cs-default-<pid>.profraw"
echo ""
echo -e " ${BOLD}NOTE (IR PGO):${RESET} For IR PGO (-fcs-profile-generate), Clang writes a"
echo " DIRECTORY named cs-default-<pid>.profraw/ containing numbered"
echo " chunk files inside — NOT a single flat file. Copy the entire"
echo " directory. Copy it (and any others from the same run) here:"
echo " ${PROFILE_DIR}/cs/"
echo ""
echo " 5. Re-run use to merge stage1 + CS and rebuild the PE:"
echo " ./build-clangtron-windows.sh use --pgo-type ir --lto ${LTO_MODE}${unity_flag}"
echo ""
echo " The use stage will:"
echo " a) Normalize and merge cs-default-*.profraw → cs-only.profdata"
echo " b) Merge default.profdata + cs-only.profdata → merged.profdata"
echo " c) Rebuild citron.exe with -fprofile-use=merged.profdata"
echo " (applied to both compile and LTO link steps)"
echo ""
echo -e "${YELLOW}================================================================${RESET}"
echo ""
}
# =============================================================================
# Stage 2: use
# =============================================================================
stage_use() {
# --pgo-type none: plain Release build (no PGO, LTO controlled by --lto).
# Outputs to build/use-nopgo/ so it never collides with a real PGO use build.
if [[ "${PGO_MODE}" == "none" ]]; then
header "Stage 2: Release Build (no PGO, LTO=${LTO_MODE})"
check_tool "${CLANG}"; check_tool "${CLANGPP}"
check_tool "ninja"; check_tool "cmake"
[[ -d "$SOURCE_DIR" ]] \
|| error "Source directory not found: ${SOURCE_DIR}\nClone citron first or use --source."
require_llvm_mingw
local nopgo_dir="${BUILD_ROOT}/use-nopgo"
mkdir -p "${nopgo_dir}"
ensure_vulkan_import_lib
compile_comsupp_stubs
setup_case_fixup_headers
# ── Qt path detection ─────────────────────────────────────────────────
# Search order: (1) generate's cached Qt (correct llvm-mingw variant),
# (2) a prior nopgo run, (3) aqt download into nopgo's own externals.
# Using find avoids hardcoding the Qt version and works after source upgrades.
_nopgo_find_qt_target() {
local root="$1"
# Search both root/externals/qt (local build) and root/ (global cache)
local search_paths=("${root}/externals/qt" "${root}")
local hit=""
for spath in "${search_paths[@]}"; do
[[ -d "${spath}" ]] || continue
# Prefer llvm-mingw_64 variant
hit=$(find "${spath}" -maxdepth 6 \
-name "Qt6Config.cmake" -path "*/llvm-mingw_64/*" 2>/dev/null | head -1)
[[ -z "${hit}" ]] && \
hit=$(find "${spath}" -maxdepth 6 \
-name "Qt6Config.cmake" 2>/dev/null | head -1)
[[ -n "${hit}" ]] && break
done
[[ -n "${hit}" ]] && dirname "${hit}" || true
}
_nopgo_find_qt_host() {
local root="$1"
local search_paths=("${root}/externals/qt-host" "${root}/externals/qt" "${root}")
local hit=""
for spath in "${search_paths[@]}"; do
[[ -d "${spath}" ]] || continue
hit=$(find "${spath}" -maxdepth 6 \
-name "Qt6Config.cmake" -path "*/gcc_64/*" 2>/dev/null | head -1)
[[ -n "${hit}" ]] && break
done
# QT_HOST_PATH must be the install root (.../gcc_64), not the cmake subdir.
# Walk up 3 levels from .../gcc_64/lib/cmake/Qt6/Qt6Config.cmake → .../gcc_64
[[ -n "${hit}" ]] && dirname "$(dirname "$(dirname "$(dirname "${hit}")")")" || true
}
local qt6_cmake_dir="" qt_host_dir=""
qt6_cmake_dir="$(_nopgo_find_qt_target "${BUILD_GENERATE}" 2>/dev/null || true)"
[[ -z "${qt6_cmake_dir}" ]] && \
qt6_cmake_dir="$(_nopgo_find_qt_target "${nopgo_dir}" 2>/dev/null || true)"
qt_host_dir="$(_nopgo_find_qt_host "${BUILD_GENERATE}" 2>/dev/null || true)"
[[ -z "${qt_host_dir}" ]] && \
qt_host_dir="$(_nopgo_find_qt_host "${nopgo_dir}" 2>/dev/null || true)"
# Check global cache for host Qt
if [[ -z "${qt_host_dir}" ]]; then
local _global_host_base="${CPM_SOURCE_CACHE}/qt-bin-host"
if [[ -d "${_global_host_base}" ]]; then
qt_host_dir="$(_nopgo_find_qt_host "${_global_host_base}" 2>/dev/null || true)"
fi
fi
# If neither cache has Qt, download via aqt directly (same logic as generate).
# This avoids citron's CMakeLists.txt auto-downloading the wrong MinGW variant.
# Qt via aqt - use global cache if available
local _nopgo_qt_base="${CPM_SOURCE_CACHE}/qt-bin"
if [[ -z "${qt6_cmake_dir}" ]]; then
# Re-check global path if CMake dir wasn't found in build logs
local _global_target="${_nopgo_qt_base}/6.9.3/llvm-mingw_64/lib/cmake/Qt6"
if [[ -f "${_global_target}/Qt6Config.cmake" ]]; then
qt6_cmake_dir="${_global_target}"
fi
fi
if [[ -z "${qt6_cmake_dir}" ]]; then
warn "No cached Qt found in generate or prior nopgo build."
warn "Downloading Qt (base + multimedia) via aqt into ${_nopgo_qt_base} ..."
ensure_aqt
local _aqt; _aqt="$(command -v aqt 2>/dev/null || echo "${HOME}/.local/bin/aqt")"
mkdir -p "${_nopgo_qt_base}"
"${_aqt}" install-qt windows desktop 6.9.3 win64_llvm_mingw \
--outputdir "${_nopgo_qt_base}" \
--modules qtmultimedia qtimageformats \
|| error "Qt download failed.\n" \
" Run generate first to cache Qt, then re-run:\n" \
" ./build-clangtron-windows.sh use --pgo none --lto ${LTO_MODE}"
qt6_cmake_dir="$(_nopgo_find_qt_target "${nopgo_dir}")"
[[ -z "${qt6_cmake_dir}" ]] && qt6_cmake_dir="$(_nopgo_find_qt_target "${_nopgo_qt_base}")"
[[ -z "${qt6_cmake_dir}" ]] && \
error "Qt downloaded but Qt6Config.cmake not found — check aqt output above."
fi
if [[ -z "${qt_host_dir}" ]]; then
if [[ "${_HOST_OS}" == "windows" ]]; then
# Native build uses target Qt tools automatically (rcc, uic, moc)
# Setting QT_HOST_PATH breaks native builds by triggering cross-compilation mode
qt_host_dir=""
else
ensure_aqt
local _aqt; _aqt="$(command -v aqt 2>/dev/null || echo "${HOME}/.local/bin/aqt")"
# Derive the Qt version from the target Qt we just located or downloaded
# Walk up from .../qt/<ver>/<variant>/lib/cmake/Qt6 to extract the version:
# dirname x3 → .../qt/<ver>/<variant> (variant dir)
# dirname x1 → .../qt/<ver> (version dir)
# basename → <ver>
local _qt_variant_dir
_qt_variant_dir="$(dirname "$(dirname "$(dirname "${qt6_cmake_dir}")")")"
local _qt_ver
_qt_ver="$(basename "$(dirname "${_qt_variant_dir}")")"
mkdir -p "${nopgo_dir}/externals/qt-host"
"${_aqt}" install-qt linux desktop "${_qt_ver}" linux_gcc_64 \
--outputdir "${nopgo_dir}/externals/qt-host" \
|| warn "Qt host tools download failed — build may still succeed without it."
qt_host_dir="$(_nopgo_find_qt_host "${nopgo_dir}" || true)"
fi
fi
info "Qt target cmake dir: ${qt6_cmake_dir}"
[[ -n "${qt_host_dir}" ]] && info "Qt host dir: ${qt_host_dir}"
local debug_flag=""
# -gcodeview (not just -g): on x86_64-w64-mingw32, clang defaults to DWARF
# debug info even with lld-link as the linker. Without -gcodeview, lld-link
# has nothing CodeView-formatted to convert, and emits no .pdb at all.
[[ "${BUILD_TYPE}" == "RelWithDebInfo" ]] && debug_flag="-g -gcodeview"
local linker_debug_flag=""
# --pdb= (not -DEBUG/-Wl,-DEBUG, and not /DEBUG): -fuse-ld=lld on this
# x86_64-w64-mingw32 target invokes LLD's MinGW driver, not lld-link
# directly. That driver has its own small option table and translates
# a few of its own flags into "-debug -pdb:<path>" internally for the
# real COFF linker — but -DEBUG itself isn't in that table, so passing
# it directly fails with "unknown argument: -DEBUG".
# --pdb is the MinGW driver's own recognized flag; an empty value
# (the trailing "=") makes it name the PDB after each output binary
# automatically, which matters since this flag is shared across
# shader_tool.exe/citron-room.exe/citron-cmd.exe/citron.exe.
# --threads=1: combining --pdb= with -flto triggers a known LLD COFF
# deadlock between PDB type/symbol-record merge threads and parallel
# LTO backend codegen threads — manifests as ld.lld stuck at low CPU
# indefinitely. --threads=N is the MinGW driver's own recognized
# spelling (LLVM D76885); unlike order/ignore, it does NOT need
# -Xlink= wrapping. Slower link, but only affects RelWithDebInfo.
[[ "${BUILD_TYPE}" == "RelWithDebInfo" ]] && linker_debug_flag="-Wl,--pdb= -Wl,--threads=1"
local bt_upper; bt_upper=$(echo "${BUILD_TYPE}" | tr '[:lower:]' '[:upper:]')
local lto_flag; lto_flag="$(lto_clang_flag)"
# Pre-build FFmpeg for this build directory
detect_ffmpeg_version
rebuild_ffmpeg_pthread_free "${nopgo_dir}"
info "Configuring CMake (no-PGO Windows PE, LTO=${LTO_MODE})..."
cd "${nopgo_dir}"
rm -f CMakeCache.txt; rm -rf CMakeFiles
# shellcheck disable=SC2034 # _CMAKE_ARGS used via array expansion below
build_common_cmake_args
_CMAKE_ARGS+=(
"-DCITRON_ENABLE_PGO_USE=OFF"
"-DCITRON_PGO_FLAGS_MANAGED_BY_SCRIPT=ON"
"-DCMAKE_C_FLAGS_${bt_upper}=-O3 -DNDEBUG ${debug_flag} ${lto_flag}"
"-DCMAKE_CXX_FLAGS_${bt_upper}=-O3 -DNDEBUG ${debug_flag} ${lto_flag}"
"-DCMAKE_EXE_LINKER_FLAGS_${bt_upper}=${linker_debug_flag}"
)
[[ -n "${qt6_cmake_dir}" ]] && _CMAKE_ARGS+=("-DQt6_DIR=${qt6_cmake_dir}")
[[ -n "${qt_host_dir}" ]] && _CMAKE_ARGS+=("-DQT_HOST_PATH=${qt_host_dir}")
cmake "${SOURCE_DIR}" "${_CMAKE_ARGS[@]}" \
|| error "CMake configure failed"
info "Building citron.exe (no PGO, ${BUILD_TYPE})..."
cmake --build . --config "${BUILD_TYPE}" -j "${JOBS}" \
|| error "cmake --build failed"
success "No-PGO Windows PE: ${nopgo_dir}/bin/citron.exe"
# Derive the Qt install root from qt6_cmake_dir for DLL deployment
local _nopgo_qt_root
_nopgo_qt_root="$(dirname "$(dirname "$(dirname "${qt6_cmake_dir}")")")"
deploy_runtime_dlls \
"${nopgo_dir}/bin" \
"${_nopgo_qt_root}" \
"${nopgo_dir}"
echo ""
success "════════════════════════════════════════════════════════════════"
success " Stage use (--pgo-type none) complete"
success " Binary: ${nopgo_dir}/bin/citron.exe"
success " PGO: none"
local lto_label; lto_label="$(lto_clang_flag)"
success " LTO: ${LTO_MODE}${lto_label:+ (${lto_label})}"
success "════════════════════════════════════════════════════════════════"
return 0
fi
header "Stage 2: PGO + LTO Optimized Build"
check_tool "${CLANG}"; check_tool "${CLANGPP}"
check_tool "ninja"; check_tool "cmake"
require_llvm_mingw
compile_comsupp_stubs
setup_case_fixup_headers
ensure_vulkan_import_lib
# ── Sentinel check: verify generate/use LTO and PGO modes match ─────────
local _gen_cfg="${BUILD_ROOT}/.citron-gen-config"
if [[ -f "${_gen_cfg}" ]]; then
local _gen_lto _gen_pgo
_gen_lto=$(awk -F= '/^LTO=/{print $2; exit}' "${_gen_cfg}" 2>/dev/null || true)
_gen_pgo=$(awk -F= '/^PGO=/{print $2; exit}' "${_gen_cfg}" 2>/dev/null || true)
if [[ -n "${_gen_lto}" && "${_gen_lto}" != "${LTO_MODE}" ]]; then
error "LTO mismatch: generate used LTO=${_gen_lto}, use has LTO=${LTO_MODE}.\n"\
" IR PGO profiles are tied to the IR produced at generate time.\n"\
" Re-run generate with --lto ${LTO_MODE}, or use with --lto ${_gen_lto}."
fi
if [[ -n "${_gen_pgo}" && "${_gen_pgo}" != "${PGO_MODE}" ]]; then
error "PGO mode mismatch: generate used PGO=${_gen_pgo}, use has PGO=${PGO_MODE}.\n"\
" Profile data from ${_gen_pgo} PGO cannot feed ${PGO_MODE} use.\n"\
" Re-run generate with --pgo-type ${PGO_MODE}."
fi
fi
# Prefer merged.profdata (stage1 + CS context-sensitive) if present.
local merged_pd="${PROFILE_DIR}/merged.profdata"
local stage1_pd="${PROFILE_DIR}/default.profdata"
local profdata
# Guard: if merged.profdata already exists but unmerged CS profraw has
# arrived since it was written, the file is stale — it contains only the
# stage1 profile. A re-run that skips CS merging would silently produce a
# binary that looks like a full CS-IRPGO build but is missing the CS layer.
# Detect this and remove the stale file so the merge block below runs.
if [[ -f "${merged_pd}" ]]; then
local _cs_dir_check="${PROFILE_DIR}/cs"
normalize_profraw_dirs "${_cs_dir_check}" 2>/dev/null || true
local _cs_pending
_cs_pending=$(find "${_cs_dir_check}" -maxdepth 1 -name "*.profraw" 2>/dev/null | wc -l)
if [[ "${_cs_pending}" -gt 0 ]]; then
warn "merged.profdata exists but ${_cs_pending} unmerged CS profraw file(s) found."
warn "The existing merged.profdata was built without the CS layer."
warn "Removing stale merged.profdata and re-merging with CS data..."
rm -f "${merged_pd}"
fi
fi
if [[ -f "${merged_pd}" ]]; then
profdata="${merged_pd}"
info "Using CS-IRPGO merged profile: ${profdata}"
elif [[ -f "${stage1_pd}" ]]; then
profdata="${stage1_pd}"
info "Using stage1 profile: ${profdata}"
else
normalize_profraw_dirs "${PROFILE_DIR}"
local profraw_count
profraw_count=$(find "${PROFILE_DIR}" -maxdepth 1 -name "*.profraw" 2>/dev/null | wc -l)
if [[ "${profraw_count}" -gt 0 ]]; then
info "Merging ${profraw_count} .profraw file(s) into default.profdata..."
"${LLVM_PROFDATA}" merge --sparse \
--output="${stage1_pd}" "${PROFILE_DIR}"/*.profraw
success "Profile data merged: ${stage1_pd}"
profdata="${stage1_pd}"
else
error "No profile data found.\n" \
" Run generate, collect .profraw on Windows,\n" \
" copy to ${PROFILE_DIR}/, then re-run."
fi
fi
# Auto-merge CS profraw if present and merged.profdata not yet written
local cs_dir="${PROFILE_DIR}/cs"
if [[ ! -f "${merged_pd}" && -d "${cs_dir}" ]]; then
normalize_profraw_dirs "${cs_dir}"
local cs_count
cs_count=$(find "${cs_dir}" -name "*.profraw" 2>/dev/null | wc -l)
if [[ "${cs_count}" -gt 0 ]]; then
info "CS profraw detected (${cs_count} files) — merging with stage1..."
# Step 1: merge CS profraw files → cs-only.profdata
# llvm-profdata auto-detects the CSIRInstr kind from the profraw header;
# no special flag needed beyond --sparse.
local cs_tmp="${PROFILE_DIR}/cs-only.profdata"
"${LLVM_PROFDATA}" merge --sparse \
--output="${cs_tmp}" "${cs_dir}"/*.profraw
# Step 2: merge stage1 default.profdata + cs-only.profdata → merged.profdata
# The result contains both regular IR records (from stage1) and CS records
# (from csgenerate). -fprofile-use= in the use stage consumes both kinds.
"${LLVM_PROFDATA}" merge --sparse \
--output="${merged_pd}" "${profdata}" "${cs_tmp}"
rm -f "${cs_tmp}"
success "CS-IRPGO merged profile written: ${merged_pd}"
info " Stage1 (IR) : ${profdata}"
info " CS layer : ${cs_dir}/*.profraw (${cs_count} file(s))"
info " Merged output : ${merged_pd}"
profdata="${merged_pd}"
fi
fi
local lto_flag; lto_flag="$(lto_clang_flag)"
local profdata_compiler="${profdata}"
[[ "${_HOST_OS}" == "windows" ]] && profdata_compiler="$(cygpath -m "${profdata}")"
local pgo_flag
if [[ "${PGO_MODE}" == "ir" ]]; then
pgo_flag="-fprofile-use=\"${profdata_compiler}\""
else
pgo_flag="-fprofile-instr-use=\"${profdata_compiler}\" -Wno-profile-instr-unprofiled -Wno-profile-instr-out-of-date"
fi
local lto_pgo_flag="${lto_flag:+${lto_flag} }${pgo_flag}"
ensure_vulkan_import_lib
# ── 2a. Cross-compiled Windows PE ────────────────────────────────────────
# Pre-build FFmpeg for this build directory
detect_ffmpeg_version
rebuild_ffmpeg_pthread_free "${BUILD_USE}"
info "Configuring CMake (PGO+LTO Windows PE)..."
mkdir -p "${BUILD_USE}"; cd "${BUILD_USE}"
rm -f CMakeCache.txt; rm -rf CMakeFiles
# Reuse generate's already-downloaded Qt — passing Qt6_DIR prevents citron's
# cmake from re-downloading Qt into use/externals/ with the wrong variant
# (win64_mingw → mingw_64 instead of win64_llvm_mingw → llvm-mingw_64).
local qt_install_dir="${BUILD_GENERATE}/externals/qt/6.9.3/llvm-mingw_64"
local qt_host_dir="${BUILD_GENERATE}/externals/qt-host/6.9.3/gcc_64"
if [[ "${_HOST_OS}" == "windows" ]]; then
qt_host_dir=""
fi
local qt6_cmake_dir="${qt_install_dir}/lib/cmake/Qt6"
# Note on BOLT PE relocation coverage:
# --emit-relocs is an ELF-only lld flag and has no equivalent in lld's MinGW
# COFF mode. This is fine: Windows PE binaries always contain a base relocation
# table (.reloc section) for ASLR, and BOLT's PE/COFF mode uses that table to
# locate and patch code references. The ELF build (use-elf) retains its own
# --emit-relocs flag for the ELF-proxy BOLT path.
local debug_flag=""
# -gcodeview: see the no-PGO block above for why plain -g isn't enough here.
[[ "${BUILD_TYPE}" == "RelWithDebInfo" ]] && debug_flag="-g -gcodeview"
local linker_debug_flag=""
# --pdb= (not -DEBUG/-Wl,-DEBUG, not /DEBUG): the MinGW driver invoked by
# -fuse-ld=lld on this target doesn't recognize -DEBUG at all (separate
# option table from lld-link) -- see stage_use's no-PGO block for the
# full explanation. --pdb is its actual recognized flag; empty value
# means auto-name the PDB per binary (this flag is shared across
# several executables built in this stage).
# --threads=1: combining --pdb= with -flto enables a known LLD COFF
# deadlock between PDB type/symbol-record merge threads and parallel LTO
# backend codegen threads (manifests as three ld.lld processes stuck at
# low CPU % indefinitely). --threads=N is the MinGW driver's own spelling
# of this option (per LLVM D76885); /threads:N is the COFF/lld-link form.
# Unlike order/ignore, "threads" IS in the MinGW driver's option table, so
# it does NOT need -Xlink= wrapping. Slower link, but only RelWithDebInfo.
[[ "${BUILD_TYPE}" == "RelWithDebInfo" ]] && linker_debug_flag="-Wl,--pdb= -Wl,--threads=1"
local bt_upper; bt_upper=$(echo "${BUILD_TYPE}" | tr '[:lower:]' '[:upper:]')
build_common_cmake_args
_CMAKE_ARGS+=(
"-DCITRON_ENABLE_PGO_USE=ON"
"-DCITRON_PGO_FLAGS_MANAGED_BY_SCRIPT=ON"
"-DCMAKE_C_FLAGS_${bt_upper}=-O3 -DNDEBUG ${debug_flag} ${lto_pgo_flag}"
"-DCMAKE_CXX_FLAGS_${bt_upper}=-O3 -DNDEBUG ${debug_flag} ${lto_pgo_flag}"
"-DCMAKE_EXE_LINKER_FLAGS_${bt_upper}=-O3 -DNDEBUG ${debug_flag} ${lto_pgo_flag} ${linker_debug_flag}"
"-DCITRON_PGO_PROFILE_DIR=${PROFILE_DIR}"
)
[[ -n "${qt6_cmake_dir}" ]] && _CMAKE_ARGS+=("-DQt6_DIR=${qt6_cmake_dir}")
[[ -n "${qt_host_dir}" ]] && _CMAKE_ARGS+=("-DQT_HOST_PATH=${qt_host_dir}")
cmake "${SOURCE_DIR}" "${_CMAKE_ARGS[@]}" \
|| error "CMake configure failed"
info "Building PGO+LTO citron.exe (${BUILD_TYPE})..."
cmake --build . --config "${BUILD_TYPE}" -j "${JOBS}"
success "PGO+LTO Windows PE: ${BUILD_USE}/bin/citron.exe"
deploy_runtime_dlls \
"${BUILD_USE}/bin" \
"${BUILD_GENERATE}/externals/qt/6.9.3/llvm-mingw_64" \
"${BUILD_USE}"
local _pgo_label
if [[ "${profdata}" == "${merged_pd}" ]]; then
_pgo_label="CS-IRPGO (merged: stage1 IR + CS layer)"
else
_pgo_label="IR PGO (stage1 only)"
fi
local unity_flag=""
[[ "${UNITY_BUILD}" == "ON" ]] && unity_flag=" --unity"
echo ""
echo -e "${GREEN}================================================================${RESET}"
echo -e "${GREEN} Stage use complete${RESET}"
echo -e "${GREEN}================================================================${RESET}"
echo ""
echo -e " ${BOLD}Binary :${RESET} ${BUILD_USE}/bin/citron.exe"
echo -e " ${BOLD}PGO :${RESET} ${_pgo_label}"
echo -e " ${BOLD}Profile :${RESET} ${profdata}"
echo -e " ${BOLD}LTO :${RESET} ${LTO_MODE}$(lto_clang_flag | grep -q . && echo " ($(lto_clang_flag))" || true)"
echo ""
echo " Next steps (choose one):"
echo ""
echo " A) Run Propeller (recommended — perf LBR function+BB layout):"
echo " ./build-clangtron-windows.sh propeller --pgo-type ${PGO_MODE} --lto ${LTO_MODE}${unity_flag}"
echo ""
echo " B) Run BOLT (ELF-proxy function ordering):"
echo " ./build-clangtron-windows.sh bolt --pgo-type ${PGO_MODE} --lto ${LTO_MODE}${unity_flag}"
echo ""
if [[ "${profdata}" != "${merged_pd}" ]] && [[ "${PGO_MODE}" == "ir" ]]; then
echo " C) Add CS-IRPGO layer (second Windows session, better profile quality):"
echo " ./build-clangtron-windows.sh csgenerate --pgo-type ir --lto ${LTO_MODE}${unity_flag}"
echo " # then collect cs-default-*.profraw (or folder) → pgo-profiles/cs/"
echo " ./build-clangtron-windows.sh use --pgo-type ir --lto ${LTO_MODE}${unity_flag}"
echo ""
fi
echo -e "${GREEN}================================================================${RESET}"
echo ""
}
# =============================================================================
# Helper: build the native Linux ELF (for BOLT/Propeller profiling)
# =============================================================================
stage_build_elf() {
if [[ "${_HOST_OS}" == "windows" ]]; then
error "build-elf requires a Linux host (ELF target). Not supported on Windows/MSYS2.\n" \
" BBAddrMap support for Windows PE/COFF is being developed — track at:\n" \
" https://discourse.llvm.org/t/rfc-extend-bbaddrmap-support-to-coff-windows/90232"
fi
# --pgo none: baseline ELF (no PGO, just -fbasic-block-address-map for BOLT/Propeller).
# Outputs to build/use-nopgo-elf/ so it never collides with the PGO ELF.
local _elf_nopgo=0
if [[ "${PGO_MODE}" == "none" ]]; then
_elf_nopgo=1
BUILD_USE_ELF="${BUILD_ROOT}/use-nopgo-elf"
header "Stage 2b: Baseline Linux ELF (no PGO, BBAddrMap)"
info "Output: ${BUILD_USE_ELF}/bin/citron"
fi
# Resolve profdata — use merged (CS+stage1) if present, else stage1.
# Works for both IR and FE PGO modes; the elf_pgo_flag below uses it.
local merged_pd="${PROFILE_DIR}/merged.profdata"
local stage1_pd="${PROFILE_DIR}/default.profdata"
local profdata=""
if [[ "${_elf_nopgo}" -eq 0 ]]; then
if [[ -f "${merged_pd}" ]]; then
profdata="${merged_pd}"
info "ELF build: using CS-IRPGO merged profile"
elif [[ -f "${stage1_pd}" ]]; then
profdata="${stage1_pd}"
info "ELF build: using stage1 profile"
else
# Try merging profraw on the fly
normalize_profraw_dirs "${PROFILE_DIR}"
local profraw_count
profraw_count=$(find "${PROFILE_DIR}" -maxdepth 1 -name "*.profraw" 2>/dev/null | wc -l)
if [[ "${profraw_count}" -gt 0 ]]; then
info "ELF build: merging ${profraw_count} profraw files..."
"${LLVM_PROFDATA}" merge --sparse \
--output="${stage1_pd}" "${PROFILE_DIR}"/*.profraw
profdata="${stage1_pd}"
else
error "No profile data found for ELF build.\n"\
" Run the use stage first so profdata exists in ${PROFILE_DIR}/"
fi
fi
# Auto-merge CS profraw if it arrived after stage1 was merged
local cs_dir="${PROFILE_DIR}/cs"
if [[ ! -f "${merged_pd}" && -d "${cs_dir}" ]]; then
normalize_profraw_dirs "${cs_dir}"
local cs_count
cs_count=$(find "${cs_dir}" -name "*.profraw" 2>/dev/null | wc -l)
if [[ "${cs_count}" -gt 0 ]]; then
info "ELF build: merging ${cs_count} CS profraw files with stage1..."
local cs_tmp="${PROFILE_DIR}/cs-only.profdata"
"${LLVM_PROFDATA}" merge --sparse \
--output="${cs_tmp}" "${cs_dir}"/*.profraw
"${LLVM_PROFDATA}" merge --sparse \
--output="${merged_pd}" "${profdata}" "${cs_tmp}"
rm -f "${cs_tmp}"
success "CS-IRPGO merged profile written: ${merged_pd}"
profdata="${merged_pd}"
fi
fi
fi # end if [[ _elf_nopgo -eq 0 ]]
info "Configuring CMake (native Linux ELF)..."
mkdir -p "${BUILD_USE_ELF}"
cd "${BUILD_USE_ELF}"
rm -f CMakeCache.txt; rm -rf CMakeFiles
# ── Qt for native ELF build ───────────────────────────────────────────────
#
# TWO PROBLEMS with DownloadExternals.cmake's Qt download for Linux:
#
# Problem 1 — aqt command uses wrong syntax with aqt 3.x:
# DownloadExternals invokes aqt with 'qt_base' and 'qtmultimedia' as
# package/module names. In aqt 3.x, 'qt_base' is not a valid module name
# and the entire command errors:
# ERROR: The packages ['qt_base', 'qtmultimedia'] were not found while
# parsing XML of package information!
# The base Qt is then NOT downloaded even though cmake prints
# "Downloaded Qt binaries" (it prints that unconditionally).
#
# Problem 2 — Qt6_DIR is set to install root, not cmake subdir:
# DownloadExternals sets Qt6_DIR via FORCE to the install root
# (.../linux) rather than the cmake config subdir (.../linux/lib/cmake/Qt6).
# find_package(Qt6) can't find Qt6Config.cmake at the install root →
# falls back to system Qt 6.4.2 → no Qt6GuiPrivate cmake config → FAIL.
#
# FIX:
# 1. Pre-download Qt 6.9.3 linux via aqt using correct 3.x syntax.
# The linux_gcc_64 base package INCLUDES Qt6GuiPrivate cmake configs,
# unlike qt-host (downloaded with broken old syntax) which does not.
# 2. Qt6Multimedia: aqt cannot install it for linux desktop. Inject a
# minimal stub + pass -DCITRON_USE_QT_MULTIMEDIA=OFF so it is never
# actually linked.
# 3. Do NOT set QT_HOST_PATH — triggers cross-compilation mode on a native
# build, causing cmake to ignore Qt6_DIR entirely.
#
local elf_qt_dir="${BUILD_USE_ELF}/externals/qt/6.9.3/linux"
local elf_qt_cmake_dir="${elf_qt_dir}/lib/cmake/Qt6"
# Remove a stale symlink to qt-host (which lacks GuiPrivate) if present.
if [[ -L "${elf_qt_dir}" ]]; then
info "ELF build: removing qt-host symlink (qt-host lacks Qt6GuiPrivate)"
rm -f "${elf_qt_dir}"
fi
# Verify that the key Qt6 component cmake configs are all present.
# Qt6::Network, Qt6::Widgets, Qt6::Svg, Qt6::DBus must exist.
# If the cached dir is missing any of these (partial old download),
# wipe it and re-download with all required modules.
local _elf_qt_ok=1
for _qtmod in Qt6 Qt6Network Qt6Widgets Qt6Gui Qt6DBus Qt6Svg Qt6OpenGL; do
if [[ ! -f "${elf_qt_dir}/lib/cmake/${_qtmod}/${_qtmod}Config.cmake" ]]; then
warn "ELF build: missing Qt cmake config: ${_qtmod}Config.cmake"
_elf_qt_ok=0
fi
done
if [[ "${_elf_qt_ok}" -eq 0 || ! -f "${elf_qt_cmake_dir}/Qt6Config.cmake" ]]; then
info "ELF build: (re-)downloading Qt 6.9.3 linux via aqt (linux_gcc_64 + modules)..."
python3 -m pip install aqtinstall --break-system-packages --quiet 2>/dev/null || true
local aqt_base_dir="${BUILD_USE_ELF}/externals/qt"
# Wipe any partial previous download
rm -rf "${aqt_base_dir}/6.9.3"
mkdir -p "${aqt_base_dir}"
# Base install: linux_gcc_64 includes Core/Gui/Widgets/Network/DBus/OpenGL/etc.
python3 -m aqt install-qt --outputdir "${aqt_base_dir}" linux desktop 6.9.3 linux_gcc_64 || warn "aqt Qt 6.9.3 base download failed"
# Rename aqt output dir to 'linux' (what DownloadExternals.cmake expects)
for _arch in gcc_64 linux_gcc_64; do
if [[ -d "${aqt_base_dir}/6.9.3/${_arch}" && "${_arch}" != "linux" ]]; then
rm -rf "${aqt_base_dir}/6.9.3/linux"
mv "${aqt_base_dir}/6.9.3/${_arch}" "${aqt_base_dir}/6.9.3/linux"
success "ELF build: Qt 6.9.3 linux downloaded (arch was ${_arch})"
break
fi
done
# Install extra modules: qtsvg is required; qtnetwork is qtbase but add explicitly
python3 -m aqt install-qt --outputdir "${aqt_base_dir}" linux desktop 6.9.3 linux_gcc_64 --modules qtsvg 2>/dev/null || warn "aqt qtsvg module install failed (may already be present)"
if [[ ! -f "${elf_qt_cmake_dir}/Qt6Config.cmake" ]]; then
warn "ELF build: Qt6Config.cmake still missing after aqt download — check aqt output"
fi
else
info "ELF build: Qt 6.9.3 already present at ${elf_qt_dir}"
fi
# Qt6Multimedia: aqt cannot install this for linux desktop (GStreamer dependency).
# Inject a stub so find_package(Qt6 REQUIRED COMPONENTS Multimedia) doesn't abort.
# -DCITRON_USE_QT_MULTIMEDIA=OFF (passed below) ensures it is never linked.
local multimedia_cmake_dir="${elf_qt_dir}/lib/cmake/Qt6Multimedia"
if [[ ! -f "${multimedia_cmake_dir}/Qt6MultimediaConfig.cmake" ]]; then
info "ELF build: injecting Qt6Multimedia stub (aqt linux cannot install multimedia)"
mkdir -p "${multimedia_cmake_dir}"
cat > "${multimedia_cmake_dir}/Qt6MultimediaConfig.cmake" << 'QTMEOF'
# Stub — aqt cannot install qtmultimedia for linux desktop (GStreamer dependency).
# Satisfies find_package(Qt6 REQUIRED COMPONENTS Multimedia); never linked because
# CITRON_USE_QT_MULTIMEDIA=OFF is set in the cmake invocation.
set(Qt6Multimedia_FOUND TRUE)
set(Qt6Multimedia_VERSION "6.9.3")
if(NOT TARGET Qt6::Multimedia)
add_library(Qt6::Multimedia INTERFACE IMPORTED GLOBAL)
endif()
QTMEOF
cat > "${multimedia_cmake_dir}/Qt6MultimediaConfigVersion.cmake" << 'QTMEOF'
set(PACKAGE_VERSION "6.9.3")
if(PACKAGE_FIND_VERSION VERSION_GREATER "6.9.3")
set(PACKAGE_VERSION_COMPATIBLE FALSE)
else()
set(PACKAGE_VERSION_COMPATIBLE TRUE)
if(PACKAGE_FIND_VERSION STREQUAL "6.9.3")
set(PACKAGE_VERSION_EXACT TRUE)
endif()
endif()
QTMEOF
fi
# Qt6GuiPrivate: the aqt base package DOES ship private headers at
# include/QtGui/6.9.3/ but does NOT ship the cmake config that wraps them.
# Without a Qt6GuiPrivateConfig.cmake, find_package fails even though the
# headers are physically present. Inject a real stub that points cmake at
# the actual private headers in the aqt download.
local guiprivate_cmake_dir="${elf_qt_dir}/lib/cmake/Qt6GuiPrivate"
if [[ ! -f "${guiprivate_cmake_dir}/Qt6GuiPrivateConfig.cmake" ]]; then
info "ELF build: injecting Qt6GuiPrivate stub (aqt base has headers, no cmake config)"
mkdir -p "${guiprivate_cmake_dir}"
cat > "${guiprivate_cmake_dir}/Qt6GuiPrivateConfig.cmake" << 'QTGPEOF'
# Auto-generated — aqt linux Qt has private headers but no cmake config for them.
# CMAKE_CURRENT_LIST_DIR = <qt>/lib/cmake/Qt6GuiPrivate/
# Private headers live at <qt>/include/QtGui/6.9.3/
set(Qt6GuiPrivate_FOUND TRUE)
set(Qt6GuiPrivate_VERSION "6.9.3")
get_filename_component(_qt6_prefix "${CMAKE_CURRENT_LIST_DIR}/../../.." ABSOLUTE)
if(NOT TARGET Qt6::GuiPrivate)
add_library(Qt6::GuiPrivate INTERFACE IMPORTED GLOBAL)
target_include_directories(Qt6::GuiPrivate INTERFACE
"${_qt6_prefix}/include/QtGui/6.9.3"
"${_qt6_prefix}/include/QtGui/6.9.3/QtGui"
)
if(TARGET Qt6::Gui)
target_link_libraries(Qt6::GuiPrivate INTERFACE Qt6::Gui)
endif()
endif()
QTGPEOF
cat > "${guiprivate_cmake_dir}/Qt6GuiPrivateConfigVersion.cmake" << 'QTGPEOF'
set(PACKAGE_VERSION "6.9.3")
if(PACKAGE_FIND_VERSION VERSION_GREATER "6.9.3")
set(PACKAGE_VERSION_COMPATIBLE FALSE)
else()
set(PACKAGE_VERSION_COMPATIBLE TRUE)
if(PACKAGE_FIND_VERSION STREQUAL "6.9.3")
set(PACKAGE_VERSION_EXACT TRUE)
endif()
endif()
QTGPEOF
fi
# CMAKE_PREFIX_PATH must include Qt AND the pre-built Vulkan/SPIRV header
# installs. The generate stage builds these header-only packages into
# VULKAN_HEADERS_INSTALL and SPIRV_HEADERS_INSTALL; they have no Windows-
# specific binaries and work identically for the native ELF build.
# Not including them here causes VulkanHeaders version mismatch (system has
# 1.3.275, externals submodule requires 1.4.337+) and missing SPIRV-Headers.
local elf_cmake_prefix="${elf_qt_dir};${VULKAN_HEADERS_INSTALL};${SPIRV_HEADERS_INSTALL}"
info "ELF build: CMAKE_PREFIX_PATH → ${elf_cmake_prefix}"
# The ELF is the profiling target for both BOLT and Propeller stages.
# CITRON_ENABLE_LTO=OFF bypasses citron's cmake check_ipo_supported() path
# which fails silently on native Linux builds. No LTO is used for this ELF:
# LTO prevents -fbasic-block-address-map from emitting the .llvm_bb_addr_map
# section (ThinLTO backend in lld does not propagate the flag). Without LTO,
# every TU compiles to native code directly and the section is always present.
# PGO data alone provides representative hot-path guidance for profiling.
local elf_lto_flag
case "${LTO_MODE}" in
full) elf_lto_flag="-flto" ;;
thin) elf_lto_flag="-flto=thin" ;;
none) elf_lto_flag="" ;;
esac
local elf_pgo_flag
if [[ "${_elf_nopgo}" -eq 1 ]]; then
elf_pgo_flag=""
elif [[ "${PGO_MODE}" == "ir" ]]; then
elf_pgo_flag="-fprofile-use=\"${profdata}\""
else
elf_pgo_flag="-fprofile-instr-use=\"${profdata}\" -Wno-profile-instr-unprofiled -Wno-profile-instr-out-of-date"
fi
# -fbasic-block-address-map: emit the .llvm_bb_addr_map section that
# create_llvm_prof reads to generate a Propeller BB+function layout profile.
#
# NOTE: LTO is intentionally DISABLED for the Propeller ELF build.
# With -flto=thin the compiler emits LLVM IR bitcode (not native code) per
# translation unit, and native code + section emission only happens in lld's
# ThinLTO backend at link time. lld's ThinLTO backend does not propagate
# -fbasic-block-address-map, so the section never appears in the final binary.
# Without LTO, every TU is compiled directly to native code and the section
# is always emitted. PGO data alone provides representative hot-path coverage.
local debug_flag=""
[[ "${BUILD_TYPE}" == "RelWithDebInfo" ]] && debug_flag="-g"
local bt_upper; bt_upper=$(echo "${BUILD_TYPE}" | tr '[:lower:]' '[:upper:]')
local elf_compile_flags="-O3 -DNDEBUG ${debug_flag} -D_stat64=stat ${elf_pgo_flag} -fbasic-block-address-map -Wno-error=backend-plugin"
local elf_linker_flags="-fuse-ld=lld-${CLANG_VERSION} -Wl,--emit-relocs"
# ── Flag-change detection: wipe stale object cache if compile flags changed ──
# Wiping CMakeCache.txt alone is not enough — ninja reuses cached .o files
# whose flags are baked in at compile time. Detect changes via md5 sentinel
# and wipe all build artifacts except externals/ (Qt/ffmpeg, ~500 MB).
local _elf_flags_hash _elf_flags_stored=""
_elf_flags_hash=$(printf '%s' "${elf_compile_flags}" | md5sum | cut -d' ' -f1)
local _elf_flags_sentinel="${BUILD_USE_ELF}/.elf_flags_hash"
[[ -f "${_elf_flags_sentinel}" ]] && _elf_flags_stored=$(cat "${_elf_flags_sentinel}" 2>/dev/null || true)
if [[ "${_elf_flags_hash}" != "${_elf_flags_stored}" ]]; then
info "ELF compile flags changed — wiping object cache (preserving externals/)..."
find "${BUILD_USE_ELF}" -mindepth 1 -maxdepth 1 \
! -name "externals" -exec rm -rf {} + 2>/dev/null || true
mkdir -p "${BUILD_USE_ELF}"
success "ELF build cache wiped — full recompile will run with new flags"
elif [[ -f "${BUILD_USE_ELF}/bin/citron" ]]; then
success "ELF already built and flags unchanged — skipping rebuild."
return 0
else
info "ELF compile flags unchanged — incremental build"
fi
# Patch DownloadExternals.cmake to include all required Qt6 components.
# The file only requests Core/Gui/Widgets by default; Network, Svg, DBus,
# and OpenGL are also needed for the native ELF build.
local _dle=""
for _f in "${SOURCE_DIR}/cmake/DownloadExternals.cmake" "${SOURCE_DIR}/CMakeModules/DownloadExternals.cmake" "${SOURCE_DIR}/externals/DownloadExternals.cmake"; do
if [[ -f "${_f}" ]]; then _dle="${_f}"; break; fi
done
# Also search one level up (top-level cmake/ dir)
if [[ -z "${_dle}" ]]; then
_dle="$(find "${SOURCE_DIR}" -maxdepth 3 -name "DownloadExternals.cmake" 2>/dev/null | head -1)"
fi
if [[ -n "${_dle}" ]]; then
info "ELF build: patching Qt6 COMPONENTS in ${_dle}..."
python3 - "${_dle}" << 'DLPYEOF'
import sys, re, pathlib
path = pathlib.Path(sys.argv[1])
text = path.read_text(encoding='utf-8', errors='replace')
needed = ['Network', 'Svg', 'DBus', 'OpenGL', 'OpenGLWidgets']
def patch_qt6_find(src):
# Find find_package(Qt6 ... COMPONENTS ...) blocks (possibly multiline)
# and ensure all needed components are present
pattern = re.compile(
r'(find_package\s*\(\s*Qt6[^)]*?COMPONENTS\s+)((?:[A-Za-z0-9_]+\s+)*[A-Za-z0-9_]+)(\s*(?:REQUIRED)?[^)]*\))',
re.DOTALL
)
def add_components(m):
prefix = m.group(1)
comps_str = m.group(2)
suffix = m.group(3)
existing = set(comps_str.split())
to_add = [c for c in needed if c not in existing]
if to_add:
print(" Adding Qt6 components: " + ' '.join(to_add))
return prefix + comps_str + ' ' + ' '.join(to_add) + suffix
return m.group(0)
patched = pattern.sub(add_components, src)
return patched
patched = patch_qt6_find(text)
if patched != text:
path.write_text(patched, encoding='utf-8')
print(" Patched " + str(path))
else:
print(" No find_package(Qt6 COMPONENTS ...) found to patch — may need manual inspection")
DLPYEOF
else
warn "ELF build: DownloadExternals.cmake not found — Qt6::Network may be missing"
warn " Searched under ${SOURCE_DIR}/cmake/, CMakeModules/, externals/"
fi
# Also patch src/citron/CMakeLists.txt directly — DownloadExternals has a
# fast-path when Qt6_DIR is cached that skips Network/DBus/OpenGL entirely.
# Injecting find_package(Qt6 OPTIONAL_COMPONENTS ...) after Qt6_DIR is set
# ensures those targets are imported. Guarded by CITRON_ELF_BUILD so the
# PE build is unaffected.
local _citron_cmake="${SOURCE_DIR}/src/citron/CMakeLists.txt"
if [[ -f "${_citron_cmake}" ]]; then
if ! grep -q "CITRON_ELF_QT_NETWORK_PATCH" "${_citron_cmake}"; then
info "ELF build: patching src/citron/CMakeLists.txt to import Qt6::Network et al..."
# Write patcher script to a file — avoids heredoc/backslash/newline issues
local _cpatcher="${BUILD_ROOT}/_citron_cmake_patcher.py"
cat > "${_cpatcher}" << 'CTPYEOF'
import sys, pathlib
path = pathlib.Path(sys.argv[1])
text = path.read_text(encoding='utf-8', errors='replace')
inject = (
"\n"
"# CITRON_ELF_QT_NETWORK_PATCH -- injected by build-clangtron-windows.sh\n"
"# DownloadExternals fast-path omits Network/DBus/OpenGL from find_package.\n"
"if(CITRON_ELF_BUILD)\n"
" find_package(Qt6 OPTIONAL_COMPONENTS Network DBus OpenGL OpenGLWidgets)\n"
"endif()\n"
)
anchor = "set(CMAKE_INCLUDE_CURRENT_DIR ON)"
if anchor in text:
idx = text.index(anchor) + len(anchor)
while idx < len(text) and text[idx] in ('\r', '\n'):
idx += 1
text = text[:idx] + inject + text[idx:]
print(" Patched " + str(path))
else:
text = inject + text
print(" Patched (fallback) " + str(path))
path.write_text(text, encoding='utf-8')
CTPYEOF
python3 "${_cpatcher}" "${_citron_cmake}"
else
info "ELF build: src/citron/CMakeLists.txt already patched"
fi
else
warn "ELF build: src/citron/CMakeLists.txt not found at ${_citron_cmake}"
fi
# externals/ffmpeg/CMakeLists.txt contains add_custom_command blocks with
# no OUTPUT or TARGET — a CMake error on Linux (the PE build takes a
# different code path and never hits these). Delete the broken blocks.
local _ffmpeg_cmake="${SOURCE_DIR}/externals/ffmpeg/CMakeLists.txt"
if [[ -f "${_ffmpeg_cmake}" ]]; then
info "ELF build: patching externals/ffmpeg/CMakeLists.txt (add_custom_command fix)..."
# Revert any previous stamp-based patch so delete-based patcher works cleanly
if grep -q "_ffmpeg_cmake_patch_0.stamp" "${_ffmpeg_cmake}" 2>/dev/null; then
info "ELF build: reverting old stamp-based ffmpeg patch via git..."
git -C "${SOURCE_DIR}" checkout -- externals/ffmpeg/CMakeLists.txt 2>/dev/null \
|| warn "ELF build: git restore failed — delete patcher will try anyway"
fi
# Write patcher using base64 — avoids ALL heredoc/backslash escaping issues.
# Deletion strategy: remove add_custom_command blocks with no OUTPUT and no TARGET.
# (Adding a dummy stamp OUTPUT fails in cmake foreach loops: same rule generated N times.)
local _patcher="${BUILD_ROOT}/_ffmpeg_cmake_patcher.py"
echo 'aW1wb3J0IHN5cywgcGF0aGxpYgoKcGF0aCA9IHBhdGhsaWIuUGF0aChzeXMuYXJndlsxXSkKdGV4dCA9IHBhdGgucmVhZF90ZXh0KGVuY29kaW5nPSd1dGYtOCcsIGVycm9ycz0ncmVwbGFjZScpCgpwcmludCgiICBmZm1wZWcgcGF0Y2hlcjogZmlsZSBsZW5ndGggPSAiICsgc3RyKGxlbih0ZXh0KSkgKyAiIGNoYXJzIikKCkFDQyA9ICdhZGRfY3VzdG9tX2NvbW1hbmQnCk9VVFBVVF9LVyA9ICdPVVRQVVQnClRBUkdFVF9LVyA9ICdUQVJHRVQnCgpkZWYgZmluZF9ibG9ja3Moc3JjKToKICAgIHNwYW5zID0gW10KICAgIGkgPSAwCiAgICB3aGlsZSBUcnVlOgogICAgICAgIGlkeCA9IHNyYy5maW5kKEFDQywgaSkKICAgICAgICBpZiBpZHggPT0gLTE6CiAgICAgICAgICAgIGJyZWFrCiAgICAgICAgaiA9IGlkeCArIGxlbihBQ0MpCiAgICAgICAgd2hpbGUgaiA8IGxlbihzcmMpIGFuZCBzcmNbal0gaW4gKCcgJywgJ1x0JywgJ1xyJywgJ1xuJyk6CiAgICAgICAgICAgIGogKz0gMQogICAgICAgIGlmIGogPj0gbGVuKHNyYykgb3Igc3JjW2pdICE9ICcoJzoKICAgICAgICAgICAgaSA9IGlkeCArIDEKICAgICAgICAgICAgY29udGludWUKICAgICAgICBkZXB0aCA9IDAKICAgICAgICBrID0gagogICAgICAgIHdoaWxlIGsgPCBsZW4oc3JjKToKICAgICAgICAgICAgaWYgc3JjW2tdID09ICcoJzoKICAgICAgICAgICAgICAgIGRlcHRoICs9IDEKICAgICAgICAgICAgZWxpZiBzcmNba10gPT0gJyknOgogICAgICAgICAgICAgICAgZGVwdGggLT0gMQogICAgICAgICAgICAgICAgaWYgZGVwdGggPT0gMDoKICAgICAgICAgICAgICAgICAgICBicmVhawogICAgICAgICAgICBrICs9IDEKICAgICAgICBzcGFucy5hcHBlbmQoKGlkeCwgayArIDEpKQogICAgICAgIGkgPSBrICsgMQogICAgcmV0dXJuIHNwYW5zCgpkZWYgc3RyaXBfY29tbWVudHMocyk6CiAgICBvdXQgPSBbXQogICAgZm9yIGxpbmUgaW4gcy5zcGxpdGxpbmVzKCk6CiAgICAgICAgaWR4ID0gbGluZS5maW5kKCcjJykKICAgICAgICBvdXQuYXBwZW5kKGxpbmVbOmlkeF0gaWYgaWR4ICE9IC0xIGVsc2UgbGluZSkKICAgIHJldHVybiAnXG4nLmpvaW4ob3V0KQoKc3BhbnMgPSBmaW5kX2Jsb2Nrcyh0ZXh0KQpwcmludCgiICBmZm1wZWcgcGF0Y2hlcjogZm91bmQgIiArIHN0cihsZW4oc3BhbnMpKSArICIgYWRkX2N1c3RvbV9jb21tYW5kIGJsb2NrcyhzKSIpCgpicm9rZW4gPSBbXQpmb3IgKHMsIGUpIGluIHNwYW5zOgogICAgYm9keSA9IHN0cmlwX2NvbW1lbnRzKHRleHRbczplXSkudXBwZXIoKQogICAgaGFzX291dHB1dCA9IE9VVFBVVF9LVyBpbiBib2R5CiAgICBoYXNfdGFyZ2V0ID0gVEFSR0VUX0tXIGluIGJvZHkKICAgIHByaW50KCIgIGJsb2NrIGF0IGNoYXIgIiArIHN0cihzKSArICI6IGhhc19vdXRwdXQ9IiArIHN0cihoYXNfb3V0cHV0KSArICIgaGFzX3RhcmdldD0iICsgc3RyKGhhc190YXJnZXQpKQogICAgaWYgbm90IGhhc19vdXRwdXQgYW5kIG5vdCBoYXNfdGFyZ2V0OgogICAgICAgIGJyb2tlbi5hcHBlbmQoKHMsIGUpKQoKaWYgbm90IGJyb2tlbjoKICAgIHByaW50KCIgIGZmbXBlZyBwYXRjaGVyOiBubyBicm9rZW4gYmxvY2tzIGZvdW5kIikKZWxzZToKICAgIHJlc3VsdCA9IGxpc3QodGV4dCkKICAgIGZvciAocywgZSkgaW4gcmV2ZXJzZWQoYnJva2VuKToKICAgICAgICBlZSA9IGUKICAgICAgICB3aGlsZSBlZSA8IGxlbih0ZXh0KSBhbmQgdGV4dFtlZV0gaW4gKCdccicsICdcbicpOgogICAgICAgICAgICBlZSArPSAxCiAgICAgICAgZGVsIHJlc3VsdFtzOmVlXQogICAgdGV4dCA9ICcnLmpvaW4ocmVzdWx0KQogICAgcGF0aC53cml0ZV90ZXh0KHRleHQsIGVuY29kaW5nPSd1dGYtOCcpCiAgICBwcmludCgiICBmZm1wZWcgcGF0Y2hlcjogcmVtb3ZlZCAiICsgc3RyKGxlbihicm9rZW4pKSArICIgYnJva2VuIGJsb2NrKHMpIGZyb20gIiArIHN0cihwYXRoKSkK' | base64 -d > "${_patcher}"
python3 "${_patcher}" "${_ffmpeg_cmake}"
else
warn "ELF build: ${_ffmpeg_cmake} not found, skipping add_custom_command patch"
fi
# ── Patch FFmpeg CMakeLists.txt: remove --disable-postproc (removed in FFmpeg 8.x) ──
# FFmpeg 8.x dropped the postproc library entirely. Any configure invocation that
# passes --disable-postproc will abort with "Unknown option".
local _ffmpeg_cfg_cmake="${SOURCE_DIR}/externals/ffmpeg/CMakeLists.txt"
if [[ ! -f "${_ffmpeg_cfg_cmake}" ]]; then
_ffmpeg_cfg_cmake="$(find "${SOURCE_DIR}/externals" -maxdepth 4 -name CMakeLists.txt -exec grep -l "disable-postproc" {} + 2>/dev/null | head -1)"
fi
if [[ -n "${_ffmpeg_cfg_cmake}" ]] && grep -q "disable-postproc" "${_ffmpeg_cfg_cmake}" 2>/dev/null; then
info "ELF build: removing --disable-postproc from FFmpeg cmake configure args..."
sed -i 's/--disable-postproc[[:space:]]*//g' "${_ffmpeg_cfg_cmake}"
info " Patched ${_ffmpeg_cfg_cmake}"
fi
# ── Patch dynarmic emit_x64_vector.cpp: cvt256() was removed in Xbyak 6.x ──
# dynarmic's emit_x64_vector.cpp calls tmp0.cvt256() to cast Xmm→Ymm.
# The bundled xbyak in dynarmic no longer has this method.
# Replacement: Xbyak::Ymm(reg.getIdx()) — identical semantics.
local _ev_cpp="${SOURCE_DIR}/externals/dynarmic/src/dynarmic/backend/x64/emit_x64_vector.cpp"
if [[ -f "${_ev_cpp}" ]] && grep -q "cvt256" "${_ev_cpp}" 2>/dev/null; then
info "ELF build: patching dynarmic emit_x64_vector.cpp (cvt256 → Ymm(getIdx()))..."
python3 - "${_ev_cpp}" << 'XBYAK_PATCH_EOF'
import sys, re, pathlib
p = pathlib.Path(sys.argv[1])
text = p.read_text(encoding='utf-8', errors='replace')
# Replace every occurrence of <reg>.cvt256() with Xbyak::Ymm(<reg>.getIdx())
patched = re.sub(r'(\w+)\.cvt256\(\)', lambda m: f'Xbyak::Ymm({m.group(1)}.getIdx())', text)
if patched != text:
p.write_text(patched, encoding='utf-8')
print(" Patched " + str(p) + " (" + str(text.count('.cvt256()')) + " replacement(s))")
else:
print(" No cvt256() found — already patched or not present")
XBYAK_PATCH_EOF
elif [[ -f "${_ev_cpp}" ]]; then
info "ELF build: emit_x64_vector.cpp has no cvt256() — already patched"
else
warn "ELF build: emit_x64_vector.cpp not found at ${_ev_cpp}"
fi
# Run cmake; if it fails, re-run with --trace-expand to pinpoint silent
# FATAL_ERROR messages that produce "Configuring incomplete" with no text.
local _elf_cmake_args=(
"${SOURCE_DIR}"
-G Ninja
-DCMAKE_BUILD_TYPE=${BUILD_TYPE}
-DCMAKE_C_COMPILER="${CLANG}"
-DCMAKE_CXX_COMPILER="${CLANGPP}"
"-DCMAKE_EXE_LINKER_FLAGS=${elf_linker_flags}"
"-DCITRON_ENABLE_LTO=OFF"
$([ "${_elf_nopgo}" -eq 1 ] && echo "-DCITRON_ENABLE_PGO_USE=OFF" || echo "-DCITRON_ENABLE_PGO_USE=ON")
"-DCITRON_PGO_FLAGS_MANAGED_BY_SCRIPT=ON"
"-DCMAKE_C_FLAGS_${bt_upper}=${elf_compile_flags}"
"-DCMAKE_CXX_FLAGS_${bt_upper}=${elf_compile_flags}"
$([ "${_elf_nopgo}" -eq 0 ] && echo "-DCITRON_PGO_PROFILE_DIR=${PROFILE_DIR}")
"-DCITRON_TESTS=OFF"
"-DCITRON_USE_BUNDLED_FFMPEG=ON"
"-DCITRON_USE_EXTERNAL_SDL2=ON"
"-DCITRON_USE_EXTERNAL_VULKAN_HEADERS=ON"
"-DCITRON_USE_EXTERNAL_VULKAN_UTILITY_LIBRARIES=ON"
"-DCITRON_USE_QT_MULTIMEDIA=OFF"
"-DCITRON_ELF_BUILD=ON"
"-DFFmpeg_COMPONENTS=avfilter;swscale;avcodec;avutil"
"-DQT_PROMOTE_TO_GLOBAL_TARGETS=TRUE"
"-DCMAKE_PREFIX_PATH=${elf_cmake_prefix}"
"-DQt6_DIR=${elf_qt_cmake_dir}"
"-DSPIRV-Headers_DIR=${SPIRV_HEADERS_INSTALL}/share/cmake/SPIRV-Headers"
"-DVulkanHeaders_DIR=${VULKAN_HEADERS_INSTALL}/share/cmake/VulkanHeaders"
"-DVulkan_INCLUDE_DIR=${SOURCE_DIR}/externals/vulkan-stub/include"
"-DVulkan_INCLUDE_DIRS=${SOURCE_DIR}/externals/vulkan-stub/include"
"-DVulkanMemoryAllocator_FOUND=TRUE"
-Wno-dev
${UNITY_BUILD:+"-DENABLE_UNITY_BUILD=${UNITY_BUILD}"}
)
set +e
cmake "${_elf_cmake_args[@]}" 2>&1
local _cmake_exit=$?
set -e
if [[ ${_cmake_exit} -ne 0 ]]; then
warn "ELF cmake configure failed — re-running with --trace-expand to find silent FATAL_ERROR..."
echo ""
# Wipe cache so trace run starts fresh
rm -f CMakeCache.txt; rm -rf CMakeFiles
local _trace_log="${BUILD_USE_ELF}/cmake-trace.log"
set +e
cmake "${_elf_cmake_args[@]}" --trace-expand 2>&1 | tee "${_trace_log}"
set -e
echo ""
warn "Trace saved to: ${_trace_log}"
warn "CMake errors found in trace:"
echo "────────────────────────────────────────────────────────"
# Show CMake Error lines WITH the following 5 lines (the actual error message)
grep -n -A 5 "CMake Error at\|CMake Error:" \
"${_trace_log}" | head -80 || true
echo "---"
grep -n "FATAL_ERROR\|SEND_ERROR\|Generate step failed\|Configuring incomplete" \
"${_trace_log}" | grep -v "cmake_minimum_required\|option(" | head -20 || true
echo ""
warn "Last 30 non-Qt-deploy trace lines:"
grep -v "QT_DEPLOY_TARGET\|Qt6CoreMacros\|QtPublicTarget\|QtPublicCMake\|file(GENERATE\|STATIC_LIBRARY\|EXECUTABLE\|SHARED_LIBRARY" \
"${_trace_log}" | tail -30
echo "────────────────────────────────────────────────────────"
error "ELF cmake configure failed — see trace above to identify the fatal error source"
fi
info "Building native Linux ELF (${BUILD_TYPE})..."
cmake --build . --config "${BUILD_TYPE}" -j "${JOBS}"
# Record the compile flags hash so the next run can detect changes
printf '%s' "${_elf_flags_hash}" > "${_elf_flags_sentinel}"
success "Native ELF: ${BUILD_USE_ELF}/bin/citron"
echo ""
if [[ "${_elf_nopgo}" -eq 1 ]]; then
info "Baseline ELF built (no PGO). Use with bolt or propeller:"
info " ./build-clangtron-windows.sh bolt --pgo none"
info " ./build-clangtron-windows.sh propeller --pgo none"
else
info "ELF built. Choose your next optimization stage:"
info ""
info " Option A — BOLT (function-level reordering via ELF instrumentation):"
info " ./build-clangtron-windows.sh bolt"
info " (bolt pauses mid-stage — run the instrumented ELF, exit, press Enter)"
info ""
info " Option B — Propeller (BB + function layout via perf LBR):"
info " ./build-clangtron-windows.sh propeller"
info " (propeller pauses mid-stage — run the perf command shown, exit, press Enter)"
fi
}
stage_bolt() {
if [[ "${_HOST_OS}" == "windows" ]]; then
error "BOLT requires a Linux host (operates on ELF binaries only). Not supported on Windows/MSYS2."
fi
resolve_bolt_binaries
header "Stage 3: BOLT Binary Layout Optimization"
check_tool "${LLVM_BOLT}"; check_tool "${MERGE_FDATA}"
require_llvm_mingw
# Build ELF if not present or if compile flags changed
stage_build_elf
local elf_binary="${BUILD_USE_ELF}/bin/citron"
[[ -f "$elf_binary" ]] \
|| error "ELF binary not found: ${elf_binary}"
mkdir -p "${BOLT_PROFILE_DIR}" "${BUILD_BOLT}"
local instrumented="${BUILD_BOLT}/citron-bolt-instrumented"
local fdata_pattern="${BOLT_PROFILE_DIR}/citron-%p.fdata"
local merged_fdata="${BOLT_PROFILE_DIR}/citron-merged.fdata"
local optimized_elf="${BUILD_BOLT}/citron-bolt-optimized"
# ── 3a. Instrument ELF ───────────────────────────────────────────────────
# Ensure the BOLT runtime library is in place before instrumenting
if [[ ! -f /usr/local/lib/libbolt_rt_instr.a ]]; then
local _bolt_build="/tmp/llvm-bolt-${CLANG_VERSION}-build"
if [[ -f "${_bolt_build}/lib/libbolt_rt_instr.a" ]]; then
info "Installing BOLT runtime from existing build..."
_sudo cp "${_bolt_build}/lib/libbolt_rt_instr.a" /usr/local/lib/libbolt_rt_instr.a
_sudo cp "${_bolt_build}/lib/libbolt_rt_hugify.a" /usr/local/lib/libbolt_rt_hugify.a 2>/dev/null || true
elif [[ -d "${_bolt_build}" ]]; then
info "Building BOLT runtime from existing build tree..."
cmake --build "${_bolt_build}" --target bolt_rt -j "${JOBS}" \
|| error "BOLT runtime build failed"
_sudo cp "${_bolt_build}/lib/libbolt_rt_instr.a" /usr/local/lib/libbolt_rt_instr.a
_sudo cp "${_bolt_build}/lib/libbolt_rt_hugify.a" /usr/local/lib/libbolt_rt_hugify.a 2>/dev/null || true
else
info "No cached BOLT build found — building from source (this takes ~15 min)..."
build_bolt_from_source
fi
fi
info "Instrumenting ELF with BOLT..."
"${LLVM_BOLT}" "${elf_binary}" \
--instrument \
--instrumentation-file="${fdata_pattern}" \
--instrumentation-file-append-pid \
-o "${instrumented}"
success "Instrumented: ${instrumented}"
echo ""
echo -e "${YELLOW}════════════════════════════════════════════════════════════════${RESET}"
echo -e "${YELLOW} Run BOLT-Instrumented Binary (native Linux ELF)${RESET}"
echo -e "${YELLOW}════════════════════════════════════════════════════════════════${RESET}"
echo ""
echo " ${instrumented}"
echo ""
echo " Play for 15-30 min. Exit cleanly. fdata files go to:"
echo " ${BOLT_PROFILE_DIR}/"
echo ""
echo -e "${YELLOW}════════════════════════════════════════════════════════════════${RESET}"
read -rp " Press Enter once you have exited the instrumented binary... "
echo ""
# ── 3b. Merge .fdata ─────────────────────────────────────────────────────
local fdata_count
fdata_count=$(find "${BOLT_PROFILE_DIR}" -name "*.fdata" 2>/dev/null | wc -l)
[[ "$fdata_count" -gt 0 ]] || error "No .fdata files in ${BOLT_PROFILE_DIR}"
info "Merging ${fdata_count} .fdata file(s)..."
"${MERGE_FDATA}" "${BOLT_PROFILE_DIR}"/*.fdata -o "${merged_fdata}"
success "Merged: ${merged_fdata}"
# ── 3c. Optimize ELF ─────────────────────────────────────────────────────
info "Optimizing ELF with BOLT..."
local bolt_log
bolt_log="$(mktemp /tmp/citron-bolt-opt.XXXXXX.log)"
"${LLVM_BOLT}" "${elf_binary}" \
-p "${merged_fdata}" \
--reorder-blocks=ext-tsp \
--reorder-functions=cdsort \
--split-functions \
--split-all-cold \
--split-eh \
--dyno-stats \
-o "${optimized_elf}" 2>&1 | tee "${bolt_log}"
# tee exits 0 even if BOLT fails — check the output file was actually produced
[[ -f "${optimized_elf}" ]] || error "BOLT optimization failed (see ${bolt_log})"
success "BOLT-optimized ELF: ${optimized_elf}"
# Preserve the BOLT-optimized ELF in a permanent location so it
# survives subsequent bolt re-runs that wipe BUILD_BOLT.
local elf_output="${BUILD_ROOT}/citron-bolt-optimized"
cp "${optimized_elf}" "${elf_output}"
success "ELF preserved: ${elf_output}"
# ── 3d. Extract BOLT function order for PE linker ─────────────────────────
#
# BOLT cannot rewrite PE/COFF binaries directly, and its --symbol-ordering-file
# flag is ELF-lld only. However, lld's COFF/PE mode supports /order:@<file>,
# which controls the placement order of functions in .text — the same benefit
# at function granularity (not basic-block, but still meaningful i-cache gain).
#
# We recover BOLT's computed layout from the optimized ELF: symbols in the
# .text section sorted by address == the order BOLT placed them. We strip
# BOLT's own internal symbols and cold-clone suffixes, then write a /order
# file that lld-link will use to place functions in the same hot-first order
# in the PE's .text section.
local order_file="${BUILD_ROOT}/bolt-function-order.txt"
info "Extracting BOLT function order from optimized ELF..."
local nm_tool
nm_tool="$(command -v "llvm-nm-${CLANG_VERSION}" 2>/dev/null \
|| command -v llvm-nm 2>/dev/null \
|| command -v nm 2>/dev/null || true)"
[[ -n "${nm_tool}" ]] || error "No nm tool found — cannot extract BOLT function order"
python3 - "${optimized_elf}" "${order_file}" "${nm_tool}" "${bolt_log:-}" << 'BOLT_ORDER_EOF'
import sys, subprocess, re
elf_path = sys.argv[1]
order_path = sys.argv[2]
nm_tool = sys.argv[3]
bolt_log = sys.argv[4] if len(sys.argv) > 4 else ""
# ── 1. Parse __hot_start / __hot_end from the saved BOLT log ─────────────
# BOLT always prints these during optimisation:
# BOLT-INFO: setting __hot_start to 0x...
# BOLT-INFO: setting __hot_end to 0x...
# This is far more reliable than post-hoc symbol table parsing (nm
# silently drops SHN_ABS symbols in PIE binaries; readelf regex can
# be fragile across LLVM versions).
hot_start = None
hot_end = None
if bolt_log:
try:
hs_re = re.compile(r'BOLT-INFO: setting __hot_start to (0x[0-9a-fA-F]+)')
he_re = re.compile(r'BOLT-INFO: setting __hot_end to (0x[0-9a-fA-F]+)')
with open(bolt_log) as fh:
for line in fh:
if hot_start is None:
m = hs_re.search(line)
if m:
hot_start = int(m.group(1), 16)
if hot_end is None:
m = he_re.search(line)
if m:
hot_end = int(m.group(1), 16)
if hot_start is not None and hot_end is not None:
break
except OSError:
pass # log file gone — fall through to fallback
# ── 2. Collect text symbols via nm --numeric-sort ─────────────────────────
nm_result = subprocess.run(
[nm_tool, "--defined-only", "--numeric-sort", "--format=posix", elf_path],
capture_output=True, text=True
)
if nm_result.returncode != 0:
print(" nm failed: " + nm_result.stderr[:200])
sys.exit(1)
# Strip BOLT internals, cold clones, LTO-local hashes, and SDL internals:
# __BOLT_* -- BOLT instrumentation/padding artifacts
# *.cold[.N] -- cold halves of split functions (placed after __hot_end)
# __COLD_* -- BOLT cold-region labels
# .llvm.<hash> -- ThinLTO-internalized copies (hash differs per build)
# SDL_*_REAL -- Linux SDL2 internal dispatch symbols absent in Windows SDL2.dll
skip = re.compile(
r'^__BOLT_'
r'|\.cold(?:\.\d+)?$'
r'|^__COLD_'
r'|\.llvm\.\d+$'
r'|^SDL_\w+_REAL$'
)
text_syms = [] # list of (addr, name)
for line in nm_result.stdout.splitlines():
parts = line.split()
if len(parts) < 3:
continue
name, typ, val_str = parts[0], parts[1], parts[2]
if typ not in ('T', 't'):
continue
if skip.search(name):
continue
try:
addr = int(val_str, 16)
except ValueError:
continue
text_syms.append((addr, name))
# ── 3. Filter to hot segment ──────────────────────────────────────────────
if hot_start is not None and hot_end is not None and hot_start < hot_end:
hot_kb = (hot_end - hot_start) // 1024
print(f" Hot segment : 0x{hot_start:08x} - 0x{hot_end:08x} ({hot_kb:,} KiB)")
hot_syms = [(a, n) for a, n in text_syms if hot_start <= a < hot_end]
cold_syms = len(text_syms) - len(hot_syms)
print(f" Hot symbols : {len(hot_syms):,} of {len(text_syms):,} total text symbols")
print(f" Cold/other : {cold_syms:,} excluded (inlined by ThinLTO in PE -> LNK4037 noise)")
symbols = [n for _, n in hot_syms]
else:
print(" WARNING: could not determine hot segment boundaries from BOLT log.")
print(f" Falling back to all {len(text_syms):,} text symbols -- expect more LNK4037 warnings.")
symbols = [n for _, n in text_syms]
if not symbols:
print(" WARNING: no text symbols extracted -- /order file will be empty")
sys.exit(0)
with open(order_path, 'w') as f:
f.write('\n'.join(symbols) + '\n')
print(f" Wrote {len(symbols)} symbols to {order_path}")
BOLT_ORDER_EOF
local order_count=0
[[ -f "${order_file}" ]] && order_count=$(wc -l < "${order_file}")
if [[ "${order_count}" -gt 0 ]]; then
success "BOLT order file: ${order_file} (${order_count} functions)"
else
warn "BOLT order file is empty — PE relink will proceed without function ordering"
order_file=""
fi
# ── 3e. Re-link Windows PE with BOLT function order ───────────────────────
info "Re-linking final Windows PE (PGO + LTO + BOLT function order)..."
# Always wipe the cmake cache — stale CMAKE_EXE_LINKER_FLAGS can persist
# from a previous failed run and cause the compiler test to fail.
rm -rf "${BUILD_BOLT}"
mkdir -p "${BUILD_BOLT}"; cd "${BUILD_BOLT}"
# Use LTO_MODE (default: full) for the final PE re-link.
# generate and use stages ran with ThinLTO, giving BOLT a consistent
# profile and function-order file. The re-link is free to apply full
# LTO on top — PGO profiles and BOLT ordering are already baked in,
# and full LTO's whole-program inlining yields better runtime performance.
local debug_flag=""
# -gcodeview: see stage_use for why plain -g isn't enough on this MinGW target.
[[ "${BUILD_TYPE}" == "RelWithDebInfo" ]] && debug_flag="-g -gcodeview"
local linker_debug_flag=""
# --pdb= (not -DEBUG/-Wl,-DEBUG, not /DEBUG): the MinGW driver invoked by
# -fuse-ld=lld on this target doesn't recognize -DEBUG at all (separate
# option table from lld-link) -- see stage_use's no-PGO block for the
# full explanation. --pdb is its actual recognized flag; empty value
# means auto-name the PDB per binary (this flag is shared across
# several executables built in this stage).
# --threads=1: combining --pdb= with -flto enables a known LLD COFF
# deadlock between PDB type/symbol-record merge threads and parallel LTO
# backend codegen threads (manifests as three ld.lld processes stuck at
# low CPU % indefinitely). --threads=N is the MinGW driver's own spelling
# of this option (per LLVM D76885); /threads:N is the COFF/lld-link form.
# Unlike order/ignore, "threads" IS in the MinGW driver's option table, so
# it does NOT need -Xlink= wrapping. Slower link, but only RelWithDebInfo.
[[ "${BUILD_TYPE}" == "RelWithDebInfo" ]] && linker_debug_flag="-Wl,--pdb= -Wl,--threads=1"
local bt_upper; bt_upper=$(echo "${BUILD_TYPE}" | tr '[:lower:]' '[:upper:]')
local lto_flag; lto_flag="$(lto_clang_flag)"
local _bolt_merged="${PROFILE_DIR}/merged.profdata"
local profdata
[[ -f "${_bolt_merged}" ]] && profdata="${_bolt_merged}" || profdata="${PROFILE_DIR}/default.profdata"
local pgo_flag
if [[ "${PGO_MODE}" == "ir" ]]; then
pgo_flag="-fprofile-use=\"${profdata}\""
else
pgo_flag="-fprofile-instr-use=\"${profdata}\" -Wno-profile-instr-unprofiled -Wno-profile-instr-out-of-date"
fi
local lto_pgo_flag="${lto_flag:+${lto_flag} }${pgo_flag}"
# /order:@<file> — COFF/PE lld flag for function placement order in .text.
# Functions listed first are placed at the start of .text (hot region).
# Functions not in the list keep their default LTO order after the listed ones.
# -Wl, prefix passes the flag through clang to lld-link.
# /ignore:4037 — suppress LNK4037 "missing symbol" warnings for /order entries.
# These warnings are harmless: symbols absent from a given binary (because
# ThinLTO inlined them, or because the binary is citron-room/citron-cmd which
# doesn't contain emulator-core code) simply keep their default link order.
# Without /ignore:4037 the 13k-entry order file produces ~28k warnings across
# the three executables that share CMAKE_EXE_LINKER_FLAGS_RELEASE.
local order_linker_flag=""
if [[ -n "${order_file}" ]]; then
# -Xlink=<arg>: the MinGW driver invoked by -fuse-ld=lld on this target
# doesn't recognize "order" or "ignore" in its own option table at all
# (unlike --pdb, which it does) — passing -Wl,-order:... directly fails
# with "unknown argument", same failure mode as the -DEBUG issue above.
# -Xlink is its documented escape hatch for passing a flag straight
# through to the underlying COFF linker (lld-link), which does
# understand /order and /ignore. This also sidesteps MSYS2's path
# mangling for free: the token starts with "-Xlink=", not "/", so the
# embedded "/order:..." is never a leading character MSYS2 rewrites.
order_linker_flag="-Wl,-Xlink=/order:@${order_file} -Wl,-Xlink=/ignore:4037"
fi
local qt_install_dir="${BUILD_GENERATE}/externals/qt/6.9.3/llvm-mingw_64"
local qt_host_dir="${BUILD_GENERATE}/externals/qt-host/6.9.3/gcc_64"
if [[ "${_HOST_OS}" == "windows" ]]; then
qt_host_dir=""
fi
local qt6_cmake_dir="${qt_install_dir}/lib/cmake/Qt6"
# Pre-build FFmpeg for this build directory
detect_ffmpeg_version
rebuild_ffmpeg_pthread_free "${BUILD_BOLT}"
# shellcheck disable=SC2034 # _CMAKE_ARGS used via array expansion below
build_common_cmake_args
_CMAKE_ARGS+=(
"-DCITRON_ENABLE_PGO_USE=ON"
"-DCITRON_PGO_FLAGS_MANAGED_BY_SCRIPT=ON"
"-DCMAKE_C_FLAGS_${bt_upper}=-O3 -DNDEBUG ${debug_flag} ${lto_pgo_flag}"
"-DCMAKE_CXX_FLAGS_${bt_upper}=-O3 -DNDEBUG ${debug_flag} ${lto_pgo_flag}"
"-DCMAKE_EXE_LINKER_FLAGS_${bt_upper}=-O3 -DNDEBUG ${debug_flag} ${lto_pgo_flag}${order_linker_flag:+ ${order_linker_flag}} ${linker_debug_flag}"
"-DCITRON_PGO_PROFILE_DIR=${PROFILE_DIR}"
)
[[ -n "${qt6_cmake_dir}" ]] && _CMAKE_ARGS+=("-DQt6_DIR=${qt6_cmake_dir}")
[[ -n "${qt_host_dir}" ]] && _CMAKE_ARGS+=("-DQT_HOST_PATH=${qt_host_dir}")
cmake "${SOURCE_DIR}" "${_CMAKE_ARGS[@]}" \
|| error "CMake configure failed"
info "Building final optimized Windows PE (PGO + LTO + BOLT function order, ${BUILD_TYPE})..."
cmake --build . --config "${BUILD_TYPE}" -j "${JOBS}"
deploy_runtime_dlls \
"${BUILD_BOLT}/bin" \
"${BUILD_GENERATE}/externals/qt/6.9.3/llvm-mingw_64" \
"${BUILD_BOLT}"
# ── 3f. BOLT reorder summary ─────────────────────────────────────────────
# Cross-reference the order file against citron.exe's actual symbol table
# to report exactly how many hot functions were successfully placed.
if [[ -n "${order_file}" && -f "${BUILD_BOLT}/bin/citron.exe" ]]; then
local elf_lto_used="${LTO_MODE}"
python3 - "${order_file}" "${BUILD_BOLT}/bin/citron.exe" "${nm_tool}" "${LTO_MODE}" "${elf_lto_used}" << 'BOLT_SUMMARY_EOF'
import sys, subprocess, re
order_path = sys.argv[1]
exe_path = sys.argv[2]
nm_tool = sys.argv[3]
lto_mode = sys.argv[4] if len(sys.argv) > 4 else "unknown"
elf_lto_mode = sys.argv[5] if len(sys.argv) > 5 else "thin"
# Resolve actual LTO used in the bolt PE re-link:
# full → -flto (whole-program LTO; most inlining → most "missing" hot symbols)
# thin → -flto=thin
# none → no LTO
lto_label = {
"full": "Full LTO (-flto)",
"thin": "ThinLTO (-flto=thin)",
"none": "No LTO",
}.get(lto_mode, f"unknown ({lto_mode})")
elf_lto_label = {
"full": "Full LTO (-flto)",
"thin": "ThinLTO (-flto=thin)",
"none": "No LTO",
}.get(elf_lto_mode, f"unknown ({elf_lto_mode})")
with open(order_path) as f:
hot_syms = set(l.strip() for l in f if l.strip())
result = subprocess.run(
[nm_tool, "--defined-only", "--format=posix", exe_path],
capture_output=True, text=True
)
pe_syms = set()
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) >= 2 and parts[1] in ('T', 't'):
pe_syms.add(parts[0])
matched = hot_syms & pe_syms
missed = hot_syms - pe_syms
total_hot = len(hot_syms)
pct = 100.0 * len(matched) / total_hot if total_hot else 0.0
W = "" # bold white
G = "" # bold green
Y = "" # bold yellow
C = "" # bold cyan
R = "" # reset
BAR_W = 40
filled = round(BAR_W * len(matched) / total_hot) if total_hot else 0
bar = "█" * filled + "░" * (BAR_W - filled)
absent_reason = "Inlined by LTO (absent)" if lto_mode == "none" else f"Inlined by {lto_label.split()[0]} (absent)"
# Build each content string at exactly IW visible chars before adding ANSI
# codes, so ║ delimiters always align regardless of color escape widths.
IW = 60
def pad(s, w=IW):
return s[:w].ljust(w)
pe_lto_str = f" PE LTO (bolt re-link) : {lto_label}"
elf_lto_str = f" ELF LTO (BOLT source) : {elf_lto_label}"
hot_str = f" Hot functions in order file : {total_hot:>7,}"
match_str = f" Successfully reordered : {len(matched):>7,} ({pct:5.1f}%)"
miss_str = f" {absent_reason:<30}: {len(missed):>7,} ({100-pct:5.1f}%)"
bar_str = f" [{bar}] {pct:.1f}%"
bar_pad = " " * max(0, IW - len(bar_str))
print()
print(f"{C} ╔════════════════════════════════════════════════════════════╗{R}")
print(f"{C} ║{R}{pad(chr(32)*8 + "BOLT Function Reorder — citron.exe Summary")}{C}║{R}")
print(f"{C} ╠════════════════════════════════════════════════════════════╣{R}")
print(f"{C} ║{R}{W}{pad(pe_lto_str)}{R}{C}║{R}")
print(f"{C} ║{R}{W}{pad(elf_lto_str)}{R}{C}║{R}")
print(f"{C} ╠════════════════════════════════════════════════════════════╣{R}")
print(f"{C} ║{R}{W}{pad(hot_str)}{R}{C}║{R}")
print(f"{C} ║{R}{G}{pad(match_str)}{R}{C}║{R}")
print(f"{C} ║{R}{Y}{pad(miss_str)}{R}{C}║{R}")
print(f"{C} ║{R}{pad("")}{C}║{R}")
print(f"{C} ║{R} [{G}{bar}{R}] {G}{pct:.1f}%{R}{bar_pad}{C}║{R}")
print(f"{C} ╚════════════════════════════════════════════════════════════╝{R}")
print()
BOLT_SUMMARY_EOF
fi
success "════════════════════════════════════════════════════════════════"
success " Final binary: ${BUILD_BOLT}/bin/citron.exe"
local _bolt_pgo_label
if [[ -f "${PROFILE_DIR}/merged.profdata" && "${profdata}" == "${PROFILE_DIR}/merged.profdata" ]]; then
_bolt_pgo_label="CS-IRPGO (-fprofile-use=merged.profdata)"
elif [[ "${PGO_MODE}" == "ir" ]]; then
_bolt_pgo_label="IR PGO (-fprofile-use)"
else
_bolt_pgo_label="${PGO_MODE} (-fprofile-instr-use)"
fi
success " Optimizations: PGO (${_bolt_pgo_label}) + LTO + BOLT (function reordering)"
success "════════════════════════════════════════════════════════════════"
}
# =============================================================================
# ensure_create_llvm_prof
#
# Builds generate_propeller_profiles from google/llvm-propeller and installs
# it as /usr/local/bin/create_llvm_prof for use by the propeller stage.
#
# google/llvm-propeller is the correct modern repo (autofdo's README says the
# Propeller codebase moved there as of 2025Q1). It has its own self-contained
# cmake build with FetchContent — no LLVM_PATH, no ENABLE_TOOL, no bundled-LLVM
# whack-a-mole. It natively understands BBAddrMap v3 (Clang 19+ format).
#
# Interface: --cc_profile / --ld_profile (not --out/--format/--propeller_symorder)
#
# The installed binary is version-stamped; rebuilds on Clang version change.
# =============================================================================
ensure_create_llvm_prof() {
local src_dir="/tmp/propeller-src"
local build_dir="/tmp/propeller-build"
local install_bin="/usr/local/bin/create_llvm_prof"
local ver_sentinel="/usr/local/bin/.create_llvm_prof_llvm_ver"
local clang_ver
clang_ver=$("${CLANG}" --version 2>&1 | head -1 || echo "unknown")
if command -v create_llvm_prof &>/dev/null; then
local stored_ver=""
[[ -f "${ver_sentinel}" ]] && stored_ver=$(cat "${ver_sentinel}" 2>/dev/null || true)
if [[ "${clang_ver}" == "${stored_ver}" ]]; then
info "create_llvm_prof already installed and up-to-date: $(command -v create_llvm_prof)"
return 0
else
warn "create_llvm_prof version mismatch — rebuilding."
_sudo rm -f "${install_bin}" "${ver_sentinel}"
rm -rf "${build_dir}" "${src_dir}"
fi
fi
info "Building create_llvm_prof from google/llvm-propeller..."
# Dependencies per google/llvm-propeller README
local _missing=()
dpkg -s libelf-dev &>/dev/null 2>&1 || _missing+=(libelf-dev)
dpkg -s libssl-dev &>/dev/null 2>&1 || _missing+=(libssl-dev)
dpkg -s libzstd-dev &>/dev/null 2>&1 || _missing+=(libzstd-dev)
if [[ ${#_missing[@]} -gt 0 ]]; then
info "Installing: ${_missing[*]}"
_sudo apt-get install -y "${_missing[@]}" || error "Failed to install dependencies"
fi
if [[ ! -d "${src_dir}/.git" ]]; then
info "Cloning google/llvm-propeller..."
git clone --depth=1 https://github.com/google/llvm-propeller.git "${src_dir}" || error "Failed to clone google/llvm-propeller"
success "llvm-propeller cloned"
else
info "Cached llvm-propeller clone found at ${src_dir}"
fi
info "Configuring llvm-propeller cmake..."
rm -rf "${build_dir}"
CC="${CLANG}" CXX="${CLANGPP}" cmake -S "${src_dir}" -B "${build_dir}" -G Ninja -DCMAKE_BUILD_TYPE=${BUILD_TYPE} || error "llvm-propeller cmake configure failed"
info "Building generate_propeller_profiles (~15-30 min)..."
cmake --build "${build_dir}" --target generate_propeller_profiles -j "${JOBS}" || error "llvm-propeller build failed"
local built_bin="${build_dir}/propeller/generate_propeller_profiles"
[[ -f "${built_bin}" ]] || error "Built binary not found at ${built_bin}"
_sudo cp "${built_bin}" "${install_bin}"
_sudo chmod +x "${install_bin}"
printf '%s' "${clang_ver}" | _sudo tee "${ver_sentinel}" > /dev/null
command -v create_llvm_prof &>/dev/null || error "create_llvm_prof installation failed"
success "create_llvm_prof installed: ${install_bin}"
}
# =============================================================================
# Stage: propeller — Propeller basic-block + function layout optimization
#
# Propeller is Google's feedback-directed optimization that operates at the
# basic-block level, feeding profiles back into the compiler before LTO inlining
# decisions are made. Unlike BOLT's post-link binary rewriting, Propeller works
# at compile time, which means:
#
# - Inlined functions inherit layout guidance at their call sites (LTO-resilient)
# - Basic-block layout influences the compiler's register allocation and code
# generation, not just the final binary section placement
# - Profile collection uses the Linux ELF (same binary already built for BOLT)
# running under perf record -b (branch stack sampling)
#
# HARDWARE REQUIREMENTS:
# Branch-stack sampling requires hardware branch recording support:
# - AMD Zen 4 (Ryzen 7940HS): uses BRBS (Branch Record Buffer Stores)
# Requires kernel 6.1+ and linux-tools-$(uname -r)
# - Intel 13th gen (i9-13900H): uses LBR (Last Branch Records)
# Requires linux-tools-$(uname -r)
# Both work with the same perf -b flag — the kernel picks the right backend.
#
# WORKFLOW:
# 1. The build-elf stage builds the ELF with -fbasic-block-address-map,
# which embeds a .llvm_bb_addr_map section mapping basic blocks to addresses.
# 2. This stage runs citron under perf record -b to collect branch stacks.
# 3. create_llvm_prof converts perf.data + ELF to two Propeller profile files:
# propeller_cc.prof — basic-block layout list (passed via -fbasic-block-sections=list=)
# propeller_symorder.txt — hot function order (passed to linker /order:@)
# 4. The Windows PE is rebuilt with:
# -fbasic-block-sections=list=propeller_cc.prof (BB-level layout in PE — distinct flag, still valid)
# /order:@propeller_symorder.txt (function ordering)
# plus the same PGO+LTO flags as the use stage.
#
# NOTE on PE/COFF + -fbasic-block-sections=list (for the Propeller rebuild):
# This flag (which feeds a BB profile back to the compiler) is different from
# -fbasic-block-address-map (which annotates the ELF for profiling). It is
# primarily designed for ELF targets. For PE/COFF, the compiler
# still emits separate COFF sections per basic block, and lld's COFF mode will
# merge them per the order file. In practice the BB-level benefit may be partial
# (COFF section granularity is coarser than ELF). The function-order benefit
# from propeller_symorder.txt is identical to the BOLT /order:@ path.
#
# OUTPUT:
# build/propeller/bin/citron.exe — Propeller-optimized Windows PE
# =============================================================================
stage_propeller() {
if [[ "${_HOST_OS}" == "windows" ]]; then
error "Propeller requires a Linux host (perf LBR + ELF target). Not supported on Windows/MSYS2."
fi
header "Stage: Propeller Basic-Block Profile Optimization"
check_tool "${CLANG}"; check_tool "${CLANGPP}"
check_tool "ninja"; check_tool "cmake"
check_tool "perf"
ensure_create_llvm_prof
require_llvm_mingw
# Build ELF if not present or if compile flags changed
stage_build_elf
local elf_binary="${BUILD_USE_ELF}/bin/citron"
[[ -f "${elf_binary}" ]] \
|| error "ELF binary not found: ${elf_binary}"
# Verify the ELF was built with -fbasic-block-address-map
# by checking for the .llvm_bb_addr_map section it emits
if ! "${LLVM_MINGW_DIR}/bin/llvm-readelf" --sections "${elf_binary}" \
2>/dev/null | grep -q '\.llvm_bb_addr_map'; then
# Fallback: use system readelf
if ! readelf --sections "${elf_binary}" 2>/dev/null | grep -q '\.llvm_bb_addr_map'; then
warn "ELF does not contain a .llvm_bb_addr_map section."
warn "The ELF may have been built with an older version of this script."
warn "Re-run './build-clangtron-windows.sh build-elf' to rebuild the ELF with BB labels."
warn "Propeller will still produce a function-order profile but no BB layout."
fi
else
success "ELF has .llvm_bb_addr_map section — BB-level profiling available"
fi
mkdir -p "${PROPELLER_PROFILE_DIR}" "${BUILD_PROPELLER}/bin"
local perf_data="${PROPELLER_PROFILE_DIR}/perf.data"
local cc_profile="${PROPELLER_PROFILE_DIR}/propeller_cc.prof"
local symorder="${PROPELLER_PROFILE_DIR}/propeller_symorder.txt"
# ── 1. Profile collection ─────────────────────────────────────────────────
# If perf.data already exists, verify its build ID matches the current ELF.
# A mismatch means the ELF was rebuilt since the profile was collected — the
# old perf.data is useless and must be discarded before re-collecting.
if [[ -f "${perf_data}" ]]; then
local _elf_buildid _perf_buildids
_elf_buildid=$(readelf -n "${elf_binary}" 2>/dev/null | grep -oP '(?<=Build ID: )[0-9a-f]+' | head -1 || true)
_perf_buildids=$(perf buildid-list -i "${perf_data}" 2>/dev/null | awk '{print $1}' || true)
if [[ -n "${_elf_buildid}" ]] && ! grep -qF "${_elf_buildid}" <<< "${_perf_buildids}"; then
warn "perf.data build ID does not match the current ELF."
warn " ELF build ID: ${_elf_buildid}"
warn " perf.data has: $(head -1 <<< "${_perf_buildids}") (first entry)"
warn "The ELF was rebuilt since the profile was collected."
info "Deleting stale perf.data — re-collection required."
rm -f "${perf_data}"
else
info "Found existing perf.data: ${perf_data}"
info "Build ID verified — skipping collection."
fi
fi
if [[ ! -f "${perf_data}" ]]; then
# ── Hardware / kernel capability checks ─────────────────────────────
# 1. perf_event_paranoid: branch stacks require <= 1
local paranoid
paranoid=$(cat /proc/sys/kernel/perf_event_paranoid 2>/dev/null || echo "unknown")
if [[ "${paranoid}" != "unknown" ]] && [[ "${paranoid}" -gt 1 ]]; then
warn "perf_event_paranoid=${paranoid} — branch stack sampling requires <= 1"
info "Fixing automatically with: _sudo sysctl kernel.perf_event_paranoid=1"
_sudo sysctl -w kernel.perf_event_paranoid=1 \
|| error "Could not set perf_event_paranoid=1 — run manually:\n _sudo sysctl kernel.perf_event_paranoid=1"
success "perf_event_paranoid set to 1"
info "To make permanent: echo 'kernel.perf_event_paranoid=1' | _sudo tee -a /etc/sysctl.conf"
else
success "perf_event_paranoid=${paranoid} (OK)"
fi
# 2. Kernel version: AMD BRBS requires 6.1+, Intel LBR works on any modern kernel
local kernel_ver kernel_maj kernel_min
kernel_ver=$(uname -r)
kernel_maj=$(echo "${kernel_ver}" | cut -d. -f1)
kernel_min=$(echo "${kernel_ver}" | cut -d. -f2 | cut -d- -f1)
if [[ "${kernel_maj}" -lt 6 ]] || { [[ "${kernel_maj}" -eq 6 ]] && [[ "${kernel_min}" -lt 1 ]]; }; then
warn "Kernel ${kernel_ver} is older than 6.1 — AMD BRBS branch stack support"
warn "requires kernel 6.1+. Intel LBR still works on older kernels."
warn "If perf fails below, upgrade your kernel and retry."
else
success "Kernel ${kernel_ver} >= 6.1 (branch stack support OK)"
fi
# 3. Verify perf can actually record branch stacks on this hardware.
# A 0.1-second test capture confirms the hardware/driver supports -b.
info "Testing perf branch-stack capability on this hardware..."
if ! perf record -b -e cycles:u -o /tmp/citron-perf-captest.data \
-- sleep 0.1 >/dev/null 2>&1; then
error "perf -b (branch stack recording) is not supported on this hardware/kernel.\n" \
" Propeller requires branch stacks for BB-level profile data.\n" \
" AMD: ensure kernel >= 6.1 and amd_iommu=off is not set.\n" \
" Intel: ensure MSR access is not blocked (no nolbr boot flag)."
fi
rm -f /tmp/citron-perf-captest.data
success "perf branch-stack recording works on this hardware"
echo ""
echo -e "${YELLOW}╔══════════════════════════════════════════════════════════════════╗${RESET}"
echo -e "${YELLOW}║ Propeller — Branch Profile Collection ║${RESET}"
echo -e "${YELLOW}╠══════════════════════════════════════════════════════════════════╣${RESET}"
echo ""
echo -e "${CYAN} Run the following commands to collect a branch-stack profile:${RESET}"
echo ""
echo " cd ${elf_binary%/*}"
echo " perf record -b -e cycles:u \\"
echo " -o ${perf_data} \\"
echo " -- ${elf_binary}"
echo ""
echo " Play games / navigate menus for 15-30 minutes."
echo " Exit citron cleanly (File > Exit or Ctrl+Q)."
echo " perf writes ${perf_data} on exit."
echo ""
echo -e "${CYAN} If citron fails to display (no GUI available):${RESET}"
echo " Run from a desktop session, or set DISPLAY=:0 before the command."
echo ""
echo -e "${YELLOW}╚══════════════════════════════════════════════════════════════════╝${RESET}"
echo ""
read -rp " Press Enter once perf has finished and perf.data is written... "
echo ""
[[ -f "${perf_data}" ]] \
|| error "perf.data not found at ${perf_data}\n" \
" Run the perf command above, then re-run this stage."
fi
# ── 2. Convert perf.data to Propeller profiles ────────────────────────────
# generate_propeller_profiles (google/llvm-propeller) uses:
# --cc_profile = BB layout profile (was: --out --format=propeller)
# --ld_profile = function order (was: --propeller_symorder)
info "Converting perf branch data to Propeller profiles..."
info " Binary: ${elf_binary}"
info " Input: ${perf_data}"
info " CC prof: ${cc_profile}"
info " LD prof: ${symorder}"
echo ""
set +e
create_llvm_prof \
--binary="${elf_binary}" \
--profile="${perf_data}" \
--cc_profile="${cc_profile}" \
--ld_profile="${symorder}" \
2>&1
local clp_exit=$?
set -e
if [[ ${clp_exit} -ne 0 ]]; then
warn "generate_propeller_profiles exited ${clp_exit}."
warn "Common causes:"
warn " - perf.data was collected without -b (branch stacks required)"
warn " - Binary mismatch: perf.data collected on a different build"
warn " - ELF has no .llvm_bb_addr_map: re-run build-elf and re-collect"
error "Propeller profile conversion failed"
fi
if [[ ! -f "${cc_profile}" ]] && [[ ! -f "${symorder}" ]]; then
error "create_llvm_prof produced no output files — check perf.data validity"
fi
local have_bb=0; local have_sym=0
[[ -f "${cc_profile}" ]] && have_bb=1 \
&& success "CC profile (BB layout): ${cc_profile} ($(wc -l < "${cc_profile}") entries)"
[[ -f "${symorder}" ]] && have_sym=1 \
&& success "Symbol order (fn layout): ${symorder} ($(wc -l < "${symorder}") functions)"
if [[ ${have_bb} -eq 0 ]]; then
warn "No CC profile produced — BB-level layout unavailable."
warn "Function ordering via symorder will still be applied if present."
fi
# ── 3. Rebuild Windows PE with Propeller profiles ─────────────────────────
info "Rebuilding Windows PE with Propeller profiles (PGO + LTO + Propeller)..."
rm -rf "${BUILD_PROPELLER}"
mkdir -p "${BUILD_PROPELLER}"; cd "${BUILD_PROPELLER}"
local debug_flag=""
# -gcodeview: see stage_use for why plain -g isn't enough on this MinGW target.
[[ "${BUILD_TYPE}" == "RelWithDebInfo" ]] && debug_flag="-g -gcodeview"
local linker_debug_flag=""
# --pdb= (not -DEBUG/-Wl,-DEBUG, not /DEBUG): the MinGW driver invoked by
# -fuse-ld=lld on this target doesn't recognize -DEBUG at all (separate
# option table from lld-link) -- see stage_use's no-PGO block for the
# full explanation. --pdb is its actual recognized flag; empty value
# means auto-name the PDB per binary (this flag is shared across
# several executables built in this stage).
# --threads=1: combining --pdb= with -flto enables a known LLD COFF
# deadlock between PDB type/symbol-record merge threads and parallel LTO
# backend codegen threads (manifests as three ld.lld processes stuck at
# low CPU % indefinitely). --threads=N is the MinGW driver's own spelling
# of this option (per LLVM D76885); /threads:N is the COFF/lld-link form.
# Unlike order/ignore, "threads" IS in the MinGW driver's option table, so
# it does NOT need -Xlink= wrapping. Slower link, but only RelWithDebInfo.
[[ "${BUILD_TYPE}" == "RelWithDebInfo" ]] && linker_debug_flag="-Wl,--pdb= -Wl,--threads=1"
local bt_upper; bt_upper=$(echo "${BUILD_TYPE}" | tr '[:lower:]' '[:upper:]')
local lto_flag; lto_flag="$(lto_clang_flag)"
local _prop_merged="${PROFILE_DIR}/merged.profdata"
local profdata
[[ -f "${_prop_merged}" ]] && profdata="${_prop_merged}" || profdata="${PROFILE_DIR}/default.profdata"
local pgo_flag
if [[ "${PGO_MODE}" == "ir" ]]; then
pgo_flag="-fprofile-use=\"${profdata}\""
else
pgo_flag="-fprofile-instr-use=\"${profdata}\" -Wno-profile-instr-unprofiled -Wno-profile-instr-out-of-date"
fi
local lto_pgo_flag="${lto_flag:+${lto_flag} }${pgo_flag}"
# -fbasic-block-sections=list=<cc_profile>:
# Compiler reads the Propeller CC profile and splits the listed basic blocks into
# separate COFF sections. lld then orders those sections per the symorder.
# Falls back gracefully if the profile references functions absent in this
# build (e.g. inlined away by LTO) — those entries are silently ignored.
# /order:@<symorder>: COFF/PE lld function placement (same mechanism as BOLT).
# /ignore:4037: suppress LNK4037 for symorder entries absent from the PE.
# -Xlink=<arg>: required wrapper — see stage_bolt for the full explanation.
# The MinGW driver from -fuse-ld=lld doesn't recognize order/ignore in its
# own option table; -Xlink passes them through to the real COFF linker
# (and incidentally sidesteps MSYS2 path-mangling too, since the token
# starts with "-Xlink=" rather than "/").
local propeller_linker_flag=""
if [[ ${have_sym} -eq 1 ]]; then
propeller_linker_flag="-Wl,-Xlink=/order:@${symorder} -Wl,-Xlink=/ignore:4037"
fi
local qt_install_dir="${BUILD_GENERATE}/externals/qt/6.9.3/llvm-mingw_64"
local qt_host_dir="${BUILD_GENERATE}/externals/qt-host/6.9.3/gcc_64"
if [[ "${_HOST_OS}" == "windows" ]]; then
qt_host_dir=""
fi
local qt6_cmake_dir="${qt_install_dir}/lib/cmake/Qt6"
# Pre-build FFmpeg for this build directory
detect_ffmpeg_version
rebuild_ffmpeg_pthread_free "${BUILD_PROPELLER}"
# shellcheck disable=SC2034 # _CMAKE_ARGS used via array expansion below
build_common_cmake_args
_CMAKE_ARGS+=(
"-DCITRON_ENABLE_PGO_USE=ON"
"-DCITRON_PGO_FLAGS_MANAGED_BY_SCRIPT=ON"
"-DCMAKE_C_FLAGS_${bt_upper}=-O3 -DNDEBUG ${debug_flag} ${lto_pgo_flag}"
"-DCMAKE_CXX_FLAGS_${bt_upper}=-O3 -DNDEBUG ${debug_flag} ${lto_pgo_flag}"
"-DCMAKE_EXE_LINKER_FLAGS_${bt_upper}=-O3 -DNDEBUG ${debug_flag} ${lto_pgo_flag}${propeller_linker_flag:+ ${propeller_linker_flag}} ${linker_debug_flag}"
"-DCITRON_PGO_PROFILE_DIR=${PROFILE_DIR}"
)
[[ -n "${qt6_cmake_dir}" ]] && _CMAKE_ARGS+=("-DQt6_DIR=${qt6_cmake_dir}")
[[ -n "${qt_host_dir}" ]] && _CMAKE_ARGS+=("-DQT_HOST_PATH=${qt_host_dir}")
cmake "${SOURCE_DIR}" "${_CMAKE_ARGS[@]}" \
|| error "CMake configure failed"
info "Building Propeller-optimized Windows PE (${BUILD_TYPE})..."
cmake --build . --config "${BUILD_TYPE}" -j "${JOBS}"
deploy_runtime_dlls \
"${BUILD_PROPELLER}/bin" \
"${BUILD_GENERATE}/externals/qt/6.9.3/llvm-mingw_64" \
"${BUILD_PROPELLER}"
# ── Agreement metric: how many symorder functions survived into the PE ──────
local nm_tool
if command -v "llvm-nm-${CLANG_VERSION}" &>/dev/null; then
nm_tool="llvm-nm-${CLANG_VERSION}"
elif command -v llvm-nm &>/dev/null; then
nm_tool="llvm-nm"
else
nm_tool=""
fi
if [[ -n "${nm_tool}" && -f "${symorder}" && -f "${BUILD_PROPELLER}/bin/citron.exe" ]]; then
python3 - "${symorder}" "${BUILD_PROPELLER}/bin/citron.exe" "${nm_tool}" "${LTO_MODE}" << 'PROPELLER_SUMMARY_EOF'
import sys, subprocess, re
symorder_path = sys.argv[1]
exe_path = sys.argv[2]
nm_tool = sys.argv[3]
lto_mode = sys.argv[4] if len(sys.argv) > 4 else "full"
lto_label = {
"full": "Full LTO (-flto)",
"thin": "ThinLTO (-flto=thin)",
"none": "No LTO",
}.get(lto_mode, f"unknown ({lto_mode})")
with open(symorder_path) as f:
# Each line is a mangled function name
hot_syms = set(l.strip() for l in f if l.strip())
result = subprocess.run(
[nm_tool, "--defined-only", "--format=posix", exe_path],
capture_output=True, text=True
)
pe_syms = set()
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) >= 2 and parts[1] in ("T", "t"):
pe_syms.add(parts[0])
matched = hot_syms & pe_syms
missed = hot_syms - pe_syms
total_hot = len(hot_syms)
pct = 100.0 * len(matched) / total_hot if total_hot else 0.0
W = ""
G = ""
Y = ""
C = ""
R = ""
BAR_W = 40
IW = 60
filled = round(BAR_W * len(matched) / total_hot) if total_hot else 0
bar = "█" * filled + "░" * (BAR_W - filled)
def pad(s, w=IW):
return s[:w].ljust(w)
lto_str = f" PE LTO (propeller rebuild) : {lto_label}"
hot_str = f" Hot functions in symorder : {total_hot:>7,}"
match_str = f" Reordered in PE : {len(matched):>7,} ({pct:5.1f}%)"
miss_str = f" Inlined/absent by LTO : {len(missed):>7,} ({100-pct:5.1f}%)"
bar_str = f" [{bar}] {pct:.1f}%"
bar_pad = " " * max(0, IW - len(bar_str))
print()
print(f"{C} ╔════════════════════════════════════════════════════════════╗{R}")
print(f"{C} ║{R}{pad(' Propeller Function Reorder — citron.exe Summary')}{C}║{R}")
print(f"{C} ╠════════════════════════════════════════════════════════════╣{R}")
print(f"{C} ║{R}{W}{pad(lto_str)}{R}{C}║{R}")
print(f"{C} ╠════════════════════════════════════════════════════════════╣{R}")
print(f"{C} ║{R}{W}{pad(hot_str)}{R}{C}║{R}")
print(f"{C} ║{R}{G}{pad(match_str)}{R}{C}║{R}")
print(f"{C} ║{R}{Y}{pad(miss_str)}{R}{C}║{R}")
print(f"{C} ║{R}{pad('')}{C}║{R}")
print(f"{C} ║{R} [{G}{bar}{R}] {G}{pct:.1f}%{R}{bar_pad}{C}║{R}")
print(f"{C} ╚════════════════════════════════════════════════════════════╝{R}")
print()
PROPELLER_SUMMARY_EOF
fi
echo ""
success "════════════════════════════════════════════════════════════════"
success " Stage propeller complete"
success " Final binary: ${BUILD_PROPELLER}/bin/citron.exe"
success " Optimizations applied:"
[[ ${have_sym} -eq 1 ]] && success " Function order: /order:@ (Propeller LD profile — ${symorder##*/})"
local _prop_pgo_label
if [[ -f "${PROFILE_DIR}/merged.profdata" && "${profdata}" == "${PROFILE_DIR}/merged.profdata" ]]; then
_prop_pgo_label="CS-IRPGO (-fprofile-use=merged.profdata)"
elif [[ "${PGO_MODE}" == "ir" ]]; then
_prop_pgo_label="IR PGO (-fprofile-use)"
else
_prop_pgo_label="${PGO_MODE} (-fprofile-instr-use)"
fi
success " PGO: ${_prop_pgo_label}"
success " LTO: $(lto_clang_flag || echo none)"
success "════════════════════════════════════════════════════════════════"
}
stage_clean() {
header "Cleaning Build Directories"
read -rp "This will delete ${BUILD_ROOT}. Are you sure? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { info "Aborted."; exit 0; }
rm -rf "${BUILD_ROOT}"
success "Build directories removed."
}
# =============================================================================
# Argument parsing
# =============================================================================
STAGE=""
while [[ $# -gt 0 ]]; do
case "$1" in
setup|generate|csgenerate|use|build-elf|bolt|propeller|clean)
STAGE="$1"; shift ;;
--source)
SOURCE_DIR="$2"; shift 2 ;;
--build)
BUILD_ROOT="$2"
BUILD_GENERATE="${BUILD_ROOT}/generate"
BUILD_CSGENERATE="${BUILD_ROOT}/cs-generate"
BUILD_USE="${BUILD_ROOT}/use"
BUILD_USE_ELF="${BUILD_ROOT}/use-elf"
BUILD_BOLT="${BUILD_ROOT}/bolt"
BUILD_PROPELLER="${BUILD_ROOT}/propeller"
PROFILE_DIR="${BUILD_ROOT}/pgo-profiles"
BOLT_PROFILE_DIR="${BUILD_ROOT}/bolt-profiles"
PROPELLER_PROFILE_DIR="${BUILD_ROOT}/propeller-profiles"
LLVM_MINGW_DIR="${BUILD_ROOT}/llvm-mingw"
shift 2 ;;
--generate-dir)
BUILD_GENERATE="$2"
shift 2 ;;
--jobs)
JOBS="$2"; shift 2 ;;
--lto)
case "$2" in
thin|full|none) LTO_MODE="$2"; shift 2 ;;
*) echo "[ERROR] --lto requires: thin, full, or none"; exit 1 ;;
esac ;;
--lite-lto)
LTO_MODE="thin"; shift ;;
--no-lto)
LTO_MODE="none"; shift ;;
--pgo-type|--pgo)
case "$2" in
ir|fe|none) PGO_MODE="$2"; shift 2 ;;
*) echo "[ERROR] --pgo-type requires: ir, fe, or none"; exit 1 ;;
esac ;;
--relwithdebinfo)
BUILD_TYPE="RelWithDebInfo"; shift ;;
--unity)
UNITY_BUILD="ON"; shift ;;
--no-unity)
UNITY_BUILD="OFF"; shift ;;
--clang-version)
CLANG_VERSION="$2"
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}"
shift 2 ;;
--llvm-mingw-version)
LLVM_MINGW_VERSION="$2"; shift 2 ;;
--help|-h)
sed -n '/^# USAGE/,/^# ====/p' "$0"
exit 0 ;;
*)
error "Unknown argument: $1\nRun with --help for usage." ;;
esac
done
[[ -n "$STAGE" ]] || error "No stage specified. Run with --help for usage."
case "$STAGE" in
setup) stage_setup ;;
generate) stage_generate ;;
csgenerate) stage_csgenerate ;;
use) stage_use ;;
build-elf) stage_build_elf ;;
bolt) stage_bolt ;;
propeller) stage_propeller ;;
clean) stage_clean ;;
esac