cmake_minimum_required(VERSION 3.20)

# AeroGauge: Recompiled — a static-recompilation PC port built on
# N64ModernRuntime (ultramodern + librecomp) with the RT64 renderer.
# Generated ROM code and hand-written port code are built as separate targets.
#
# See BUILDING.md for full instructions. In short, from the repo root:
#
#   # 1. Recompile the game code from YOUR ROM (produces RecompiledFuncs/):
#   python3 scripts/gen_syms_toml.py
#   ./build/lib/N64ModernRuntime/librecomp/N64Recomp/N64Recomp aerogauge.us.toml
#
#   # 2. Configure + build (Linux example; MinGW/Windows details in BUILDING.md):
#   cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
#   cmake --build build --target aerogauge_modern -j
#
# Run from the repo root: the ROM path in aerogauge.us.toml is cwd-relative.
# The RT64/plume MinGW patches (patches/0004+0005) are applied to the submodule
# working tree on Windows; DirectX-Headers v1.614.1 is fetched at configure time.
# RecompiledFuncs/ is gitignored — it is regenerated from your ROM (step 1).

project(aerogauge_modern LANGUAGES C CXX)

set(CMAKE_C_STANDARD 17)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

# ARM64 FMA would round translated a*b+c once, unlike the x64 baseline.
# Keep the port, both regions, RT64, runtime, frontend, and host tests under
# the same rounding policy. Directory options propagate into dependencies.
set(AERO_FP_COMPILE_OPTIONS)
if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND NOT ANDROID
   AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(aarch64|arm64|ARM64)$")
    list(APPEND AERO_FP_COMPILE_OPTIONS -ffp-contract=off)
endif()
add_compile_options(${AERO_FP_COMPILE_OPTIONS})

option(AERO_HOST_TESTS_ONLY "Build dependency-free host tests without ROMs or submodules" OFF)

# The logging policy lives in its own static library. The port's source files
# call into it, and the host tests compile some of those files directly, so
# both need the implementation. It is defined before the host-test-only early
# return so both build modes see the same target.
add_library(aero_log STATIC src/aero_log.cpp)
target_include_directories(aero_log PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src)
set_target_properties(aero_log PROPERTIES POSITION_INDEPENDENT_CODE ON)

if(AERO_HOST_TESTS_ONLY)
    enable_testing()
    include(cmake/HostTests.cmake)
    return()
endif()

set(N64MR ${CMAKE_CURRENT_SOURCE_DIR}/lib/N64ModernRuntime)
if(ANDROID)
    include(cmake/Android.cmake)
endif()

# std::thread, std::mutex and std::condition_variable are used by the port itself
# (src/main.cpp, src/aero_config.cpp), not only by dependencies. Resolve the
# thread library once here and link it explicitly below rather than relying on a
# transitive pthread from a subproject.
set(THREADS_PREFER_PTHREAD_FLAG ON)
find_package(Threads REQUIRED)

# --- RT64 renderer (default presenter; hard build dependency) ---
# lib/rt64 is a pinned git submodule; RT64 is always compiled in and is the
# default presenter at runtime (AERO_HEADLESS=1 selects the headless software
# renderer used by framebuffer tests). The target setup follows RT64's public
# CMake interface and this repository's generated-code boundary.
if(NOT EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/lib/rt64/CMakeLists.txt)
    message(FATAL_ERROR "lib/rt64 submodule is missing. Initialise it first:\n"
        "  git submodule update --init --recursive")
endif()
set(RT64_STATIC TRUE)
if(WIN32)
    # Windows: RT64 uses its D3D12 backend + a plain Win32 window (HWND handed
    # over via SDL_GetWindowWMInfo in create_window_stub), so no SDL Vulkan
    # surface. SDL2 comes from rt64's own bundled Windows deps — the same
    # tree rt64's CMake points itself at (mupen64plus-win32-deps/SDL2-2.26.3).
    set(SDL2_WIN32_DEPS ${CMAKE_CURRENT_SOURCE_DIR}/lib/rt64/src/contrib/mupen64plus-win32-deps/SDL2-2.26.3)
    set(SDL2_INCLUDE_DIRS ${SDL2_WIN32_DEPS}/include)
    set(SDL2_LIBRARIES SDL2 SDL2main)
    link_directories(${SDL2_WIN32_DEPS}/lib/x64)
    # MinGW's bundled d3d12.h is years behind the D3D12 SDK. Plume needs
    # D3D12_HEAP_TYPE_GPU_UPLOAD, and D3D12MemAlloc needs <dxguids.h>.
    # Microsoft's DirectX-Headers package is the official fix and supports
    # MinGW; put its d3d12.h AHEAD of the MinGW system header and link the GUID
    # definitions library everywhere.
    include(FetchContent)
    FetchContent_Declare(DirectX-Headers
        GIT_REPOSITORY https://github.com/microsoft/DirectX-Headers
        GIT_TAG v1.614.1
        GIT_SHALLOW TRUE)
    FetchContent_MakeAvailable(DirectX-Headers)
    include_directories(BEFORE
        ${directx-headers_SOURCE_DIR}/include
        ${directx-headers_SOURCE_DIR}/include/directx
        ${directx-headers_SOURCE_DIR}/include/dxguids)
else()
    set(RT64_SDL_WINDOW_VULKAN TRUE) # non-Windows: RT64 expects an SDL window + Vulkan surface
    # rt64's vendored nativefiledialog-extended defaults to the GTK3 backend;
    # prefer the xdg-desktop-portal backend (dbus-1) on boxes without libgtk-3-dev.
    set(NFD_PORTAL ON CACHE BOOL "" FORCE)
endif()
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/lib/rt64 ${CMAKE_BINARY_DIR}/rt64)
# RT64 generates shader headers in its binary directory.
target_include_directories(rt64 PRIVATE ${CMAKE_BINARY_DIR}/rt64/src)
if(WIN32)
    # The shipped Windows application is a GUI-subsystem executable that owns
    # its own WinMain (src/main.cpp). SDL2main supplies a conflicting entry
    # point and renames main() to SDL_main(), so it must come off the link
    # line. RT64's bundled CMake adds SDL2main to its own link list; remove
    # only that transitive entry-point library while keeping SDL2 itself for
    # the renderer and input backends.
    if(TARGET rt64)
        get_target_property(RT64_LINK_LIBRARIES rt64 LINK_LIBRARIES)
        list(REMOVE_ITEM RT64_LINK_LIBRARIES SDL2main)
        set_target_properties(rt64 PROPERTIES LINK_LIBRARIES "${RT64_LINK_LIBRARIES}")
        get_target_property(RT64_INTERFACE_LINK_LIBRARIES rt64 INTERFACE_LINK_LIBRARIES)
        list(REMOVE_ITEM RT64_INTERFACE_LINK_LIBRARIES SDL2main)
        set_target_properties(rt64 PROPERTIES
            INTERFACE_LINK_LIBRARIES "${RT64_INTERFACE_LINK_LIBRARIES}")
    endif()
