Files
libakgl/CMakeLists.txt
Andrew Kesterson d8458d1e68
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 8m56s
libakgl CI Build / memory_check (push) Has been cancelled
libakgl CI Build / mutation_test (push) Has been cancelled
libakgl CI Build / performance (push) Has been cancelled
Stop set_tests_properties from eating the test environment lists
The example games failed on the CI runner with "ALSA: Couldn't open audio
device" despite SDL_AUDIODRIVER=dummy sitting right there in their CMake --
because it never reached the process. set_tests_properties parses its
PROPERTIES arguments as name/value pairs, so a semicolon-separated value
list is split and every element after the first is consumed as a bogus
property name: ENVIRONMENT kept only SDL_VIDEODRIVER=dummy, and the
LD_LIBRARY_PATH prepend list kept only its first directory. ctest
--show-only=json-v1 shows the truncation plainly.

Nobody could see it. Video survived because SDL3 falls through its driver
list to dummy on a headless machine with no hint at all; audio survived on
every developer machine because the real device works there; the library
paths survived because RPATH covered them. A runner with no sound card was
the first environment where any of it mattered.

Every list-valued test property now goes through set_property(TEST ...),
which takes real list arguments -- the examples' ENVIRONMENT triplets and
vendored-path modifications, the suites' LD_LIBRARY_PATH prepends, and the
docs harness's driver variables. Verified by property dump (3 environment
entries and all 7 prepends present) and by running the example tests with
ALSA_CONFIG_PATH=/dev/null, which now pass where a real ALSA open would
fail: the dummy driver is finally the one being asked.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 14:19:05 -04:00

