cmake_minimum_required(VERSION 3.10) # The single source of truth for the library version. It drives the generated # include/akgl/version.h, the shared library's VERSION and SOVERSION, and the # Version field in akgl.pc. Bump it here and nowhere else. project(akgl VERSION 0.8.0 LANGUAGES C) # Memory checking reuses the suites that already exist -- `ctest -T memcheck` # runs every registered test under valgrind -- rather than adding programs of its # own. The perf suites carry most of the weight there: they are the only things # in the tree that load assets, draw a scene, and run a frame loop in one # process, and tests/benchutil.h drops their iteration counts by three orders of # magnitude when it finds itself under valgrind, which turns a benchmark into # exactly the broad, once-through path coverage a leak check wants. # # These have to be set before include(CTest): that is what writes them into # DartConfiguration.tcl, and a memcheck run reads them from there. find_program(MEMORYCHECK_COMMAND valgrind) # Set with FORCE, but only when empty. include(CTest) declares both of these as # empty cache entries, so a build tree configured before this block existed has # them already and a plain set(... CACHE ...) would be ignored -- the values # would silently never reach DartConfiguration.tcl. Guarding on emptiness still # leaves a deliberate -DMEMORYCHECK_SUPPRESSIONS_FILE=... alone. if(NOT MEMORYCHECK_SUPPRESSIONS_FILE) set(MEMORYCHECK_SUPPRESSIONS_FILE "${CMAKE_CURRENT_SOURCE_DIR}/scripts/valgrind.supp" CACHE FILEPATH "Suppressions for third-party findings the memcheck run cannot fix" FORCE) endif() # Definite losses only. "Still reachable" is every global SDL and FreeType keeps # for the process lifetime and says nothing about libakgl; "possibly lost" is # dominated by interior pointers into pools and thread stacks. Neither is worth # the false positives. if(NOT MEMORYCHECK_COMMAND_OPTIONS) set(MEMORYCHECK_COMMAND_OPTIONS "--leak-check=full --show-leak-kinds=definite --errors-for-leak-kinds=definite --track-origins=yes --num-callers=25" CACHE STRING "Options passed to the memory checker" FORCE) endif() include(CTest) option(AKGL_COVERAGE "Instrument libakgl and generate coverage reports with CTest" OFF) # -Wall is always on for code this project owns. -Werror is not, and the # distinction is deliberate. # # libakgl is consumed with add_subdirectory() -- akbasic does exactly that -- so # a target-level -Werror makes every new compiler diagnostic a broken build for # somebody else's project. The warning set is not even stable across build types # here: an -O2 build reports ten -Wstringop-truncation warnings that -O0 and # -fsyntax-only do not, because they need the middle end. A newer GCC or a # switch to clang moves the line again. # # So the errors live in CI, where the compiler is pinned and a new warning is a # task for the maintainer rather than an outage for a consumer. option(AKGL_WERROR "Treat compiler warnings as errors. For CI; leave OFF when embedding." OFF) set(AKGL_WARNING_FLAGS -Wall) if(AKGL_WERROR) list(APPEND AKGL_WARNING_FLAGS -Werror) endif() # -Wextra is deliberately not here. It adds 22 findings, 17 of which are # inherent to the design rather than defects: 13 -Wunused-parameter, because a # backend vtable entry or an SDL callback has to match a signature whether it # reads every argument or not, and 4 -Wimplicit-fallthrough from libakerror's # own PROCESS/HANDLE/HANDLE_GROUP, which fall through between case labels by # design. Adopting it would mean disabling both permanently to gain 5 # -Wsign-compare. See deps/libakerror/TODO.md item 8. if(AKGL_COVERAGE) if(NOT CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") message(FATAL_ERROR "AKGL_COVERAGE requires GCC or Clang") endif() find_program(GCOVR_EXECUTABLE gcovr REQUIRED) endif() # Vendored projects own their test suites. Suppress their CTest registration so # the suite that runs contains only the targets built here. The override is # lifted again below, before this project registers its own tests. # # **Only when this project is top-level, and that guard is load-bearing.** CMake # exposes an overridden command as `_name` and chains exactly one level deep: a # second override rebinds `_add_test` to the *first* override and the builtin # becomes unreachable to everybody, forever. So two projects in one tree cannot # both shadow add_test() -- whoever goes second breaks, and an embedding # consumer that shadows it first sees its own registrations recurse until CMake # stops at depth 1000. # # An embedded libakgl therefore leaves the override alone and lets its consumer # suppress what it does not want; the consumer has to have that machinery # anyway, for libakerror and libakstdlib. libakstdlib guards its own shadow the # same way and for the same reason. if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) set(AKGL_SUPPRESS_DEPENDENCY_TESTS TRUE) function(add_test) if(NOT AKGL_SUPPRESS_DEPENDENCY_TESTS) _add_test(${ARGV}) endif() endfunction() function(set_tests_properties) if(NOT AKGL_SUPPRESS_DEPENDENCY_TESTS) _set_tests_properties(${ARGV}) endif() endfunction() endif() set(JANSSON_WITHOUT_TESTS ON CACHE BOOL "Do not build vendored Jansson tests" FORCE) set(JANSSON_EXAMPLES OFF CACHE BOOL "Do not build vendored Jansson examples" FORCE) set(JANSSON_BUILD_DOCS OFF CACHE BOOL "Do not build vendored Jansson docs" FORCE) # Add one vendored dependency, if nobody has already declared it and the # submodule is actually checked out. # # A macro rather than a function on purpose: add_subdirectory() inside a # function runs with that function's variable scope, so every cache-ish variable # these projects set for their own subdirectories would be discarded on return. macro(akgl_add_vendored_dependency target dir) if(NOT TARGET ${target}) if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${dir}/CMakeLists.txt") add_subdirectory(${dir} EXCLUDE_FROM_ALL) set(AKGL_VENDORED_DEPENDENCIES TRUE) endif() endif() endmacro() # Added on both paths, not just when this is the top-level project. Embedded # with add_subdirectory(), the submodules under deps/ are sitting right there -- # the recursive clone the consumer just did put them there -- and refusing to # configure until SDL3 is installed system-wide is a failure with its own answer # three directories away. if(NOT TARGET ...) means a consumer that has already # declared one of these wins; the EXISTS check means a checkout without # submodules falls through to find_package below. akgl_add_vendored_dependency(jansson::jansson deps/jansson) akgl_add_vendored_dependency(akerror::akerror deps/libakerror) akgl_add_vendored_dependency(akstdlib::akstdlib deps/libakstdlib) akgl_add_vendored_dependency(SDL3::SDL3 deps/SDL) akgl_add_vendored_dependency(SDL3_image::SDL3_image deps/SDL_image) akgl_add_vendored_dependency(SDL3_mixer::SDL3_mixer deps/SDL_mixer) akgl_add_vendored_dependency(SDL3_ttf::SDL3_ttf deps/SDL_ttf) # libakerror sizes its own status-name registry; consumers no longer do. libakgl # claims its status codes at runtime in akgl_heap_init() instead. # # The floor is 2.0.1, and it is a hard one. 2.0.0 moved __akerr_last_ignored to # thread-local storage and made akerr_next_error() return a context that already # holds its reference -- both of which expand at *libakgl's* call sites through # IGNORE and the FAIL macros, and at a *consumer's* too, because akerror.h is # part of libakgl's public interface. A tree that mixes headers and libraries # across that line double-counts every reference and never returns a pool slot. # The soname moved to libakerror.so.2 to stop it happening by accident; the # #error in include/akgl/error.h catches a stale header in an install tree. set(AKGL_SUPPRESS_DEPENDENCY_TESTS FALSE) # Anything the vendored block did not supply has to come from the system. if(NOT (TARGET SDL3::SDL3 AND TARGET SDL3_image::SDL3_image AND TARGET SDL3_mixer::SDL3_mixer AND TARGET SDL3_ttf::SDL3_ttf AND TARGET akerror::akerror AND TARGET akstdlib::akstdlib AND TARGET jansson::jansson)) # Only needed to locate installed copies; a fully vendored build does not # require pkg-config to be present at all. find_package(PkgConfig REQUIRED) endif() if(NOT TARGET SDL3::SDL3) find_package(SDL3 REQUIRED) endif() if(NOT TARGET SDL3_image::SDL3_image) find_package(SDL3_image REQUIRED) endif() if(NOT TARGET SDL3_mixer::SDL3_mixer) find_package(SDL3_mixer REQUIRED) endif() if(NOT TARGET SDL3_ttf::SDL3_ttf) find_package(SDL3_ttf REQUIRED) endif() # No version here: libakerror ships no akerrorConfigVersion.cmake, so asking # for one makes find_package reject every install. The floor is enforced by # the #error in include/akgl/error.h instead, which feature-tests # AKERR_FIRST_CONSUMER_STATUS and AKERR_EXIT_STATUS_UNREPRESENTABLE -- the # latter arriving in 2.0.1, which is the version libakgl needs. if(NOT TARGET akerror::akerror) find_package(akerror REQUIRED) endif() # 0.2 rather than bare: libakstdlib 0.2.0 ships an akstdlibConfigVersion.cmake # with SameMinorVersion compatibility, mirroring its soname, so this accepts # any 0.2.x and refuses 0.3 and 1.0. Unversioned, this path would silently # accept an ABI-incompatible libakstdlib. if(NOT TARGET akstdlib::akstdlib) find_package(akstdlib 0.2 REQUIRED) endif() if(NOT TARGET jansson::jansson) find_package(jansson) endif() # Every header installed into include/akgl/, by base name. This list is the # single source of truth for two things that must not drift apart: what gets # installed, and what the `headers` suite proves is self-contained. Adding a # header here is all a new one needs. SDL_GameControllerDB.h and the generated # version.h are installed separately -- neither is an API header. set(AKGL_PUBLIC_HEADERS actor assets audio character collision collision_arena controller draw error game heap iterator json_helpers physics registry renderer sprite staticstring text tilemap types util ) set(GAMECONTROLLERDB_H "include/akgl/SDL_GameControllerDB.h") set(prefix ${CMAKE_INSTALL_PREFIX}) set(exec_prefix "\${prefix}") set(libdir "\${exec_prefix}/lib") set(includedir "\${prefix}/include") configure_file(${CMAKE_CURRENT_SOURCE_DIR}/akgl.pc.in ${CMAKE_CURRENT_BINARY_DIR}/akgl.pc @ONLY) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/include/akgl/version.h.in ${CMAKE_CURRENT_BINARY_DIR}/include/akgl/version.h @ONLY) # Tests use both relative paths and SDL_GetBasePath(), so stage fixtures beside # test executables in every out-of-tree build. file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/tests/assets" DESTINATION "${CMAKE_CURRENT_BINARY_DIR}") # Regenerating the controller database is a deliberate act, not part of a build. # # This used to be an add_custom_command whose OUTPUT was the *relative* path # "include/akgl/SDL_GameControllerDB.h", which CMake resolves against the binary # directory while the script writes to the source directory. The declared output # therefore never appeared, the command was permanently out of date, and every # build re-ran it -- so every build needed network access and left the tracked # header dirty with a fresh $(date) stamp. # # `cmake --build build --target controllerdb` when newer mappings are actually # wanted. The result is its own commit, per AGENTS.md. if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) set(AKGL_CONTROLLERDB_TARGET controllerdb) else() set(AKGL_CONTROLLERDB_TARGET akgl_controllerdb) endif() add_custom_target(${AKGL_CONTROLLERDB_TARGET} COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/mkcontrollermappings.sh ${CMAKE_CURRENT_SOURCE_DIR} WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} USES_TERMINAL COMMENT "Fetching controller mappings and rewriting ${GAMECONTROLLERDB_H} ..." ) # libccd supplies the collision narrowphase (GJK, EPA and MPR). # # Its sources are listed here rather than add_subdirectory()'d. Four things in # deps/libccd/CMakeLists.txt make it unusable as a subproject, and none of them # is ours to fix in a submodule: # # - project(libccd) declares no LANGUAGES, so it enables CXX and makes a C++ # toolchain a requirement of every build of libakgl. CI installs gcc. # - it declares option(BUILD_SHARED_LIBS ... ON), a global cache variable, and # calls include(CTest), which would register its own suites into ours and # build the LGPL-licensed CU framework under src/testsuites/. # - install(FILES "${CMAKE_BINARY_DIR}/ccd.pc") reads the *top level* binary # dir while configure_file writes CMAKE_CURRENT_BINARY_DIR. Those are the # same directory standalone and different ones embedded, so `cmake --install` # fails on a missing file. # - cmake_minimum_required(VERSION 2.8.11) warns on CMake 3.28 and is a hard # error on CMake 4. # # Compiling the sources directly, the way deps/semver/semver.c already is, sidesteps # all four and never configures the LGPL testsuites at all. set(CCD_SINGLE 1) configure_file( ${CMAKE_CURRENT_SOURCE_DIR}/deps/libccd/src/ccd/config.h.cmake.in ${CMAKE_CURRENT_BINARY_DIR}/ccd/config.h) set(AKGL_CCD_SOURCES deps/libccd/src/ccd.c deps/libccd/src/mpr.c deps/libccd/src/polytope.c deps/libccd/src/support.c deps/libccd/src/vec3.c) # Add include directories include_directories(${SDL3_INCLUDE_DIRS}) add_library(akgl SHARED deps/semver/semver.c ${AKGL_CCD_SOURCES} src/actor.c src/collision.c src/collision_arena.c src/collision_bsp.c src/collision_grid.c src/collision_shape.c src/actor_state_string_names.c src/audio.c src/text.c src/assets.c src/character.c src/draw.c src/error.c src/game.c src/controller.c src/heap.c src/json_helpers.c src/registry.c src/renderer.c src/physics.c src/sprite.c src/staticstring.c src/tilemap.c src/util.c src/version.c ) # While the major version is 0 the ABI is not stable across minor releases, so # the soname carries major.minor -- libakgl.so.0.1. A plain SOVERSION 0 would # claim 0.1.0 and 0.2.0 are interchangeable, which is exactly the silent # mispairing the soname is here to prevent. At 1.0.0 this becomes the major # alone, matching libakerror. if(PROJECT_VERSION_MAJOR EQUAL 0) set(AKGL_SOVERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}") else() set(AKGL_SOVERSION "${PROJECT_VERSION_MAJOR}") endif() set_target_properties(akgl PROPERTIES VERSION ${PROJECT_VERSION} SOVERSION ${AKGL_SOVERSION} ) target_compile_options(akgl PRIVATE ${AKGL_WARNING_FLAGS}) # deps/semver/semver.c is vendored and listed directly in add_library(), so the # target's PRIVATE options reach it. Exempt it: a future semver update must not # be able to fail this build. (PRIVATE options do not reach SDL, jansson, # akerror or akstdlib -- those are separate targets.) set_source_files_properties(deps/semver/semver.c PROPERTIES COMPILE_OPTIONS "-w") # libccd is vendored on the same terms as semver: -w so a future bump cannot fail # this build on a warning we do not own. # # src/ccd_arena_shim.h is what keeps the "libakgl does not call malloc at # runtime" promise true with the whole of libccd compiled in. Injected with # -include, it redirects both halves of libccd's allocation -- the # __CCD_ALLOC_MEMORY macro every CCD_ALLOC* expands to, and the four bare free() # calls -- at the arena in src/collision_arena.c. It applies to these five files # and nothing else. # # The redirect lives in that header rather than in COMPILE_DEFINITIONS because # CMake cannot carry a function-like macro through a -D: it dropped it silently, # and libccd went on calling the C library while free() became a no-op. Do not # move it back. # # CCD_STATIC_DEFINE plus -fvisibility=hidden keeps every ccd* symbol out of # libakgl.so's dynamic table. That is not tidiness: AGENTS.md records a shipped # defect where an exported `renderer` in this library was preempted by a # same-named symbol in a test executable, and a game linking both libakgl and a # system libccd would hit exactly that. set_source_files_properties(${AKGL_CCD_SOURCES} PROPERTIES COMPILE_OPTIONS "-w;-fvisibility=hidden;-include;${CMAKE_CURRENT_SOURCE_DIR}/src/ccd_arena_shim.h" COMPILE_DEFINITIONS "CCD_STATIC_DEFINE") # PRIVATE, so that ccd/*.h never reaches a consumer's include path. The `headers` # suite compiles each public header as the first include of a translation unit # with only include/ available, so a public header that pulled in # would fail there immediately. target_include_directories(akgl PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/deps/libccd/src" "${CMAKE_CURRENT_BINARY_DIR}") add_library(akgl::akgl ALIAS akgl) add_executable(charviewer util/charviewer.c) add_executable(akgl_test_semver_unit deps/semver/semver_unit.c) # Every suite here is a standalone C program named tests/.c, built as # akgl_test_ and registered with CTest under . set(AKGL_TEST_SUITES actor audio bitmasks character collision collision_arena controller draw error game headers heap json_helpers partition physics physics_sim registry renderer sprite staticstring text tilemap util version ) # The performance suites are built and registered exactly like the unit suites, # but they are benchmarks: they drive hot paths for millions of iterations, print # a table of nanoseconds per operation, and fail only when a measurement exceeds # a budget set at roughly ten times the recorded baseline. They carry the `perf` # label, so `ctest -L perf` runs only them and `ctest -LE perf` leaves them out # of an ordinary run, and they get a much longer timeout for obvious reasons. # # Set AKGL_BENCH_SCALE in the environment to change how long they run -- # `AKGL_BENCH_SCALE=0.1 ctest -L perf` for a tenth of the iterations. Below 1.0 # the budgets are measured and reported but not enforced, because a short run is # a noisy one. set(AKGL_PERF_SUITES perf perf_render ) # The executables carry an akgl_ prefix but the CTest names do not: a vendored # dependency is free to ship its own tests/version.c, and libakstdlib now does. # Its target is created by add_subdirectory even though EXCLUDE_FROM_ALL keeps # it from being built, so an unprefixed test_version here is a configure error. foreach(suite IN LISTS AKGL_TEST_SUITES AKGL_PERF_SUITES) add_executable(akgl_test_${suite} tests/${suite}.c) add_test(NAME ${suite} COMMAND akgl_test_${suite}) endforeach() add_test(NAME semver_unit COMMAND akgl_test_semver_unit) # Not a C program, so it is not in AKGL_TEST_SUITES: it reads the built library's # dynamic symbol table and every public header, and fails when a function with # external linkage is declared nowhere. That is an ABI question rather than a # behavioural one, and nineteen symbols had drifted out of the headers before # anything was checking. add_test( NAME api_surface COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/scripts/check_api_surface.sh $ ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_BINARY_DIR}/include ) set_tests_properties(api_surface PROPERTIES SKIP_RETURN_CODE 2) # The two akerror control-flow rules whose failure mode is silent: a *_RETURN # inside an ATTEMPT block, and a return out of a HANDLE block. Neither produces # a compiler diagnostic and neither fails a test run until the pool it drains is # empty, which is why both have already shipped. # # The mutation target below wants the same interpreter; find_package caches, so # asking here as well costs nothing and keeps this block self-contained. find_package(Python3 COMPONENTS Interpreter) if(Python3_Interpreter_FOUND) add_test( NAME error_protocol COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/scripts/check_error_protocol.py ${CMAKE_CURRENT_SOURCE_DIR}/src ) endif() # One translation unit per public header, each including exactly that header and # nothing before it. This is the only shape that proves a header is # self-contained: a second #include in the same file proves nothing about the # second, because by then the first has dragged its dependencies in. Generated # rather than written by hand so the check cannot fall behind # AKGL_PUBLIC_HEADERS -- the failure being guarded against is a header nobody # remembered to cover. set(AKGL_HEADER_SELFTEST_SOURCES "") foreach(header IN LISTS AKGL_PUBLIC_HEADERS) set(generated "${CMAKE_CURRENT_BINARY_DIR}/tests/header_${header}.c") file(WRITE "${generated}" "/* Generated by CMakeLists.txt from AKGL_PUBLIC_HEADERS. Do not edit. */\n\ #include \n\ \n\ /* ISO C forbids an empty translation unit; the compile is the assertion. */\n\ int akgl_header_selftest_${header}(void);\n\ int akgl_header_selftest_${header}(void)\n\ {\n\ return 0;\n\ }\n") list(APPEND AKGL_HEADER_SELFTEST_SOURCES "${generated}") endforeach() target_sources(akgl_test_headers PRIVATE ${AKGL_HEADER_SELFTEST_SOURCES}) # Hand-written rather than generated: it checks a header's behaviour under a # caller-supplied override, which is not something the generated shape covers. target_sources(akgl_test_headers PRIVATE tests/header_pool_override.c) # TIMEOUT is generous because CTest applies the same property to `ctest -T # memcheck`, where every suite runs under valgrind at something like twenty # times its normal cost. The unit suites finish in well under a second each # without it; the ceiling is there for the checked run, not the ordinary one. set_tests_properties( ${AKGL_TEST_SUITES} semver_unit PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/tests" TIMEOUT 300 ) set_tests_properties( ${AKGL_PERF_SUITES} PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/tests" TIMEOUT 900 LABELS perf ) # Specify include directories for the library's headers (if applicable) target_include_directories(akgl PUBLIC include/ deps/semver/ # akgl/version.h is generated by configure_file, so it lives in the build tree # rather than beside the headers it is included from. ${CMAKE_CURRENT_BINARY_DIR}/include/ ) if(AKGL_COVERAGE) target_compile_options(akgl PRIVATE --coverage -O0 -g) target_link_options(akgl PRIVATE --coverage) set(AKGL_COVERAGE_DIR "${CMAKE_CURRENT_BINARY_DIR}/coverage") file(MAKE_DIRECTORY "${AKGL_COVERAGE_DIR}") # Both gcovr invocations take the build tree as an explicit positional search # path, and neither passes --object-directory. # # gcovr looks for .gcda/.gcno under its search paths, and with none given it # searches --root -- the source directory, which is where developers keep # their build trees. --object-directory does *not* narrow that: per gcovr's # own help it only identifies "the path between gcda files and the directory # where the compiler was originally run". So every instrumented tree left # inside the source directory was folded into the report alongside the one # being measured, and with two trees describing different line numbers for the # same function, coverage_reset failed before any test ran: # # AssertionError: Got function akgl_game_lowfps on multiple lines: 45, 46 # # The `build*/` entry in .gitignore hides those trees from git status, which # makes the state easy to get into and hard to notice. add_test( NAME coverage_reset COMMAND ${GCOVR_EXECUTABLE} --root "${CMAKE_CURRENT_SOURCE_DIR}" --delete "${CMAKE_CURRENT_BINARY_DIR}" ) set_tests_properties(coverage_reset PROPERTIES FIXTURES_SETUP akgl_coverage) # Any suite missing from this list runs outside the fixture and has its # counters discarded by coverage_reset. The perf suites are deliberately # outside it: an instrumented -O0 build measures gcov, not libakgl, and the # lines they cover are covered by the unit suites anyway. set_tests_properties( ${AKGL_TEST_SUITES} semver_unit PROPERTIES FIXTURES_REQUIRED akgl_coverage ) add_test( NAME coverage_report COMMAND ${GCOVR_EXECUTABLE} --root "${CMAKE_CURRENT_SOURCE_DIR}" --filter "${CMAKE_CURRENT_SOURCE_DIR}/src/" --xml-pretty --xml "${AKGL_COVERAGE_DIR}/coverage.xml" --html-details "${AKGL_COVERAGE_DIR}/index.html" "${CMAKE_CURRENT_BINARY_DIR}" ) set_tests_properties( coverage_report PROPERTIES FIXTURES_CLEANUP akgl_coverage WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" ) endif() target_link_libraries(akgl PUBLIC SDL3::SDL3 SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer SDL3_ttf::SDL3_ttf akstdlib::akstdlib akerror::akerror jansson::jansson ) foreach(suite IN LISTS AKGL_TEST_SUITES AKGL_PERF_SUITES) target_link_libraries(akgl_test_${suite} PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm) target_include_directories(akgl_test_${suite} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/tests") target_compile_options(akgl_test_${suite} PRIVATE ${AKGL_WARNING_FLAGS}) endforeach() target_link_libraries(charviewer PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm) target_compile_options(charviewer PRIVATE ${AKGL_WARNING_FLAGS}) # When the vendored SDL satellite libraries are built in-tree they land in per- # project subdirectories that are not on the loader's default search path, so a # freshly built test aborts before main() with "cannot open shared object file". # Bake those directories into the build-tree RPATH. Installed builds resolve the # same libraries through find_package and need no help, so this keys on whether # anything was actually vendored rather than on being the top-level project. if(AKGL_VENDORED_DEPENDENCIES) set(AKGL_VENDORED_RPATH "$" "$" "$" "$" "$" "$" ) foreach(suite IN LISTS AKGL_TEST_SUITES AKGL_PERF_SUITES) set_target_properties(akgl_test_${suite} PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}") endforeach() set_target_properties(charviewer akgl PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}") # RPATH alone is not enough: LD_LIBRARY_PATH is searched first, so a developer # who has previously run rebuild.sh has an installed libakgl.so ahead of the # one under test. Prepend the build tree for the CTest run so the suite always # exercises what was just compiled. set(AKGL_TEST_LIBPATH "${CMAKE_CURRENT_BINARY_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/deps/SDL" "${CMAKE_CURRENT_BINARY_DIR}/deps/SDL_image" "${CMAKE_CURRENT_BINARY_DIR}/deps/SDL_ttf" "${CMAKE_CURRENT_BINARY_DIR}/deps/SDL_mixer" "${CMAKE_CURRENT_BINARY_DIR}/deps/libakerror" "${CMAKE_CURRENT_BINARY_DIR}/deps/libakstdlib" ) if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22") set(AKGL_TEST_ENV_MOD "") foreach(dir IN LISTS AKGL_TEST_LIBPATH) list(APPEND AKGL_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}") endforeach() set_tests_properties( ${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES} PROPERTIES ENVIRONMENT_MODIFICATION "${AKGL_TEST_ENV_MOD}" ) else() string(REPLACE ";" ":" AKGL_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}") set_tests_properties( ${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES} PROPERTIES ENVIRONMENT "LD_LIBRARY_PATH=${AKGL_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}" ) endif() endif() # --------------------------------------------------------------------------- # The documentation's own examples. # # docs/ is a manual full of C snippets, asset files and shell commands, and the # README this project started with proves what happens to samples nothing # executes: unbalanced PASS() calls, a stray `9` inside a bitmask expression, an # `int screenwidth = NULL`, and the exact strncpy call AGENTS.md forbids. Two # prose claims in the headers were false against src/ as well. # # Documentation goes stale because the *code* moved, not because somebody edited # a chapter, so this is an ordinary test case rather than something a docs-only # CI job runs behind a path filter: it fails when the library changes under the # chapter, which is exactly the case a path filter would miss. # # tests/docs_examples.sh reads the fence info strings; docs/MAINTENANCE.md # documents them. # --------------------------------------------------------------------------- # The snippets compile against the real include path, which is transitive # through akerror, akstdlib, SDL3, SDL3_image and jansson. Writing it out from # the target's own INCLUDE_DIRECTORIES keeps one source of truth: a hardcoded -I # list in the script would rot exactly the way the documentation does. # # INTERFACE_INCLUDE_DIRECTORIES of the linked targets as well as libakgl's own, # because akerror.h and akstdlib.h are part of libakgl's public interface and # neither lives under include/akgl. akgl's own INCLUDE_DIRECTORIES does not # carry them -- they arrive at a consumer through target_link_libraries -- so # taking only that property leaves a snippet unable to find . set(AKGL_DOCS_DEPENDENCY_TARGETS SDL3::SDL3 SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer SDL3_ttf::SDL3_ttf akstdlib::akstdlib akerror::akerror jansson::jansson ) set(AKGL_DOCS_CFLAGS_FILE "${CMAKE_CURRENT_BINARY_DIR}/docs_cflags.txt") # Each entry is wrapped in $<$:...>, because a target whose property # is empty would otherwise contribute a bare `-I` line that swallows the next # flag. $ and $ would say it more directly and both # need CMake 3.15; this file declares 3.10 and means it. set(AKGL_DOCS_CFLAGS "$<$>:-I$,\n-I>\n>") foreach(_docstarget IN LISTS AKGL_DOCS_DEPENDENCY_TARGETS) string(APPEND AKGL_DOCS_CFLAGS "$<$>:-I$,\n-I>\n>") endforeach() file(GENERATE OUTPUT "${AKGL_DOCS_CFLAGS_FILE}" CONTENT "${AKGL_DOCS_CFLAGS}" ) # The link line for the `c run=` and `c screenshot=` blocks. Full library paths # plus an rpath entry per directory: a vendored build leaves the SDL satellite # libraries in per-project subdirectories that are not on the loader's default # search path, which is the same problem AKGL_VENDORED_RPATH solves for the test # executables. set(AKGL_DOCS_LDFLAGS_FILE "${CMAKE_CURRENT_BINARY_DIR}/docs_ldflags.txt") set(AKGL_DOCS_LDFLAGS "") foreach(_docstarget akgl ${AKGL_DOCS_DEPENDENCY_TARGETS}) string(APPEND AKGL_DOCS_LDFLAGS "$\n") string(APPEND AKGL_DOCS_LDFLAGS "-Wl,-rpath,$\n") endforeach() string(APPEND AKGL_DOCS_LDFLAGS "-lm\n") file(GENERATE OUTPUT "${AKGL_DOCS_LDFLAGS_FILE}" CONTENT "${AKGL_DOCS_LDFLAGS}" ) # The json kind= validator: a host that brings libakgl up headless and hands the # block to the loader that really reads that format. Not a test of its own -- it # answers a question about a document rather than about the library -- and not # instrumented, so a documentation run cannot flatter the coverage figure. add_executable(akgl_docs_checkjson tools/docs_checkjson.c) target_compile_options(akgl_docs_checkjson PRIVATE ${AKGL_WARNING_FLAGS}) target_link_libraries(akgl_docs_checkjson PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 jansson::jansson -lm) if(AKGL_VENDORED_DEPENDENCIES) set_target_properties(akgl_docs_checkjson PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}") endif() # tools/docs_screenshot.c is deliberately not a target here. It supplies main() # for a program whose other translation unit is a block of markdown, so it is # compiled by tools/docs_screenshots.sh together with the listing it is hosting; # there is nothing for `all` to build. add_test( NAME docs_examples COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/tests/docs_examples.sh --root "${CMAKE_CURRENT_SOURCE_DIR}" --cflags-file "${AKGL_DOCS_CFLAGS_FILE}" --ldflags-file "${AKGL_DOCS_LDFLAGS_FILE}" --checkjson $ ) # Every argument is spelled out rather than assembled from a generator # expression: an expression that evaluates to nothing still contributes an # *empty argument*, which the script reads as the first filename. akbasic's # suite passed having checked no documents at all that way, and was caught only # because it reports what it ran. set_tests_properties(docs_examples PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" TIMEOUT 900 ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software" ) # Every figure in docs/ re-rendered and byte-compared against the tracked copy. # docs_examples only asks whether the file *exists*, which catches a figure that # was never generated and not one that stopped being of the code beside it -- # and that second failure is the one this whole arrangement is against. # # **A byte comparison of a rendered PNG is a deliberate bet**: that the dummy # video driver and the software renderer are reproducible run to run and build to # build. What is untested is an SDL upgrade that shifts one pixel of a diagonal, # and the answer to that is to regenerate the figures in the same commit as the # bump -- not to weaken this to a size check, which would pass for every wrong # picture that happened to be 320x240. add_test( NAME docs_screenshots COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/tools/docs_screenshots.sh --root "${CMAKE_CURRENT_SOURCE_DIR}" --cflags-file "${AKGL_DOCS_CFLAGS_FILE}" --ldflags-file "${AKGL_DOCS_LDFLAGS_FILE}" --check ) set_tests_properties(docs_screenshots PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" TIMEOUT 900 ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software" ) # Regenerating the figures is a deliberate act, never part of a build: the PNGs # are tracked, and a rebuild that silently rewrote them would put a binary diff # in front of anybody who happened to run `make`. Same contract as the # controllerdb target above, and for the same reason. if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) set(AKGL_DOCS_SCREENSHOTS_TARGET docs_screenshots) else() set(AKGL_DOCS_SCREENSHOTS_TARGET akgl_docs_screenshots) endif() add_custom_target(${AKGL_DOCS_SCREENSHOTS_TARGET} COMMAND "${CMAKE_CURRENT_SOURCE_DIR}/tools/docs_screenshots.sh" --root "${CMAKE_CURRENT_SOURCE_DIR}" --cflags-file "${AKGL_DOCS_CFLAGS_FILE}" --ldflags-file "${AKGL_DOCS_LDFLAGS_FILE}" DEPENDS akgl WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} COMMENT "Regenerating the documentation figures in docs/images" VERBATIM ) # The two tutorial games. Guarded because they are written by a later pass and # an absent directory must not break the configure -- and guarded again inside # examples/CMakeLists.txt, one game at a time, for the same reason. if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/examples/CMakeLists.txt") add_subdirectory(examples) endif() # Mutation testing copies the repository to scratch space, applies one small # source change at a time, and verifies that the passing tests detect it. The # intentionally failing character test is excluded by the harness. find_package(Python3 COMPONENTS Interpreter) if(Python3_FOUND) if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) set(AKGL_MUTATION_TARGET mutation) else() set(AKGL_MUTATION_TARGET akgl_mutation) endif() add_custom_target(${AKGL_MUTATION_TARGET} COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/scripts/mutation_test.py --source-root ${CMAKE_CURRENT_SOURCE_DIR} WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} USES_TERMINAL COMMENT "Running mutation tests (breaks a scratch copy, expects tests to fail)" ) endif() # Memory checking runs the whole registered suite under valgrind. The wrapper # script exists because `ctest -T memcheck` records defects and still exits 0, # which cannot gate anything; it also forces the headless drivers, so the vendor # GPU stack is never loaded and never has to be suppressed. if(MEMORYCHECK_COMMAND) if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) set(AKGL_MEMCHECK_TARGET memcheck) else() set(AKGL_MEMCHECK_TARGET akgl_memcheck) endif() add_custom_target(${AKGL_MEMCHECK_TARGET} COMMAND ${CMAKE_COMMAND} -E env AKGL_BUILD_DIR=${CMAKE_CURRENT_BINARY_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/scripts/memcheck.sh WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} USES_TERMINAL COMMENT "Running every test suite under valgrind (slow; the perf suites scale themselves down)" ) endif() install(FILES ${CMAKE_CURRENT_BINARY_DIR}/akgl.pc DESTINATION "lib/pkgconfig/") install(FILES ${CMAKE_CURRENT_BINARY_DIR}/include/akgl/version.h DESTINATION "include/akgl/") install(TARGETS akgl DESTINATION "lib/") install(FILES "deps/semver/semver.h" DESTINATION "include/") foreach(header IN LISTS AKGL_PUBLIC_HEADERS) install(FILES "include/akgl/${header}.h" DESTINATION "include/akgl/") endforeach() install(FILES ${GAMECONTROLLERDB_H} DESTINATION "include/akgl/")