endif()
if(WIN32)
    # GUID definitions for GCC's __uuidof emulation (see the DirectX-Headers
    # block above). Scoped per-target: a global link_libraries() leaks into
    # zstd's install-export set and breaks the CMake generate step.
    target_link_libraries(plume PRIVATE DirectX-Guids)
    # dwmapi (imgui_impl_win32) + d3dcompiler (imgui_impl_dx12's D3DCompile):
    # both are pulled in via #pragma comment(lib,...) which only MSVC honours;
    # MinGW needs the explicit link.
    target_link_libraries(rt64 DirectX-Guids dwmapi d3dcompiler)
    if(NOT MSVC)
        # MinGW: force-include the __mingw_uuidof compat header into every C++
        # TU of the D3D12-touching targets (why: see the header itself).
        set(DX12_UUID_COMPAT ${CMAKE_CURRENT_SOURCE_DIR}/src/mingw_dx12_uuid_compat.h)
        foreach(dx12_target plume rt64)
            target_compile_options(${dx12_target} PRIVATE
                $<$<COMPILE_LANGUAGE:CXX>:-include${DX12_UUID_COMPAT}>)
        endforeach()
    endif()
endif()
if(NOT WIN32)
    find_package(SDL2 REQUIRED)
endif()

# ultramodern + librecomp, which cascades N64Recomp / LiveRecomp / miniz.
# The runtime root CMake (lib/N64ModernRuntime/CMakeLists.txt) deliberately
# omits RT64 — it is pulled in only by a game's own CMake.
add_subdirectory(${N64MR})