949 lines
41 KiB
CMake

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.9.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
ui
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/ui.c
src/ui_clay.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")
# clay supplies the UI layout engine. Its sources are listed rather than
# add_subdirectory()'d, on the semver/libccd precedent, because
# deps/clay/CMakeLists.txt is unusable as a subproject and none of it is ours
# to fix in a submodule:
#
# - it declares no library target at all -- clay is a single header, and the
# add_library(INTERFACE) lines at the bottom are commented out upstream.
# - option(CLAY_INCLUDE_ALL_EXAMPLES ... ON) add_subdirectory()s every
# example by default, dragging raylib, cairo and sokol into our configure.
# - cmake_minimum_required(VERSION 3.27) against this project's 3.10.
#
# src/ui_clay.c is the one translation unit that defines CLAY_IMPLEMENTATION;
# it is 99% vendored code, so it gets -w on the same terms as semver and
# libccd. Deliberately NOT -fvisibility=hidden, unlike libccd: the CLAY()
# macros in a consuming game expand to Clay__OpenElement() and friends, which
# must resolve against libakgl.so, so clay's symbols are part of the ABI on
# purpose. The matching obligation -- a consumer must not link a second clay --
# is documented in akgl/ui.h.
set_source_files_properties(src/ui_clay.c PROPERTIES COMPILE_OPTIONS "-w")
# 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 <ccd/ccd.h>
# 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/<name>.c, built as
# akgl_test_<name> and registered with CTest under <name>.
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
ui
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
$<TARGET_FILE:akgl>
${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 <akgl/${header}.h>\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/
# PUBLIC, unlike libccd's PRIVATE include above, because akgl/ui.h includes
# <clay.h> in its public interface -- consumers write CLAY() blocks, so
# hiding the header would hide the point. clay.h is installed beside
# semver.h for the same reason.
deps/clay/
# 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
"$<TARGET_FILE_DIR:SDL3::SDL3>"
"$<TARGET_FILE_DIR:SDL3_image::SDL3_image>"
"$<TARGET_FILE_DIR:SDL3_ttf::SDL3_ttf>"
"$<TARGET_FILE_DIR:SDL3_mixer::SDL3_mixer>"
"$<TARGET_FILE_DIR:akerror::akerror>"
"$<TARGET_FILE_DIR:akstdlib::akstdlib>"
)
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"
)
# set_property rather than set_tests_properties for the list-valued
# property: set_tests_properties parses its PROPERTIES arguments as
# name/value pairs, so a semicolon-separated value is split and every
# element after the first is consumed as a bogus property name -- which
# silently reduced this prepend list to its first directory for as long as
# it existed. RPATH covered for it locally; a runner found it.
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_property(
TEST ${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES}
PROPERTY ENVIRONMENT_MODIFICATION ${AKGL_TEST_ENV_MOD}
)
else()
string(REPLACE ";" ":" AKGL_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}")
set_property(
TEST ${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES}
PROPERTY 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 <akerror.h>.
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 $<$<BOOL:...>:...>, because a target whose property
# is empty would otherwise contribute a bare `-I` line that swallows the next
# flag. $<FILTER> and $<REMOVE_DUPLICATES> would say it more directly and both
# need CMake 3.15; this file declares 3.10 and means it.
set(AKGL_DOCS_CFLAGS "$<$<BOOL:$<TARGET_PROPERTY:akgl,INCLUDE_DIRECTORIES>>:-I$<JOIN:$<TARGET_PROPERTY:akgl,INCLUDE_DIRECTORIES>,\n-I>\n>")
foreach(_docstarget IN LISTS AKGL_DOCS_DEPENDENCY_TARGETS)
string(APPEND AKGL_DOCS_CFLAGS
"$<$<BOOL:$<TARGET_PROPERTY:${_docstarget},INTERFACE_INCLUDE_DIRECTORIES>>:-I$<JOIN:$<TARGET_PROPERTY:${_docstarget},INTERFACE_INCLUDE_DIRECTORIES>,\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 "$<TARGET_LINKER_FILE:${_docstarget}>\n")
string(APPEND AKGL_DOCS_LDFLAGS "-Wl,-rpath,$<TARGET_FILE_DIR:${_docstarget}>\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 $<TARGET_FILE:akgl_docs_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
)
# set_property for the same pair-splitting reason as the suites above.
set_property(TEST docs_examples PROPERTY 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
)
set_property(TEST docs_screenshots PROPERTY 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)
# The figures at the top of chapters 20 and 21 are frames out of the games
# themselves, taken by --screenshot between the world being drawn and the
# frame being presented. Same contract as docs_screenshots above: deliberate,
# never part of a build, because the PNGs are tracked.
#
# There is no --check counterpart. docs_screenshots can compare because its
# figures are one deterministic frame of drawing calls; a game frame is not.
# The sidescroller drives its physics from the wall clock, so which pixel the
# player occupies depends on how fast the machine ran, and a byte comparison
# would fail for reasons that have nothing to do with the documentation.
add_custom_target(docs_game_figures
COMMAND ${CMAKE_COMMAND} -E env
SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy
$<TARGET_FILE:sidescroller>
--assets "${CMAKE_CURRENT_SOURCE_DIR}/docs/tutorials/assets/sidescroller"
--autoplay --frames 110
--screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/sidescroller.png"
--screenshot-frame 100
COMMAND ${CMAKE_COMMAND} -E env
SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy
$<TARGET_FILE:jrpg>
--demo --frames 240
--screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/jrpg.png"
--screenshot-frame 230
# Frame 100 of the UI demo's scripted tour is the play screen with the
# dialog up and the score mid-count -- all three widgets in one frame.
COMMAND ${CMAKE_COMMAND} -E env
SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy
$<TARGET_FILE:uidemo>
--demo --frames 240
--screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/uidemo.png"
--screenshot-frame 100
DEPENDS sidescroller jrpg uidemo
COMMENT "Regenerating the tutorial figures in docs/images"
VERBATIM
)
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/")
install(FILES "deps/clay/clay.h" DESTINATION "include/")
# libccd and clay are compiled into libakgl.so, and their licences (BSD-3 and
# zlib respectively) ask that the notice travel with the binary form. Nothing
# else in deps/ is linked in statically -- SDL, jansson, libakerror and
# libakstdlib are all separate shared objects that ship their own -- so these
# are the only third-party notices we owe.
install(FILES "deps/libccd/BSD-LICENSE" DESTINATION "share/doc/akgl/" RENAME "BSD-LICENSE.libccd")
install(FILES "deps/clay/LICENSE.md" DESTINATION "share/doc/akgl/" RENAME "LICENSE.clay")
foreach(header IN LISTS AKGL_PUBLIC_HEADERS)
install(FILES "include/akgl/${header}.h" DESTINATION "include/akgl/")
endforeach()
install(FILES ${GAMECONTROLLERDB_H} DESTINATION "include/akgl/")