# Texas Hold'em recomp — build definition.
#
# Layers (see README.md and PLAN.md "Architecture"):
#   build/gen-pure/      the pure recompilation, generated by tools/emit.py for the exact
#                        oracle only; compiled with relaxed warnings, never edited
#   src/runtime/         guest CPU state, guest memory, ARM C-runtime bindings, frame pump
#   src/framework/       what the game may ask the platform to do: graphics, audio, storage,
#                        controls, device — headers only, one namespace each
#   src/libeapp/         the iPod eApp frameworks implementing them (OpenGLES, Audio, ...)
#   src/game/            the game, hand-decompiled — what the ordinary build compiles
#   src/gamedata/        the manifest of the game's files, verification, first-run install (zlib)
#   src/platform/        paths shared by every platform, then one directory per platform:
#   src/platform/sdl3/   window, audio, input  -> target `holdem`
#   src/platform/null/   no window, no audio   -> target `holdem-headless` (tests, CI)
#
# Everything hand-written is compiled with warnings as errors; see `holdem_strict` below.
# Layers whose directory is still empty are skipped with a message rather than failing, so the
# tree configures at every stage of PLAN.md.

cmake_minimum_required(VERSION 3.21)
# The oldest macOS a release runs on, set before `project()` because that is when it is
# read. 11 is the first that ran on Apple silicon, so one number covers both architectures.
if(APPLE AND NOT CMAKE_OSX_DEPLOYMENT_TARGET)
    set(CMAKE_OSX_DEPLOYMENT_TARGET 11.0 CACHE STRING "The oldest macOS this build runs on")
endif()
project(holdem VERSION 1.0.1 LANGUAGES CXX)
if(APPLE)
    enable_language(OBJCXX)  # the SDL build's menu bar (src/platform/sdl3/macos_menu.mm)
endif()
if(WIN32)
    enable_language(RC)  # the manifest that gives the settings window its controls' modern look
endif()

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

# Two configurations, and which one is the default matters more here than it does in most
# projects, because **this program's renderer is a software rasteriser**: there is no GPU to take
# the per-pixel work, so the optimiser's output *is* the frame rate. Measured over the same
# 2 400-frame run at render scale 4 — 4.9 million fragments a frame — `-O1` took 60.1 ms a frame
# and `-O3` took 35.3, and that is before a line of the rasteriser was changed. A default of `-O1`
# meant everyone who followed the README got the slow one.
#
# Release is therefore the default and is what `cmake -B build` gives you.
if(NOT CMAKE_BUILD_TYPE)
    set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
if(NOT MSVC)
    # Release: optimised, and still carries its symbols and its assertions. `-g` costs nothing at
    # run time and is what makes a profile readable — every measurement above was taken from a
    # build with it. Dropping CMake's default `-DNDEBUG` keeps `assert` live, which was measured
    # to cost nothing at all here (84.8 s against 85.8 s over that same run), so there is no
    # reason to trade the checks away.
    set(CMAKE_CXX_FLAGS_RELEASE "-O3 -g")
    # RelWithDebInfo is the *debugging* configuration in this project, and -O1 is deliberate: it
    # keeps the generated code steppable — which is how a recompiled function is compared against
    # the emulator instruction by instruction — while still folding the obvious redundancy that
    # makes -O0 unusably slow. Ask for it by name when that is what you are doing.
    set(CMAKE_CXX_FLAGS_RELWITHDEBINFO "-O1 -g")
endif()

option(HOLDEM_SANITIZE "Build with AddressSanitizer and UndefinedBehaviorSanitizer" OFF)
# The console memory model (src/runtime/memory.cpp): four allocated regions rather than one big
# mapping. On by default where there is no mmap; this builds it on a desktop as well, so that the
# oracle can be run against the model the console uses.
option(HOLDEM_REGION_MEMORY "Use the console's guest-memory model on this host too" OFF)
option(HOLDEM_SDL3 "Build the SDL3 desktop target (needs sdl3 via pkg-config)" ON)
# Where tools/emit.py wrote the recompiled code, which the build compiles alongside whatever
# has been hand-decompiled into src/game/. The two never overlap: emit.py leaves out every
# address listed in src/game/replaced.txt, so a decompiled function replaces its recompiled
# twin rather than colliding with it, and the build shrinks on the generated side as src/game/
# grows. When src/game/ is finally the whole game this can go back to empty, which is where the
# Mini Golf recomp ended up.
set(HOLDEM_GEN_DIR "${CMAKE_CURRENT_SOURCE_DIR}/gen/src"
    CACHE PATH "Directory of generated sources from tools/emit.py")