# --- RecompiledFuncs: the whole-ROM recompiled image as a static library -----
# The generated funcs #include "recomp.h" (N64Recomp/include), "funcs.h"
# (resolved relative to each TU in RecompiledFuncs/), and lookup.cpp is picked
# up by the *.cpp glob. recomp_overlays.inl is an include (NOT a TU) and is
# intentionally excluded by the *.c/*.cpp globs.
#
# Skipped when empty so the first configure (which builds the recompiler
# tools) succeeds before N64Recomp populates this directory.
file(GLOB FUNC_C_SOURCES   CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/RecompiledFuncs/*.c)
file(GLOB FUNC_CXX_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/RecompiledFuncs/*.cpp)
if(FUNC_C_SOURCES OR FUNC_CXX_SOURCES)
    add_library(RecompiledFuncs STATIC)
    target_sources(RecompiledFuncs PRIVATE ${FUNC_C_SOURCES} ${FUNC_CXX_SOURCES})

    target_compile_options(RecompiledFuncs PRIVATE
        -fno-strict-aliasing
        # Runtime mod replacement writes an architecture-sized jump at each
        # function entry. Keep room at aligned, distinct function addresses.
        -fpatchable-function-entry=16
        -falign-functions=16
        $<$<C_COMPILER_ID:GNU>:-fno-ipa-icf>
        -ggdb
        -Wno-unused-variable
        # implicit-function-declaration is a C-only diagnostic; lookup.cpp is C++.
        $<$<COMPILE_LANGUAGE:C>:-Wno-implicit-function-declaration>
    )

    target_include_directories(RecompiledFuncs PRIVATE
        ${N64MR}/ultramodern/include
        ${N64MR}/librecomp/include
        ${N64MR}/N64Recomp/include
    )
endif()

# --- Game executable: added once src/ exists ---------------------------------
# Japanese code has distinct function names and its own overlay table. SDK and
# native replacements remain shared. The generated lookup.cpp is USA-only.
file(GLOB JP_FUNC_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/RecompiledFuncsJP/*.c)
set(AERO_HAS_JAPAN 0)
if(JP_FUNC_SOURCES)
    set(AERO_HAS_JAPAN 1)
    add_library(RecompiledFuncsJP STATIC ${JP_FUNC_SOURCES})
    target_compile_definitions(RecompiledFuncsJP PRIVATE recomp_entrypoint=recomp_entrypoint_jp)
    target_compile_options(RecompiledFuncsJP PRIVATE
        -fno-strict-aliasing -ggdb
        -fpatchable-function-entry=16 -falign-functions=16
        $<$<C_COMPILER_ID:GNU>:-fno-ipa-icf>
        -Wno-unused-variable -Wno-implicit-function-declaration)
    target_include_directories(RecompiledFuncsJP PRIVATE
        ${N64MR}/ultramodern/include ${N64MR}/librecomp/include ${N64MR}/N64Recomp/include)
endif()
file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/aero_japan_support.txt" "${AERO_HAS_JAPAN}")

if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/src/main.cpp)
    # src/aspMain.cpp is RSPRecomp output (see BUILDING.md step 3); appended
    # only when present so the first configure (which builds the recompilers)
    # can succeed before RSPRecomp runs.
    set(AERO_SOURCES
        src/main.cpp
        src/aero_gyro_sdl.cpp # SDL motion sampling, per-source calibration and host steering
        src/aero_haptics.cpp   # race-event feedback and motor callback publisher
        src/aero_cheats.cpp
        src/aero_cheats_runtime.cpp # game-thread damage and standings hooks
        src/aero_pak.cpp       # persistent block device for the ROM Controller Pak filesystem
        src/aero_startup.cpp   # desktop startup decision: game by default, AERO_LAUNCHER=1 for the launcher
        src/aero_rom_desktop.cpp # validated ROM selection before region-specific runtime setup
        src/aero_race_intro.c  # full-output race fade and scrolling ticker
        src/aero_scene_scissor.c # remove full-screen world overscan clipping
        src/aero_car_lod.c      # optional maximum car detail and distance visibility
        src/register_overlays.cpp
        src/stub_renderer.cpp
        src/aero_audio.cpp     # SDL2 push-audio sink for the AI buffer
        src/aero_config.cpp    # persistent graphics.json -> ultramodern GraphicsConfig
        src/aero_mods.cpp      # runtime package registration and RT64 texture snapshots
        src/aero_menu.cpp      # portable RecompFrontend overlay and SDL input capture
        src/aero_crash.cpp     # native crash backtrace: Win32 EXCEPTION_POINTERS +
                               # POSIX sigaction handler printing KSEG0/RDRAM offset + a 32-entry
                               # function-lookup trace ring. No deps beyond libc + DbgHelp (Win) /
                               # libc backtrace(3) (POSIX).
        src/libultra_stubs.c   # no-op symbols for ignored libultra CP0 helpers (e.g. __osSetSR)
        src/aero_draw_distance.cpp  # native guPerspectiveF: far-clip-plane scale (pop-in fix);
                                    # routed via `[patches] ignored` in aerogauge.us.toml
        src/aero_full_track.cpp     # native course-geometry registrars: full-track rendering
                                    # (large-scale pop-in fix); same `ignored` routing
        src/aero_shadow_depth.cpp   # guarded racer-shadow depth-mode patch at RT64 task boundary
        src/aero_warp.c        # developer warp menu: Ctrl+Shift+1-6 / AERO_WARP race warp;
                               # tick hooked at func_80015FD0 via [[patches.hook]]
        src/aero_hud_widescreen.c  # widescreen 1P-HUD pinning: whole-frame texrect
                                   # retag pass + needle matrix shift, hooked around the
                                   # 2D dispatcher func_80022408 via [[patches.hook]]
        src/aero_savestate.c   # developer save-state: F7/F8 / AERO_STATE_LOAD RDRAM
                               # snapshot; tick hooked one instruction into func_80015C8C
        src/aero_turbo_boost.c # accelerator-only Boost Start + button-operated race Turbo;
                               # hooked after P1's configured controls are mapped
        src/aero_audio_sched.c # scheduler audio-backlog recovery; hooked at the entry of
                               # the ROM's stock __scSchedule via [[patches.hook]]
    )
    if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/src/aspMain.cpp)
        list(APPEND AERO_SOURCES src/aspMain.cpp)
    endif()
    if(TARGET RecompiledFuncsJP)
        list(APPEND AERO_SOURCES src/register_overlays_jp.cpp)
    endif()

    if(ANDROID)
        add_library(aerogauge_modern SHARED ${AERO_SOURCES} src/android/aero_android.cpp)
        set_target_properties(aerogauge_modern PROPERTIES OUTPUT_NAME main)
        target_link_libraries(aerogauge_modern PRIVATE SDL2::SDL2 SDL2main android log aero_log)
        target_compile_definitions(aerogauge_modern PRIVATE PLUME_SDL_VULKAN_ENABLED RT64_SDL_WINDOW_VULKAN)
        add_dependencies(aerogauge_modern aero_vulkan)
    else()
        # The shipped Windows application is a GUI-subsystem executable: an
        # Explorer launch must not open a console window. Its WinMain wrapper
        # still accepts normal command-line arguments, and --console is what
        # allocates a console when the user asks for one.
        if(WIN32)
            add_executable(aerogauge_modern WIN32 ${AERO_SOURCES})
        else()
            add_executable(aerogauge_modern ${AERO_SOURCES})
        endif()
        target_link_libraries(aerogauge_modern PRIVATE nfd)
    endif()
    if(WIN32)
        # We own WinMain for the GUI-subsystem executable, so SDL must not
        # rename main() to SDL_main(). src/main.cpp calls SDL_SetMainReady()
        # before the first SDL call instead.
        target_compile_definitions(aerogauge_modern PRIVATE SDL_MAIN_HANDLED)
    endif()
    find_package(Python3 3.11 COMPONENTS Interpreter REQUIRED)
    set(AERO_MOD_GUARDS ${CMAKE_CURRENT_BINARY_DIR}/generated/aero_mod_patch_guards.inc)
    add_custom_command(OUTPUT ${AERO_MOD_GUARDS}
        COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/scripts/gen_mod_patch_guards.py
            ${CMAKE_CURRENT_SOURCE_DIR}/aerogauge.us.toml
            ${CMAKE_CURRENT_SOURCE_DIR}/aerogauge.syms.toml ${AERO_MOD_GUARDS}
        DEPENDS scripts/gen_mod_patch_guards.py aerogauge.us.toml aerogauge.syms.toml
        VERBATIM)
    target_sources(aerogauge_modern PRIVATE ${AERO_MOD_GUARDS})
    target_include_directories(aerogauge_modern PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/generated)
    add_executable(test_mod_guards tests/test_mod_guards.cpp ${AERO_MOD_GUARDS})
    target_include_directories(test_mod_guards PRIVATE src
        ${CMAKE_CURRENT_BINARY_DIR}/generated ${N64MR}/librecomp/include
        ${N64MR}/N64Recomp/include)
    add_test(NAME mod_guards COMMAND test_mod_guards)
    if(TARGET RecompiledFuncsJP)
        set(AERO_JP_MOD_GUARDS ${CMAKE_CURRENT_BINARY_DIR}/generated/aero_mod_patch_guards_jp.inc)
        add_custom_command(OUTPUT ${AERO_JP_MOD_GUARDS}
            COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/scripts/gen_mod_patch_guards.py
                ${CMAKE_CURRENT_SOURCE_DIR}/aerogauge.jp.toml
                ${CMAKE_CURRENT_SOURCE_DIR}/aerogauge.jp.syms.toml ${AERO_JP_MOD_GUARDS}
            DEPENDS scripts/gen_mod_patch_guards.py aerogauge.jp.toml aerogauge.jp.syms.toml
            VERBATIM)
        target_sources(aerogauge_modern PRIVATE ${AERO_JP_MOD_GUARDS})
        target_sources(test_mod_guards PRIVATE ${AERO_JP_MOD_GUARDS})
        target_compile_definitions(test_mod_guards PRIVATE AERO_JAPAN_SUPPORT)
        add_test(NAME mod_guards_jp COMMAND test_mod_guards jp)
        target_compile_definitions(aerogauge_modern PRIVATE AERO_JAPAN_SUPPORT)
        target_link_libraries(aerogauge_modern PRIVATE RecompiledFuncsJP)
    endif()
    target_compile_options(aerogauge_modern PRIVATE
        -fno-strict-aliasing
        -fms-extensions
        -ggdb
    )
    if(CMAKE_SIZEOF_VOID_P EQUAL 8 AND CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|amd64|AMD64")
        target_compile_options(aerogauge_modern PRIVATE -march=nehalem)
    endif()
    target_include_directories(aerogauge_modern PRIVATE
        ${N64MR}/ultramodern/include
        ${N64MR}/librecomp/include
        ${N64MR}/N64Recomp/include
        ${CMAKE_CURRENT_SOURCE_DIR}/RecompiledFuncs
    )
    target_link_libraries(aerogauge_modern PRIVATE
        librecomp
        ultramodern
        Threads::Threads
    )
    # Host unit tests (pure math/classification, no ROM or runtime needed). Run with
    # `ctest --test-dir build` or execute build/test_hud_shift_scale directly.
    if(NOT ANDROID)
    enable_testing()
    include(cmake/HostTests.cmake)
    add_executable(test_audio_playback tests/test_audio_playback.cpp)
    target_include_directories(test_audio_playback PRIVATE
        ${N64MR}/ultramodern/include ${N64MR}/N64Recomp/include
        ${N64MR}/thirdparty ${N64MR}/thirdparty/concurrentqueue ${SDL2_INCLUDE_DIRS})
    target_link_libraries(test_audio_playback PRIVATE ${SDL2_LIBRARIES} aero_log)
    if(WIN32)
        add_custom_command(TARGET test_audio_playback POST_BUILD
            COMMAND ${CMAKE_COMMAND} -E copy_if_different
                ${SDL2_WIN32_DEPS}/lib/x64/SDL2.dll
                $<TARGET_FILE_DIR:test_audio_playback>)
    endif()
    add_test(NAME audio_playback_buffering COMMAND test_audio_playback)
    # Host master gain: the audio module scales its own copy of the submitted
    # PCM and leaves the guest buffer untouched. Same stubbed device as the
    # playback test, but the stub captures the queued samples.
    add_executable(test_audio_gain tests/test_audio_gain.cpp)
    target_include_directories(test_audio_gain PRIVATE
        ${N64MR}/ultramodern/include ${N64MR}/N64Recomp/include
        ${N64MR}/thirdparty ${N64MR}/thirdparty/concurrentqueue ${SDL2_INCLUDE_DIRS})
    target_link_libraries(test_audio_gain PRIVATE ${SDL2_LIBRARIES} aero_log)
    if(WIN32)
        add_custom_command(TARGET test_audio_gain POST_BUILD
            COMMAND ${CMAKE_COMMAND} -E copy_if_different
                ${SDL2_WIN32_DEPS}/lib/x64/SDL2.dll
                $<TARGET_FILE_DIR:test_audio_gain>)
    endif()
    add_test(NAME audio_gain COMMAND test_audio_gain)
    if(WIN32)
        add_test(NAME audio_intro_playback
            COMMAND powershell -NoProfile -ExecutionPolicy Bypass
                -File ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_audio_intro.ps1
                -Exe $<TARGET_FILE:aerogauge_modern>
                -RepoRoot ${CMAKE_CURRENT_SOURCE_DIR})
        set_tests_properties(audio_intro_playback PROPERTIES
            TIMEOUT 150 LABELS "e2e" SKIP_RETURN_CODE 77)
    endif()
    add_executable(test_accessories tests/test_accessories.cpp src/aero_pak.cpp src/aero_haptics.cpp)
    target_include_directories(test_accessories PRIVATE src ${N64MR}/N64Recomp/include)
    target_link_libraries(test_accessories PRIVATE aero_log)
    add_test(NAME controller_accessories COMMAND test_accessories)
    if(WIN32)
        add_test(NAME haptics_race_e2e
            COMMAND powershell -NoProfile -ExecutionPolicy Bypass
                    -File ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_haptics_e2e.ps1
                    -Exe $<TARGET_FILE:aerogauge_modern>)
        set_tests_properties(haptics_race_e2e PROPERTIES
            TIMEOUT 180 LABELS "e2e" SKIP_RETURN_CODE 77)
    endif()

    # Exercise the actual runtime saving worker and file publication; unrelated
    # PI entrypoints have fail-fast stubs, so this needs neither ROM nor RT64.
    add_executable(test_eeprom_flush tests/test_eeprom_flush.cpp
        ${N64MR}/librecomp/src/pi.cpp ${N64MR}/librecomp/src/files.cpp)
    # librecomp/rsp_vu.hpp includes <sse2neon.h> on ARM64 (x86_64 uses the
    # compiler's own intrinsics header), so this target needs the same
    # thirdparty/sse2neon include that ultramodern's own CMake adds.
    target_include_directories(test_eeprom_flush PRIVATE
        ${N64MR}/N64Recomp/include ${N64MR}/ultramodern/include
        ${N64MR}/librecomp/include ${N64MR}/librecomp/include/librecomp
        ${N64MR}/thirdparty ${N64MR}/thirdparty/concurrentqueue
        ${N64MR}/thirdparty/sse2neon)
    target_compile_options(test_eeprom_flush PRIVATE -ffunction-sections -fdata-sections)
    target_link_options(test_eeprom_flush PRIVATE -Wl,--gc-sections)
    add_test(NAME eeprom_exit_flush COMMAND test_eeprom_flush)
    set_tests_properties(eeprom_exit_flush PROPERTIES TIMEOUT 30)

    # Use production submission code so an omitted runtime patch fails on
    # either host, without requiring a ROM, audio device, or renderer.
    add_executable(test_rsp_submission tests/test_rsp_submission.cpp
        ${N64MR}/ultramodern/src/events.cpp)
    target_include_directories(test_rsp_submission PRIVATE
        ${N64MR}/N64Recomp/include ${N64MR}/ultramodern/include
        ${N64MR}/thirdparty ${N64MR}/thirdparty/concurrentqueue)
    target_compile_options(test_rsp_submission PRIVATE -ffunction-sections -fdata-sections)
    target_link_options(test_rsp_submission PRIVATE -Wl,--gc-sections)
    # MinGW's unwind sections otherwise retain unrelated renderer/thread code.
    set_property(TARGET test_rsp_submission PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE)
    add_test(NAME rsp_task_submission COMMAND test_rsp_submission)
    set_tests_properties(rsp_task_submission PROPERTIES TIMEOUT 10)

    # Use the dependency's real shutdown coordinator, so the coordinated
    # game-thread shutdown contract is checked against the code the port
    # actually calls (issue #114).
    add_executable(test_shutdown_contract tests/test_shutdown_contract.cpp
        ${N64MR}/ultramodern/src/shutdown.cpp)
    target_include_directories(test_shutdown_contract PRIVATE
        ${N64MR}/ultramodern/include)
    add_test(NAME shutdown_contract COMMAND test_shutdown_contract)
    set_tests_properties(shutdown_contract PROPERTIES TIMEOUT 30)

    # Pure host test for the guarded RT64 task-display-list shadow patch.
    add_executable(test_shadow_depth tests/test_shadow_depth.cpp src/aero_shadow_depth.cpp)
    target_include_directories(test_shadow_depth PRIVATE
        ${CMAKE_CURRENT_SOURCE_DIR}/src
        ${N64MR}/ultramodern/include
        ${N64MR}/N64Recomp/include)
    add_test(NAME racer_shadow_depth COMMAND test_shadow_depth)

    if(TARGET RecompiledFuncs)
        # This extractor is part of the required regression suite. A missing or
        # unusable interpreter must fail configure instead of silently omitting
        # controller_pak_rom_filesystem from CTest.
        if(NOT Python3_EXECUTABLE)
            if(WIN32)
                find_program(Python3_EXECUTABLE NAMES py.exe python.exe python3.exe py python3)
            else()
                find_program(Python3_EXECUTABLE NAMES python3 python py)
            endif()
        endif()
        find_package(Python3 COMPONENTS Interpreter REQUIRED)
        add_test(NAME vi_special_features
            COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_vi_features.py
                    --exe $<TARGET_FILE:aerogauge_modern>
                    --repo ${CMAKE_CURRENT_SOURCE_DIR})
        set_tests_properties(vi_special_features PROPERTIES TIMEOUT 100)
        set(SCISSOR_TEST_FUNCS ${CMAKE_CURRENT_BINARY_DIR}/scene_scissor_test_funcs.c)
        add_custom_command(OUTPUT ${SCISSOR_TEST_FUNCS}
            COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/extract_scene_scissor_test.py
                    ${CMAKE_CURRENT_SOURCE_DIR}/RecompiledFuncs ${SCISSOR_TEST_FUNCS}
            DEPENDS tests/extract_scene_scissor_test.py ${FUNC_C_SOURCES}
            VERBATIM)
        add_executable(test_scene_scissor tests/test_scene_scissor.c
            src/aero_scene_scissor.c ${SCISSOR_TEST_FUNCS})
        target_include_directories(test_scene_scissor PRIVATE ${N64MR}/N64Recomp/include)
        target_compile_options(test_scene_scissor PRIVATE -fno-strict-aliasing)
        if(NOT WIN32)
            target_link_libraries(test_scene_scissor PRIVATE m)
        endif()
        add_test(NAME scene_scissor COMMAND test_scene_scissor)
        set(CAR_LOD_TEST_FUNCS ${CMAKE_CURRENT_BINARY_DIR}/car_lod_test_funcs.c)
        add_custom_command(OUTPUT ${CAR_LOD_TEST_FUNCS}
            COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/extract_car_lod_test.py
                    ${CMAKE_CURRENT_SOURCE_DIR}/RecompiledFuncs ${CAR_LOD_TEST_FUNCS}
            DEPENDS tests/extract_car_lod_test.py ${FUNC_C_SOURCES}
            VERBATIM)
        add_executable(test_car_lod tests/test_car_lod.c src/aero_car_lod.c ${CAR_LOD_TEST_FUNCS})
        target_include_directories(test_car_lod PRIVATE ${N64MR}/N64Recomp/include)
        target_compile_options(test_car_lod PRIVATE -fno-strict-aliasing)
        if(NOT WIN32)
            target_link_libraries(test_car_lod PRIVATE m)
        endif()
        add_test(NAME car_lod COMMAND test_car_lod)
        foreach(region IN ITEMS us jp)
            if(region STREQUAL "jp" AND NOT TARGET RecompiledFuncsJP)
                continue()
            endif()
            set(input_funcs "${CMAKE_CURRENT_BINARY_DIR}/multiplayer_input_${region}_funcs.c")
            if(region STREQUAL "jp")
                set(input_source "${CMAKE_CURRENT_SOURCE_DIR}/RecompiledFuncsJP")
                set(input_dependencies ${JP_FUNC_SOURCES})
            else()
                set(input_source "${CMAKE_CURRENT_SOURCE_DIR}/RecompiledFuncs")
                set(input_dependencies ${FUNC_C_SOURCES})
            endif()
            add_custom_command(OUTPUT ${input_funcs}
                COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/extract_multiplayer_input_test.py
                        ${input_source} ${input_funcs} ${region}
                DEPENDS tests/extract_multiplayer_input_test.py ${input_dependencies}
                VERBATIM)
            add_executable(test_multiplayer_rom_input_${region} tests/test_multiplayer_rom_input.c ${input_funcs})
            target_include_directories(test_multiplayer_rom_input_${region} PRIVATE src ${N64MR}/N64Recomp/include)
            target_compile_options(test_multiplayer_rom_input_${region} PRIVATE -fno-strict-aliasing)
            if(region STREQUAL "jp")
                target_compile_definitions(test_multiplayer_rom_input_${region} PRIVATE AERO_JAPAN_SUPPORT)
            endif()
            if(NOT WIN32)
                target_link_libraries(test_multiplayer_rom_input_${region} PRIVATE m)
            endif()
            add_test(NAME multiplayer_rom_input_${region} COMMAND test_multiplayer_rom_input_${region})
        endforeach()
        if(TARGET RecompiledFuncsJP)
            foreach(boundary IN ITEMS car_lod scene_scissor)
                set(jp_test_funcs "${CMAKE_CURRENT_BINARY_DIR}/${boundary}_jp_test_funcs.c")
                add_custom_command(OUTPUT ${jp_test_funcs}
                    COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/extract_${boundary}_test.py
                            ${CMAKE_CURRENT_SOURCE_DIR}/RecompiledFuncsJP ${jp_test_funcs} jp
                    DEPENDS tests/extract_${boundary}_test.py ${JP_FUNC_SOURCES}
                    VERBATIM)
                add_executable(test_${boundary}_jp tests/test_${boundary}.c
                    src/aero_${boundary}.c ${jp_test_funcs})
                target_compile_definitions(test_${boundary}_jp PRIVATE AERO_JAPAN_SUPPORT)
                target_include_directories(test_${boundary}_jp PRIVATE ${N64MR}/N64Recomp/include)
                target_compile_options(test_${boundary}_jp PRIVATE -fno-strict-aliasing)
                if(NOT WIN32)
                    target_link_libraries(test_${boundary}_jp PRIVATE m)
                endif()
                add_test(NAME ${boundary}_jp COMMAND test_${boundary}_jp)
            endforeach()
        endif()
        set(PFS_TEST_FUNCS ${CMAKE_CURRENT_BINARY_DIR}/pfs_test_funcs.c)
        add_custom_command(OUTPUT ${PFS_TEST_FUNCS}
            COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/extract_pfs_test.py
                    ${CMAKE_CURRENT_SOURCE_DIR}/RecompiledFuncs ${PFS_TEST_FUNCS}
            DEPENDS tests/extract_pfs_test.py ${FUNC_C_SOURCES})
        add_executable(test_pfs_rom tests/test_pfs_rom.cpp src/aero_pak.cpp ${PFS_TEST_FUNCS})
        target_include_directories(test_pfs_rom PRIVATE src ${N64MR}/N64Recomp/include)
        target_link_libraries(test_pfs_rom PRIVATE aero_log)
        target_compile_options(test_pfs_rom PRIVATE -fno-strict-aliasing)
        add_test(NAME controller_pak_rom_filesystem COMMAND test_pfs_rom
                 "${CMAKE_CURRENT_SOURCE_DIR}/AeroGauge (USA).z64")
        set_tests_properties(controller_pak_rom_filesystem PROPERTIES TIMEOUT 30)
        if(TARGET RecompiledFuncsJP)
            set(JP_PFS_TEST_FUNCS ${CMAKE_CURRENT_BINARY_DIR}/pfs_jp_test_funcs.c)
            add_custom_command(OUTPUT ${JP_PFS_TEST_FUNCS}
                COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/extract_pfs_test.py
                        ${CMAKE_CURRENT_SOURCE_DIR}/RecompiledFuncsJP ${JP_PFS_TEST_FUNCS} jp
                DEPENDS tests/extract_pfs_test.py ${JP_FUNC_SOURCES} VERBATIM)
            add_executable(test_pfs_rom_jp tests/test_pfs_rom.cpp src/aero_pak.cpp ${JP_PFS_TEST_FUNCS})
            target_compile_definitions(test_pfs_rom_jp PRIVATE AERO_JAPAN_SUPPORT)
            target_include_directories(test_pfs_rom_jp PRIVATE src ${N64MR}/N64Recomp/include)
            target_link_libraries(test_pfs_rom_jp PRIVATE aero_log)
            target_compile_options(test_pfs_rom_jp PRIVATE -fno-strict-aliasing)
            add_test(NAME controller_pak_rom_filesystem_jp COMMAND test_pfs_rom_jp
                     "${CMAKE_CURRENT_SOURCE_DIR}/AeroGauge (Japan) (Rev A).z64")
            set_tests_properties(controller_pak_rom_filesystem_jp PROPERTIES TIMEOUT 30)
        endif()
    endif()
    add_executable(test_hud_messages tests/test_hud_messages.c)
    target_include_directories(test_hud_messages PRIVATE
        ${N64MR}/N64Recomp/include
        ${CMAKE_CURRENT_SOURCE_DIR}/lib/rt64/include)
    target_link_libraries(test_hud_messages PRIVATE aero_log)
    add_test(NAME hud_messages COMMAND test_hud_messages)
    add_executable(test_race_intro tests/test_race_intro.c)
    target_include_directories(test_race_intro PRIVATE
        ${N64MR}/N64Recomp/include
        ${CMAKE_CURRENT_SOURCE_DIR}/lib/rt64/include)
    if(NOT WIN32)
        target_link_libraries(test_race_intro PRIVATE m)
    endif()
    add_test(NAME race_intro COMMAND test_race_intro)
    add_executable(test_race_intro_jp tests/test_race_intro.c)
    target_compile_definitions(test_race_intro_jp PRIVATE AERO_JAPAN_SUPPORT)
    target_include_directories(test_race_intro_jp PRIVATE
        ${N64MR}/N64Recomp/include ${CMAKE_CURRENT_SOURCE_DIR}/lib/rt64/include)
    if(NOT WIN32)
        target_link_libraries(test_race_intro_jp PRIVATE m)
    endif()
    add_test(NAME race_intro_jp COMMAND test_race_intro_jp)
    # Native registrars with synthetic course tables and recorded ROM-helper calls.
    add_executable(test_full_track_registration
        tests/test_full_track_registration.cpp src/aero_full_track.cpp)
    target_include_directories(test_full_track_registration PRIVATE
        ${CMAKE_CURRENT_SOURCE_DIR}/src ${N64MR}/N64Recomp/include
        ${N64MR}/ultramodern/include ${N64MR}/thirdparty)
    target_compile_definitions(test_full_track_registration PRIVATE AERO_JAPAN_SUPPORT)
    target_compile_options(test_full_track_registration PRIVATE -fno-strict-aliasing)
    target_link_libraries(test_full_track_registration PRIVATE aero_log)
    add_test(NAME full_track_registration COMMAND test_full_track_registration)
    add_test(NAME full_track_registration_jp COMMAND test_full_track_registration jp)

    # Semantic-control state machine (src/aero_turbo_boost.c). Plain C with a
    # stubbed aero_easy_turbo_enabled(); no ROM needed.
    add_executable(test_turbo_boost tests/test_turbo_boost.cpp src/aero_turbo_boost.c)
    target_include_directories(test_turbo_boost PRIVATE ${N64MR}/N64Recomp/include)
    add_test(NAME turbo_boost_gate COMMAND test_turbo_boost)

    # Scheduler audio-backlog recovery (src/aero_audio_sched.c) against a model
    # of the ROM's __scAppendList/__scSchedule doAudio rules; no ROM needed.
    add_executable(test_audio_sched tests/test_audio_sched.cpp src/aero_audio_sched.c)
    target_include_directories(test_audio_sched PRIVATE ${N64MR}/N64Recomp/include)
    target_link_libraries(test_audio_sched PRIVATE aero_log)
    add_test(NAME audio_sched_backlog COMMAND test_audio_sched)
    if(TARGET RecompiledFuncs)
        foreach(region us jp)
            if(region STREQUAL "jp")
                if(NOT TARGET RecompiledFuncsJP)
                    continue()
                endif()
                set(CHEAT_INPUT_DIR RecompiledFuncsJP)
                set(CHEAT_INPUT_SOURCES ${JP_FUNC_SOURCES})
            else()
                set(CHEAT_INPUT_DIR RecompiledFuncs)
                set(CHEAT_INPUT_SOURCES ${FUNC_C_SOURCES})
            endif()
            set(CHEAT_TEST_FUNCS ${CMAKE_CURRENT_BINARY_DIR}/cheat_test_${region}.c)
            add_custom_command(OUTPUT ${CHEAT_TEST_FUNCS}
                COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/extract_cheat_test.py
                    ${CMAKE_CURRENT_SOURCE_DIR}/${CHEAT_INPUT_DIR} ${CHEAT_TEST_FUNCS} ${region}
                DEPENDS tests/extract_cheat_test.py ${CHEAT_INPUT_SOURCES} VERBATIM)
            add_executable(test_cheats_${region} tests/test_cheats.cpp
                src/aero_cheats.cpp src/aero_cheats_runtime.cpp ${CHEAT_TEST_FUNCS})
            target_include_directories(test_cheats_${region} PRIVATE src ${N64MR}/N64Recomp/include)
            target_compile_definitions(test_cheats_${region} PRIVATE AERO_JAPAN_SUPPORT)
            if(region STREQUAL "jp")
                target_compile_definitions(test_cheats_${region} PRIVATE TEST_JP_ROM)
            endif()
            target_compile_options(test_cheats_${region} PRIVATE -fno-strict-aliasing)
            add_test(NAME cheats_${region} COMMAND test_cheats_${region})
        endforeach()
    endif()
    add_executable(test_multiplayer_assists tests/test_multiplayer_assists.cpp
        src/aero_turbo_boost.c src/aero_haptics.cpp)
    target_include_directories(test_multiplayer_assists PRIVATE src ${N64MR}/N64Recomp/include)
    target_compile_definitions(test_multiplayer_assists PRIVATE AERO_JAPAN_SUPPORT)
    add_test(NAME multiplayer_assists COMMAND test_multiplayer_assists)
    if(WIN32)
        add_test(NAME turbo_boost_e2e
            COMMAND powershell -NoProfile -ExecutionPolicy Bypass
                    -File ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_turbo_boost_e2e.ps1)
        set_tests_properties(turbo_boost_e2e PROPERTIES
            TIMEOUT 180 LABELS "e2e" SKIP_RETURN_CODE 77)
    endif()

    # The native menu delegates all setting changes to these port-owned live
    # configuration helpers. Test the snapshot, atomic enhancement knobs, and
    # graphics.json persistence without needing an SDL window or RT64 device.
    add_executable(test_live_config
        tests/test_live_config.cpp
        src/aero_config.cpp
    )
    target_include_directories(test_live_config PRIVATE
        ${CMAKE_CURRENT_SOURCE_DIR}/src
        ${N64MR}/ultramodern/include
        ${N64MR}/thirdparty
    )
    # aero_config starts a std::thread for the debounced persistence worker.
    target_link_libraries(test_live_config PRIVATE Threads::Threads aero_log)
    add_test(NAME live_config_updates COMMAND test_live_config)

    # Host stick mapping (src/aero_input.h) round-tripped through the runtime's own
    # convert_to_n64_range(), so a changed divisor cannot silently shrink the analog range the
    # ROM's menus and steering are tuned for. Needs no ROM, SDL, or renderer; links the
    # dependency source under test and stubs its unrelated SI entry points.
    add_executable(test_input_scaling tests/test_input_scaling.cpp
        ${N64MR}/ultramodern/src/input.cpp)
    target_include_directories(test_input_scaling PRIVATE
        ${CMAKE_CURRENT_SOURCE_DIR}/src
        ${N64MR}/N64Recomp/include ${N64MR}/ultramodern/include
        ${N64MR}/thirdparty ${N64MR}/thirdparty/concurrentqueue)
    target_compile_options(test_input_scaling PRIVATE -ffunction-sections -fdata-sections)
    target_link_options(test_input_scaling PRIVATE -Wl,--gc-sections)
    if(NOT WIN32)
        target_link_libraries(test_input_scaling PRIVATE m)
    endif()
    add_test(NAME input_stick_scaling COMMAND test_input_scaling)
    set_tests_properties(input_stick_scaling PROPERTIES TIMEOUT 10)

    # Live graphics-configuration handoff (issue #25). Patch 0018 makes the runtime's accessor
    # return a snapshot copy instead of an unlocked reference. This links the dependency's
    # renderer_context.cpp so a return to the reference shape fails the test's compile-time
    # check, and hammers set/get from several threads so a torn copy fails at run time too.
    # Needs neither ROM nor renderer; ThreadSanitizer reports the same defect directly.
    add_executable(test_graphics_config_threadsafe
        tests/test_graphics_config_threadsafe.cpp
        ${N64MR}/ultramodern/src/renderer_context.cpp)
    target_include_directories(test_graphics_config_threadsafe PRIVATE
        ${N64MR}/N64Recomp/include ${N64MR}/ultramodern/include
        ${N64MR}/thirdparty ${N64MR}/thirdparty/concurrentqueue)
    target_compile_options(test_graphics_config_threadsafe PRIVATE -ffunction-sections -fdata-sections)
    target_link_options(test_graphics_config_threadsafe PRIVATE -Wl,--gc-sections)
    target_link_libraries(test_graphics_config_threadsafe PRIVATE Threads::Threads)
    add_test(NAME graphics_config_threadsafe COMMAND test_graphics_config_threadsafe)
    set_tests_properties(graphics_config_threadsafe PROPERTIES TIMEOUT 60)
    if(WIN32)
        # Full-game audio regression for the aspMain "Unhandled jump target" crash
        # (2026-07-17): 60 s headless warp race with music; pre-fix this crashed on the
        # first run. Needs the ROM + built exe; the script self-skips when missing.
        add_test(NAME audio_task_crash COMMAND powershell -NoProfile -ExecutionPolicy Bypass
                 -File ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_audio_task_crash.ps1)
    endif()

    # E2E regression: a default play run must not emit periodic stderr lines from hot
    # threads (the 1 Hz gfx-thread heartbeat / VI-thread fb-swap line caused a visible
    # hitch every second when stderr was a console -- see tests/test_play_logging_quiet.ps1).
    # Runs the real binary ~15 s, so it needs the ROM (same precondition as the build).
    if(WIN32 AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/AeroGauge (USA).z64")
        add_test(NAME play_logging_quiet
                 COMMAND powershell -NoProfile -ExecutionPolicy Bypass
                         -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_play_logging_quiet.ps1"
                         -Exe $<TARGET_FILE:aerogauge_modern>
                         -RepoRoot "${CMAKE_CURRENT_SOURCE_DIR}")
        set_tests_properties(play_logging_quiet PROPERTIES TIMEOUT 180)

        # E2E regression: the coordinated game-thread shutdown must join every
        # guest thread and must not crash. A guest thread that unwinds has to
        # leave the guest message queue it was blocked on, otherwise a later
        # send resumes a thread whose context was already released.
        # See tests/test_coordinated_shutdown.ps1.
        add_test(NAME coordinated_shutdown
                 COMMAND powershell -NoProfile -ExecutionPolicy Bypass
                         -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_coordinated_shutdown.ps1"
                         -Exe $<TARGET_FILE:aerogauge_modern>
                         -RepoRoot "${CMAKE_CURRENT_SOURCE_DIR}")
        set_tests_properties(coordinated_shutdown PROPERTIES TIMEOUT 180)
    endif()

    endif() # Host regression executables are not APK payload.
    if(TARGET RecompiledFuncs)
        target_link_libraries(aerogauge_modern PRIVATE RecompiledFuncs)
    endif()

    # RT64 renderer (default presenter; see the RT64 block above).
    target_sources(aerogauge_modern PRIVATE src/rt64_renderer.cpp)
    include(cmake/Frontend.cmake)
    # rt64's include_directories() are directory-scoped and do not propagate;
    # RT64's include directories are directory-scoped, so repeat the list for
    # the game target.
    set(RT64_DIR ${CMAKE_CURRENT_SOURCE_DIR}/lib/rt64)
    target_include_directories(aerogauge_modern PRIVATE
        ${RT64_DIR}/include
        ${RT64_DIR}/src
        ${RT64_DIR}/src/rhi
        ${RT64_DIR}/src/render
        ${RT64_DIR}/src/contrib
        ${RT64_DIR}/src/contrib/plume
        ${RT64_DIR}/src/contrib/imgui
        ${RT64_DIR}/src/contrib/hlslpp/include
        ${RT64_DIR}/src/contrib/nativefiledialog-extended/src/include
        ${SDL2_INCLUDE_DIRS}
    )
    # We own WinMain for the GUI-subsystem executable; SDL2main supplies its
    # own entry point and would conflict with it. Every other target in this
    # file still links SDL2main and keeps SDL's main() handling.
    set(AERO_SDL_LIBRARIES ${SDL2_LIBRARIES})
    if(WIN32)
        list(REMOVE_ITEM AERO_SDL_LIBRARIES SDL2main)
    endif()
    target_link_libraries(aerogauge_modern PRIVATE rt64 ${AERO_SDL_LIBRARIES} aero_log)

    if(WIN32)
        # Per-Monitor V2 DPI awareness: embed src/app.manifest so the process
        # gets true physical pixel dimensions at >100% display scaling --
        # without it DWM bitmap-stretches the window by the scale factor and a
        # 1600x900 window overflows 1080p screens at 175% scaling. MinGW ld has
        # no /MANIFEST switch, so compile the resource script with windres and
        # link the manifest and application icon.
        find_program(AERO_WINDRES_EXECUTABLE NAMES windres REQUIRED)
        set(AERO_WINDOWS_RESOURCES_OBJ ${CMAKE_CURRENT_BINARY_DIR}/aero_resources.rc.o)
        add_custom_command(OUTPUT ${AERO_WINDOWS_RESOURCES_OBJ}
            COMMAND ${AERO_WINDRES_EXECUTABLE}
                -I ${CMAKE_CURRENT_SOURCE_DIR}/src
                -I ${CMAKE_CURRENT_SOURCE_DIR}/assets
                -O coff -o ${AERO_WINDOWS_RESOURCES_OBJ}
                ${CMAKE_CURRENT_SOURCE_DIR}/src/aero_manifest.rc
            DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/src/aero_manifest.rc
                    ${CMAKE_CURRENT_SOURCE_DIR}/src/app.manifest
                    ${CMAKE_CURRENT_SOURCE_DIR}/assets/aerogauge-icon.ico
            COMMENT "Embedding Windows manifest and application icon")
        target_sources(aerogauge_modern PRIVATE ${AERO_WINDOWS_RESOURCES_OBJ})

        # RecompFrontend resolves the per-user configuration directory.
        target_link_libraries(aerogauge_modern PRIVATE shell32)
        # Native crash backtrace: CaptureStackBackTrace lives in dbghelp.dll.
        # MinGW64 ships the import lib (libdbghelp.a) so an explicit
        # target_link_libraries entry is enough; we don't ship the DLL — the
        # system one is fine.
        target_link_libraries(aerogauge_modern PRIVATE dbghelp)
        # Self-contained exe dir: RT64 dlopen()s dxcompiler/dxil at runtime and SDL2 is
        # a dynamic dep; MinGW's own runtime is linked statically so the exe runs
        # outside an MSYS/MinGW shell.
        target_link_options(aerogauge_modern PRIVATE -static-libgcc -static-libstdc++
            -Wl,-Bstatic,--whole-archive -lwinpthread -Wl,--no-whole-archive,-Bdynamic)
        set(RT64_DXC_BIN ${CMAKE_CURRENT_SOURCE_DIR}/lib/rt64/src/contrib/dxc/bin/x64)
        add_custom_command(TARGET aerogauge_modern POST_BUILD
            COMMAND ${CMAKE_COMMAND} -E copy_if_different
                ${SDL2_WIN32_DEPS}/lib/x64/SDL2.dll
                ${RT64_DXC_BIN}/dxcompiler.dll
                ${RT64_DXC_BIN}/dxil.dll
                $<TARGET_FILE_DIR:aerogauge_modern>)
    endif()
endif()