# ---------------------------------------------------------------------------------------------
# Compiler settings shared by every target
# ---------------------------------------------------------------------------------------------

# Common: the project never throws and never uses RTTI (the guest code cannot, and neither can
# every target platform cheaply). The three compiler families spell all of this differently, so
# each option set is chosen once here rather than repeated per target.
# The software rasteriser draws a frame on several threads (src/libeapp/gles.cpp). Every target
# needs the platform's thread library, and on a single-core host the pool simply never starts.
set(THREADS_PREFER_PTHREAD_FLAG ON)
find_package(Threads REQUIRED)

# The shared core: code that belongs to no single title, compiled once from
# `common/` rather than copied into this tree. See ../common/README.md.
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../common ${CMAKE_CURRENT_BINARY_DIR}/common)
# The shared CMake: the macOS .app, and whatever else every title shapes the same way.
include(${CMAKE_CURRENT_SOURCE_DIR}/../common/cmake/IpodMacBundle.cmake)

add_library(holdem_common INTERFACE)
if(MSVC)
    target_compile_options(holdem_common INTERFACE /EHs-c- /GR- /utf-8)
    target_compile_definitions(holdem_common INTERFACE _CRT_SECURE_NO_WARNINGS)
else()
    target_compile_options(holdem_common INTERFACE -fno-exceptions -fno-rtti)
endif()
if(MINGW)
    # MinGW's printf is Microsoft's old C runtime's by default, which has no %zu and whose
    # format checks say so under -Werror; this asks for MinGW's own, which is C99.
    target_compile_definitions(holdem_common INTERFACE __USE_MINGW_ANSI_STDIO=1)
endif()
target_link_libraries(holdem_common INTERFACE Threads::Threads)
target_link_libraries(holdem_common INTERFACE ipod_core)
target_include_directories(holdem_common INTERFACE
    ${CMAKE_CURRENT_SOURCE_DIR}/src
    ${CMAKE_CURRENT_SOURCE_DIR}/src/libeapp/include)
if(HOLDEM_GEN_DIR)
    target_include_directories(holdem_common INTERFACE ${HOLDEM_GEN_DIR})
endif()
if(HOLDEM_REGION_MEMORY)
    target_compile_definitions(holdem_common INTERFACE HOLDEM_REGION_MEMORY)
endif()
if(HOLDEM_SANITIZE)
    if(MSVC)
        target_compile_options(holdem_common INTERFACE /fsanitize=address)
    else()
        target_compile_options(holdem_common INTERFACE -fsanitize=address,undefined
                               -fno-omit-frame-pointer)
        target_link_options(holdem_common INTERFACE -fsanitize=address,undefined)
    endif()
endif()

# Strict: hand-written code only. Warnings are errors.
add_library(holdem_strict INTERFACE)
target_link_libraries(holdem_strict INTERFACE holdem_common)
if(MSVC)
    target_compile_options(holdem_strict INTERFACE /W4 /WX)
else()
    target_compile_options(holdem_strict INTERFACE
        -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Wshadow -Werror)
endif()

# Generated: the recompiled tree is mechanical output. Unused labels and always-true
# conditions are expected
# there; real errors (implicit declarations, bad types) still fail the build.
add_library(holdem_generated INTERFACE)
target_link_libraries(holdem_generated INTERFACE holdem_common)
if(MSVC)
    target_compile_options(holdem_generated INTERFACE /W3 /WX)
else()
    target_compile_options(holdem_generated INTERFACE
        -Wall -Werror -Wno-unused-label -Wno-unused-variable -Wno-unused-but-set-variable)
endif()

# ---------------------------------------------------------------------------------------------
# Source discovery. CONFIGURE_DEPENDS re-globs on every build so new files are picked up.
# ---------------------------------------------------------------------------------------------

if(HOLDEM_GEN_DIR)
    file(GLOB GEN_SOURCES  CONFIGURE_DEPENDS ${HOLDEM_GEN_DIR}/*.cpp)
    if(NOT GEN_SOURCES)
        # Say it here rather than let the link fail. Without the generated half, everything
        # compiles and the link ends in a page of undefined symbols that names the symptom and
        # not the cause. The generated code is not in the repository on purpose: it is a
        # translation of the game's own binary. See README.md ("Building").
        message(FATAL_ERROR
            "no generated sources in ${HOLDEM_GEN_DIR}.\n"
            "This title compiles machine-translated code that is not kept in the repository. "
            "Write it first, from your own copy of the game:\n"
            "    python3 tools/funcs.py --image PATH/TO/Executables/<the game>.bin\n"
            "    python3 tools/emit.py\n"
            "See README.md, \"Building\".")
    endif()
endif()
file(GLOB RUNTIME_SOURCES  CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/*.cpp)
file(GLOB LIBEAPP_SOURCES  CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/src/libeapp/*.cpp)
if(NOT GEN_SOURCES)
    # The ARM calling convention is only how *recompiled* code reaches the frameworks; a
    # hand-decompiled function calls the typed entry points in src/framework/ directly. So the
    # adapter is compiled exactly while there is generated code left to need it.
    list(FILTER LIBEAPP_SOURCES EXCLUDE REGEX "/arm_abi\\.cpp$")
endif()
file(GLOB GAME_SOURCES     CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/src/game/*.cpp)
file(GLOB GAMEDATA_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/src/gamedata/*.cpp)
file(GLOB PLATFORM_COMMON  CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/src/platform/*.cpp)
file(GLOB SDL3_SOURCES     CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/src/platform/sdl3/*.cpp)
# …and the shared core's SDL code, compiled into this title's window build alongside it.
list(APPEND SDL3_SOURCES ${IPOD_CORE_SDL3_SOURCES})
if(APPLE)
    file(GLOB SDL3_OBJC_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/src/platform/sdl3/*.mm)
    list(APPEND SDL3_SOURCES ${SDL3_OBJC_SOURCES})
endif()
if(WIN32)
    list(APPEND SDL3_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/platform/sdl3/win32_settings.rc)
endif()
file(GLOB NULL_SOURCES     CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/src/platform/null/*.cpp)
file(GLOB UNIT_TEST_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/tests/unit/*.cpp)

# main.cpp is the program entry shared by both executables; everything else in src/runtime is
# the runtime library.
list(FILTER RUNTIME_SOURCES EXCLUDE REGEX "/main\\.cpp$")
set(MAIN_SOURCE ${CMAKE_CURRENT_SOURCE_DIR}/src/runtime/main.cpp)

# ---------------------------------------------------------------------------------------------
# Libraries
# ---------------------------------------------------------------------------------------------

set(HOLDEM_CORE_LIBS "")

if(RUNTIME_SOURCES)
    add_library(holdem_runtime STATIC ${RUNTIME_SOURCES})
    target_link_libraries(holdem_runtime PUBLIC holdem_strict)
    list(APPEND HOLDEM_CORE_LIBS holdem_runtime)
else()
    message(STATUS "src/runtime/ has no sources yet — runtime library skipped")
endif()

if(LIBEAPP_SOURCES)
    add_library(holdem_eapp STATIC ${LIBEAPP_SOURCES})
    target_link_libraries(holdem_eapp PUBLIC holdem_strict)
    list(APPEND HOLDEM_CORE_LIBS holdem_eapp)
else()
    message(STATUS "src/libeapp/ has no sources yet — eapp library skipped")
endif()

if(GEN_SOURCES)
    add_library(holdem_gen STATIC ${GEN_SOURCES})
    target_link_libraries(holdem_gen PUBLIC holdem_generated)
    list(APPEND HOLDEM_CORE_LIBS holdem_gen)
endif()

if(GAMEDATA_SOURCES)
    find_package(ZLIB REQUIRED)  # inflating the game's zip, and the CRC-32 of every file
    add_library(holdem_gamedata STATIC ${GAMEDATA_SOURCES})
    target_link_libraries(holdem_gamedata PUBLIC holdem_strict ZLIB::ZLIB)
    list(APPEND HOLDEM_CORE_LIBS holdem_gamedata)
endif()

if(PLATFORM_COMMON)
    # The portable half of the platform layer: where saves go, the key bindings, the settings
    # file, the .wav decoder. Every platform links it; none of it is host-specific.
    add_library(holdem_platform STATIC ${PLATFORM_COMMON})
    target_link_libraries(holdem_platform PUBLIC holdem_strict)
    list(APPEND HOLDEM_CORE_LIBS holdem_platform)
endif()

if(GAME_SOURCES)
    add_library(holdem_game STATIC ${GAME_SOURCES})
    target_link_libraries(holdem_game PUBLIC holdem_strict)
    list(APPEND HOLDEM_CORE_LIBS holdem_game)
endif()

# ---------------------------------------------------------------------------------------------
# Executables
# ---------------------------------------------------------------------------------------------

# Link a program against the hand-written libraries and the shared core. The static libraries
# reference each other in both directions — gen calls libeapp, libeapp calls back into the
# runtime, and the shared core's rasteriser calls this title's call log — which Apple's linker
# and MSVC's resolve in one pass. A GNU-style linker scans each archive once, in order, so those
# go between --start-group and --end-group, which has it rescan them until nothing is left
# unresolved. The core is named by file there rather than by target: as a target it is already
# an interface dependency of every library, and CMake would move it out of the group.
function(holdem_link_core NAME)
    if(APPLE OR MSVC)
        target_link_libraries(${NAME} PRIVATE holdem_strict ${HOLDEM_CORE_LIBS})
    else()
        target_link_libraries(${NAME} PRIVATE holdem_strict -Wl,--start-group
                              ${HOLDEM_CORE_LIBS} $<TARGET_FILE:ipod_core> -Wl,--end-group)
    endif()
endfunction()

function(holdem_add_executable NAME PLATFORM_SOURCES)
    if(NOT EXISTS ${MAIN_SOURCE} OR NOT HOLDEM_CORE_LIBS)
        message(STATUS "${NAME}: runtime not written yet — target skipped")
        return()
    endif()
    add_executable(${NAME} ${MAIN_SOURCE} ${PLATFORM_SOURCES})
    holdem_link_core(${NAME})
endfunction()

if(NULL_SOURCES)
    holdem_add_executable(holdem-headless "${NULL_SOURCES}")
else()
    message(STATUS "src/platform/null/ has no sources yet — holdem-headless skipped")
endif()

if(HOLDEM_SDL3 AND SDL3_SOURCES)
    # SDL3 ships a CMake package; fall back to pkg-config for distributions that do not install
    # it (and so that a Windows or console toolchain, which has no pkg-config, still configures).
    find_package(SDL3 QUIET CONFIG)
    if(TARGET SDL3::SDL3)
        set(HOLDEM_SDL3_TARGET SDL3::SDL3)
    else()
        find_package(PkgConfig REQUIRED)
        pkg_check_modules(SDL3 REQUIRED IMPORTED_TARGET sdl3)
        set(HOLDEM_SDL3_TARGET PkgConfig::SDL3)
    endif()
    holdem_add_executable(holdem "${SDL3_SOURCES}")
    if(TARGET holdem)
        target_link_libraries(holdem PRIVATE ${HOLDEM_SDL3_TARGET})
        if(APPLE)
            # Cocoa for the settings window; AudioToolbox to decode the AAC music, which SDL does
            # not (common/src/ipod/platform/sdl3/music_decoder.cpp). Both are system
            # frameworks.
            target_link_libraries(holdem PRIVATE "-framework Cocoa" "-framework AudioToolbox")
            # A double-clickable .app carrying its own SDL, rather than a bare executable
            # linked against whatever SDL this machine happens to have.
            # The SDL it linked against is named for the bundle, because a framework's
            # install name says nothing about where the framework came from and there
            # would be nothing for the bundling step to go and find. A pkg-config SDL is
            # a plain dylib and is found the ordinary way, so it is not named here.
            set(MAC_CARRY "")
            if(TARGET SDL3::SDL3)
                set(MAC_CARRY "$<TARGET_FILE:SDL3::SDL3>")
            endif()
            ipod_macos_app_bundle(holdem "Texas Hold'em" "org.ipodrecomp.texasholdem"
                                  ${PROJECT_VERSION} ${MAC_CARRY})
        elseif(WIN32)
            # The common controls and the visual-styles API, for the settings window
            # (src/platform/sdl3/win32_settings.cpp); Media Foundation and COM to decode the
            # AAC music (music_decoder.cpp). All of them ship with Windows.
            target_link_libraries(holdem PRIVATE comctl32 uxtheme mfplat mfreadwrite mfuuid
                                  ole32)
            # A game, not a command-line tool: no console window behind the game's own. Its
            # messages still reach a terminal that started it, and anything fatal is a message
            # box (common/src/ipod/platform/windows_console.h). The headless build is
            # deliberately not this: its output is the whole point of it.
            set_target_properties(holdem PROPERTIES WIN32_EXECUTABLE TRUE)
        endif()
    endif()
elseif(HOLDEM_SDL3)
    message(STATUS "src/platform/sdl3/ has no sources yet — holdem skipped")
endif()

# ---------------------------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------------------------

enable_testing()

# Each tests/unit/*.cpp is a stand-alone program that exits non-zero on failure.
if(TRUE)
    foreach(TEST_SOURCE ${UNIT_TEST_SOURCES})
        get_filename_component(TEST_NAME ${TEST_SOURCE} NAME_WE)
        add_executable(unit_${TEST_NAME} ${TEST_SOURCE})
        holdem_link_core(unit_${TEST_NAME})
        add_test(NAME unit_${TEST_NAME} COMMAND unit_${TEST_NAME})
    endforeach()
endif()

# Oracle tests: one per recorded call log. tests/diff.sh compares the headless recomp's
# framework-call log with the emulator's recording of the same input script.
file(GLOB EXPECTED_LOGS CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/tests/expected/*.calls)
if(TARGET holdem-headless AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/tests/diff.sh)
    foreach(LOG ${EXPECTED_LOGS})
        get_filename_component(CASE ${LOG} NAME_WE)
        add_test(NAME oracle_${CASE}
                 COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/tests/diff.sh ${CASE}
                         $<TARGET_FILE:holdem-headless>
                 WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
        # An oracle case needs the player's own copy of the game. Without it diff.sh exits 2,
        # which is a skip rather than a failure: someone who has the source but not the game
        # should still see the unit tests pass.
        set_tests_properties(oracle_${CASE} PROPERTIES SKIP_RETURN_CODE 2)

        # The same case compared word for word — every logged register and every stack word
        # behind it, leftovers included. Only register-for-register code can pass that, which is
        # what this build still is: `src/game/` is empty, so `holdem-headless` *is* the pure
        # recompilation. The first hand-decompiled function will start failing these, and at that
        # point they move to a build made from an unmodified emit.py run, the way the Mini Golf
        # recomp's tests/check-recomp.sh does it.
        add_test(NAME exact_${CASE}
                 COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/tests/diff.sh ${CASE}
                         $<TARGET_FILE:holdem-headless> --exact
                 WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
        set_tests_properties(exact_${CASE} PROPERTIES SKIP_RETURN_CODE 2)
    endforeach()
endif()

# The picture oracle: the same script through the recomp and the pinned emulator, compared frame
# by frame. One test per script that takes a screenshot. It needs the emulator built (see
# reference/MANIFEST.md), and skips rather than fails when it is not there.
if(TARGET holdem-headless AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/tests/frames.sh)
    file(GLOB FRAME_SCRIPTS CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/tests/scripts/*.script)
    foreach(SCRIPT ${FRAME_SCRIPTS})
        file(STRINGS ${SCRIPT} TAKES_SHOT REGEX "^[0-9]+: *shot")
        if(TAKES_SHOT)
            get_filename_component(CASE ${SCRIPT} NAME_WE)
            add_test(NAME frames_${CASE}
                     COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/tests/frames.sh ${CASE}
                             $<TARGET_FILE:holdem-headless>
                     WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
            set_tests_properties(frames_${CASE} PROPERTIES SKIP_RETURN_CODE 2)
        endif()
    endforeach()
endif()

# ---------------------------------------------------------------------------------------------
# Formatting
# ---------------------------------------------------------------------------------------------

find_program(CLANG_FORMAT NAMES clang-format HINTS /opt/homebrew/opt/llvm/bin)
if(CLANG_FORMAT)
    file(GLOB_RECURSE FORMAT_SOURCES CONFIGURE_DEPENDS
         ${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/*.h
         ${CMAKE_CURRENT_SOURCE_DIR}/src/*.mm ${CMAKE_CURRENT_SOURCE_DIR}/tests/unit/*.cpp)
    add_custom_target(format COMMAND ${CLANG_FORMAT} -i ${FORMAT_SOURCES}
                      COMMENT "clang-format over hand-written sources")
endif()
