Files
libakgl/CMakeLists.txt

934 lines
41 KiB
CMake
Raw Normal View History

cmake_minimum_required(VERSION 3.10)
Derive the library version from one place and give the .so a soname The version was written down twice and agreed with itself by luck. AKGL_VERSION was hand-maintained in include/akgl/game.h, and nothing connected it to the build, which had no version at all: - project() declared no VERSION, so the @PROJECT_VERSION@ substitution in akgl.pc.in expanded to nothing and every installed akgl.pc shipped an empty Version field. pkg-config --modversion returned "" and --atleast-version could not work at all. - libakgl.so had no VERSION or SOVERSION, so there was no soname and no symlink chain. A stale libakgl could be paired with new headers silently, which is the failure libakerror added its own soname to prevent. - MY_LIBRARY_VERSION at CMakeLists.txt:288 was never set anywhere. It built main_lib_dest as "lib/akgl-", and main_lib_dest was then never read. Dead line, removed. project(akgl VERSION 0.1.0) is now the only place the number appears. It drives the generated include/akgl/version.h, the shared library VERSION and SOVERSION, and the Version field in akgl.pc. Held at 0.1.0 to match the constant it replaces, so akgl_game_load_versioncmp still compares savegames against the same value. include/akgl/version.h is generated by configure_file from version.h.in and publishes AKGL_VERSION, AKGL_VERSION_MAJOR/MINOR/PATCH and AKGL_VERSION_AT_LEAST(major, minor, patch). That macro is the point: libakstdlib could not write that test against libakerror, which publishes no version macro, and had to feature-test on AKERR_FIRST_CONSUMER_STATUS instead. game.h now includes the generated header rather than defining the version itself, so consumers see AKGL_VERSION exactly where they saw it before. While the major version is 0 the soname carries major.minor, giving libakgl.so.0.1. A plain SOVERSION 0 would assert that 0.1.0 and 0.2.0 are ABI-interchangeable, and they are not -- the preceding commit alone added akgl_error_init. At 1.0.0 the soname becomes the major alone, matching libakerror. akgl_version() in src/version.c reports the version of the libakgl that is actually linked, where AKGL_VERSION reports what the caller compiled against. Comparing the two is the only way to catch a stale shared library on the loader path; the macro alone cannot see it. It returns a string rather than an akerr_ErrorContext * because it cannot fail, for the same reason akerr_name_for_status does. Add tests/version.c: the string and the numeric components must describe one version, the linked library must agree with the headers, and AKGL_VERSION_AT_LEAST must order correctly at the major, minor and patch boundaries. Verified out of band that bumping project() to 1.2.3 moves version.h, akgl.pc and the soname together, and that an install produces the libakgl.so -> .so.0.1 -> .so.0.1.0 chain with SONAME libakgl.so.0.1. Also ignore include/akgl/version.h. include/ precedes the build tree on the include path, so a stray copy there would shadow the generated one and pin every consumer to whatever version it was generated at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:36:50 -04:00
# 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)
Check memory with the suites that already exist `cmake --build build --target memcheck` runs every registered CTest suite under valgrind. There are no new test programs, and there should not be any: tests/benchutil.h notices valgrind in LD_PRELOAD and drops the benchmark scale to 0.0005, which turns the perf suites into the broadest path coverage in the tree at a cost valgrind can survive. A benchmark walks one path a hundred thousand times; a leak check wants every path walked once. Same binaries, one flag apart, and the whole run is about thirty seconds. scripts/memcheck.sh wraps `ctest -T memcheck` because that command records defects and still exits 0, which cannot gate anything. It also forces the headless drivers, so the vendor GPU stack is never loaded -- that removes thousands of unfixable findings inside amdgpu_dri.so without suppressing anything, and it is what the suites are written for anyway. Only definite losses, invalid accesses and uninitialised reads count; "still reachable" is what SDL and FreeType keep for the process lifetime and says nothing about this library. The three suppressions in scripts/valgrind.supp are each a decision that a finding belongs to somebody else. Six defects, all filed in TODO.md under "Memory checking", the first of which is the one that matters: not one of the four json_load_file calls in src/ is ever matched by a json_decref, so every asset load abandons its parsed document -- 1.5 KB per sprite, 2.1 KB per character, 9 KB per load of the 2x2 fixture map, and on the order of a megabyte for a real one. A game that reloads a level on death leaks a level's worth of JSON every time. akgl_get_property also reads up to 4 KiB past the end of every property value it copies, and the savegame name tables read past the end of every registry key and write what they find into the file. tests/util.c zeroes three fixtures it used to leave as stack garbage. The library was never at fault there -- the tests handed it uninitialised floats and then made one real call with them -- but sixteen findings of noise in a new gate is how a gate gets ignored. The unit suites' TIMEOUT goes from 30 to 300 because CTest applies the same property to the checked run, where everything is twenty times slower. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:59:57 -04:00
# 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)
Build clean under -Wall, with -Werror in CI only TODO.md item 37 said the cast sweep buys nothing until the build turns on the warnings those casts suppress. This is that precondition, and the argument turned out to be right with evidence: -Wall found three genuine signedness mismatches in sprite.c, where uint32_t width, height and speed were passed straight to akgl_get_json_integer_value(..., int *). A cast would have silenced all three. Fixing them properly also turned up that speed is scaled by a million into a 32-bit field, so anything past 4294 ms overflowed rather than being held. The other real finding was ten -Wstringop-truncation warnings, every one a fixed-width strncpy. Those leave a name field unterminated when the input fills it -- and those fields are registry keys, handed to strcmp and SDL property calls that do not stop at the field. Same overread class fixed twice already this release. All ten now use aksl_strncpy from akstdlib, which always terminates and never NUL-pads, bounded to size - 1 so an over-long name still truncates rather than being refused. staticstring.h documented the old strncpy semantics explicitly and has been rewritten to match. Two sites in game.c are deliberately left on strncpy: both stage into a pre-zeroed buffer for the savegame's fixed-width fields, where the NUL padding is part of the format. Neither is flagged. sprite.c also had a memcpy of a full 128-byte field from a NUL-terminated string, which reads past the end of any shorter name. Not flagged by anything; found while converting its neighbours. -Werror is an option, default OFF, on only in the cmake_build and performance CI jobs. libakgl is consumed with add_subdirectory -- akbasic does exactly that -- so a target-level -Werror turns every diagnostic a newer compiler invents into a broken build for somebody else. The warning set is not even stable across build types here: an -O2 build reports ten warnings that -O0 and -fsyntax-only do not, because they need the middle end. The two jobs that set it are one of each. -Wextra is deliberately not adopted. It adds 22 findings, 17 inherent to the design: 13 -Wunused-parameter from backend vtables and SDL callbacks that must match a signature, and 4 -Wimplicit-fallthrough from libakerror's own PROCESS/HANDLE/HANDLE_GROUP, which fall through by design. Filed upstream as deps/libakerror TODO item 8; the submodule bump carries it. deps/semver/semver.c is listed directly in add_library, so the target's PRIVATE options reach it. Exempted with -w so a future semver update cannot fail this build -- verified by forcing -Wextra -Werror and watching our files fail while semver compiled clean. Verified: RelWithDebInfo, Debug and coverage builds all clean under -Werror; -Werror actually fails on a planted unused variable; an embedded consumer with AKGL_WERROR unset still configures and builds. 25/25 pass, memcheck clean, reindent --check, check_api_surface and check_error_protocol clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 09:18:52 -04:00
# -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)
Take libakerror 2.0.1 and drop the workaround it makes obsolete Every libakgl test suite could report success while failing. libakerror's default unhandled-error handler ended in exit(errctx->status), an exit status is one byte wide, and libakgl's band starts at 256 -- so AKGL_ERR_SDL, the most common failure a library built on SDL can have, exited 0 and CTest recorded a pass. tests/character.c aborted at its second of four tests on a bad renderer and was green for months. 0.5.0 worked around that here with TEST_TRAP_UNHANDLED_ERRORS() in tests/testutil.h, and TODO.md ended the entry saying any consumer's suites have the same problem and it was worth raising upstream. It was. 2.0.1 fixes it at the source: akerr_exit() owns the mapping and the default handler calls it, so 0 exits 0, 1 through 255 exit themselves, and anything else exits AKERR_EXIT_STATUS_UNREPRESENTABLE (125). The trap and its 21 call sites are gone. Verified by putting the original failure back rather than by reading the release notes: a FAIL_BREAK(AKGL_ERR_SDL) in tests/character.c's main exits 125 and CTest reports a failure. A standalone consumer raising the same status unhandled exits 125 where it exited 0 before. tests/actor.c installs its own handler and called exit(errctx->status) from it, which is the same defect one layer up. It calls akerr_exit() now. 2.0.0 also makes the error pool and the status registry thread safe, which libakgl needs more than it knew: audio_stream_callback raises error contexts on SDL's audio thread. With an unlocked pool that callback and the main thread could scan AKERR_ARRAY_ERROR at the same time and be handed the same slot. The comment there says so. This is a hard dependency floor, not a preference. 2.0.0 moved __akerr_last_ignored to thread-local storage and made akerr_next_error() return a context that already holds its reference, and both expand at libakgl's call sites -- and at a consumer's, because akerror.h is part of libakgl's public interface. Mixing headers and libraries across that line double-counts every reference and never returns a pool slot. The soname moved to libakerror.so.2; include/akgl/error.h now also feature- tests AKERR_EXIT_STATUS_UNREPRESENTABLE, which is the narrowest probe for 2.0.1 since libakerror publishes no version macro. 0.7.0 for that reason: libakgl's own ABI is unchanged, but the one it re-exports through its headers is not. TODO.md records the pkg-config gap this makes sharper -- akgl.pc names no dependencies at all, so nothing tells a pkg-config consumer which libakerror it needs. Clean build, 26/26 ctest, memcheck clean, warning-clean at -Wall -Werror. libakgl.so.0.7 links libakerror.so.2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8T5FAYXE8HEJqFLCYwNNc
2026-08-01 13:05:43 -04:00
# 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
Take libakerror 2.0.1 and drop the workaround it makes obsolete Every libakgl test suite could report success while failing. libakerror's default unhandled-error handler ended in exit(errctx->status), an exit status is one byte wide, and libakgl's band starts at 256 -- so AKGL_ERR_SDL, the most common failure a library built on SDL can have, exited 0 and CTest recorded a pass. tests/character.c aborted at its second of four tests on a bad renderer and was green for months. 0.5.0 worked around that here with TEST_TRAP_UNHANDLED_ERRORS() in tests/testutil.h, and TODO.md ended the entry saying any consumer's suites have the same problem and it was worth raising upstream. It was. 2.0.1 fixes it at the source: akerr_exit() owns the mapping and the default handler calls it, so 0 exits 0, 1 through 255 exit themselves, and anything else exits AKERR_EXIT_STATUS_UNREPRESENTABLE (125). The trap and its 21 call sites are gone. Verified by putting the original failure back rather than by reading the release notes: a FAIL_BREAK(AKGL_ERR_SDL) in tests/character.c's main exits 125 and CTest reports a failure. A standalone consumer raising the same status unhandled exits 125 where it exited 0 before. tests/actor.c installs its own handler and called exit(errctx->status) from it, which is the same defect one layer up. It calls akerr_exit() now. 2.0.0 also makes the error pool and the status registry thread safe, which libakgl needs more than it knew: audio_stream_callback raises error contexts on SDL's audio thread. With an unlocked pool that callback and the main thread could scan AKERR_ARRAY_ERROR at the same time and be handed the same slot. The comment there says so. This is a hard dependency floor, not a preference. 2.0.0 moved __akerr_last_ignored to thread-local storage and made akerr_next_error() return a context that already holds its reference, and both expand at libakgl's call sites -- and at a consumer's, because akerror.h is part of libakgl's public interface. Mixing headers and libraries across that line double-counts every reference and never returns a pool slot. The soname moved to libakerror.so.2; include/akgl/error.h now also feature- tests AKERR_EXIT_STATUS_UNREPRESENTABLE, which is the narrowest probe for 2.0.1 since libakerror publishes no version macro. 0.7.0 for that reason: libakgl's own ABI is unchanged, but the one it re-exports through its headers is not. TODO.md records the pkg-config gap this makes sharper -- akgl.pc names no dependencies at all, so nothing tells a pkg-config consumer which libakerror it needs. Clean build, 26/26 ctest, memcheck clean, warning-clean at -Wall -Werror. libakgl.so.0.7 links libakerror.so.2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8T5FAYXE8HEJqFLCYwNNc
2026-08-01 13:05:43 -04:00
# 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()
Namespace every exported symbol, and bump to 0.5.0 Closes internal-consistency items 1 through 6, 12 and 13. Every include guard is _AKGL_<FILE>_H_, every in-project header include is angled, and every exported function, type and global carries the akgl_ prefix. This is an ABI break; the soname goes to libakgl.so.0.5. TODO.md carries the full rename table. The renames were driven by renaming each declaration and letting the compiler find the uses, not by pattern substitution: renderer, physics and camera are also parameter and struct-member names, and a sed would have rewritten map->physics and every akgl_RenderBackend *renderer parameter without a word. Item 4 turned out not to be cosmetic. The library exported a global called renderer and tests/character.c defined an SDL_Renderer *renderer of its own; the executable's definition preempted the library's, akgl_sprite_load_json read a SDL_Renderer * through an akgl_RenderBackend *, and every texture load in that suite failed. The suite reported success anyway, because libakerror's unhandled-error handler ends in exit(errctx->status), exit keeps only the low byte, and AKGL_ERR_SDL is exactly 256. So character had been green while running one of its four tests, and every suite in the tree was unable to fail on the most common status in a library built on SDL. Both are fixed. tests/testutil.h gains TEST_TRAP_UNHANDLED_ERRORS(), which collapses any status a byte cannot carry onto 1, and every suite installs it. character binds a real backend with akgl_render_2d_bind. Its fourth test then runs for the first time and fails on a defect it has asserted all along, so akgl_heap_release_character now walks state_sprites with AKGL_ITERATOR_OP_RELEASE and destroys the property set before zeroing the slot -- TODO.md Defects item 21 and half of Carried over item 1. AKGL_TIME_ONESEC_MS said "one second in milliseconds" and held 1000000, so akgl_game_state_lock waited roughly sixteen minutes rather than one second. It is AKGL_TIME_ONEMS_NS now, the budget is its own named constant, and tests/game.c holds the mutex from a second thread to assert the wait -- the contended path had no coverage at all. Headers are self-contained and it is enforced: AKGL_PUBLIC_HEADERS drives both install() and a generated translation unit per header, so a header that ships is a header that is checked. Writing that found registry.h, which used SDL_PropertiesID in eight declarations and included no SDL header. 23/23 suites pass, memcheck is clean, reindent --check is clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:32:21 -04:00
# 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
Add collision shapes, on the character and overridable per actor A shape is a convex volume positioned relative to an actor's origin. It lives on akgl_Character, so every goblin sharing a character shares one shape definition the way they already share speeds and the state-to-sprite map, and an actor may override it with akgl_Actor::shape_override. The flag is not redundant with an empty shape. "This actor deliberately has no collider" and "this actor has not been given one yet" are different states, and without somewhere to record the difference akgl_actor_set_character cannot tell whether it is allowed to overwrite what it finds. include/akgl/collision.h is a leaf: it names actors and tilemaps as incomplete struct pointers rather than including their headers, because both of those need akgl_CollisionShape by value and a cycle forms otherwise. The `headers` suite compiles it as the first include of a translation unit, so that property is enforced rather than merely intended -- which is why the tilemap types got struct tags two commits ago. The masks are asymmetric and the defaults are the load-bearing part: layermask ACTOR, collidemask STATIC. An actor given a shape and nothing else collides with map geometry and with no other actor. "Everything with a hitbox shoves everything else" would be a surprising default for a town full of scenery and a painful one to discover after the fact; opting in to actor-versus-actor is one bit, opting out of it would have been a hunt through every NPC a game spawns. Every shape gets a z depth, because the narrowphase behind this is three dimensional and answers with the *minimum* separating translation. A thin extrusion makes z the cheapest axis, and an actor pushed along z goes nowhere a player can see while remaining inside the floor -- a collision system reporting success while doing nothing. The ratio of 2 is the smallest for which the z overlap of any two shapes the setters build provably exceeds any planar penetration they can reach. The test for that asserts the inequality across a spread of sizes rather than asserting the constant is 2, so a change to the constant fails here rather than in a game. Two things about that test are worth recording, because the first version had both: - It could not fail. TEST_ASSERT expands to FAIL_BREAK, which reports by `break`ing, and the assertion was inside a nested `for` -- so the break left the loop rather than the ATTEMPT block and the failure evaporated. This is the hazard AGENTS.md documents for CATCH; it applies to the whole family. Verified by setting the ratio to 0.5 and watching the suite still pass, then fixing it and watching it report the 512x512 case by name. - tests/collision_arena.c had the same bug in a loop added in the previous commit, and it is fixed here too. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:48:05 -04:00
collision
Give libccd a static arena so libakgl still allocates nothing libakgl does not call malloc at runtime. That is stated in seven places in the manual and is AGENTS.md's second standing rule, and every object comes from a fixed array in akgl/heap.h. libccd's EPA path does not know that: it builds its expanding polytope out of realloc and free, and ccdGJKPenetration documents a -2 return for when that fails. The alternative was to compile only the three files MPR needs and leave EPA out of the build. That works and it puts a copy of somebody else's source list in this repository, where it rots silently on the next submodule bump. This instead compiles all of libccd and points its allocator at a bump allocator over static BSS, which keeps the promise literally true -- and keeps EPA available rather than amputated, for the day a precise contact manifold is worth having. The arena is reset at the top of each query rather than freed block by block, so the lifetime is one narrowphase call, `free` is a no-op, and allocation is a pointer bump. Exhaustion returns NULL, which libccd already handles by unwinding to -2, which becomes AKGL_ERR_COLLISION naming the high-water mark -- a loud failure with a number in it rather than a silently missed collision, which would be a floor an actor falls through reported as success. One GJK/EPA box pair costs 7,264 bytes, measured and printed by the suite. The 64 KB ceiling is that with about nine times headroom, and the high-water mark is reported so the next reader can re-derive it rather than trust this line. The redirect lives in src/ccd_arena_shim.h, injected with -include, and not in CMake's COMPILE_DEFINITIONS. It was in COMPILE_DEFINITIONS first, and that is a mistake worth recording: CMake cannot carry a function-like macro through a -D, so it dropped __CCD_ALLOC_MEMORY without a diagnostic. libccd went on calling the C library's realloc while the shim's `free` quietly discarded the results -- strictly worse than doing nothing, and invisible, because it leaks rather than crashes. What caught it was insisting the test prove the wiring rather than the outcome. The first version asserted the arena balanced back to zero after a query, which turned out to be the wrong assertion for a different reason -- free is a no-op by design, so it cannot balance -- but a test that had merely checked "two boxes collide" would have passed throughout, against an allocator nothing was using. The suite now asserts what is actually provable: that a query allocates from the arena at all, and that the process survives, since glibc aborts when the real free(3) is handed a pointer it never issued. Also here: AKGL_ERR_COLLISION, with the name registered -- tests/error.c asserts the band and the names agree, and it caught the missing one immediately. -fvisibility=hidden and CCD_STATIC_DEFINE keep every ccd* symbol out of libakgl.so's dynamic table, which `nm -D` confirms is empty; AGENTS.md records a shipped defect where an exported `renderer` was preempted by a same-named symbol elsewhere, and a game linking a system libccd would hit exactly that. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:29:05 -04:00
collision_arena
Namespace every exported symbol, and bump to 0.5.0 Closes internal-consistency items 1 through 6, 12 and 13. Every include guard is _AKGL_<FILE>_H_, every in-project header include is angled, and every exported function, type and global carries the akgl_ prefix. This is an ABI break; the soname goes to libakgl.so.0.5. TODO.md carries the full rename table. The renames were driven by renaming each declaration and letting the compiler find the uses, not by pattern substitution: renderer, physics and camera are also parameter and struct-member names, and a sed would have rewritten map->physics and every akgl_RenderBackend *renderer parameter without a word. Item 4 turned out not to be cosmetic. The library exported a global called renderer and tests/character.c defined an SDL_Renderer *renderer of its own; the executable's definition preempted the library's, akgl_sprite_load_json read a SDL_Renderer * through an akgl_RenderBackend *, and every texture load in that suite failed. The suite reported success anyway, because libakerror's unhandled-error handler ends in exit(errctx->status), exit keeps only the low byte, and AKGL_ERR_SDL is exactly 256. So character had been green while running one of its four tests, and every suite in the tree was unable to fail on the most common status in a library built on SDL. Both are fixed. tests/testutil.h gains TEST_TRAP_UNHANDLED_ERRORS(), which collapses any status a byte cannot carry onto 1, and every suite installs it. character binds a real backend with akgl_render_2d_bind. Its fourth test then runs for the first time and fails on a defect it has asserted all along, so akgl_heap_release_character now walks state_sprites with AKGL_ITERATOR_OP_RELEASE and destroys the property set before zeroing the slot -- TODO.md Defects item 21 and half of Carried over item 1. AKGL_TIME_ONESEC_MS said "one second in milliseconds" and held 1000000, so akgl_game_state_lock waited roughly sixteen minutes rather than one second. It is AKGL_TIME_ONEMS_NS now, the budget is its own named constant, and tests/game.c holds the mutex from a second thread to assert the wait -- the contended path had no coverage at all. Headers are self-contained and it is enforced: AKGL_PUBLIC_HEADERS drives both install() and a generated translation unit per header, so a header that ships is a header that is checked. Writing that found registry.h, which used SDL_PropertiesID in eight declarations and included no SDL header. 23/23 suites pass, memcheck is clean, reindent --check is clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:32:21 -04:00
controller
draw
error
game
heap
iterator
json_helpers
physics
registry
renderer
sprite
staticstring
text
tilemap
types
Vendor clay and bring the UI subsystem up: arena, lifecycle, status band clay v0.14 (deps/clay, zlib licence) supplies the layout engine for the new akgl_ui subsystem. Sources-listed rather than add_subdirectory()'d, on the semver/libccd precedent: clay's CMakeLists declares no library target, builds its examples by default, and requires CMake 3.27 against this project's 3.10. src/ui_clay.c is the one CLAY_IMPLEMENTATION translation unit, compiled -w on the vendored-code terms but with clay's symbols deliberately exported -- consumers' CLAY() macros resolve against libakgl.so, and akgl/ui.h warns them never to link a second clay. The subsystem is runtime-optional the way collision is: always compiled in, inert until akgl_ui_init(), which bounds clay to the overridable AKGL_UI_* ceilings, checks Clay_MinMemorySize() against a static BSS arena (no malloc), and refuses with both numbers in the message when it does not fit. Measured: 812544 bytes at the default 1024 elements / 4096 measured words, against a 1 MiB arena. clay's void-callback layout errors are logged as they happen and the first is stashed for the error protocol to raise later. AKGL_ERR_UI joins the status band before AKGL_ERR_LIMIT, named in akgl_error_init. New `ui` test suite covers validation, the init/shutdown lifecycle, and arena exhaustion via the akgl_ui_arena_limit test hook (same contract as akgl_ccd_arena_set_limit). clay.h is installed beside semver.h, its licence beside libccd's, and the headers suite proves akgl/ui.h self-contained -- clay.h compiles clean under -Wall. 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 10:53:11 -04:00
ui
Namespace every exported symbol, and bump to 0.5.0 Closes internal-consistency items 1 through 6, 12 and 13. Every include guard is _AKGL_<FILE>_H_, every in-project header include is angled, and every exported function, type and global carries the akgl_ prefix. This is an ABI break; the soname goes to libakgl.so.0.5. TODO.md carries the full rename table. The renames were driven by renaming each declaration and letting the compiler find the uses, not by pattern substitution: renderer, physics and camera are also parameter and struct-member names, and a sed would have rewritten map->physics and every akgl_RenderBackend *renderer parameter without a word. Item 4 turned out not to be cosmetic. The library exported a global called renderer and tests/character.c defined an SDL_Renderer *renderer of its own; the executable's definition preempted the library's, akgl_sprite_load_json read a SDL_Renderer * through an akgl_RenderBackend *, and every texture load in that suite failed. The suite reported success anyway, because libakerror's unhandled-error handler ends in exit(errctx->status), exit keeps only the low byte, and AKGL_ERR_SDL is exactly 256. So character had been green while running one of its four tests, and every suite in the tree was unable to fail on the most common status in a library built on SDL. Both are fixed. tests/testutil.h gains TEST_TRAP_UNHANDLED_ERRORS(), which collapses any status a byte cannot carry onto 1, and every suite installs it. character binds a real backend with akgl_render_2d_bind. Its fourth test then runs for the first time and fails on a defect it has asserted all along, so akgl_heap_release_character now walks state_sprites with AKGL_ITERATOR_OP_RELEASE and destroys the property set before zeroing the slot -- TODO.md Defects item 21 and half of Carried over item 1. AKGL_TIME_ONESEC_MS said "one second in milliseconds" and held 1000000, so akgl_game_state_lock waited roughly sixteen minutes rather than one second. It is AKGL_TIME_ONEMS_NS now, the budget is its own named constant, and tests/game.c holds the mutex from a second thread to assert the wait -- the contended path had no coverage at all. Headers are self-contained and it is enforced: AKGL_PUBLIC_HEADERS drives both install() and a generated translation unit per header, so a header that ships is a header that is checked. Writing that found registry.h, which used SDL_PropertiesID in eight declarations and included no SDL header. 23/23 suites pass, memcheck is clean, reindent --check is clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:32:21 -04:00
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)
Derive the library version from one place and give the .so a soname The version was written down twice and agreed with itself by luck. AKGL_VERSION was hand-maintained in include/akgl/game.h, and nothing connected it to the build, which had no version at all: - project() declared no VERSION, so the @PROJECT_VERSION@ substitution in akgl.pc.in expanded to nothing and every installed akgl.pc shipped an empty Version field. pkg-config --modversion returned "" and --atleast-version could not work at all. - libakgl.so had no VERSION or SOVERSION, so there was no soname and no symlink chain. A stale libakgl could be paired with new headers silently, which is the failure libakerror added its own soname to prevent. - MY_LIBRARY_VERSION at CMakeLists.txt:288 was never set anywhere. It built main_lib_dest as "lib/akgl-", and main_lib_dest was then never read. Dead line, removed. project(akgl VERSION 0.1.0) is now the only place the number appears. It drives the generated include/akgl/version.h, the shared library VERSION and SOVERSION, and the Version field in akgl.pc. Held at 0.1.0 to match the constant it replaces, so akgl_game_load_versioncmp still compares savegames against the same value. include/akgl/version.h is generated by configure_file from version.h.in and publishes AKGL_VERSION, AKGL_VERSION_MAJOR/MINOR/PATCH and AKGL_VERSION_AT_LEAST(major, minor, patch). That macro is the point: libakstdlib could not write that test against libakerror, which publishes no version macro, and had to feature-test on AKERR_FIRST_CONSUMER_STATUS instead. game.h now includes the generated header rather than defining the version itself, so consumers see AKGL_VERSION exactly where they saw it before. While the major version is 0 the soname carries major.minor, giving libakgl.so.0.1. A plain SOVERSION 0 would assert that 0.1.0 and 0.2.0 are ABI-interchangeable, and they are not -- the preceding commit alone added akgl_error_init. At 1.0.0 the soname becomes the major alone, matching libakerror. akgl_version() in src/version.c reports the version of the libakgl that is actually linked, where AKGL_VERSION reports what the caller compiled against. Comparing the two is the only way to catch a stale shared library on the loader path; the macro alone cannot see it. It returns a string rather than an akerr_ErrorContext * because it cannot fail, for the same reason akerr_name_for_status does. Add tests/version.c: the string and the numeric components must describe one version, the linked library must agree with the headers, and AKGL_VERSION_AT_LEAST must order correctly at the major, minor and patch boundaries. Verified out of band that bumping project() to 1.2.3 moves version.h, akgl.pc and the soname together, and that an install produces the libakgl.so -> .so.0.1 -> .so.0.1.0 chain with SONAME libakgl.so.0.1. Also ignore include/akgl/version.h. include/ precedes the build tree on the include path, so a stray copy there would shadow the generated one and pin every consumer to whatever version it was generated at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:36:50 -04:00
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/include/akgl/version.h.in
${CMAKE_CURRENT_BINARY_DIR}/include/akgl/version.h @ONLY)
2025-08-09 13:53:37 -04:00
# 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}")
Stop a failed controller-DB fetch from destroying the tracked fallback Closes Defects -> Known and still open item 12, both halves. include/akgl/SDL_GameControllerDB.h is tracked on purpose: it is the offline fallback that keeps the library buildable when upstream is unreachable. The script that writes it had no set -e and never checked curl, so a failed fetch wrote AKGL_SDL_GAMECONTROLLER_DB_LEN 0 and an empty array over the good copy and exited 0. The safety net destroyed itself, and because CMake re-ran the generator on every build, an offline build was enough to do it. The script now runs under set -euo pipefail, fetches into a temporary directory, and moves the result into place only after checking curl's exit status (with --fail, so an HTTP error is a status rather than an error page in the body), a plausible minimum mapping count, and that no mapping carries a quote or backslash that would break the C string literal it becomes. Any of those failing leaves the tracked header exactly as it was and exits non-zero. Verified against all five failure modes -- unresolvable host, 404, truncated response, empty-but-successful response (the original failure exactly), and a response containing a quote. Each refuses, and the header's checksum is unchanged after every one. AKGL_CONTROLLERDB_URL and AKGL_CONTROLLERDB_MIN_LINES are environment-overridable, which is how. The build no longer runs it at all. The add_custom_command declared a relative OUTPUT, which CMake resolves against the binary directory while the script writes to the source directory, so the declared output never appeared and every build re-ran the command -- needing network access and leaving the tree dirty. It is an explicit `controllerdb` target now, and the header is no longer listed as a library source, which is all it was there for. The empty-initializer problem goes with it: an empty array initializer is a constraint violation in ISO C that compiles only as a GCC extension, and the minimum-count check guarantees at least one entry. Also here, because it rested on a premise that turned out to be false: the character suite is no longer excluded from CI's coverage and memory-check jobs, nor from the mutation harness by default. It was excluded on the grounds that it failed deliberately. It did not -- it was reporting success while running one of its four tests. 25/25 pass, memcheck clean, shellcheck clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 06:54:31 -04:00
# 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}
Stop a failed controller-DB fetch from destroying the tracked fallback Closes Defects -> Known and still open item 12, both halves. include/akgl/SDL_GameControllerDB.h is tracked on purpose: it is the offline fallback that keeps the library buildable when upstream is unreachable. The script that writes it had no set -e and never checked curl, so a failed fetch wrote AKGL_SDL_GAMECONTROLLER_DB_LEN 0 and an empty array over the good copy and exited 0. The safety net destroyed itself, and because CMake re-ran the generator on every build, an offline build was enough to do it. The script now runs under set -euo pipefail, fetches into a temporary directory, and moves the result into place only after checking curl's exit status (with --fail, so an HTTP error is a status rather than an error page in the body), a plausible minimum mapping count, and that no mapping carries a quote or backslash that would break the C string literal it becomes. Any of those failing leaves the tracked header exactly as it was and exits non-zero. Verified against all five failure modes -- unresolvable host, 404, truncated response, empty-but-successful response (the original failure exactly), and a response containing a quote. Each refuses, and the header's checksum is unchanged after every one. AKGL_CONTROLLERDB_URL and AKGL_CONTROLLERDB_MIN_LINES are environment-overridable, which is how. The build no longer runs it at all. The add_custom_command declared a relative OUTPUT, which CMake resolves against the binary directory while the script writes to the source directory, so the declared output never appeared and every build re-ran the command -- needing network access and leaving the tree dirty. It is an explicit `controllerdb` target now, and the header is no longer listed as a library source, which is all it was there for. The empty-initializer problem goes with it: an empty array initializer is a constraint violation in ISO C that compiles only as a GCC extension, and the minimum-count check guarantees at least one entry. Also here, because it rested on a premise that turned out to be false: the character suite is no longer excluded from CI's coverage and memory-check jobs, nor from the mutation harness by default. It was excluded on the grounds that it failed deliberately. It did not -- it was reporting success while running one of its four tests. 25/25 pass, memcheck clean, shellcheck clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 06:54:31 -04:00
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
USES_TERMINAL
COMMENT "Fetching controller mappings and rewriting ${GAMECONTROLLERDB_H} ..."
2025-08-09 13:53:37 -04:00
)
Give libccd a static arena so libakgl still allocates nothing libakgl does not call malloc at runtime. That is stated in seven places in the manual and is AGENTS.md's second standing rule, and every object comes from a fixed array in akgl/heap.h. libccd's EPA path does not know that: it builds its expanding polytope out of realloc and free, and ccdGJKPenetration documents a -2 return for when that fails. The alternative was to compile only the three files MPR needs and leave EPA out of the build. That works and it puts a copy of somebody else's source list in this repository, where it rots silently on the next submodule bump. This instead compiles all of libccd and points its allocator at a bump allocator over static BSS, which keeps the promise literally true -- and keeps EPA available rather than amputated, for the day a precise contact manifold is worth having. The arena is reset at the top of each query rather than freed block by block, so the lifetime is one narrowphase call, `free` is a no-op, and allocation is a pointer bump. Exhaustion returns NULL, which libccd already handles by unwinding to -2, which becomes AKGL_ERR_COLLISION naming the high-water mark -- a loud failure with a number in it rather than a silently missed collision, which would be a floor an actor falls through reported as success. One GJK/EPA box pair costs 7,264 bytes, measured and printed by the suite. The 64 KB ceiling is that with about nine times headroom, and the high-water mark is reported so the next reader can re-derive it rather than trust this line. The redirect lives in src/ccd_arena_shim.h, injected with -include, and not in CMake's COMPILE_DEFINITIONS. It was in COMPILE_DEFINITIONS first, and that is a mistake worth recording: CMake cannot carry a function-like macro through a -D, so it dropped __CCD_ALLOC_MEMORY without a diagnostic. libccd went on calling the C library's realloc while the shim's `free` quietly discarded the results -- strictly worse than doing nothing, and invisible, because it leaks rather than crashes. What caught it was insisting the test prove the wiring rather than the outcome. The first version asserted the arena balanced back to zero after a query, which turned out to be the wrong assertion for a different reason -- free is a no-op by design, so it cannot balance -- but a test that had merely checked "two boxes collide" would have passed throughout, against an allocator nothing was using. The suite now asserts what is actually provable: that a query allocates from the arena at all, and that the process survives, since glibc aborts when the real free(3) is handed a pointer it never issued. Also here: AKGL_ERR_COLLISION, with the name registered -- tests/error.c asserts the band and the names agree, and it caught the missing one immediately. -fvisibility=hidden and CCD_STATIC_DEFINE keep every ccd* symbol out of libakgl.so's dynamic table, which `nm -D` confirms is empty; AGENTS.md records a shipped defect where an exported `renderer` was preempted by a same-named symbol elsewhere, and a game linking a system libccd would hit exactly that. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:29:05 -04:00
# 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
Give libccd a static arena so libakgl still allocates nothing libakgl does not call malloc at runtime. That is stated in seven places in the manual and is AGENTS.md's second standing rule, and every object comes from a fixed array in akgl/heap.h. libccd's EPA path does not know that: it builds its expanding polytope out of realloc and free, and ccdGJKPenetration documents a -2 return for when that fails. The alternative was to compile only the three files MPR needs and leave EPA out of the build. That works and it puts a copy of somebody else's source list in this repository, where it rots silently on the next submodule bump. This instead compiles all of libccd and points its allocator at a bump allocator over static BSS, which keeps the promise literally true -- and keeps EPA available rather than amputated, for the day a precise contact manifold is worth having. The arena is reset at the top of each query rather than freed block by block, so the lifetime is one narrowphase call, `free` is a no-op, and allocation is a pointer bump. Exhaustion returns NULL, which libccd already handles by unwinding to -2, which becomes AKGL_ERR_COLLISION naming the high-water mark -- a loud failure with a number in it rather than a silently missed collision, which would be a floor an actor falls through reported as success. One GJK/EPA box pair costs 7,264 bytes, measured and printed by the suite. The 64 KB ceiling is that with about nine times headroom, and the high-water mark is reported so the next reader can re-derive it rather than trust this line. The redirect lives in src/ccd_arena_shim.h, injected with -include, and not in CMake's COMPILE_DEFINITIONS. It was in COMPILE_DEFINITIONS first, and that is a mistake worth recording: CMake cannot carry a function-like macro through a -D, so it dropped __CCD_ALLOC_MEMORY without a diagnostic. libccd went on calling the C library's realloc while the shim's `free` quietly discarded the results -- strictly worse than doing nothing, and invisible, because it leaks rather than crashes. What caught it was insisting the test prove the wiring rather than the outcome. The first version asserted the arena balanced back to zero after a query, which turned out to be the wrong assertion for a different reason -- free is a no-op by design, so it cannot balance -- but a test that had merely checked "two boxes collide" would have passed throughout, against an allocator nothing was using. The suite now asserts what is actually provable: that a query allocates from the arena at all, and that the process survives, since glibc aborts when the real free(3) is handed a pointer it never issued. Also here: AKGL_ERR_COLLISION, with the name registered -- tests/error.c asserts the band and the names agree, and it caught the missing one immediately. -fvisibility=hidden and CCD_STATIC_DEFINE keep every ccd* symbol out of libakgl.so's dynamic table, which `nm -D` confirms is empty; AGENTS.md records a shipped defect where an exported `renderer` was preempted by a same-named symbol elsewhere, and a game linking a system libccd would hit exactly that. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:29:05 -04:00
${AKGL_CCD_SOURCES}
src/actor.c
Give libccd a static arena so libakgl still allocates nothing libakgl does not call malloc at runtime. That is stated in seven places in the manual and is AGENTS.md's second standing rule, and every object comes from a fixed array in akgl/heap.h. libccd's EPA path does not know that: it builds its expanding polytope out of realloc and free, and ccdGJKPenetration documents a -2 return for when that fails. The alternative was to compile only the three files MPR needs and leave EPA out of the build. That works and it puts a copy of somebody else's source list in this repository, where it rots silently on the next submodule bump. This instead compiles all of libccd and points its allocator at a bump allocator over static BSS, which keeps the promise literally true -- and keeps EPA available rather than amputated, for the day a precise contact manifold is worth having. The arena is reset at the top of each query rather than freed block by block, so the lifetime is one narrowphase call, `free` is a no-op, and allocation is a pointer bump. Exhaustion returns NULL, which libccd already handles by unwinding to -2, which becomes AKGL_ERR_COLLISION naming the high-water mark -- a loud failure with a number in it rather than a silently missed collision, which would be a floor an actor falls through reported as success. One GJK/EPA box pair costs 7,264 bytes, measured and printed by the suite. The 64 KB ceiling is that with about nine times headroom, and the high-water mark is reported so the next reader can re-derive it rather than trust this line. The redirect lives in src/ccd_arena_shim.h, injected with -include, and not in CMake's COMPILE_DEFINITIONS. It was in COMPILE_DEFINITIONS first, and that is a mistake worth recording: CMake cannot carry a function-like macro through a -D, so it dropped __CCD_ALLOC_MEMORY without a diagnostic. libccd went on calling the C library's realloc while the shim's `free` quietly discarded the results -- strictly worse than doing nothing, and invisible, because it leaks rather than crashes. What caught it was insisting the test prove the wiring rather than the outcome. The first version asserted the arena balanced back to zero after a query, which turned out to be the wrong assertion for a different reason -- free is a no-op by design, so it cannot balance -- but a test that had merely checked "two boxes collide" would have passed throughout, against an allocator nothing was using. The suite now asserts what is actually provable: that a query allocates from the arena at all, and that the process survives, since glibc aborts when the real free(3) is handed a pointer it never issued. Also here: AKGL_ERR_COLLISION, with the name registered -- tests/error.c asserts the band and the names agree, and it caught the missing one immediately. -fvisibility=hidden and CCD_STATIC_DEFINE keep every ccd* symbol out of libakgl.so's dynamic table, which `nm -D` confirms is empty; AGENTS.md records a shipped defect where an exported `renderer` was preempted by a same-named symbol elsewhere, and a game linking a system libccd would hit exactly that. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:29:05 -04:00
src/collision.c
src/collision_arena.c
Add a second partitioner, so the vtable is a seam and not a decoration A binary space partition on libakstdlib's tree links and lists, registered in the factory as "bsp". It runs the same assertions the grid does, because tests/partition.c is a table over partitioners and adding a row is all it takes to be held to the contract. **Use the grid.** This is here to prove the vtable works and to have something to measure the grid against, and the file says so at the top. It rebuilds whenever the proxy set changes, which is the shape PERFORMANCE.md records Phaser using and capping out around five thousand bodies. It would earn its place in a world with wildly non-uniform object sizes, or one larger than the grid's fixed cell array covers. The difference is visible in the code rather than buried in a benchmark: the grid's `move` compares four integers and returns when a proxy has not left its cells, and this one marks the whole partition stale. The incremental-move assertion in the suite is therefore grid-only, and says why. aksl_tree_iterate is not used, and the file carries the three reasons so the next reader does not rediscover them: it is a complete traversal whose only control signal stops the entire walk, so there is no way to prune a subtree -- which is the only operation a spatial query is made of; it carries no per-node context, and a pruning descent needs each node's bounds and plane; and its breadth-first modes allocate, while only the depth-first ones are malloc-free and those are the ones without pruning. aksl_tree_insert is unusable for a different reason again: it is a comparator-ordered BST, and a spatial insert has to compare a leaf against a plane, which aksl_TreeCompareFunc cannot express. What is used is the link structure and the lists, neither of which allocates, which is why they fit here at all. The descent is an explicit stack rather than recursion, so the depth bound is an array bound the compiler can see -- this library already has one documented way to blow the C stack and does not need a second. Split planes are the median of the item centres on the longer axis, not the spatial midpoint: actors in a tile game cluster on the floor, and a midpoint split gives one empty child and one full one for several levels running. A split that separates nothing degrades the node to a leaf rather than recursing forever on the same set. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:08:54 -04:00
src/collision_bsp.c
Add a pluggable broad phase, and the incremental uniform grid behind it akgl_Partitioner is a record of function pointers and an initializer, the same shape as the render and physics backends. `move` is its own slot rather than remove-then-insert, and that is the whole design: a uniform grid's move is a comparison and a return when the proxy has not left the cells it was in, which is the steady state for a walking actor and the permanent state for a static one. Spelling it as remove-and-insert would turn the incremental grid into the rebuild-every-frame tree that PERFORMANCE.md already measured and rejected. The grid is a dense 128x128 array of cell heads over the world, cells keyed on tile size, with two intrusive chains per entry -- one through the cell so a query can walk it, one through the proxy so removal unlinks a whole span without searching. Everything is an int16_t index rather than a pointer, so the structure is relocatable and clearing it is a memset. A proxy covering more cells than AKGL_COLLISION_GRID_MAX_SPAN spills onto one chain every query walks, which is what makes the cell pool's ceiling provable rather than hopeful. tests/partition.c compares every query against a linear scan over the same proxies and asserts containment in one direction, not equality. That asymmetry is the contract: over-reporting costs a narrowphase call, under-reporting is a wall an actor walks through. The suite is a table over partitioners so the same assertions run against every implementation, which is what the second one landing later will be held to. Three deliberate breaks, and two of them were not caught the first time: - Removing the min-cell rule so a shared-cell pair reports repeatedly: caught. - Dropping the unlink in `move`: **not caught**, initially. Stale entries do not produce wrong query answers, because the bounds re-check filters them out -- they leak the cell pool until the grid stops working, on a timescale no unit test reaches by accident. Counting live pool entries across 200 moves is the only thing that sees it, and now does: 505 entries against an expected 5. - Swapping floorf for a truncating cast: **not caught, and correctly so.** Everything below zero is clamped to cell 0 either way, so today the two are indistinguishable. The comment claiming truncation is a defect was overstating it and now says what is actually true: the clamp is the only thing hiding it, and a negative world origin or a relaxed clamp would make it real with no test standing in front of it. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:29:02 -04:00
src/collision_grid.c
Add collision shapes, on the character and overridable per actor A shape is a convex volume positioned relative to an actor's origin. It lives on akgl_Character, so every goblin sharing a character shares one shape definition the way they already share speeds and the state-to-sprite map, and an actor may override it with akgl_Actor::shape_override. The flag is not redundant with an empty shape. "This actor deliberately has no collider" and "this actor has not been given one yet" are different states, and without somewhere to record the difference akgl_actor_set_character cannot tell whether it is allowed to overwrite what it finds. include/akgl/collision.h is a leaf: it names actors and tilemaps as incomplete struct pointers rather than including their headers, because both of those need akgl_CollisionShape by value and a cycle forms otherwise. The `headers` suite compiles it as the first include of a translation unit, so that property is enforced rather than merely intended -- which is why the tilemap types got struct tags two commits ago. The masks are asymmetric and the defaults are the load-bearing part: layermask ACTOR, collidemask STATIC. An actor given a shape and nothing else collides with map geometry and with no other actor. "Everything with a hitbox shoves everything else" would be a surprising default for a town full of scenery and a painful one to discover after the fact; opting in to actor-versus-actor is one bit, opting out of it would have been a hunt through every NPC a game spawns. Every shape gets a z depth, because the narrowphase behind this is three dimensional and answers with the *minimum* separating translation. A thin extrusion makes z the cheapest axis, and an actor pushed along z goes nowhere a player can see while remaining inside the floor -- a collision system reporting success while doing nothing. The ratio of 2 is the smallest for which the z overlap of any two shapes the setters build provably exceeds any planar penetration they can reach. The test for that asserts the inequality across a spread of sizes rather than asserting the constant is 2, so a change to the constant fails here rather than in a game. Two things about that test are worth recording, because the first version had both: - It could not fail. TEST_ASSERT expands to FAIL_BREAK, which reports by `break`ing, and the assertion was inside a nested `for` -- so the break left the loop rather than the ATTEMPT block and the failure evaporated. This is the hazard AGENTS.md documents for CATCH; it applies to the whole family. Verified by setting the ratio to 0.5 and watching the suite still pass, then fixing it and watching it report the 512x512 case by name. - tests/collision_arena.c had the same bug in a loop added in the previous commit, and it is fixed here too. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:48:05 -04:00
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
Migrate to the libakerror 1.0.0 status registry libakerror 1.0.0 replaced the consumer-sized status-name array with a private registry and made status-code ownership explicit and enforced. AKERR_MAX_ERR_VALUE and __AKERR_ERROR_NAMES are gone, and the registry entry points raise akerr_ErrorContext * instead of returning int. See deps/libakerror/UPGRADING.md. The break was not only source-level. libakgl's codes sat at AKERR_LAST_ERRNO_VALUE + 18 through + 22, and 1.0.0 claimed exactly those five offsets for its own AKERR_STATUS_* registry codes, so every AKGL_ERR_* was aliasing a libakerror status. HANDLE(e, AKGL_ERR_LOGICINTERRUPT) at physics.c:222 would have swallowed a foreign-name refusal. - Move the band to AKERR_FIRST_CONSUMER_STATUS (256) as fixed offsets, so a libc that grows an errno cannot move the codes, and add AKGL_ERR_OWNER, AKGL_ERR_LIMIT and AKGL_ERR_COUNT to describe it. - Reserve the range and register the names through the owned entry points, PASS-ing each: these are AKERR_NOIGNORE, and the old akerr_name_for_status calls discarded failure silently. - Drop the AKERR_MAX_ERR_VALUE=256 compile definition. - Guard on AKERR_FIRST_CONSUMER_STATUS in include/akgl/error.h, which now includes <akerror.h> so the guard is reliable. The embedded build is fine, but the find_package path can pick up a stale installed header, and 1.0.0 has an soname, so that pairing is an ABI mismatch rather than a compile problem. Same guard libakstdlib already carries. Registration also moves out of akgl_heap_init into a new akgl_error_init in src/error.c. It was in the heap pool's initializer only because that was the first thing akgl_game_init called, and the upgrade turned five fire-and-forget name calls into a library-wide ownership claim that can fail. That placement was hiding a defect: game.c raises AKGL_ERR_SDL when SDL_CreateMutex fails, five lines before akgl_heap_init ran, so the earliest error path in the library was guaranteed to print "Unknown Error". akgl_error_init is now the first statement in akgl_game_init. Callers that drive subsystems directly must call akgl_error_init first; it is idempotent, so ordering it precisely is not required. The eleven test suites that relied on akgl_heap_init to name their statuses now call it explicitly, or their failure messages would have degraded to "Unknown Error". Add tests/error.c: assert every code reads back its registered name, that the name table and AKGL_ERR_COUNT agree, that a foreign owner is refused with AKERR_STATUS_NAME_FOREIGN and AKERR_STATUS_RANGE_OVERLAP, and that repeating the init is a no-op. That last one is a live constraint, not a triviality -- libakerror treats only an identical reservation as a repeat, so a subset or superset raises. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:20:28 -04:00
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
Vendor clay and bring the UI subsystem up: arena, lifecycle, status band clay v0.14 (deps/clay, zlib licence) supplies the layout engine for the new akgl_ui subsystem. Sources-listed rather than add_subdirectory()'d, on the semver/libccd precedent: clay's CMakeLists declares no library target, builds its examples by default, and requires CMake 3.27 against this project's 3.10. src/ui_clay.c is the one CLAY_IMPLEMENTATION translation unit, compiled -w on the vendored-code terms but with clay's symbols deliberately exported -- consumers' CLAY() macros resolve against libakgl.so, and akgl/ui.h warns them never to link a second clay. The subsystem is runtime-optional the way collision is: always compiled in, inert until akgl_ui_init(), which bounds clay to the overridable AKGL_UI_* ceilings, checks Clay_MinMemorySize() against a static BSS arena (no malloc), and refuses with both numbers in the message when it does not fit. Measured: 812544 bytes at the default 1024 elements / 4096 measured words, against a 1 MiB arena. clay's void-callback layout errors are logged as they happen and the first is stashed for the error protocol to raise later. AKGL_ERR_UI joins the status band before AKGL_ERR_LIMIT, named in akgl_error_init. New `ui` test suite covers validation, the init/shutdown lifecycle, and arena exhaustion via the akgl_ui_arena_limit test hook (same contract as akgl_ccd_arena_set_limit). clay.h is installed beside semver.h, its licence beside libccd's, and the headers suite proves akgl/ui.h self-contained -- clay.h compiles clean under -Wall. 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 10:53:11 -04:00
src/ui.c
src/ui_clay.c
src/util.c
Derive the library version from one place and give the .so a soname The version was written down twice and agreed with itself by luck. AKGL_VERSION was hand-maintained in include/akgl/game.h, and nothing connected it to the build, which had no version at all: - project() declared no VERSION, so the @PROJECT_VERSION@ substitution in akgl.pc.in expanded to nothing and every installed akgl.pc shipped an empty Version field. pkg-config --modversion returned "" and --atleast-version could not work at all. - libakgl.so had no VERSION or SOVERSION, so there was no soname and no symlink chain. A stale libakgl could be paired with new headers silently, which is the failure libakerror added its own soname to prevent. - MY_LIBRARY_VERSION at CMakeLists.txt:288 was never set anywhere. It built main_lib_dest as "lib/akgl-", and main_lib_dest was then never read. Dead line, removed. project(akgl VERSION 0.1.0) is now the only place the number appears. It drives the generated include/akgl/version.h, the shared library VERSION and SOVERSION, and the Version field in akgl.pc. Held at 0.1.0 to match the constant it replaces, so akgl_game_load_versioncmp still compares savegames against the same value. include/akgl/version.h is generated by configure_file from version.h.in and publishes AKGL_VERSION, AKGL_VERSION_MAJOR/MINOR/PATCH and AKGL_VERSION_AT_LEAST(major, minor, patch). That macro is the point: libakstdlib could not write that test against libakerror, which publishes no version macro, and had to feature-test on AKERR_FIRST_CONSUMER_STATUS instead. game.h now includes the generated header rather than defining the version itself, so consumers see AKGL_VERSION exactly where they saw it before. While the major version is 0 the soname carries major.minor, giving libakgl.so.0.1. A plain SOVERSION 0 would assert that 0.1.0 and 0.2.0 are ABI-interchangeable, and they are not -- the preceding commit alone added akgl_error_init. At 1.0.0 the soname becomes the major alone, matching libakerror. akgl_version() in src/version.c reports the version of the libakgl that is actually linked, where AKGL_VERSION reports what the caller compiled against. Comparing the two is the only way to catch a stale shared library on the loader path; the macro alone cannot see it. It returns a string rather than an akerr_ErrorContext * because it cannot fail, for the same reason akerr_name_for_status does. Add tests/version.c: the string and the numeric components must describe one version, the linked library must agree with the headers, and AKGL_VERSION_AT_LEAST must order correctly at the major, minor and patch boundaries. Verified out of band that bumping project() to 1.2.3 moves version.h, akgl.pc and the soname together, and that an install produces the libakgl.so -> .so.0.1 -> .so.0.1.0 chain with SONAME libakgl.so.0.1. Also ignore include/akgl/version.h. include/ precedes the build tree on the include path, so a stray copy there would shadow the generated one and pin every consumer to whatever version it was generated at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:36:50 -04:00
src/version.c
)
Derive the library version from one place and give the .so a soname The version was written down twice and agreed with itself by luck. AKGL_VERSION was hand-maintained in include/akgl/game.h, and nothing connected it to the build, which had no version at all: - project() declared no VERSION, so the @PROJECT_VERSION@ substitution in akgl.pc.in expanded to nothing and every installed akgl.pc shipped an empty Version field. pkg-config --modversion returned "" and --atleast-version could not work at all. - libakgl.so had no VERSION or SOVERSION, so there was no soname and no symlink chain. A stale libakgl could be paired with new headers silently, which is the failure libakerror added its own soname to prevent. - MY_LIBRARY_VERSION at CMakeLists.txt:288 was never set anywhere. It built main_lib_dest as "lib/akgl-", and main_lib_dest was then never read. Dead line, removed. project(akgl VERSION 0.1.0) is now the only place the number appears. It drives the generated include/akgl/version.h, the shared library VERSION and SOVERSION, and the Version field in akgl.pc. Held at 0.1.0 to match the constant it replaces, so akgl_game_load_versioncmp still compares savegames against the same value. include/akgl/version.h is generated by configure_file from version.h.in and publishes AKGL_VERSION, AKGL_VERSION_MAJOR/MINOR/PATCH and AKGL_VERSION_AT_LEAST(major, minor, patch). That macro is the point: libakstdlib could not write that test against libakerror, which publishes no version macro, and had to feature-test on AKERR_FIRST_CONSUMER_STATUS instead. game.h now includes the generated header rather than defining the version itself, so consumers see AKGL_VERSION exactly where they saw it before. While the major version is 0 the soname carries major.minor, giving libakgl.so.0.1. A plain SOVERSION 0 would assert that 0.1.0 and 0.2.0 are ABI-interchangeable, and they are not -- the preceding commit alone added akgl_error_init. At 1.0.0 the soname becomes the major alone, matching libakerror. akgl_version() in src/version.c reports the version of the libakgl that is actually linked, where AKGL_VERSION reports what the caller compiled against. Comparing the two is the only way to catch a stale shared library on the loader path; the macro alone cannot see it. It returns a string rather than an akerr_ErrorContext * because it cannot fail, for the same reason akerr_name_for_status does. Add tests/version.c: the string and the numeric components must describe one version, the linked library must agree with the headers, and AKGL_VERSION_AT_LEAST must order correctly at the major, minor and patch boundaries. Verified out of band that bumping project() to 1.2.3 moves version.h, akgl.pc and the soname together, and that an install produces the libakgl.so -> .so.0.1 -> .so.0.1.0 chain with SONAME libakgl.so.0.1. Also ignore include/akgl/version.h. include/ precedes the build tree on the include path, so a stray copy there would shadow the generated one and pin every consumer to whatever version it was generated at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:36:50 -04:00
# 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}
)
Build clean under -Wall, with -Werror in CI only TODO.md item 37 said the cast sweep buys nothing until the build turns on the warnings those casts suppress. This is that precondition, and the argument turned out to be right with evidence: -Wall found three genuine signedness mismatches in sprite.c, where uint32_t width, height and speed were passed straight to akgl_get_json_integer_value(..., int *). A cast would have silenced all three. Fixing them properly also turned up that speed is scaled by a million into a 32-bit field, so anything past 4294 ms overflowed rather than being held. The other real finding was ten -Wstringop-truncation warnings, every one a fixed-width strncpy. Those leave a name field unterminated when the input fills it -- and those fields are registry keys, handed to strcmp and SDL property calls that do not stop at the field. Same overread class fixed twice already this release. All ten now use aksl_strncpy from akstdlib, which always terminates and never NUL-pads, bounded to size - 1 so an over-long name still truncates rather than being refused. staticstring.h documented the old strncpy semantics explicitly and has been rewritten to match. Two sites in game.c are deliberately left on strncpy: both stage into a pre-zeroed buffer for the savegame's fixed-width fields, where the NUL padding is part of the format. Neither is flagged. sprite.c also had a memcpy of a full 128-byte field from a NUL-terminated string, which reads past the end of any shorter name. Not flagged by anything; found while converting its neighbours. -Werror is an option, default OFF, on only in the cmake_build and performance CI jobs. libakgl is consumed with add_subdirectory -- akbasic does exactly that -- so a target-level -Werror turns every diagnostic a newer compiler invents into a broken build for somebody else. The warning set is not even stable across build types here: an -O2 build reports ten warnings that -O0 and -fsyntax-only do not, because they need the middle end. The two jobs that set it are one of each. -Wextra is deliberately not adopted. It adds 22 findings, 17 inherent to the design: 13 -Wunused-parameter from backend vtables and SDL callbacks that must match a signature, and 4 -Wimplicit-fallthrough from libakerror's own PROCESS/HANDLE/HANDLE_GROUP, which fall through by design. Filed upstream as deps/libakerror TODO item 8; the submodule bump carries it. deps/semver/semver.c is listed directly in add_library, so the target's PRIVATE options reach it. Exempted with -w so a future semver update cannot fail this build -- verified by forcing -Wextra -Werror and watching our files fail while semver compiled clean. Verified: RelWithDebInfo, Debug and coverage builds all clean under -Werror; -Werror actually fails on a planted unused variable; an embedded consumer with AKGL_WERROR unset still configures and builds. 25/25 pass, memcheck clean, reindent --check, check_api_surface and check_error_protocol clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 09:18:52 -04:00
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")
Give libccd a static arena so libakgl still allocates nothing libakgl does not call malloc at runtime. That is stated in seven places in the manual and is AGENTS.md's second standing rule, and every object comes from a fixed array in akgl/heap.h. libccd's EPA path does not know that: it builds its expanding polytope out of realloc and free, and ccdGJKPenetration documents a -2 return for when that fails. The alternative was to compile only the three files MPR needs and leave EPA out of the build. That works and it puts a copy of somebody else's source list in this repository, where it rots silently on the next submodule bump. This instead compiles all of libccd and points its allocator at a bump allocator over static BSS, which keeps the promise literally true -- and keeps EPA available rather than amputated, for the day a precise contact manifold is worth having. The arena is reset at the top of each query rather than freed block by block, so the lifetime is one narrowphase call, `free` is a no-op, and allocation is a pointer bump. Exhaustion returns NULL, which libccd already handles by unwinding to -2, which becomes AKGL_ERR_COLLISION naming the high-water mark -- a loud failure with a number in it rather than a silently missed collision, which would be a floor an actor falls through reported as success. One GJK/EPA box pair costs 7,264 bytes, measured and printed by the suite. The 64 KB ceiling is that with about nine times headroom, and the high-water mark is reported so the next reader can re-derive it rather than trust this line. The redirect lives in src/ccd_arena_shim.h, injected with -include, and not in CMake's COMPILE_DEFINITIONS. It was in COMPILE_DEFINITIONS first, and that is a mistake worth recording: CMake cannot carry a function-like macro through a -D, so it dropped __CCD_ALLOC_MEMORY without a diagnostic. libccd went on calling the C library's realloc while the shim's `free` quietly discarded the results -- strictly worse than doing nothing, and invisible, because it leaks rather than crashes. What caught it was insisting the test prove the wiring rather than the outcome. The first version asserted the arena balanced back to zero after a query, which turned out to be the wrong assertion for a different reason -- free is a no-op by design, so it cannot balance -- but a test that had merely checked "two boxes collide" would have passed throughout, against an allocator nothing was using. The suite now asserts what is actually provable: that a query allocates from the arena at all, and that the process survives, since glibc aborts when the real free(3) is handed a pointer it never issued. Also here: AKGL_ERR_COLLISION, with the name registered -- tests/error.c asserts the band and the names agree, and it caught the missing one immediately. -fvisibility=hidden and CCD_STATIC_DEFINE keep every ccd* symbol out of libakgl.so's dynamic table, which `nm -D` confirms is empty; AGENTS.md records a shipped defect where an exported `renderer` was preempted by a same-named symbol elsewhere, and a game linking a system libccd would hit exactly that. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:29:05 -04:00
# 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")
Vendor clay and bring the UI subsystem up: arena, lifecycle, status band clay v0.14 (deps/clay, zlib licence) supplies the layout engine for the new akgl_ui subsystem. Sources-listed rather than add_subdirectory()'d, on the semver/libccd precedent: clay's CMakeLists declares no library target, builds its examples by default, and requires CMake 3.27 against this project's 3.10. src/ui_clay.c is the one CLAY_IMPLEMENTATION translation unit, compiled -w on the vendored-code terms but with clay's symbols deliberately exported -- consumers' CLAY() macros resolve against libakgl.so, and akgl/ui.h warns them never to link a second clay. The subsystem is runtime-optional the way collision is: always compiled in, inert until akgl_ui_init(), which bounds clay to the overridable AKGL_UI_* ceilings, checks Clay_MinMemorySize() against a static BSS arena (no malloc), and refuses with both numbers in the message when it does not fit. Measured: 812544 bytes at the default 1024 elements / 4096 measured words, against a 1 MiB arena. clay's void-callback layout errors are logged as they happen and the first is stashed for the error protocol to raise later. AKGL_ERR_UI joins the status band before AKGL_ERR_LIMIT, named in akgl_error_init. New `ui` test suite covers validation, the init/shutdown lifecycle, and arena exhaustion via the akgl_ui_arena_limit test hook (same contract as akgl_ccd_arena_set_limit). clay.h is installed beside semver.h, its licence beside libccd's, and the headers suite proves akgl/ui.h self-contained -- clay.h compiles clean under -Wall. 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 10:53:11 -04:00
# 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")
Give libccd a static arena so libakgl still allocates nothing libakgl does not call malloc at runtime. That is stated in seven places in the manual and is AGENTS.md's second standing rule, and every object comes from a fixed array in akgl/heap.h. libccd's EPA path does not know that: it builds its expanding polytope out of realloc and free, and ccdGJKPenetration documents a -2 return for when that fails. The alternative was to compile only the three files MPR needs and leave EPA out of the build. That works and it puts a copy of somebody else's source list in this repository, where it rots silently on the next submodule bump. This instead compiles all of libccd and points its allocator at a bump allocator over static BSS, which keeps the promise literally true -- and keeps EPA available rather than amputated, for the day a precise contact manifold is worth having. The arena is reset at the top of each query rather than freed block by block, so the lifetime is one narrowphase call, `free` is a no-op, and allocation is a pointer bump. Exhaustion returns NULL, which libccd already handles by unwinding to -2, which becomes AKGL_ERR_COLLISION naming the high-water mark -- a loud failure with a number in it rather than a silently missed collision, which would be a floor an actor falls through reported as success. One GJK/EPA box pair costs 7,264 bytes, measured and printed by the suite. The 64 KB ceiling is that with about nine times headroom, and the high-water mark is reported so the next reader can re-derive it rather than trust this line. The redirect lives in src/ccd_arena_shim.h, injected with -include, and not in CMake's COMPILE_DEFINITIONS. It was in COMPILE_DEFINITIONS first, and that is a mistake worth recording: CMake cannot carry a function-like macro through a -D, so it dropped __CCD_ALLOC_MEMORY without a diagnostic. libccd went on calling the C library's realloc while the shim's `free` quietly discarded the results -- strictly worse than doing nothing, and invisible, because it leaks rather than crashes. What caught it was insisting the test prove the wiring rather than the outcome. The first version asserted the arena balanced back to zero after a query, which turned out to be the wrong assertion for a different reason -- free is a no-op by design, so it cannot balance -- but a test that had merely checked "two boxes collide" would have passed throughout, against an allocator nothing was using. The suite now asserts what is actually provable: that a query allocates from the arena at all, and that the process survives, since glibc aborts when the real free(3) is handed a pointer it never issued. Also here: AKGL_ERR_COLLISION, with the name registered -- tests/error.c asserts the band and the names agree, and it caught the missing one immediately. -fvisibility=hidden and CCD_STATIC_DEFINE keep every ccd* symbol out of libakgl.so's dynamic table, which `nm -D` confirms is empty; AGENTS.md records a shipped defect where an exported `renderer` was preempted by a same-named symbol elsewhere, and a game linking a system libccd would hit exactly that. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:29:05 -04:00
# 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)
2025-08-09 13:53:37 -04:00
add_executable(charviewer util/charviewer.c)
Upgrade to libakstdlib 0.1.0 Bump deps/libakstdlib five commits to 95e5002, which versions the library at 0.1.0 with an soname and a ConfigVersion file, upgrades its own libakerror to 1.0.0, namespaces its coverage target when embedded, and raises its libc-wrapper coverage. The public header diff is purely additive -- nothing removed, no signature changed -- so libakgl's nine aksl_* call sites needed no edits. What broke was the build. A default `cmake -S . -B build` stopped configuring: add_executable cannot create target "test_version" because another target with the same name already exists. The existing target is an executable created in source directory ".../deps/libakstdlib" The versioning commit added tests/test_version.c to libakstdlib. EXCLUDE_FROM_ALL keeps that target from being built, but add_subdirectory still creates it, so it collided with the version suite added in the preceding commit. Build libakgl's test programs as akgl_test_<name> instead. The CTest names are unchanged -- ctest -R version still selects it -- and a dependency is now free to ship a test of any name. Ask find_package for libakstdlib 0.1 rather than any version. 0.1.0 ships akstdlibConfigVersion.cmake with SameMinorVersion compatibility, mirroring its libakstdlib.so.0.1 soname, so this accepts any 0.1.x and refuses 0.2 and 1.0. Unversioned, the non-embedded path would have taken an ABI-incompatible libakstdlib without complaint. libakerror is deliberately left unversioned in find_package. It installs akerrorConfig.cmake and akerrorTargets.cmake but no akerrorConfigVersion.cmake, so find_package(akerror 1.0) fails against a correct install rather than a stale one. Its floor stays with the #error in include/akgl/error.h. Worth fixing upstream. Verified: clean configure, build and 16/16 ctest; a -DAKGL_COVERAGE=ON tree configures, builds and passes 18/18, so libakstdlib's embedded coverage-target rename does not collide here. Against a temp-prefix install of 95e5002, find_package(akstdlib) accepts 0.1 and 0.1.0 and refuses 0.2 and 1.0, and find_package(akerror 1.0) fails for the missing version file as described. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:55:18 -04:00
add_executable(akgl_test_semver_unit deps/semver/semver_unit.c)
Add physics, heap, json_helpers, game, and actor test suites Raise line coverage from 39.6 to 61.8 percent with four new suites and an extension to the actor suite, and register every suite through a single CMake list so a new test file cannot be left out of the coverage fixture. Give the test targets a build-tree RPATH and prepend the build tree to LD_LIBRARY_PATH for CTest, so a developer with a previously installed libakgl.so exercises the library that was just compiled. Fix six defects the new tests exposed: - akgl_physics_simulate read self->gravity_time before its NULL check, so a NULL backend crashed instead of reporting AKERR_NULLPOINTER. - akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose inside the PROCESS switch. An ordinary save never flushed or closed its stream and produced an empty file. - akgl_game_save_actors wrote each name table terminator from the address of a single char, emitting stack contents into the save file and a sentinel the loader could not recognize. - akgl_game_load_objectnamemap used CATCH directly inside while(1), where the break leaves the loop rather than propagating, so a truncated name table loaded as a successful game. - akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no NULL check, unlike their left and right counterparts. - akgl_actor_logic_movement checked actor twice instead of checking actor->basechar before dereferencing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
# Every suite here is a standalone C program named tests/<name>.c, built as
Upgrade to libakstdlib 0.1.0 Bump deps/libakstdlib five commits to 95e5002, which versions the library at 0.1.0 with an soname and a ConfigVersion file, upgrades its own libakerror to 1.0.0, namespaces its coverage target when embedded, and raises its libc-wrapper coverage. The public header diff is purely additive -- nothing removed, no signature changed -- so libakgl's nine aksl_* call sites needed no edits. What broke was the build. A default `cmake -S . -B build` stopped configuring: add_executable cannot create target "test_version" because another target with the same name already exists. The existing target is an executable created in source directory ".../deps/libakstdlib" The versioning commit added tests/test_version.c to libakstdlib. EXCLUDE_FROM_ALL keeps that target from being built, but add_subdirectory still creates it, so it collided with the version suite added in the preceding commit. Build libakgl's test programs as akgl_test_<name> instead. The CTest names are unchanged -- ctest -R version still selects it -- and a dependency is now free to ship a test of any name. Ask find_package for libakstdlib 0.1 rather than any version. 0.1.0 ships akstdlibConfigVersion.cmake with SameMinorVersion compatibility, mirroring its libakstdlib.so.0.1 soname, so this accepts any 0.1.x and refuses 0.2 and 1.0. Unversioned, the non-embedded path would have taken an ABI-incompatible libakstdlib without complaint. libakerror is deliberately left unversioned in find_package. It installs akerrorConfig.cmake and akerrorTargets.cmake but no akerrorConfigVersion.cmake, so find_package(akerror 1.0) fails against a correct install rather than a stale one. Its floor stays with the #error in include/akgl/error.h. Worth fixing upstream. Verified: clean configure, build and 16/16 ctest; a -DAKGL_COVERAGE=ON tree configures, builds and passes 18/18, so libakstdlib's embedded coverage-target rename does not collide here. Against a temp-prefix install of 95e5002, find_package(akstdlib) accepts 0.1 and 0.1.0 and refuses 0.2 and 1.0, and find_package(akerror 1.0) fails for the missing version file as described. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:55:18 -04:00
# akgl_test_<name> and registered with CTest under <name>.
Add physics, heap, json_helpers, game, and actor test suites Raise line coverage from 39.6 to 61.8 percent with four new suites and an extension to the actor suite, and register every suite through a single CMake list so a new test file cannot be left out of the coverage fixture. Give the test targets a build-tree RPATH and prepend the build tree to LD_LIBRARY_PATH for CTest, so a developer with a previously installed libakgl.so exercises the library that was just compiled. Fix six defects the new tests exposed: - akgl_physics_simulate read self->gravity_time before its NULL check, so a NULL backend crashed instead of reporting AKERR_NULLPOINTER. - akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose inside the PROCESS switch. An ordinary save never flushed or closed its stream and produced an empty file. - akgl_game_save_actors wrote each name table terminator from the address of a single char, emitting stack contents into the save file and a sentinel the loader could not recognize. - akgl_game_load_objectnamemap used CATCH directly inside while(1), where the break leaves the loop rather than propagating, so a truncated name table loaded as a successful game. - akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no NULL check, unlike their left and right counterparts. - akgl_actor_logic_movement checked actor twice instead of checking actor->basechar before dereferencing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
set(AKGL_TEST_SUITES
actor
audio
Add physics, heap, json_helpers, game, and actor test suites Raise line coverage from 39.6 to 61.8 percent with four new suites and an extension to the actor suite, and register every suite through a single CMake list so a new test file cannot be left out of the coverage fixture. Give the test targets a build-tree RPATH and prepend the build tree to LD_LIBRARY_PATH for CTest, so a developer with a previously installed libakgl.so exercises the library that was just compiled. Fix six defects the new tests exposed: - akgl_physics_simulate read self->gravity_time before its NULL check, so a NULL backend crashed instead of reporting AKERR_NULLPOINTER. - akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose inside the PROCESS switch. An ordinary save never flushed or closed its stream and produced an empty file. - akgl_game_save_actors wrote each name table terminator from the address of a single char, emitting stack contents into the save file and a sentinel the loader could not recognize. - akgl_game_load_objectnamemap used CATCH directly inside while(1), where the break leaves the loop rather than propagating, so a truncated name table loaded as a successful game. - akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no NULL check, unlike their left and right counterparts. - akgl_actor_logic_movement checked actor twice instead of checking actor->basechar before dereferencing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
bitmasks
character
Add collision shapes, on the character and overridable per actor A shape is a convex volume positioned relative to an actor's origin. It lives on akgl_Character, so every goblin sharing a character shares one shape definition the way they already share speeds and the state-to-sprite map, and an actor may override it with akgl_Actor::shape_override. The flag is not redundant with an empty shape. "This actor deliberately has no collider" and "this actor has not been given one yet" are different states, and without somewhere to record the difference akgl_actor_set_character cannot tell whether it is allowed to overwrite what it finds. include/akgl/collision.h is a leaf: it names actors and tilemaps as incomplete struct pointers rather than including their headers, because both of those need akgl_CollisionShape by value and a cycle forms otherwise. The `headers` suite compiles it as the first include of a translation unit, so that property is enforced rather than merely intended -- which is why the tilemap types got struct tags two commits ago. The masks are asymmetric and the defaults are the load-bearing part: layermask ACTOR, collidemask STATIC. An actor given a shape and nothing else collides with map geometry and with no other actor. "Everything with a hitbox shoves everything else" would be a surprising default for a town full of scenery and a painful one to discover after the fact; opting in to actor-versus-actor is one bit, opting out of it would have been a hunt through every NPC a game spawns. Every shape gets a z depth, because the narrowphase behind this is three dimensional and answers with the *minimum* separating translation. A thin extrusion makes z the cheapest axis, and an actor pushed along z goes nowhere a player can see while remaining inside the floor -- a collision system reporting success while doing nothing. The ratio of 2 is the smallest for which the z overlap of any two shapes the setters build provably exceeds any planar penetration they can reach. The test for that asserts the inequality across a spread of sizes rather than asserting the constant is 2, so a change to the constant fails here rather than in a game. Two things about that test are worth recording, because the first version had both: - It could not fail. TEST_ASSERT expands to FAIL_BREAK, which reports by `break`ing, and the assertion was inside a nested `for` -- so the break left the loop rather than the ATTEMPT block and the failure evaporated. This is the hazard AGENTS.md documents for CATCH; it applies to the whole family. Verified by setting the ratio to 0.5 and watching the suite still pass, then fixing it and watching it report the 512x512 case by name. - tests/collision_arena.c had the same bug in a loop added in the previous commit, and it is fixed here too. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:48:05 -04:00
collision
Give libccd a static arena so libakgl still allocates nothing libakgl does not call malloc at runtime. That is stated in seven places in the manual and is AGENTS.md's second standing rule, and every object comes from a fixed array in akgl/heap.h. libccd's EPA path does not know that: it builds its expanding polytope out of realloc and free, and ccdGJKPenetration documents a -2 return for when that fails. The alternative was to compile only the three files MPR needs and leave EPA out of the build. That works and it puts a copy of somebody else's source list in this repository, where it rots silently on the next submodule bump. This instead compiles all of libccd and points its allocator at a bump allocator over static BSS, which keeps the promise literally true -- and keeps EPA available rather than amputated, for the day a precise contact manifold is worth having. The arena is reset at the top of each query rather than freed block by block, so the lifetime is one narrowphase call, `free` is a no-op, and allocation is a pointer bump. Exhaustion returns NULL, which libccd already handles by unwinding to -2, which becomes AKGL_ERR_COLLISION naming the high-water mark -- a loud failure with a number in it rather than a silently missed collision, which would be a floor an actor falls through reported as success. One GJK/EPA box pair costs 7,264 bytes, measured and printed by the suite. The 64 KB ceiling is that with about nine times headroom, and the high-water mark is reported so the next reader can re-derive it rather than trust this line. The redirect lives in src/ccd_arena_shim.h, injected with -include, and not in CMake's COMPILE_DEFINITIONS. It was in COMPILE_DEFINITIONS first, and that is a mistake worth recording: CMake cannot carry a function-like macro through a -D, so it dropped __CCD_ALLOC_MEMORY without a diagnostic. libccd went on calling the C library's realloc while the shim's `free` quietly discarded the results -- strictly worse than doing nothing, and invisible, because it leaks rather than crashes. What caught it was insisting the test prove the wiring rather than the outcome. The first version asserted the arena balanced back to zero after a query, which turned out to be the wrong assertion for a different reason -- free is a no-op by design, so it cannot balance -- but a test that had merely checked "two boxes collide" would have passed throughout, against an allocator nothing was using. The suite now asserts what is actually provable: that a query allocates from the arena at all, and that the process survives, since glibc aborts when the real free(3) is handed a pointer it never issued. Also here: AKGL_ERR_COLLISION, with the name registered -- tests/error.c asserts the band and the names agree, and it caught the missing one immediately. -fvisibility=hidden and CCD_STATIC_DEFINE keep every ccd* symbol out of libakgl.so's dynamic table, which `nm -D` confirms is empty; AGENTS.md records a shipped defect where an exported `renderer` was preempted by a same-named symbol elsewhere, and a game linking a system libccd would hit exactly that. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:29:05 -04:00
collision_arena
controller
draw
Migrate to the libakerror 1.0.0 status registry libakerror 1.0.0 replaced the consumer-sized status-name array with a private registry and made status-code ownership explicit and enforced. AKERR_MAX_ERR_VALUE and __AKERR_ERROR_NAMES are gone, and the registry entry points raise akerr_ErrorContext * instead of returning int. See deps/libakerror/UPGRADING.md. The break was not only source-level. libakgl's codes sat at AKERR_LAST_ERRNO_VALUE + 18 through + 22, and 1.0.0 claimed exactly those five offsets for its own AKERR_STATUS_* registry codes, so every AKGL_ERR_* was aliasing a libakerror status. HANDLE(e, AKGL_ERR_LOGICINTERRUPT) at physics.c:222 would have swallowed a foreign-name refusal. - Move the band to AKERR_FIRST_CONSUMER_STATUS (256) as fixed offsets, so a libc that grows an errno cannot move the codes, and add AKGL_ERR_OWNER, AKGL_ERR_LIMIT and AKGL_ERR_COUNT to describe it. - Reserve the range and register the names through the owned entry points, PASS-ing each: these are AKERR_NOIGNORE, and the old akerr_name_for_status calls discarded failure silently. - Drop the AKERR_MAX_ERR_VALUE=256 compile definition. - Guard on AKERR_FIRST_CONSUMER_STATUS in include/akgl/error.h, which now includes <akerror.h> so the guard is reliable. The embedded build is fine, but the find_package path can pick up a stale installed header, and 1.0.0 has an soname, so that pairing is an ABI mismatch rather than a compile problem. Same guard libakstdlib already carries. Registration also moves out of akgl_heap_init into a new akgl_error_init in src/error.c. It was in the heap pool's initializer only because that was the first thing akgl_game_init called, and the upgrade turned five fire-and-forget name calls into a library-wide ownership claim that can fail. That placement was hiding a defect: game.c raises AKGL_ERR_SDL when SDL_CreateMutex fails, five lines before akgl_heap_init ran, so the earliest error path in the library was guaranteed to print "Unknown Error". akgl_error_init is now the first statement in akgl_game_init. Callers that drive subsystems directly must call akgl_error_init first; it is idempotent, so ordering it precisely is not required. The eleven test suites that relied on akgl_heap_init to name their statuses now call it explicitly, or their failure messages would have degraded to "Unknown Error". Add tests/error.c: assert every code reads back its registered name, that the name table and AKGL_ERR_COUNT agree, that a foreign owner is refused with AKERR_STATUS_NAME_FOREIGN and AKERR_STATUS_RANGE_OVERLAP, and that repeating the init is a no-op. That last one is a live constraint, not a triviality -- libakerror treats only an identical reservation as a repeat, so a subset or superset raises. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:20:28 -04:00
error
Add physics, heap, json_helpers, game, and actor test suites Raise line coverage from 39.6 to 61.8 percent with four new suites and an extension to the actor suite, and register every suite through a single CMake list so a new test file cannot be left out of the coverage fixture. Give the test targets a build-tree RPATH and prepend the build tree to LD_LIBRARY_PATH for CTest, so a developer with a previously installed libakgl.so exercises the library that was just compiled. Fix six defects the new tests exposed: - akgl_physics_simulate read self->gravity_time before its NULL check, so a NULL backend crashed instead of reporting AKERR_NULLPOINTER. - akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose inside the PROCESS switch. An ordinary save never flushed or closed its stream and produced an empty file. - akgl_game_save_actors wrote each name table terminator from the address of a single char, emitting stack contents into the save file and a sentinel the loader could not recognize. - akgl_game_load_objectnamemap used CATCH directly inside while(1), where the break leaves the loop rather than propagating, so a truncated name table loaded as a successful game. - akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no NULL check, unlike their left and right counterparts. - akgl_actor_logic_movement checked actor twice instead of checking actor->basechar before dereferencing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
game
headers
Add physics, heap, json_helpers, game, and actor test suites Raise line coverage from 39.6 to 61.8 percent with four new suites and an extension to the actor suite, and register every suite through a single CMake list so a new test file cannot be left out of the coverage fixture. Give the test targets a build-tree RPATH and prepend the build tree to LD_LIBRARY_PATH for CTest, so a developer with a previously installed libakgl.so exercises the library that was just compiled. Fix six defects the new tests exposed: - akgl_physics_simulate read self->gravity_time before its NULL check, so a NULL backend crashed instead of reporting AKERR_NULLPOINTER. - akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose inside the PROCESS switch. An ordinary save never flushed or closed its stream and produced an empty file. - akgl_game_save_actors wrote each name table terminator from the address of a single char, emitting stack contents into the save file and a sentinel the loader could not recognize. - akgl_game_load_objectnamemap used CATCH directly inside while(1), where the break leaves the loop rather than propagating, so a truncated name table loaded as a successful game. - akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no NULL check, unlike their left and right counterparts. - akgl_actor_logic_movement checked actor twice instead of checking actor->basechar before dereferencing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
heap
json_helpers
Add a pluggable broad phase, and the incremental uniform grid behind it akgl_Partitioner is a record of function pointers and an initializer, the same shape as the render and physics backends. `move` is its own slot rather than remove-then-insert, and that is the whole design: a uniform grid's move is a comparison and a return when the proxy has not left the cells it was in, which is the steady state for a walking actor and the permanent state for a static one. Spelling it as remove-and-insert would turn the incremental grid into the rebuild-every-frame tree that PERFORMANCE.md already measured and rejected. The grid is a dense 128x128 array of cell heads over the world, cells keyed on tile size, with two intrusive chains per entry -- one through the cell so a query can walk it, one through the proxy so removal unlinks a whole span without searching. Everything is an int16_t index rather than a pointer, so the structure is relocatable and clearing it is a memset. A proxy covering more cells than AKGL_COLLISION_GRID_MAX_SPAN spills onto one chain every query walks, which is what makes the cell pool's ceiling provable rather than hopeful. tests/partition.c compares every query against a linear scan over the same proxies and asserts containment in one direction, not equality. That asymmetry is the contract: over-reporting costs a narrowphase call, under-reporting is a wall an actor walks through. The suite is a table over partitioners so the same assertions run against every implementation, which is what the second one landing later will be held to. Three deliberate breaks, and two of them were not caught the first time: - Removing the min-cell rule so a shared-cell pair reports repeatedly: caught. - Dropping the unlink in `move`: **not caught**, initially. Stale entries do not produce wrong query answers, because the bounds re-check filters them out -- they leak the cell pool until the grid stops working, on a timescale no unit test reaches by accident. Counting live pool entries across 200 moves is the only thing that sees it, and now does: 505 entries against an expected 5. - Swapping floorf for a truncating cast: **not caught, and correctly so.** Everything below zero is clamped to cell 0 either way, so today the two are indistinguishable. The comment claiming truncation is a defect was overstating it and now says what is actually true: the clamp is the only thing hiding it, and a negative world origin or a relaxed clamp would make it real with no test standing in front of it. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:29:02 -04:00
partition
Add physics, heap, json_helpers, game, and actor test suites Raise line coverage from 39.6 to 61.8 percent with four new suites and an extension to the actor suite, and register every suite through a single CMake list so a new test file cannot be left out of the coverage fixture. Give the test targets a build-tree RPATH and prepend the build tree to LD_LIBRARY_PATH for CTest, so a developer with a previously installed libakgl.so exercises the library that was just compiled. Fix six defects the new tests exposed: - akgl_physics_simulate read self->gravity_time before its NULL check, so a NULL backend crashed instead of reporting AKERR_NULLPOINTER. - akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose inside the PROCESS switch. An ordinary save never flushed or closed its stream and produced an empty file. - akgl_game_save_actors wrote each name table terminator from the address of a single char, emitting stack contents into the save file and a sentinel the loader could not recognize. - akgl_game_load_objectnamemap used CATCH directly inside while(1), where the break leaves the loop rather than propagating, so a truncated name table loaded as a successful game. - akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no NULL check, unlike their left and right counterparts. - akgl_actor_logic_movement checked actor twice instead of checking actor->basechar before dereferencing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
physics
Fix three arcade physics defects the unit tests could not see tests/physics_sim.c runs the arcade backend the way a game does -- a Mario-esque jump and fall, Zelda-style top-down walking, and a run reversed at full speed -- and prints what the actor actually did. tests/physics.c already checked that akgl_physics_simulate does the arithmetic it claims. Every one of these got past it. The first step was however long the level took to load. gravity_time was never initialized and dt was unbounded, so a 250 ms load produced a 250 ms step: 100 px of fall where a 60 Hz frame under the same gravity is 0.44 px, straight through whatever was underneath. Both initializers seed the clock, and simulate bounds dt to max_timestep -- a new physics.max_timestep property, default 0.05 s, read like gravity and drag. Zero disables the bound. The field replaces the dead timer_gravity, so the struct is the same size. Releasing a vertical key cancelled gravity. The _off handlers zeroed ay, ey, ty and vy together, and ey is where the arcade backend accumulates gravity -- tapping down mid-jump stopped the character in the air. They clear ax/tx (or ay/ty) and nothing else now. Velocity was never theirs to clear either: simulate recomputes v as e + t every step. Diagonal movement was 41% too fast. Thrust was capped per axis, so an actor holding two directions got both caps at once and travelled their diagonal. It is capped as a vector against the sx/sy/sz ellipse, which also keeps a character whose horizontal and vertical top speeds differ moving at the ratio it asked for. An axis with a zero max speed stays out of the magnitude and is forced to zero, as the old clamp did. Each fix was checked by reverting it and confirming the simulation goes red: 100.1 px, vy 0.0 after a down tap, and 141% diagonal respectively. tests/actor.c and tests/physics.c pinned the old behaviour in both places and now assert the new contract. Bumped to 0.6.0: akgl_PhysicsBackend changed. TODO.md records the four things the simulations found and this does not fix -- no terminal velocity, no deceleration on release, Euler's frame-rate dependence, and a drag coefficient large enough to invert velocity. 26/26 ctest, memcheck clean, warning-clean at -Wall -Werror. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8T5FAYXE8HEJqFLCYwNNc
2026-08-01 12:10:53 -04:00
physics_sim
Add physics, heap, json_helpers, game, and actor test suites Raise line coverage from 39.6 to 61.8 percent with four new suites and an extension to the actor suite, and register every suite through a single CMake list so a new test file cannot be left out of the coverage fixture. Give the test targets a build-tree RPATH and prepend the build tree to LD_LIBRARY_PATH for CTest, so a developer with a previously installed libakgl.so exercises the library that was just compiled. Fix six defects the new tests exposed: - akgl_physics_simulate read self->gravity_time before its NULL check, so a NULL backend crashed instead of reporting AKERR_NULLPOINTER. - akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose inside the PROCESS switch. An ordinary save never flushed or closed its stream and produced an empty file. - akgl_game_save_actors wrote each name table terminator from the address of a single char, emitting stack contents into the save file and a sentinel the loader could not recognize. - akgl_game_load_objectnamemap used CATCH directly inside while(1), where the break leaves the loop rather than propagating, so a truncated name table loaded as a successful game. - akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no NULL check, unlike their left and right counterparts. - akgl_actor_logic_movement checked actor twice instead of checking actor->basechar before dereferencing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
registry
renderer
Add physics, heap, json_helpers, game, and actor test suites Raise line coverage from 39.6 to 61.8 percent with four new suites and an extension to the actor suite, and register every suite through a single CMake list so a new test file cannot be left out of the coverage fixture. Give the test targets a build-tree RPATH and prepend the build tree to LD_LIBRARY_PATH for CTest, so a developer with a previously installed libakgl.so exercises the library that was just compiled. Fix six defects the new tests exposed: - akgl_physics_simulate read self->gravity_time before its NULL check, so a NULL backend crashed instead of reporting AKERR_NULLPOINTER. - akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose inside the PROCESS switch. An ordinary save never flushed or closed its stream and produced an empty file. - akgl_game_save_actors wrote each name table terminator from the address of a single char, emitting stack contents into the save file and a sentinel the loader could not recognize. - akgl_game_load_objectnamemap used CATCH directly inside while(1), where the break leaves the loop rather than propagating, so a truncated name table loaded as a successful game. - akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no NULL check, unlike their left and right counterparts. - akgl_actor_logic_movement checked actor twice instead of checking actor->basechar before dereferencing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
sprite
staticstring
text
Add physics, heap, json_helpers, game, and actor test suites Raise line coverage from 39.6 to 61.8 percent with four new suites and an extension to the actor suite, and register every suite through a single CMake list so a new test file cannot be left out of the coverage fixture. Give the test targets a build-tree RPATH and prepend the build tree to LD_LIBRARY_PATH for CTest, so a developer with a previously installed libakgl.so exercises the library that was just compiled. Fix six defects the new tests exposed: - akgl_physics_simulate read self->gravity_time before its NULL check, so a NULL backend crashed instead of reporting AKERR_NULLPOINTER. - akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose inside the PROCESS switch. An ordinary save never flushed or closed its stream and produced an empty file. - akgl_game_save_actors wrote each name table terminator from the address of a single char, emitting stack contents into the save file and a sentinel the loader could not recognize. - akgl_game_load_objectnamemap used CATCH directly inside while(1), where the break leaves the loop rather than propagating, so a truncated name table loaded as a successful game. - akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no NULL check, unlike their left and right counterparts. - akgl_actor_logic_movement checked actor twice instead of checking actor->basechar before dereferencing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
tilemap
Vendor clay and bring the UI subsystem up: arena, lifecycle, status band clay v0.14 (deps/clay, zlib licence) supplies the layout engine for the new akgl_ui subsystem. Sources-listed rather than add_subdirectory()'d, on the semver/libccd precedent: clay's CMakeLists declares no library target, builds its examples by default, and requires CMake 3.27 against this project's 3.10. src/ui_clay.c is the one CLAY_IMPLEMENTATION translation unit, compiled -w on the vendored-code terms but with clay's symbols deliberately exported -- consumers' CLAY() macros resolve against libakgl.so, and akgl/ui.h warns them never to link a second clay. The subsystem is runtime-optional the way collision is: always compiled in, inert until akgl_ui_init(), which bounds clay to the overridable AKGL_UI_* ceilings, checks Clay_MinMemorySize() against a static BSS arena (no malloc), and refuses with both numbers in the message when it does not fit. Measured: 812544 bytes at the default 1024 elements / 4096 measured words, against a 1 MiB arena. clay's void-callback layout errors are logged as they happen and the first is stashed for the error protocol to raise later. AKGL_ERR_UI joins the status band before AKGL_ERR_LIMIT, named in akgl_error_init. New `ui` test suite covers validation, the init/shutdown lifecycle, and arena exhaustion via the akgl_ui_arena_limit test hook (same contract as akgl_ccd_arena_set_limit). clay.h is installed beside semver.h, its licence beside libccd's, and the headers suite proves akgl/ui.h self-contained -- clay.h compiles clean under -Wall. 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 10:53:11 -04:00
ui
Add physics, heap, json_helpers, game, and actor test suites Raise line coverage from 39.6 to 61.8 percent with four new suites and an extension to the actor suite, and register every suite through a single CMake list so a new test file cannot be left out of the coverage fixture. Give the test targets a build-tree RPATH and prepend the build tree to LD_LIBRARY_PATH for CTest, so a developer with a previously installed libakgl.so exercises the library that was just compiled. Fix six defects the new tests exposed: - akgl_physics_simulate read self->gravity_time before its NULL check, so a NULL backend crashed instead of reporting AKERR_NULLPOINTER. - akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose inside the PROCESS switch. An ordinary save never flushed or closed its stream and produced an empty file. - akgl_game_save_actors wrote each name table terminator from the address of a single char, emitting stack contents into the save file and a sentinel the loader could not recognize. - akgl_game_load_objectnamemap used CATCH directly inside while(1), where the break leaves the loop rather than propagating, so a truncated name table loaded as a successful game. - akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no NULL check, unlike their left and right counterparts. - akgl_actor_logic_movement checked actor twice instead of checking actor->basechar before dereferencing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
util
Derive the library version from one place and give the .so a soname The version was written down twice and agreed with itself by luck. AKGL_VERSION was hand-maintained in include/akgl/game.h, and nothing connected it to the build, which had no version at all: - project() declared no VERSION, so the @PROJECT_VERSION@ substitution in akgl.pc.in expanded to nothing and every installed akgl.pc shipped an empty Version field. pkg-config --modversion returned "" and --atleast-version could not work at all. - libakgl.so had no VERSION or SOVERSION, so there was no soname and no symlink chain. A stale libakgl could be paired with new headers silently, which is the failure libakerror added its own soname to prevent. - MY_LIBRARY_VERSION at CMakeLists.txt:288 was never set anywhere. It built main_lib_dest as "lib/akgl-", and main_lib_dest was then never read. Dead line, removed. project(akgl VERSION 0.1.0) is now the only place the number appears. It drives the generated include/akgl/version.h, the shared library VERSION and SOVERSION, and the Version field in akgl.pc. Held at 0.1.0 to match the constant it replaces, so akgl_game_load_versioncmp still compares savegames against the same value. include/akgl/version.h is generated by configure_file from version.h.in and publishes AKGL_VERSION, AKGL_VERSION_MAJOR/MINOR/PATCH and AKGL_VERSION_AT_LEAST(major, minor, patch). That macro is the point: libakstdlib could not write that test against libakerror, which publishes no version macro, and had to feature-test on AKERR_FIRST_CONSUMER_STATUS instead. game.h now includes the generated header rather than defining the version itself, so consumers see AKGL_VERSION exactly where they saw it before. While the major version is 0 the soname carries major.minor, giving libakgl.so.0.1. A plain SOVERSION 0 would assert that 0.1.0 and 0.2.0 are ABI-interchangeable, and they are not -- the preceding commit alone added akgl_error_init. At 1.0.0 the soname becomes the major alone, matching libakerror. akgl_version() in src/version.c reports the version of the libakgl that is actually linked, where AKGL_VERSION reports what the caller compiled against. Comparing the two is the only way to catch a stale shared library on the loader path; the macro alone cannot see it. It returns a string rather than an akerr_ErrorContext * because it cannot fail, for the same reason akerr_name_for_status does. Add tests/version.c: the string and the numeric components must describe one version, the linked library must agree with the headers, and AKGL_VERSION_AT_LEAST must order correctly at the major, minor and patch boundaries. Verified out of band that bumping project() to 1.2.3 moves version.h, akgl.pc and the soname together, and that an install produces the libakgl.so -> .so.0.1 -> .so.0.1.0 chain with SONAME libakgl.so.0.1. Also ignore include/akgl/version.h. include/ precedes the build tree on the include path, so a stray copy there would shadow the generated one and pin every consumer to whatever version it was generated at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:36:50 -04:00
version
Add physics, heap, json_helpers, game, and actor test suites Raise line coverage from 39.6 to 61.8 percent with four new suites and an extension to the actor suite, and register every suite through a single CMake list so a new test file cannot be left out of the coverage fixture. Give the test targets a build-tree RPATH and prepend the build tree to LD_LIBRARY_PATH for CTest, so a developer with a previously installed libakgl.so exercises the library that was just compiled. Fix six defects the new tests exposed: - akgl_physics_simulate read self->gravity_time before its NULL check, so a NULL backend crashed instead of reporting AKERR_NULLPOINTER. - akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose inside the PROCESS switch. An ordinary save never flushed or closed its stream and produced an empty file. - akgl_game_save_actors wrote each name table terminator from the address of a single char, emitting stack contents into the save file and a sentinel the loader could not recognize. - akgl_game_load_objectnamemap used CATCH directly inside while(1), where the break leaves the loop rather than propagating, so a truncated name table loaded as a successful game. - akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no NULL check, unlike their left and right counterparts. - akgl_actor_logic_movement checked actor twice instead of checking actor->basechar before dereferencing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
)
Measure the library: perf suites, a recorded baseline, and targets tests/perf.c and tests/perf_render.c drive the hot paths hard enough to time them -- pool acquire and release at both ends of the scan, the registry, the state-to-sprite lookup, the per-actor update and render, the physics sweep, an all-pairs collision sweep, the JSON accessors, path resolution, the drawing primitives, text, asset loading, a screenful of tiles, and a whole frame through akgl_game_update. They are registered like any other suite but carry the `perf` label, so `ctest -L perf` runs only them and `-LE perf` leaves them out. Every measurement is held to a budget at roughly ten times the recorded baseline, enforced only in an optimized build at full scale. Three things had to be got right before the numbers meant anything, and each was wrong first: - No error checking inside the clock. PASS and CATCH call akerr_valid_error_address, which walks AKERR_ARRAY_ERROR -- more work than several of the calls being measured. - Flush the renderer before stopping it. SDL batches, so the first version measured queueing, reported a tilemap frame 250 times faster than it is, and paid the real cost at teardown inside SDL_DestroyTexture. - A drawing benchmark needs a raw-SDL control doing the same pixel work with the same access pattern. With one, akgl_tilemap_draw turns out to cost 0.2% of the frame it appeared to own: 16.26 ms against a control's 16.23 ms for the same 1200 blits. The rasterizer is the frame. PERFORMANCE.md records the baseline, the frame budget it adds up to, and what the numbers say -- including that the string pool's acquire is 64x slower full than empty because it is a megabyte of PATH_MAX buffers, that a handled missing-sprite condition costs nine times the update it replaces, that text rasterizes and throws away a texture on every call, and that 28 MB of the library's BSS is one akgl_Tilemap. TODO.md gains a Performance section: five defects the stress tests found that the unit suites do not reach (a tilemap load leaking five pooled strings, two JSON accessors that turn pool exhaustion into a segfault rather than AKGL_ERR_HEAP, akgl_game_update crashing without akgl_game_init, and its actor update sweep running once per tilemap layer), and eighteen targets with today's number and whether it is met. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:24:05 -04:00
# 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
)
Upgrade to libakstdlib 0.1.0 Bump deps/libakstdlib five commits to 95e5002, which versions the library at 0.1.0 with an soname and a ConfigVersion file, upgrades its own libakerror to 1.0.0, namespaces its coverage target when embedded, and raises its libc-wrapper coverage. The public header diff is purely additive -- nothing removed, no signature changed -- so libakgl's nine aksl_* call sites needed no edits. What broke was the build. A default `cmake -S . -B build` stopped configuring: add_executable cannot create target "test_version" because another target with the same name already exists. The existing target is an executable created in source directory ".../deps/libakstdlib" The versioning commit added tests/test_version.c to libakstdlib. EXCLUDE_FROM_ALL keeps that target from being built, but add_subdirectory still creates it, so it collided with the version suite added in the preceding commit. Build libakgl's test programs as akgl_test_<name> instead. The CTest names are unchanged -- ctest -R version still selects it -- and a dependency is now free to ship a test of any name. Ask find_package for libakstdlib 0.1 rather than any version. 0.1.0 ships akstdlibConfigVersion.cmake with SameMinorVersion compatibility, mirroring its libakstdlib.so.0.1 soname, so this accepts any 0.1.x and refuses 0.2 and 1.0. Unversioned, the non-embedded path would have taken an ABI-incompatible libakstdlib without complaint. libakerror is deliberately left unversioned in find_package. It installs akerrorConfig.cmake and akerrorTargets.cmake but no akerrorConfigVersion.cmake, so find_package(akerror 1.0) fails against a correct install rather than a stale one. Its floor stays with the #error in include/akgl/error.h. Worth fixing upstream. Verified: clean configure, build and 16/16 ctest; a -DAKGL_COVERAGE=ON tree configures, builds and passes 18/18, so libakstdlib's embedded coverage-target rename does not collide here. Against a temp-prefix install of 95e5002, find_package(akstdlib) accepts 0.1 and 0.1.0 and refuses 0.2 and 1.0, and find_package(akerror 1.0) fails for the missing version file as described. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:55:18 -04:00
# 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.
Measure the library: perf suites, a recorded baseline, and targets tests/perf.c and tests/perf_render.c drive the hot paths hard enough to time them -- pool acquire and release at both ends of the scan, the registry, the state-to-sprite lookup, the per-actor update and render, the physics sweep, an all-pairs collision sweep, the JSON accessors, path resolution, the drawing primitives, text, asset loading, a screenful of tiles, and a whole frame through akgl_game_update. They are registered like any other suite but carry the `perf` label, so `ctest -L perf` runs only them and `-LE perf` leaves them out. Every measurement is held to a budget at roughly ten times the recorded baseline, enforced only in an optimized build at full scale. Three things had to be got right before the numbers meant anything, and each was wrong first: - No error checking inside the clock. PASS and CATCH call akerr_valid_error_address, which walks AKERR_ARRAY_ERROR -- more work than several of the calls being measured. - Flush the renderer before stopping it. SDL batches, so the first version measured queueing, reported a tilemap frame 250 times faster than it is, and paid the real cost at teardown inside SDL_DestroyTexture. - A drawing benchmark needs a raw-SDL control doing the same pixel work with the same access pattern. With one, akgl_tilemap_draw turns out to cost 0.2% of the frame it appeared to own: 16.26 ms against a control's 16.23 ms for the same 1200 blits. The rasterizer is the frame. PERFORMANCE.md records the baseline, the frame budget it adds up to, and what the numbers say -- including that the string pool's acquire is 64x slower full than empty because it is a megabyte of PATH_MAX buffers, that a handled missing-sprite condition costs nine times the update it replaces, that text rasterizes and throws away a texture on every call, and that 28 MB of the library's BSS is one akgl_Tilemap. TODO.md gains a Performance section: five defects the stress tests found that the unit suites do not reach (a tilemap load leaking five pooled strings, two JSON accessors that turn pool exhaustion into a segfault rather than AKGL_ERR_HEAP, akgl_game_update crashing without akgl_game_init, and its actor update sweep running once per tilemap layer), and eighteen targets with today's number and whether it is met. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:24:05 -04:00
foreach(suite IN LISTS AKGL_TEST_SUITES AKGL_PERF_SUITES)
Upgrade to libakstdlib 0.1.0 Bump deps/libakstdlib five commits to 95e5002, which versions the library at 0.1.0 with an soname and a ConfigVersion file, upgrades its own libakerror to 1.0.0, namespaces its coverage target when embedded, and raises its libc-wrapper coverage. The public header diff is purely additive -- nothing removed, no signature changed -- so libakgl's nine aksl_* call sites needed no edits. What broke was the build. A default `cmake -S . -B build` stopped configuring: add_executable cannot create target "test_version" because another target with the same name already exists. The existing target is an executable created in source directory ".../deps/libakstdlib" The versioning commit added tests/test_version.c to libakstdlib. EXCLUDE_FROM_ALL keeps that target from being built, but add_subdirectory still creates it, so it collided with the version suite added in the preceding commit. Build libakgl's test programs as akgl_test_<name> instead. The CTest names are unchanged -- ctest -R version still selects it -- and a dependency is now free to ship a test of any name. Ask find_package for libakstdlib 0.1 rather than any version. 0.1.0 ships akstdlibConfigVersion.cmake with SameMinorVersion compatibility, mirroring its libakstdlib.so.0.1 soname, so this accepts any 0.1.x and refuses 0.2 and 1.0. Unversioned, the non-embedded path would have taken an ABI-incompatible libakstdlib without complaint. libakerror is deliberately left unversioned in find_package. It installs akerrorConfig.cmake and akerrorTargets.cmake but no akerrorConfigVersion.cmake, so find_package(akerror 1.0) fails against a correct install rather than a stale one. Its floor stays with the #error in include/akgl/error.h. Worth fixing upstream. Verified: clean configure, build and 16/16 ctest; a -DAKGL_COVERAGE=ON tree configures, builds and passes 18/18, so libakstdlib's embedded coverage-target rename does not collide here. Against a temp-prefix install of 95e5002, find_package(akstdlib) accepts 0.1 and 0.1.0 and refuses 0.2 and 1.0, and find_package(akerror 1.0) fails for the missing version file as described. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:55:18 -04:00
add_executable(akgl_test_${suite} tests/${suite}.c)
add_test(NAME ${suite} COMMAND akgl_test_${suite})
Add physics, heap, json_helpers, game, and actor test suites Raise line coverage from 39.6 to 61.8 percent with four new suites and an extension to the actor suite, and register every suite through a single CMake list so a new test file cannot be left out of the coverage fixture. Give the test targets a build-tree RPATH and prepend the build tree to LD_LIBRARY_PATH for CTest, so a developer with a previously installed libakgl.so exercises the library that was just compiled. Fix six defects the new tests exposed: - akgl_physics_simulate read self->gravity_time before its NULL check, so a NULL backend crashed instead of reporting AKERR_NULLPOINTER. - akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose inside the PROCESS switch. An ordinary save never flushed or closed its stream and produced an empty file. - akgl_game_save_actors wrote each name table terminator from the address of a single char, emitting stack contents into the save file and a sentinel the loader could not recognize. - akgl_game_load_objectnamemap used CATCH directly inside while(1), where the break leaves the loop rather than propagating, so a truncated name table loaded as a successful game. - akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no NULL check, unlike their left and right counterparts. - akgl_actor_logic_movement checked actor twice instead of checking actor->basechar before dereferencing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
endforeach()
Upgrade to libakstdlib 0.1.0 Bump deps/libakstdlib five commits to 95e5002, which versions the library at 0.1.0 with an soname and a ConfigVersion file, upgrades its own libakerror to 1.0.0, namespaces its coverage target when embedded, and raises its libc-wrapper coverage. The public header diff is purely additive -- nothing removed, no signature changed -- so libakgl's nine aksl_* call sites needed no edits. What broke was the build. A default `cmake -S . -B build` stopped configuring: add_executable cannot create target "test_version" because another target with the same name already exists. The existing target is an executable created in source directory ".../deps/libakstdlib" The versioning commit added tests/test_version.c to libakstdlib. EXCLUDE_FROM_ALL keeps that target from being built, but add_subdirectory still creates it, so it collided with the version suite added in the preceding commit. Build libakgl's test programs as akgl_test_<name> instead. The CTest names are unchanged -- ctest -R version still selects it -- and a dependency is now free to ship a test of any name. Ask find_package for libakstdlib 0.1 rather than any version. 0.1.0 ships akstdlibConfigVersion.cmake with SameMinorVersion compatibility, mirroring its libakstdlib.so.0.1 soname, so this accepts any 0.1.x and refuses 0.2 and 1.0. Unversioned, the non-embedded path would have taken an ABI-incompatible libakstdlib without complaint. libakerror is deliberately left unversioned in find_package. It installs akerrorConfig.cmake and akerrorTargets.cmake but no akerrorConfigVersion.cmake, so find_package(akerror 1.0) fails against a correct install rather than a stale one. Its floor stays with the #error in include/akgl/error.h. Worth fixing upstream. Verified: clean configure, build and 16/16 ctest; a -DAKGL_COVERAGE=ON tree configures, builds and passes 18/18, so libakstdlib's embedded coverage-target rename does not collide here. Against a temp-prefix install of 95e5002, find_package(akstdlib) accepts 0.1 and 0.1.0 and refuses 0.2 and 1.0, and find_package(akerror 1.0) fails for the missing version file as described. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:55:18 -04:00
add_test(NAME semver_unit COMMAND akgl_test_semver_unit)
Give every exported function a declaration, and check that it stays that way Closes internal-consistency items 7 through 15. Nineteen non-static functions were in the ABI with no declaration anywhere, so no consumer could call them and any consumer could collide with them. The four gamepad_handle_* functions are the ones that mattered: controller.h declared akgl_controller_handle_button_down and three siblings that did not exist, so anything compiled against the header alone failed to link. The definitions carry the declared names now, which also closes Defects -> Known and still open item 10, and their documentation moved to the header. The rest are either declared under a "part of the internal API" block -- akgl_game_save_actors and akgl_game_load_versioncmp, which tests/game.c had to declare for itself, plus six tilemap loader helpers the untested-loader work wants to reach -- or static, which is what the four save iterators and load_objectnamemap should always have been. akgl_path_relative_from is deleted: declared nowhere, called from nowhere, never wrote its output, and leaked a pooled string on every call, so it closes Known and still open item 4 and item 40 by ceasing to exist. scripts/check_api_surface.sh keeps it closed. It reads the built library's dynamic symbol table and every public header with comments stripped, and fails on an exported akgl_* symbol that is declared nowhere. Stripping comments is the whole point -- four of these were mentioned in controller.h prose, which is how they went unnoticed. The pool-size ceilings are defined once, in heap.h, so the #ifndef override hook fires for the first time; actor.h, sprite.h and character.h were defining the same four unconditionally from headers heap.h includes above its own guard. tests/header_pool_override.c fails the compile if that regresses. Also here: (void) rather than () on the twelve no-argument entry points, AKERR_NOIGNORE only on declarations, static helpers with the akgl_ prefix dropped, and the six parameter-name mismatches. akgl_get_json_with_default had its two contexts swapped rather than merely misspelled -- the incoming one was `err` and its own was `e`, which is the name reserved for an incoming one. 24/24 pass, reindent --check clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:44:38 -04:00
# 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)
Stop returning past CLEANUP, and validate the arguments every sibling validates Closes internal-consistency items 16 and 17. Ten *_RETURN macros sat inside ATTEMPT blocks, which return past CLEANUP and skip every release in it. The one that mattered was the success path of akgl_get_json_tilemap_property: it leaked two of the string pool's 256 entries on every lookup that *found* what it was asked for, and a map load does that several times per layer. tests/tilemap.c now runs each of its three paths -- found, absent, wrong type -- twice the pool size and asserts the pool is where it started. Against the old code that test does not merely fail, it segfaults, which is Defects item 30 seen from the outside: pool exhaustion arriving as a NULL strncpy rather than as AKGL_ERR_HEAP. Two of the conversions needed more than swapping the macro. In akgl_get_json_tilemap_property a plain break would have fallen through to the "property not found" FAIL_RETURN after FINISH, reporting a miss for something found, so the success path sets a flag. In akgl_collide_rectangles the eight early exits are followed by `*collide = false;`, which would have overwritten the hit that broke out; each corner test writes the flag itself, so that line is gone rather than moved. The same function also released its scratch string once per loop iteration while continuing to use it, so the slot was free while still live -- one claim now covers the whole scan. akgl_controller_default is the other behavioural one: its SUCCEED_RETURN was the last statement in the ATTEMPT block, so the path that falls out of FINISH reached the closing brace of a non-void function. scripts/check_error_protocol.py keeps both rules enforced -- a *_RETURN inside ATTEMPT, and a return out of a HANDLE block -- as the error_protocol test. 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 once. For item 17: the eight typed JSON accessors that validated their container and then wrote through dest unconditionally now check key and dest as the two string accessors always did; the null physics backend checks its actors like the arcade one; and akgl_render_2d_frame_start, _frame_end and _shutdown check self, which the first two read straight through. tests/renderer.c calls all three with NULL, which segfaulted before. 25/25 pass, memcheck clean, reindent --check clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:56:10 -04:00
# 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()
Namespace every exported symbol, and bump to 0.5.0 Closes internal-consistency items 1 through 6, 12 and 13. Every include guard is _AKGL_<FILE>_H_, every in-project header include is angled, and every exported function, type and global carries the akgl_ prefix. This is an ABI break; the soname goes to libakgl.so.0.5. TODO.md carries the full rename table. The renames were driven by renaming each declaration and letting the compiler find the uses, not by pattern substitution: renderer, physics and camera are also parameter and struct-member names, and a sed would have rewritten map->physics and every akgl_RenderBackend *renderer parameter without a word. Item 4 turned out not to be cosmetic. The library exported a global called renderer and tests/character.c defined an SDL_Renderer *renderer of its own; the executable's definition preempted the library's, akgl_sprite_load_json read a SDL_Renderer * through an akgl_RenderBackend *, and every texture load in that suite failed. The suite reported success anyway, because libakerror's unhandled-error handler ends in exit(errctx->status), exit keeps only the low byte, and AKGL_ERR_SDL is exactly 256. So character had been green while running one of its four tests, and every suite in the tree was unable to fail on the most common status in a library built on SDL. Both are fixed. tests/testutil.h gains TEST_TRAP_UNHANDLED_ERRORS(), which collapses any status a byte cannot carry onto 1, and every suite installs it. character binds a real backend with akgl_render_2d_bind. Its fourth test then runs for the first time and fails on a defect it has asserted all along, so akgl_heap_release_character now walks state_sprites with AKGL_ITERATOR_OP_RELEASE and destroys the property set before zeroing the slot -- TODO.md Defects item 21 and half of Carried over item 1. AKGL_TIME_ONESEC_MS said "one second in milliseconds" and held 1000000, so akgl_game_state_lock waited roughly sixteen minutes rather than one second. It is AKGL_TIME_ONEMS_NS now, the budget is its own named constant, and tests/game.c holds the mutex from a second thread to assert the wait -- the contended path had no coverage at all. Headers are self-contained and it is enforced: AKGL_PUBLIC_HEADERS drives both install() and a generated translation unit per header, so a header that ships is a header that is checked. Writing that found registry.h, which used SDL_PropertiesID in eight declarations and included no SDL header. 23/23 suites pass, memcheck is clean, reindent --check is clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:32:21 -04:00
# 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})
Give every exported function a declaration, and check that it stays that way Closes internal-consistency items 7 through 15. Nineteen non-static functions were in the ABI with no declaration anywhere, so no consumer could call them and any consumer could collide with them. The four gamepad_handle_* functions are the ones that mattered: controller.h declared akgl_controller_handle_button_down and three siblings that did not exist, so anything compiled against the header alone failed to link. The definitions carry the declared names now, which also closes Defects -> Known and still open item 10, and their documentation moved to the header. The rest are either declared under a "part of the internal API" block -- akgl_game_save_actors and akgl_game_load_versioncmp, which tests/game.c had to declare for itself, plus six tilemap loader helpers the untested-loader work wants to reach -- or static, which is what the four save iterators and load_objectnamemap should always have been. akgl_path_relative_from is deleted: declared nowhere, called from nowhere, never wrote its output, and leaked a pooled string on every call, so it closes Known and still open item 4 and item 40 by ceasing to exist. scripts/check_api_surface.sh keeps it closed. It reads the built library's dynamic symbol table and every public header with comments stripped, and fails on an exported akgl_* symbol that is declared nowhere. Stripping comments is the whole point -- four of these were mentioned in controller.h prose, which is how they went unnoticed. The pool-size ceilings are defined once, in heap.h, so the #ifndef override hook fires for the first time; actor.h, sprite.h and character.h were defining the same four unconditionally from headers heap.h includes above its own guard. tests/header_pool_override.c fails the compile if that regresses. Also here: (void) rather than () on the twelve no-argument entry points, AKERR_NOIGNORE only on declarations, static helpers with the akgl_ prefix dropped, and the six parameter-name mismatches. akgl_get_json_with_default had its two contexts swapped rather than merely misspelled -- the incoming one was `err` and its own was `e`, which is the name reserved for an incoming one. 24/24 pass, reindent --check clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:44:38 -04:00
# 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)
Namespace every exported symbol, and bump to 0.5.0 Closes internal-consistency items 1 through 6, 12 and 13. Every include guard is _AKGL_<FILE>_H_, every in-project header include is angled, and every exported function, type and global carries the akgl_ prefix. This is an ABI break; the soname goes to libakgl.so.0.5. TODO.md carries the full rename table. The renames were driven by renaming each declaration and letting the compiler find the uses, not by pattern substitution: renderer, physics and camera are also parameter and struct-member names, and a sed would have rewritten map->physics and every akgl_RenderBackend *renderer parameter without a word. Item 4 turned out not to be cosmetic. The library exported a global called renderer and tests/character.c defined an SDL_Renderer *renderer of its own; the executable's definition preempted the library's, akgl_sprite_load_json read a SDL_Renderer * through an akgl_RenderBackend *, and every texture load in that suite failed. The suite reported success anyway, because libakerror's unhandled-error handler ends in exit(errctx->status), exit keeps only the low byte, and AKGL_ERR_SDL is exactly 256. So character had been green while running one of its four tests, and every suite in the tree was unable to fail on the most common status in a library built on SDL. Both are fixed. tests/testutil.h gains TEST_TRAP_UNHANDLED_ERRORS(), which collapses any status a byte cannot carry onto 1, and every suite installs it. character binds a real backend with akgl_render_2d_bind. Its fourth test then runs for the first time and fails on a defect it has asserted all along, so akgl_heap_release_character now walks state_sprites with AKGL_ITERATOR_OP_RELEASE and destroys the property set before zeroing the slot -- TODO.md Defects item 21 and half of Carried over item 1. AKGL_TIME_ONESEC_MS said "one second in milliseconds" and held 1000000, so akgl_game_state_lock waited roughly sixteen minutes rather than one second. It is AKGL_TIME_ONEMS_NS now, the budget is its own named constant, and tests/game.c holds the mutex from a second thread to assert the wait -- the contended path had no coverage at all. Headers are self-contained and it is enforced: AKGL_PUBLIC_HEADERS drives both install() and a generated translation unit per header, so a header that ships is a header that is checked. Writing that found registry.h, which used SDL_PropertiesID in eight declarations and included no SDL header. 23/23 suites pass, memcheck is clean, reindent --check is clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:32:21 -04:00
Check memory with the suites that already exist `cmake --build build --target memcheck` runs every registered CTest suite under valgrind. There are no new test programs, and there should not be any: tests/benchutil.h notices valgrind in LD_PRELOAD and drops the benchmark scale to 0.0005, which turns the perf suites into the broadest path coverage in the tree at a cost valgrind can survive. A benchmark walks one path a hundred thousand times; a leak check wants every path walked once. Same binaries, one flag apart, and the whole run is about thirty seconds. scripts/memcheck.sh wraps `ctest -T memcheck` because that command records defects and still exits 0, which cannot gate anything. It also forces the headless drivers, so the vendor GPU stack is never loaded -- that removes thousands of unfixable findings inside amdgpu_dri.so without suppressing anything, and it is what the suites are written for anyway. Only definite losses, invalid accesses and uninitialised reads count; "still reachable" is what SDL and FreeType keep for the process lifetime and says nothing about this library. The three suppressions in scripts/valgrind.supp are each a decision that a finding belongs to somebody else. Six defects, all filed in TODO.md under "Memory checking", the first of which is the one that matters: not one of the four json_load_file calls in src/ is ever matched by a json_decref, so every asset load abandons its parsed document -- 1.5 KB per sprite, 2.1 KB per character, 9 KB per load of the 2x2 fixture map, and on the order of a megabyte for a real one. A game that reloads a level on death leaks a level's worth of JSON every time. akgl_get_property also reads up to 4 KiB past the end of every property value it copies, and the savegame name tables read past the end of every registry key and write what they find into the file. tests/util.c zeroes three fixtures it used to leave as stack garbage. The library was never at fault there -- the tests handed it uninitialised floats and then made one real call with them -- but sixteen findings of noise in a new gate is how a gate gets ignored. The unit suites' TIMEOUT goes from 30 to 300 because CTest applies the same property to the checked run, where everything is twenty times slower. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:59:57 -04:00
# 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(
Add physics, heap, json_helpers, game, and actor test suites Raise line coverage from 39.6 to 61.8 percent with four new suites and an extension to the actor suite, and register every suite through a single CMake list so a new test file cannot be left out of the coverage fixture. Give the test targets a build-tree RPATH and prepend the build tree to LD_LIBRARY_PATH for CTest, so a developer with a previously installed libakgl.so exercises the library that was just compiled. Fix six defects the new tests exposed: - akgl_physics_simulate read self->gravity_time before its NULL check, so a NULL backend crashed instead of reporting AKERR_NULLPOINTER. - akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose inside the PROCESS switch. An ordinary save never flushed or closed its stream and produced an empty file. - akgl_game_save_actors wrote each name table terminator from the address of a single char, emitting stack contents into the save file and a sentinel the loader could not recognize. - akgl_game_load_objectnamemap used CATCH directly inside while(1), where the break leaves the loop rather than propagating, so a truncated name table loaded as a successful game. - akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no NULL check, unlike their left and right counterparts. - akgl_actor_logic_movement checked actor twice instead of checking actor->basechar before dereferencing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
${AKGL_TEST_SUITES} semver_unit
Check memory with the suites that already exist `cmake --build build --target memcheck` runs every registered CTest suite under valgrind. There are no new test programs, and there should not be any: tests/benchutil.h notices valgrind in LD_PRELOAD and drops the benchmark scale to 0.0005, which turns the perf suites into the broadest path coverage in the tree at a cost valgrind can survive. A benchmark walks one path a hundred thousand times; a leak check wants every path walked once. Same binaries, one flag apart, and the whole run is about thirty seconds. scripts/memcheck.sh wraps `ctest -T memcheck` because that command records defects and still exits 0, which cannot gate anything. It also forces the headless drivers, so the vendor GPU stack is never loaded -- that removes thousands of unfixable findings inside amdgpu_dri.so without suppressing anything, and it is what the suites are written for anyway. Only definite losses, invalid accesses and uninitialised reads count; "still reachable" is what SDL and FreeType keep for the process lifetime and says nothing about this library. The three suppressions in scripts/valgrind.supp are each a decision that a finding belongs to somebody else. Six defects, all filed in TODO.md under "Memory checking", the first of which is the one that matters: not one of the four json_load_file calls in src/ is ever matched by a json_decref, so every asset load abandons its parsed document -- 1.5 KB per sprite, 2.1 KB per character, 9 KB per load of the 2x2 fixture map, and on the order of a megabyte for a real one. A game that reloads a level on death leaks a level's worth of JSON every time. akgl_get_property also reads up to 4 KiB past the end of every property value it copies, and the savegame name tables read past the end of every registry key and write what they find into the file. tests/util.c zeroes three fixtures it used to leave as stack garbage. The library was never at fault there -- the tests handed it uninitialised floats and then made one real call with them -- but sixteen findings of noise in a new gate is how a gate gets ignored. The unit suites' TIMEOUT goes from 30 to 300 because CTest applies the same property to the checked run, where everything is twenty times slower. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:59:57 -04:00
PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/tests" TIMEOUT 300
)
Measure the library: perf suites, a recorded baseline, and targets tests/perf.c and tests/perf_render.c drive the hot paths hard enough to time them -- pool acquire and release at both ends of the scan, the registry, the state-to-sprite lookup, the per-actor update and render, the physics sweep, an all-pairs collision sweep, the JSON accessors, path resolution, the drawing primitives, text, asset loading, a screenful of tiles, and a whole frame through akgl_game_update. They are registered like any other suite but carry the `perf` label, so `ctest -L perf` runs only them and `-LE perf` leaves them out. Every measurement is held to a budget at roughly ten times the recorded baseline, enforced only in an optimized build at full scale. Three things had to be got right before the numbers meant anything, and each was wrong first: - No error checking inside the clock. PASS and CATCH call akerr_valid_error_address, which walks AKERR_ARRAY_ERROR -- more work than several of the calls being measured. - Flush the renderer before stopping it. SDL batches, so the first version measured queueing, reported a tilemap frame 250 times faster than it is, and paid the real cost at teardown inside SDL_DestroyTexture. - A drawing benchmark needs a raw-SDL control doing the same pixel work with the same access pattern. With one, akgl_tilemap_draw turns out to cost 0.2% of the frame it appeared to own: 16.26 ms against a control's 16.23 ms for the same 1200 blits. The rasterizer is the frame. PERFORMANCE.md records the baseline, the frame budget it adds up to, and what the numbers say -- including that the string pool's acquire is 64x slower full than empty because it is a megabyte of PATH_MAX buffers, that a handled missing-sprite condition costs nine times the update it replaces, that text rasterizes and throws away a texture on every call, and that 28 MB of the library's BSS is one akgl_Tilemap. TODO.md gains a Performance section: five defects the stress tests found that the unit suites do not reach (a tilemap load leaking five pooled strings, two JSON accessors that turn pool exhaustion into a segfault rather than AKGL_ERR_HEAP, akgl_game_update crashing without akgl_game_init, and its actor update sweep running once per tilemap layer), and eighteen targets with today's number and whether it is met. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:24:05 -04:00
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/
Vendor clay and bring the UI subsystem up: arena, lifecycle, status band clay v0.14 (deps/clay, zlib licence) supplies the layout engine for the new akgl_ui subsystem. Sources-listed rather than add_subdirectory()'d, on the semver/libccd precedent: clay's CMakeLists declares no library target, builds its examples by default, and requires CMake 3.27 against this project's 3.10. src/ui_clay.c is the one CLAY_IMPLEMENTATION translation unit, compiled -w on the vendored-code terms but with clay's symbols deliberately exported -- consumers' CLAY() macros resolve against libakgl.so, and akgl/ui.h warns them never to link a second clay. The subsystem is runtime-optional the way collision is: always compiled in, inert until akgl_ui_init(), which bounds clay to the overridable AKGL_UI_* ceilings, checks Clay_MinMemorySize() against a static BSS arena (no malloc), and refuses with both numbers in the message when it does not fit. Measured: 812544 bytes at the default 1024 elements / 4096 measured words, against a 1 MiB arena. clay's void-callback layout errors are logged as they happen and the first is stashed for the error protocol to raise later. AKGL_ERR_UI joins the status band before AKGL_ERR_LIMIT, named in akgl_error_init. New `ui` test suite covers validation, the init/shutdown lifecycle, and arena exhaustion via the akgl_ui_arena_limit test hook (same contract as akgl_ccd_arena_set_limit). clay.h is installed beside semver.h, its licence beside libccd's, and the headers suite proves akgl/ui.h self-contained -- clay.h compiles clean under -Wall. 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 10:53:11 -04:00
# 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/
Derive the library version from one place and give the .so a soname The version was written down twice and agreed with itself by luck. AKGL_VERSION was hand-maintained in include/akgl/game.h, and nothing connected it to the build, which had no version at all: - project() declared no VERSION, so the @PROJECT_VERSION@ substitution in akgl.pc.in expanded to nothing and every installed akgl.pc shipped an empty Version field. pkg-config --modversion returned "" and --atleast-version could not work at all. - libakgl.so had no VERSION or SOVERSION, so there was no soname and no symlink chain. A stale libakgl could be paired with new headers silently, which is the failure libakerror added its own soname to prevent. - MY_LIBRARY_VERSION at CMakeLists.txt:288 was never set anywhere. It built main_lib_dest as "lib/akgl-", and main_lib_dest was then never read. Dead line, removed. project(akgl VERSION 0.1.0) is now the only place the number appears. It drives the generated include/akgl/version.h, the shared library VERSION and SOVERSION, and the Version field in akgl.pc. Held at 0.1.0 to match the constant it replaces, so akgl_game_load_versioncmp still compares savegames against the same value. include/akgl/version.h is generated by configure_file from version.h.in and publishes AKGL_VERSION, AKGL_VERSION_MAJOR/MINOR/PATCH and AKGL_VERSION_AT_LEAST(major, minor, patch). That macro is the point: libakstdlib could not write that test against libakerror, which publishes no version macro, and had to feature-test on AKERR_FIRST_CONSUMER_STATUS instead. game.h now includes the generated header rather than defining the version itself, so consumers see AKGL_VERSION exactly where they saw it before. While the major version is 0 the soname carries major.minor, giving libakgl.so.0.1. A plain SOVERSION 0 would assert that 0.1.0 and 0.2.0 are ABI-interchangeable, and they are not -- the preceding commit alone added akgl_error_init. At 1.0.0 the soname becomes the major alone, matching libakerror. akgl_version() in src/version.c reports the version of the libakgl that is actually linked, where AKGL_VERSION reports what the caller compiled against. Comparing the two is the only way to catch a stale shared library on the loader path; the macro alone cannot see it. It returns a string rather than an akerr_ErrorContext * because it cannot fail, for the same reason akerr_name_for_status does. Add tests/version.c: the string and the numeric components must describe one version, the linked library must agree with the headers, and AKGL_VERSION_AT_LEAST must order correctly at the major, minor and patch boundaries. Verified out of band that bumping project() to 1.2.3 moves version.h, akgl.pc and the soname together, and that an install produces the libakgl.so -> .so.0.1 -> .so.0.1.0 chain with SONAME libakgl.so.0.1. Also ignore include/akgl/version.h. include/ precedes the build tree on the include path, so a stray copy there would shadow the generated one and pin every consumer to whatever version it was generated at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:36:50 -04:00
# 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}")
Make savegames, optional array elements, empty text and coverage work Closes Known-and-still-open items 7 and 13, and Defects items 18, 25 and 27. The savegame name tables carry no length prefix, so writer and reader have to agree on a field width exactly. The writer used each object's own maximum name length -- 512 for a spritesheet, a filename -- and the reader used AKGL_ACTOR_MAX_NAME_LENGTH for all four. Four AKGL_GAME_SAVE_*_NAME_WIDTH constants drive both sides now. The failure turned out to be worse than "cannot be read back": a reader stepping the wrong width does not run off anything, it finds a run of zeros inside an entry, stops early, and reports success with silently wrong maps. A test asserting only that the load succeeded passed against the broken reader. So akgl_game_load checks it is at EOF once the tables are read, which turns a width disagreement into AKERR_IO instead of a corruption. That is the assertion the new roundtrip test -- the first with all four registries populated -- hangs on. akgl_get_json_with_default gains a third HANDLE_GROUP for AKERR_OUTOFBOUNDS, which is what the array index accessors report, so "this element is optional" works for an array element and not only for an object member. The arm goes above the one holding the memcpy: HANDLE_GROUP emits no break and every arm falls into that body. That test needed a second attempt. with_default returns *the context it was given* when it does not handle the status, so TEST_EXPECT_OK -- which releases whatever the statement returns -- double-released it against a CLEANUP block that released it too, and a double-released context corrupts the failure rather than reporting it. The first draft passed against the unfixed library. akgl_text_rendertextat returns success without rasterizing for the empty string, matching akgl_text_measure, which has always accepted it. The check sits after the font and backend guards, so drawing nothing still refuses what drawing something refuses. tests/text.c had this case written and unasserted waiting for the two halves of the header to agree. character_load_json_state_int_from_strings guards dest rather than testing states twice. Not asserted: the function is static with one call site that passes a real pointer, so the guard cannot fire, and reaching it from a test would mean giving it external linkage purely for that. Both gcovr invocations take the build tree as an explicit positional search path. gcovr searches --root when given none, which is the source directory, where build trees live; --object-directory does not narrow it. Verified by building two instrumented trees with a source edit between them: the old invocation fails with "Got function write_exact on multiple lines: 46, 48" and exits 64, the new one exits 0 and the full coverage run passes with the stale tree still present. 25/25 pass, reindent --check, check_api_surface and check_error_protocol clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:18:31 -04:00
# 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
Make savegames, optional array elements, empty text and coverage work Closes Known-and-still-open items 7 and 13, and Defects items 18, 25 and 27. The savegame name tables carry no length prefix, so writer and reader have to agree on a field width exactly. The writer used each object's own maximum name length -- 512 for a spritesheet, a filename -- and the reader used AKGL_ACTOR_MAX_NAME_LENGTH for all four. Four AKGL_GAME_SAVE_*_NAME_WIDTH constants drive both sides now. The failure turned out to be worse than "cannot be read back": a reader stepping the wrong width does not run off anything, it finds a run of zeros inside an entry, stops early, and reports success with silently wrong maps. A test asserting only that the load succeeded passed against the broken reader. So akgl_game_load checks it is at EOF once the tables are read, which turns a width disagreement into AKERR_IO instead of a corruption. That is the assertion the new roundtrip test -- the first with all four registries populated -- hangs on. akgl_get_json_with_default gains a third HANDLE_GROUP for AKERR_OUTOFBOUNDS, which is what the array index accessors report, so "this element is optional" works for an array element and not only for an object member. The arm goes above the one holding the memcpy: HANDLE_GROUP emits no break and every arm falls into that body. That test needed a second attempt. with_default returns *the context it was given* when it does not handle the status, so TEST_EXPECT_OK -- which releases whatever the statement returns -- double-released it against a CLEANUP block that released it too, and a double-released context corrupts the failure rather than reporting it. The first draft passed against the unfixed library. akgl_text_rendertextat returns success without rasterizing for the empty string, matching akgl_text_measure, which has always accepted it. The check sits after the font and backend guards, so drawing nothing still refuses what drawing something refuses. tests/text.c had this case written and unasserted waiting for the two halves of the header to agree. character_load_json_state_int_from_strings guards dest rather than testing states twice. Not asserted: the function is static with one call site that passes a real pointer, so the guard cannot fire, and reaching it from a test would mean giving it external linkage purely for that. Both gcovr invocations take the build tree as an explicit positional search path. gcovr searches --root when given none, which is the source directory, where build trees live; --object-directory does not narrow it. Verified by building two instrumented trees with a source edit between them: the old invocation fails with "Got function write_exact on multiple lines: 46, 48" and exits 64, the new one exits 0 and the full coverage run passes with the stale tree still present. 25/25 pass, reindent --check, check_api_surface and check_error_protocol clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:18:31 -04:00
"${CMAKE_CURRENT_BINARY_DIR}"
)
set_tests_properties(coverage_reset PROPERTIES FIXTURES_SETUP akgl_coverage)
Add physics, heap, json_helpers, game, and actor test suites Raise line coverage from 39.6 to 61.8 percent with four new suites and an extension to the actor suite, and register every suite through a single CMake list so a new test file cannot be left out of the coverage fixture. Give the test targets a build-tree RPATH and prepend the build tree to LD_LIBRARY_PATH for CTest, so a developer with a previously installed libakgl.so exercises the library that was just compiled. Fix six defects the new tests exposed: - akgl_physics_simulate read self->gravity_time before its NULL check, so a NULL backend crashed instead of reporting AKERR_NULLPOINTER. - akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose inside the PROCESS switch. An ordinary save never flushed or closed its stream and produced an empty file. - akgl_game_save_actors wrote each name table terminator from the address of a single char, emitting stack contents into the save file and a sentinel the loader could not recognize. - akgl_game_load_objectnamemap used CATCH directly inside while(1), where the break leaves the loop rather than propagating, so a truncated name table loaded as a successful game. - akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no NULL check, unlike their left and right counterparts. - akgl_actor_logic_movement checked actor twice instead of checking actor->basechar before dereferencing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
# Any suite missing from this list runs outside the fixture and has its
Measure the library: perf suites, a recorded baseline, and targets tests/perf.c and tests/perf_render.c drive the hot paths hard enough to time them -- pool acquire and release at both ends of the scan, the registry, the state-to-sprite lookup, the per-actor update and render, the physics sweep, an all-pairs collision sweep, the JSON accessors, path resolution, the drawing primitives, text, asset loading, a screenful of tiles, and a whole frame through akgl_game_update. They are registered like any other suite but carry the `perf` label, so `ctest -L perf` runs only them and `-LE perf` leaves them out. Every measurement is held to a budget at roughly ten times the recorded baseline, enforced only in an optimized build at full scale. Three things had to be got right before the numbers meant anything, and each was wrong first: - No error checking inside the clock. PASS and CATCH call akerr_valid_error_address, which walks AKERR_ARRAY_ERROR -- more work than several of the calls being measured. - Flush the renderer before stopping it. SDL batches, so the first version measured queueing, reported a tilemap frame 250 times faster than it is, and paid the real cost at teardown inside SDL_DestroyTexture. - A drawing benchmark needs a raw-SDL control doing the same pixel work with the same access pattern. With one, akgl_tilemap_draw turns out to cost 0.2% of the frame it appeared to own: 16.26 ms against a control's 16.23 ms for the same 1200 blits. The rasterizer is the frame. PERFORMANCE.md records the baseline, the frame budget it adds up to, and what the numbers say -- including that the string pool's acquire is 64x slower full than empty because it is a megabyte of PATH_MAX buffers, that a handled missing-sprite condition costs nine times the update it replaces, that text rasterizes and throws away a texture on every call, and that 28 MB of the library's BSS is one akgl_Tilemap. TODO.md gains a Performance section: five defects the stress tests found that the unit suites do not reach (a tilemap load leaking five pooled strings, two JSON accessors that turn pool exhaustion into a segfault rather than AKGL_ERR_HEAP, akgl_game_update crashing without akgl_game_init, and its actor update sweep running once per tilemap layer), and eighteen targets with today's number and whether it is met. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:24:05 -04:00
# 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(
Add physics, heap, json_helpers, game, and actor test suites Raise line coverage from 39.6 to 61.8 percent with four new suites and an extension to the actor suite, and register every suite through a single CMake list so a new test file cannot be left out of the coverage fixture. Give the test targets a build-tree RPATH and prepend the build tree to LD_LIBRARY_PATH for CTest, so a developer with a previously installed libakgl.so exercises the library that was just compiled. Fix six defects the new tests exposed: - akgl_physics_simulate read self->gravity_time before its NULL check, so a NULL backend crashed instead of reporting AKERR_NULLPOINTER. - akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose inside the PROCESS switch. An ordinary save never flushed or closed its stream and produced an empty file. - akgl_game_save_actors wrote each name table terminator from the address of a single char, emitting stack contents into the save file and a sentinel the loader could not recognize. - akgl_game_load_objectnamemap used CATCH directly inside while(1), where the break leaves the loop rather than propagating, so a truncated name table loaded as a successful game. - akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no NULL check, unlike their left and right counterparts. - akgl_actor_logic_movement checked actor twice instead of checking actor->basechar before dereferencing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
${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"
Make savegames, optional array elements, empty text and coverage work Closes Known-and-still-open items 7 and 13, and Defects items 18, 25 and 27. The savegame name tables carry no length prefix, so writer and reader have to agree on a field width exactly. The writer used each object's own maximum name length -- 512 for a spritesheet, a filename -- and the reader used AKGL_ACTOR_MAX_NAME_LENGTH for all four. Four AKGL_GAME_SAVE_*_NAME_WIDTH constants drive both sides now. The failure turned out to be worse than "cannot be read back": a reader stepping the wrong width does not run off anything, it finds a run of zeros inside an entry, stops early, and reports success with silently wrong maps. A test asserting only that the load succeeded passed against the broken reader. So akgl_game_load checks it is at EOF once the tables are read, which turns a width disagreement into AKERR_IO instead of a corruption. That is the assertion the new roundtrip test -- the first with all four registries populated -- hangs on. akgl_get_json_with_default gains a third HANDLE_GROUP for AKERR_OUTOFBOUNDS, which is what the array index accessors report, so "this element is optional" works for an array element and not only for an object member. The arm goes above the one holding the memcpy: HANDLE_GROUP emits no break and every arm falls into that body. That test needed a second attempt. with_default returns *the context it was given* when it does not handle the status, so TEST_EXPECT_OK -- which releases whatever the statement returns -- double-released it against a CLEANUP block that released it too, and a double-released context corrupts the failure rather than reporting it. The first draft passed against the unfixed library. akgl_text_rendertextat returns success without rasterizing for the empty string, matching akgl_text_measure, which has always accepted it. The check sits after the font and backend guards, so drawing nothing still refuses what drawing something refuses. tests/text.c had this case written and unasserted waiting for the two halves of the header to agree. character_load_json_state_int_from_strings guards dest rather than testing states twice. Not asserted: the function is static with one call site that passes a real pointer, so the guard cannot fire, and reaching it from a test would mean giving it external linkage purely for that. Both gcovr invocations take the build tree as an explicit positional search path. gcovr searches --root when given none, which is the source directory, where build trees live; --object-directory does not narrow it. Verified by building two instrumented trees with a source edit between them: the old invocation fails with "Got function write_exact on multiple lines: 46, 48" and exits 64, the new one exits 0 and the full coverage run passes with the stale tree still present. 25/25 pass, reindent --check, check_api_surface and check_error_protocol clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:18:31 -04:00
"${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
)
Measure the library: perf suites, a recorded baseline, and targets tests/perf.c and tests/perf_render.c drive the hot paths hard enough to time them -- pool acquire and release at both ends of the scan, the registry, the state-to-sprite lookup, the per-actor update and render, the physics sweep, an all-pairs collision sweep, the JSON accessors, path resolution, the drawing primitives, text, asset loading, a screenful of tiles, and a whole frame through akgl_game_update. They are registered like any other suite but carry the `perf` label, so `ctest -L perf` runs only them and `-LE perf` leaves them out. Every measurement is held to a budget at roughly ten times the recorded baseline, enforced only in an optimized build at full scale. Three things had to be got right before the numbers meant anything, and each was wrong first: - No error checking inside the clock. PASS and CATCH call akerr_valid_error_address, which walks AKERR_ARRAY_ERROR -- more work than several of the calls being measured. - Flush the renderer before stopping it. SDL batches, so the first version measured queueing, reported a tilemap frame 250 times faster than it is, and paid the real cost at teardown inside SDL_DestroyTexture. - A drawing benchmark needs a raw-SDL control doing the same pixel work with the same access pattern. With one, akgl_tilemap_draw turns out to cost 0.2% of the frame it appeared to own: 16.26 ms against a control's 16.23 ms for the same 1200 blits. The rasterizer is the frame. PERFORMANCE.md records the baseline, the frame budget it adds up to, and what the numbers say -- including that the string pool's acquire is 64x slower full than empty because it is a megabyte of PATH_MAX buffers, that a handled missing-sprite condition costs nine times the update it replaces, that text rasterizes and throws away a texture on every call, and that 28 MB of the library's BSS is one akgl_Tilemap. TODO.md gains a Performance section: five defects the stress tests found that the unit suites do not reach (a tilemap load leaking five pooled strings, two JSON accessors that turn pool exhaustion into a segfault rather than AKGL_ERR_HEAP, akgl_game_update crashing without akgl_game_init, and its actor update sweep running once per tilemap layer), and eighteen targets with today's number and whether it is met. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:24:05 -04:00
foreach(suite IN LISTS AKGL_TEST_SUITES AKGL_PERF_SUITES)
Upgrade to libakstdlib 0.1.0 Bump deps/libakstdlib five commits to 95e5002, which versions the library at 0.1.0 with an soname and a ConfigVersion file, upgrades its own libakerror to 1.0.0, namespaces its coverage target when embedded, and raises its libc-wrapper coverage. The public header diff is purely additive -- nothing removed, no signature changed -- so libakgl's nine aksl_* call sites needed no edits. What broke was the build. A default `cmake -S . -B build` stopped configuring: add_executable cannot create target "test_version" because another target with the same name already exists. The existing target is an executable created in source directory ".../deps/libakstdlib" The versioning commit added tests/test_version.c to libakstdlib. EXCLUDE_FROM_ALL keeps that target from being built, but add_subdirectory still creates it, so it collided with the version suite added in the preceding commit. Build libakgl's test programs as akgl_test_<name> instead. The CTest names are unchanged -- ctest -R version still selects it -- and a dependency is now free to ship a test of any name. Ask find_package for libakstdlib 0.1 rather than any version. 0.1.0 ships akstdlibConfigVersion.cmake with SameMinorVersion compatibility, mirroring its libakstdlib.so.0.1 soname, so this accepts any 0.1.x and refuses 0.2 and 1.0. Unversioned, the non-embedded path would have taken an ABI-incompatible libakstdlib without complaint. libakerror is deliberately left unversioned in find_package. It installs akerrorConfig.cmake and akerrorTargets.cmake but no akerrorConfigVersion.cmake, so find_package(akerror 1.0) fails against a correct install rather than a stale one. Its floor stays with the #error in include/akgl/error.h. Worth fixing upstream. Verified: clean configure, build and 16/16 ctest; a -DAKGL_COVERAGE=ON tree configures, builds and passes 18/18, so libakstdlib's embedded coverage-target rename does not collide here. Against a temp-prefix install of 95e5002, find_package(akstdlib) accepts 0.1 and 0.1.0 and refuses 0.2 and 1.0, and find_package(akerror 1.0) fails for the missing version file as described. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:55:18 -04:00
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")
Build clean under -Wall, with -Werror in CI only TODO.md item 37 said the cast sweep buys nothing until the build turns on the warnings those casts suppress. This is that precondition, and the argument turned out to be right with evidence: -Wall found three genuine signedness mismatches in sprite.c, where uint32_t width, height and speed were passed straight to akgl_get_json_integer_value(..., int *). A cast would have silenced all three. Fixing them properly also turned up that speed is scaled by a million into a 32-bit field, so anything past 4294 ms overflowed rather than being held. The other real finding was ten -Wstringop-truncation warnings, every one a fixed-width strncpy. Those leave a name field unterminated when the input fills it -- and those fields are registry keys, handed to strcmp and SDL property calls that do not stop at the field. Same overread class fixed twice already this release. All ten now use aksl_strncpy from akstdlib, which always terminates and never NUL-pads, bounded to size - 1 so an over-long name still truncates rather than being refused. staticstring.h documented the old strncpy semantics explicitly and has been rewritten to match. Two sites in game.c are deliberately left on strncpy: both stage into a pre-zeroed buffer for the savegame's fixed-width fields, where the NUL padding is part of the format. Neither is flagged. sprite.c also had a memcpy of a full 128-byte field from a NUL-terminated string, which reads past the end of any shorter name. Not flagged by anything; found while converting its neighbours. -Werror is an option, default OFF, on only in the cmake_build and performance CI jobs. libakgl is consumed with add_subdirectory -- akbasic does exactly that -- so a target-level -Werror turns every diagnostic a newer compiler invents into a broken build for somebody else. The warning set is not even stable across build types here: an -O2 build reports ten warnings that -O0 and -fsyntax-only do not, because they need the middle end. The two jobs that set it are one of each. -Wextra is deliberately not adopted. It adds 22 findings, 17 inherent to the design: 13 -Wunused-parameter from backend vtables and SDL callbacks that must match a signature, and 4 -Wimplicit-fallthrough from libakerror's own PROCESS/HANDLE/HANDLE_GROUP, which fall through by design. Filed upstream as deps/libakerror TODO item 8; the submodule bump carries it. deps/semver/semver.c is listed directly in add_library, so the target's PRIVATE options reach it. Exempted with -w so a future semver update cannot fail this build -- verified by forcing -Wextra -Werror and watching our files fail while semver compiled clean. Verified: RelWithDebInfo, Debug and coverage builds all clean under -Werror; -Werror actually fails on a planted unused variable; an embedded consumer with AKGL_WERROR unset still configures and builds. 25/25 pass, memcheck clean, reindent --check, check_api_surface and check_error_protocol clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 09:18:52 -04:00
target_compile_options(akgl_test_${suite} PRIVATE ${AKGL_WARNING_FLAGS})
Add physics, heap, json_helpers, game, and actor test suites Raise line coverage from 39.6 to 61.8 percent with four new suites and an extension to the actor suite, and register every suite through a single CMake list so a new test file cannot be left out of the coverage fixture. Give the test targets a build-tree RPATH and prepend the build tree to LD_LIBRARY_PATH for CTest, so a developer with a previously installed libakgl.so exercises the library that was just compiled. Fix six defects the new tests exposed: - akgl_physics_simulate read self->gravity_time before its NULL check, so a NULL backend crashed instead of reporting AKERR_NULLPOINTER. - akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose inside the PROCESS switch. An ordinary save never flushed or closed its stream and produced an empty file. - akgl_game_save_actors wrote each name table terminator from the address of a single char, emitting stack contents into the save file and a sentinel the loader could not recognize. - akgl_game_load_objectnamemap used CATCH directly inside while(1), where the break leaves the loop rather than propagating, so a truncated name table loaded as a successful game. - akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no NULL check, unlike their left and right counterparts. - akgl_actor_logic_movement checked actor twice instead of checking actor->basechar before dereferencing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
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)
Build clean under -Wall, with -Werror in CI only TODO.md item 37 said the cast sweep buys nothing until the build turns on the warnings those casts suppress. This is that precondition, and the argument turned out to be right with evidence: -Wall found three genuine signedness mismatches in sprite.c, where uint32_t width, height and speed were passed straight to akgl_get_json_integer_value(..., int *). A cast would have silenced all three. Fixing them properly also turned up that speed is scaled by a million into a 32-bit field, so anything past 4294 ms overflowed rather than being held. The other real finding was ten -Wstringop-truncation warnings, every one a fixed-width strncpy. Those leave a name field unterminated when the input fills it -- and those fields are registry keys, handed to strcmp and SDL property calls that do not stop at the field. Same overread class fixed twice already this release. All ten now use aksl_strncpy from akstdlib, which always terminates and never NUL-pads, bounded to size - 1 so an over-long name still truncates rather than being refused. staticstring.h documented the old strncpy semantics explicitly and has been rewritten to match. Two sites in game.c are deliberately left on strncpy: both stage into a pre-zeroed buffer for the savegame's fixed-width fields, where the NUL padding is part of the format. Neither is flagged. sprite.c also had a memcpy of a full 128-byte field from a NUL-terminated string, which reads past the end of any shorter name. Not flagged by anything; found while converting its neighbours. -Werror is an option, default OFF, on only in the cmake_build and performance CI jobs. libakgl is consumed with add_subdirectory -- akbasic does exactly that -- so a target-level -Werror turns every diagnostic a newer compiler invents into a broken build for somebody else. The warning set is not even stable across build types here: an -O2 build reports ten warnings that -O0 and -fsyntax-only do not, because they need the middle end. The two jobs that set it are one of each. -Wextra is deliberately not adopted. It adds 22 findings, 17 inherent to the design: 13 -Wunused-parameter from backend vtables and SDL callbacks that must match a signature, and 4 -Wimplicit-fallthrough from libakerror's own PROCESS/HANDLE/HANDLE_GROUP, which fall through by design. Filed upstream as deps/libakerror TODO item 8; the submodule bump carries it. deps/semver/semver.c is listed directly in add_library, so the target's PRIVATE options reach it. Exempted with -w so a future semver update cannot fail this build -- verified by forcing -Wextra -Werror and watching our files fail while semver compiled clean. Verified: RelWithDebInfo, Debug and coverage builds all clean under -Werror; -Werror actually fails on a planted unused variable; an embedded consumer with AKGL_WERROR unset still configures and builds. 25/25 pass, memcheck clean, reindent --check, check_api_surface and check_error_protocol clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 09:18:52 -04:00
target_compile_options(charviewer PRIVATE ${AKGL_WARNING_FLAGS})
Add physics, heap, json_helpers, game, and actor test suites Raise line coverage from 39.6 to 61.8 percent with four new suites and an extension to the actor suite, and register every suite through a single CMake list so a new test file cannot be left out of the coverage fixture. Give the test targets a build-tree RPATH and prepend the build tree to LD_LIBRARY_PATH for CTest, so a developer with a previously installed libakgl.so exercises the library that was just compiled. Fix six defects the new tests exposed: - akgl_physics_simulate read self->gravity_time before its NULL check, so a NULL backend crashed instead of reporting AKERR_NULLPOINTER. - akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose inside the PROCESS switch. An ordinary save never flushed or closed its stream and produced an empty file. - akgl_game_save_actors wrote each name table terminator from the address of a single char, emitting stack contents into the save file and a sentinel the loader could not recognize. - akgl_game_load_objectnamemap used CATCH directly inside while(1), where the break leaves the loop rather than propagating, so a truncated name table loaded as a successful game. - akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no NULL check, unlike their left and right counterparts. - akgl_actor_logic_movement checked actor twice instead of checking actor->basechar before dereferencing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
# 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)
Add physics, heap, json_helpers, game, and actor test suites Raise line coverage from 39.6 to 61.8 percent with four new suites and an extension to the actor suite, and register every suite through a single CMake list so a new test file cannot be left out of the coverage fixture. Give the test targets a build-tree RPATH and prepend the build tree to LD_LIBRARY_PATH for CTest, so a developer with a previously installed libakgl.so exercises the library that was just compiled. Fix six defects the new tests exposed: - akgl_physics_simulate read self->gravity_time before its NULL check, so a NULL backend crashed instead of reporting AKERR_NULLPOINTER. - akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose inside the PROCESS switch. An ordinary save never flushed or closed its stream and produced an empty file. - akgl_game_save_actors wrote each name table terminator from the address of a single char, emitting stack contents into the save file and a sentinel the loader could not recognize. - akgl_game_load_objectnamemap used CATCH directly inside while(1), where the break leaves the loop rather than propagating, so a truncated name table loaded as a successful game. - akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no NULL check, unlike their left and right counterparts. - akgl_actor_logic_movement checked actor twice instead of checking actor->basechar before dereferencing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
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>"
)
Measure the library: perf suites, a recorded baseline, and targets tests/perf.c and tests/perf_render.c drive the hot paths hard enough to time them -- pool acquire and release at both ends of the scan, the registry, the state-to-sprite lookup, the per-actor update and render, the physics sweep, an all-pairs collision sweep, the JSON accessors, path resolution, the drawing primitives, text, asset loading, a screenful of tiles, and a whole frame through akgl_game_update. They are registered like any other suite but carry the `perf` label, so `ctest -L perf` runs only them and `-LE perf` leaves them out. Every measurement is held to a budget at roughly ten times the recorded baseline, enforced only in an optimized build at full scale. Three things had to be got right before the numbers meant anything, and each was wrong first: - No error checking inside the clock. PASS and CATCH call akerr_valid_error_address, which walks AKERR_ARRAY_ERROR -- more work than several of the calls being measured. - Flush the renderer before stopping it. SDL batches, so the first version measured queueing, reported a tilemap frame 250 times faster than it is, and paid the real cost at teardown inside SDL_DestroyTexture. - A drawing benchmark needs a raw-SDL control doing the same pixel work with the same access pattern. With one, akgl_tilemap_draw turns out to cost 0.2% of the frame it appeared to own: 16.26 ms against a control's 16.23 ms for the same 1200 blits. The rasterizer is the frame. PERFORMANCE.md records the baseline, the frame budget it adds up to, and what the numbers say -- including that the string pool's acquire is 64x slower full than empty because it is a megabyte of PATH_MAX buffers, that a handled missing-sprite condition costs nine times the update it replaces, that text rasterizes and throws away a texture on every call, and that 28 MB of the library's BSS is one akgl_Tilemap. TODO.md gains a Performance section: five defects the stress tests found that the unit suites do not reach (a tilemap load leaking five pooled strings, two JSON accessors that turn pool exhaustion into a segfault rather than AKGL_ERR_HEAP, akgl_game_update crashing without akgl_game_init, and its actor update sweep running once per tilemap layer), and eighteen targets with today's number and whether it is met. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:24:05 -04:00
foreach(suite IN LISTS AKGL_TEST_SUITES AKGL_PERF_SUITES)
Upgrade to libakstdlib 0.1.0 Bump deps/libakstdlib five commits to 95e5002, which versions the library at 0.1.0 with an soname and a ConfigVersion file, upgrades its own libakerror to 1.0.0, namespaces its coverage target when embedded, and raises its libc-wrapper coverage. The public header diff is purely additive -- nothing removed, no signature changed -- so libakgl's nine aksl_* call sites needed no edits. What broke was the build. A default `cmake -S . -B build` stopped configuring: add_executable cannot create target "test_version" because another target with the same name already exists. The existing target is an executable created in source directory ".../deps/libakstdlib" The versioning commit added tests/test_version.c to libakstdlib. EXCLUDE_FROM_ALL keeps that target from being built, but add_subdirectory still creates it, so it collided with the version suite added in the preceding commit. Build libakgl's test programs as akgl_test_<name> instead. The CTest names are unchanged -- ctest -R version still selects it -- and a dependency is now free to ship a test of any name. Ask find_package for libakstdlib 0.1 rather than any version. 0.1.0 ships akstdlibConfigVersion.cmake with SameMinorVersion compatibility, mirroring its libakstdlib.so.0.1 soname, so this accepts any 0.1.x and refuses 0.2 and 1.0. Unversioned, the non-embedded path would have taken an ABI-incompatible libakstdlib without complaint. libakerror is deliberately left unversioned in find_package. It installs akerrorConfig.cmake and akerrorTargets.cmake but no akerrorConfigVersion.cmake, so find_package(akerror 1.0) fails against a correct install rather than a stale one. Its floor stays with the #error in include/akgl/error.h. Worth fixing upstream. Verified: clean configure, build and 16/16 ctest; a -DAKGL_COVERAGE=ON tree configures, builds and passes 18/18, so libakstdlib's embedded coverage-target rename does not collide here. Against a temp-prefix install of 95e5002, find_package(akstdlib) accepts 0.1 and 0.1.0 and refuses 0.2 and 1.0, and find_package(akerror 1.0) fails for the missing version file as described. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:55:18 -04:00
set_target_properties(akgl_test_${suite} PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}")
Add physics, heap, json_helpers, game, and actor test suites Raise line coverage from 39.6 to 61.8 percent with four new suites and an extension to the actor suite, and register every suite through a single CMake list so a new test file cannot be left out of the coverage fixture. Give the test targets a build-tree RPATH and prepend the build tree to LD_LIBRARY_PATH for CTest, so a developer with a previously installed libakgl.so exercises the library that was just compiled. Fix six defects the new tests exposed: - akgl_physics_simulate read self->gravity_time before its NULL check, so a NULL backend crashed instead of reporting AKERR_NULLPOINTER. - akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose inside the PROCESS switch. An ordinary save never flushed or closed its stream and produced an empty file. - akgl_game_save_actors wrote each name table terminator from the address of a single char, emitting stack contents into the save file and a sentinel the loader could not recognize. - akgl_game_load_objectnamemap used CATCH directly inside while(1), where the break leaves the loop rather than propagating, so a truncated name table loaded as a successful game. - akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no NULL check, unlike their left and right counterparts. - akgl_actor_logic_movement checked actor twice instead of checking actor->basechar before dereferencing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
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(
Measure the library: perf suites, a recorded baseline, and targets tests/perf.c and tests/perf_render.c drive the hot paths hard enough to time them -- pool acquire and release at both ends of the scan, the registry, the state-to-sprite lookup, the per-actor update and render, the physics sweep, an all-pairs collision sweep, the JSON accessors, path resolution, the drawing primitives, text, asset loading, a screenful of tiles, and a whole frame through akgl_game_update. They are registered like any other suite but carry the `perf` label, so `ctest -L perf` runs only them and `-LE perf` leaves them out. Every measurement is held to a budget at roughly ten times the recorded baseline, enforced only in an optimized build at full scale. Three things had to be got right before the numbers meant anything, and each was wrong first: - No error checking inside the clock. PASS and CATCH call akerr_valid_error_address, which walks AKERR_ARRAY_ERROR -- more work than several of the calls being measured. - Flush the renderer before stopping it. SDL batches, so the first version measured queueing, reported a tilemap frame 250 times faster than it is, and paid the real cost at teardown inside SDL_DestroyTexture. - A drawing benchmark needs a raw-SDL control doing the same pixel work with the same access pattern. With one, akgl_tilemap_draw turns out to cost 0.2% of the frame it appeared to own: 16.26 ms against a control's 16.23 ms for the same 1200 blits. The rasterizer is the frame. PERFORMANCE.md records the baseline, the frame budget it adds up to, and what the numbers say -- including that the string pool's acquire is 64x slower full than empty because it is a megabyte of PATH_MAX buffers, that a handled missing-sprite condition costs nine times the update it replaces, that text rasterizes and throws away a texture on every call, and that 28 MB of the library's BSS is one akgl_Tilemap. TODO.md gains a Performance section: five defects the stress tests found that the unit suites do not reach (a tilemap load leaking five pooled strings, two JSON accessors that turn pool exhaustion into a segfault rather than AKGL_ERR_HEAP, akgl_game_update crashing without akgl_game_init, and its actor update sweep running once per tilemap layer), and eighteen targets with today's number and whether it is met. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:24:05 -04:00
${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES}
Add physics, heap, json_helpers, game, and actor test suites Raise line coverage from 39.6 to 61.8 percent with four new suites and an extension to the actor suite, and register every suite through a single CMake list so a new test file cannot be left out of the coverage fixture. Give the test targets a build-tree RPATH and prepend the build tree to LD_LIBRARY_PATH for CTest, so a developer with a previously installed libakgl.so exercises the library that was just compiled. Fix six defects the new tests exposed: - akgl_physics_simulate read self->gravity_time before its NULL check, so a NULL backend crashed instead of reporting AKERR_NULLPOINTER. - akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose inside the PROCESS switch. An ordinary save never flushed or closed its stream and produced an empty file. - akgl_game_save_actors wrote each name table terminator from the address of a single char, emitting stack contents into the save file and a sentinel the loader could not recognize. - akgl_game_load_objectnamemap used CATCH directly inside while(1), where the break leaves the loop rather than propagating, so a truncated name table loaded as a successful game. - akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no NULL check, unlike their left and right counterparts. - akgl_actor_logic_movement checked actor twice instead of checking actor->basechar before dereferencing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
PROPERTIES ENVIRONMENT_MODIFICATION "${AKGL_TEST_ENV_MOD}"
)
else()
string(REPLACE ";" ":" AKGL_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}")
set_tests_properties(
Measure the library: perf suites, a recorded baseline, and targets tests/perf.c and tests/perf_render.c drive the hot paths hard enough to time them -- pool acquire and release at both ends of the scan, the registry, the state-to-sprite lookup, the per-actor update and render, the physics sweep, an all-pairs collision sweep, the JSON accessors, path resolution, the drawing primitives, text, asset loading, a screenful of tiles, and a whole frame through akgl_game_update. They are registered like any other suite but carry the `perf` label, so `ctest -L perf` runs only them and `-LE perf` leaves them out. Every measurement is held to a budget at roughly ten times the recorded baseline, enforced only in an optimized build at full scale. Three things had to be got right before the numbers meant anything, and each was wrong first: - No error checking inside the clock. PASS and CATCH call akerr_valid_error_address, which walks AKERR_ARRAY_ERROR -- more work than several of the calls being measured. - Flush the renderer before stopping it. SDL batches, so the first version measured queueing, reported a tilemap frame 250 times faster than it is, and paid the real cost at teardown inside SDL_DestroyTexture. - A drawing benchmark needs a raw-SDL control doing the same pixel work with the same access pattern. With one, akgl_tilemap_draw turns out to cost 0.2% of the frame it appeared to own: 16.26 ms against a control's 16.23 ms for the same 1200 blits. The rasterizer is the frame. PERFORMANCE.md records the baseline, the frame budget it adds up to, and what the numbers say -- including that the string pool's acquire is 64x slower full than empty because it is a megabyte of PATH_MAX buffers, that a handled missing-sprite condition costs nine times the update it replaces, that text rasterizes and throws away a texture on every call, and that 28 MB of the library's BSS is one akgl_Tilemap. TODO.md gains a Performance section: five defects the stress tests found that the unit suites do not reach (a tilemap load leaking five pooled strings, two JSON accessors that turn pool exhaustion into a segfault rather than AKGL_ERR_HEAP, akgl_game_update crashing without akgl_game_init, and its actor update sweep running once per tilemap layer), and eighteen targets with today's number and whether it is met. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:24:05 -04:00
${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES}
Add physics, heap, json_helpers, game, and actor test suites Raise line coverage from 39.6 to 61.8 percent with four new suites and an extension to the actor suite, and register every suite through a single CMake list so a new test file cannot be left out of the coverage fixture. Give the test targets a build-tree RPATH and prepend the build tree to LD_LIBRARY_PATH for CTest, so a developer with a previously installed libakgl.so exercises the library that was just compiled. Fix six defects the new tests exposed: - akgl_physics_simulate read self->gravity_time before its NULL check, so a NULL backend crashed instead of reporting AKERR_NULLPOINTER. - akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose inside the PROCESS switch. An ordinary save never flushed or closed its stream and produced an empty file. - akgl_game_save_actors wrote each name table terminator from the address of a single char, emitting stack contents into the save file and a sentinel the loader could not recognize. - akgl_game_load_objectnamemap used CATCH directly inside while(1), where the break leaves the loop rather than propagating, so a truncated name table loaded as a successful game. - akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no NULL check, unlike their left and right counterparts. - akgl_actor_logic_movement checked actor twice instead of checking actor->basechar before dereferencing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
PROPERTIES ENVIRONMENT "LD_LIBRARY_PATH=${AKGL_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}"
)
endif()
endif()
Compile, link and run every example in the documentation libakgl exports 157 functions and the only user-facing documentation was a FAQ in README.md whose examples did not compile: two unbalanced `PASS()` calls, a `sprite->frameids = [0, 1, 2, 3];` that is not C in any dialect, a stray `9` inside a bitmask expression, an `int screenwidth = NULL`, and -- in the first snippet a reader ever saw -- the exact `strncpy` call AGENTS.md forbids. That is not a reader routing around typos. It is what happens to samples that nothing executes. This is akbasic's documentation harness with the interpreter taken out and the ability to link and run put in. The contract is a set of fence info strings, so an example is ordinary markdown that still highlights on the forge: ```c compiled with -fsyntax-only -Wall -Werror ```c wrap=NAME the same, wrapped in tests/docs_preludes/NAME.pre/.post ```c run=NAME linked against akgl and run headless ```c excerpt=PATH must still appear verbatim in PATH ```c screenshot=NAME also the source of docs/images/NAME.png ```json kind=KIND loaded through the real akgl_*_load_json ```output the exact stdout of the runnable block above it `run=` is why this links at all: -fsyntax-only proves a call typechecks, not that the startup order works or that an ATTEMPT block gives back what it took. `json kind=` exists because the asset formats are documented in prose and read by four loaders with nothing tying the two together -- util/assets/littleguy.json is already invalid against the loader it ships with. A fence with no info string is a hard error, and so is an unknown one. The failure mode this exists to prevent is passing because it quietly ran nothing, so an unannotated block is a missing decision rather than a default. Exit status is the number of failed examples; 2 for a usage error, which is a different thing and has to be distinguishable. Proven to fail on all ten of its failure modes -- untagged fence, non-compiling snippet, stale excerpt, orphan output block, unknown info string, run= output mismatch, invalid JSON, missing figure, a -Werror warning, and a dangling preload= -- because a check that has never failed has not been tested. `-Werror` here although AKGL_WERROR stays off for the library: that option is off so a new compiler's diagnostic cannot break a consumer's build, and a doc snippet is not a consumer. A sample that warns is a sample that teaches the warning. Registered as `docs_examples` and `docs_screenshots`. No docs-path filter in CI, deliberately: documentation goes stale because the code moved, not because somebody edited a chapter. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:58:11 -04:00
# ---------------------------------------------------------------------------
# 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
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)
# 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
Add the UI demo: title menu, raw-CLAY options screen, HUD and dialog examples/uidemo is the program the UI chapter quotes. Three screens, three ways of building an interface: the title screen is one akgl_UiMenu plus a heading label; the options screen is written in raw CLAY() declarations -- hover highlighting inside the declaration, clicks paired with the application's own press edge -- because the widgets are a convenience, not a boundary; and the play screen is two HUD labels and the one-call dialog over a checkerboard standing in for a game world. No tilemap, no actors, no physics: everything left on screen is the subject. Its route_event() is the documented call-order contract in the flesh -- UI first, consumed events stop there, then the current screen's keys -- and the screen routing is the focus model, stated rather than invented. The headless smoke run (example_uidemo, --frames 240 --demo) tours every path through the real event chain: a mouse click on the centred menu (the consumed path, landing on the middle row by symmetry), keyboard into and out of the options screen, the dialog opened and dismissed, and Quit confirmed from the menu. The run prints its score and settings so a pass can be read, not just counted. docs_game_figures gains a uidemo frame -- the play screen with the dialog up -- for the chapter to come. 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 11:24:58 -04:00
# 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
)
Compile, link and run every example in the documentation libakgl exports 157 functions and the only user-facing documentation was a FAQ in README.md whose examples did not compile: two unbalanced `PASS()` calls, a `sprite->frameids = [0, 1, 2, 3];` that is not C in any dialect, a stray `9` inside a bitmask expression, an `int screenwidth = NULL`, and -- in the first snippet a reader ever saw -- the exact `strncpy` call AGENTS.md forbids. That is not a reader routing around typos. It is what happens to samples that nothing executes. This is akbasic's documentation harness with the interpreter taken out and the ability to link and run put in. The contract is a set of fence info strings, so an example is ordinary markdown that still highlights on the forge: ```c compiled with -fsyntax-only -Wall -Werror ```c wrap=NAME the same, wrapped in tests/docs_preludes/NAME.pre/.post ```c run=NAME linked against akgl and run headless ```c excerpt=PATH must still appear verbatim in PATH ```c screenshot=NAME also the source of docs/images/NAME.png ```json kind=KIND loaded through the real akgl_*_load_json ```output the exact stdout of the runnable block above it `run=` is why this links at all: -fsyntax-only proves a call typechecks, not that the startup order works or that an ATTEMPT block gives back what it took. `json kind=` exists because the asset formats are documented in prose and read by four loaders with nothing tying the two together -- util/assets/littleguy.json is already invalid against the loader it ships with. A fence with no info string is a hard error, and so is an unknown one. The failure mode this exists to prevent is passing because it quietly ran nothing, so an unannotated block is a missing decision rather than a default. Exit status is the number of failed examples; 2 for a usage error, which is a different thing and has to be distinguishable. Proven to fail on all ten of its failure modes -- untagged fence, non-compiling snippet, stale excerpt, orphan output block, unknown info string, run= output mismatch, invalid JSON, missing figure, a -Werror warning, and a dangling preload= -- because a check that has never failed has not been tested. `-Werror` here although AKGL_WERROR stays off for the library: that option is off so a new compiler's diagnostic cannot break a consumer's build, and a doc snippet is not a consumer. A sample that warns is a sample that teaches the warning. Registered as `docs_examples` and `docs_screenshots`. No docs-path filter in CI, deliberately: documentation goes stale because the code moved, not because somebody edited a chapter. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:58:11 -04:00
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()
Check memory with the suites that already exist `cmake --build build --target memcheck` runs every registered CTest suite under valgrind. There are no new test programs, and there should not be any: tests/benchutil.h notices valgrind in LD_PRELOAD and drops the benchmark scale to 0.0005, which turns the perf suites into the broadest path coverage in the tree at a cost valgrind can survive. A benchmark walks one path a hundred thousand times; a leak check wants every path walked once. Same binaries, one flag apart, and the whole run is about thirty seconds. scripts/memcheck.sh wraps `ctest -T memcheck` because that command records defects and still exits 0, which cannot gate anything. It also forces the headless drivers, so the vendor GPU stack is never loaded -- that removes thousands of unfixable findings inside amdgpu_dri.so without suppressing anything, and it is what the suites are written for anyway. Only definite losses, invalid accesses and uninitialised reads count; "still reachable" is what SDL and FreeType keep for the process lifetime and says nothing about this library. The three suppressions in scripts/valgrind.supp are each a decision that a finding belongs to somebody else. Six defects, all filed in TODO.md under "Memory checking", the first of which is the one that matters: not one of the four json_load_file calls in src/ is ever matched by a json_decref, so every asset load abandons its parsed document -- 1.5 KB per sprite, 2.1 KB per character, 9 KB per load of the 2x2 fixture map, and on the order of a megabyte for a real one. A game that reloads a level on death leaks a level's worth of JSON every time. akgl_get_property also reads up to 4 KiB past the end of every property value it copies, and the savegame name tables read past the end of every registry key and write what they find into the file. tests/util.c zeroes three fixtures it used to leave as stack garbage. The library was never at fault there -- the tests handed it uninitialised floats and then made one real call with them -- but sixteen findings of noise in a new gate is how a gate gets ignored. The unit suites' TIMEOUT goes from 30 to 300 because CTest applies the same property to the checked run, where everything is twenty times slower. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:59:57 -04:00
# 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/")
Derive the library version from one place and give the .so a soname The version was written down twice and agreed with itself by luck. AKGL_VERSION was hand-maintained in include/akgl/game.h, and nothing connected it to the build, which had no version at all: - project() declared no VERSION, so the @PROJECT_VERSION@ substitution in akgl.pc.in expanded to nothing and every installed akgl.pc shipped an empty Version field. pkg-config --modversion returned "" and --atleast-version could not work at all. - libakgl.so had no VERSION or SOVERSION, so there was no soname and no symlink chain. A stale libakgl could be paired with new headers silently, which is the failure libakerror added its own soname to prevent. - MY_LIBRARY_VERSION at CMakeLists.txt:288 was never set anywhere. It built main_lib_dest as "lib/akgl-", and main_lib_dest was then never read. Dead line, removed. project(akgl VERSION 0.1.0) is now the only place the number appears. It drives the generated include/akgl/version.h, the shared library VERSION and SOVERSION, and the Version field in akgl.pc. Held at 0.1.0 to match the constant it replaces, so akgl_game_load_versioncmp still compares savegames against the same value. include/akgl/version.h is generated by configure_file from version.h.in and publishes AKGL_VERSION, AKGL_VERSION_MAJOR/MINOR/PATCH and AKGL_VERSION_AT_LEAST(major, minor, patch). That macro is the point: libakstdlib could not write that test against libakerror, which publishes no version macro, and had to feature-test on AKERR_FIRST_CONSUMER_STATUS instead. game.h now includes the generated header rather than defining the version itself, so consumers see AKGL_VERSION exactly where they saw it before. While the major version is 0 the soname carries major.minor, giving libakgl.so.0.1. A plain SOVERSION 0 would assert that 0.1.0 and 0.2.0 are ABI-interchangeable, and they are not -- the preceding commit alone added akgl_error_init. At 1.0.0 the soname becomes the major alone, matching libakerror. akgl_version() in src/version.c reports the version of the libakgl that is actually linked, where AKGL_VERSION reports what the caller compiled against. Comparing the two is the only way to catch a stale shared library on the loader path; the macro alone cannot see it. It returns a string rather than an akerr_ErrorContext * because it cannot fail, for the same reason akerr_name_for_status does. Add tests/version.c: the string and the numeric components must describe one version, the linked library must agree with the headers, and AKGL_VERSION_AT_LEAST must order correctly at the major, minor and patch boundaries. Verified out of band that bumping project() to 1.2.3 moves version.h, akgl.pc and the soname together, and that an install produces the libakgl.so -> .so.0.1 -> .so.0.1.0 chain with SONAME libakgl.so.0.1. Also ignore include/akgl/version.h. include/ precedes the build tree on the include path, so a stray copy there would shadow the generated one and pin every consumer to whatever version it was generated at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:36:50 -04:00
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/")
Vendor clay and bring the UI subsystem up: arena, lifecycle, status band clay v0.14 (deps/clay, zlib licence) supplies the layout engine for the new akgl_ui subsystem. Sources-listed rather than add_subdirectory()'d, on the semver/libccd precedent: clay's CMakeLists declares no library target, builds its examples by default, and requires CMake 3.27 against this project's 3.10. src/ui_clay.c is the one CLAY_IMPLEMENTATION translation unit, compiled -w on the vendored-code terms but with clay's symbols deliberately exported -- consumers' CLAY() macros resolve against libakgl.so, and akgl/ui.h warns them never to link a second clay. The subsystem is runtime-optional the way collision is: always compiled in, inert until akgl_ui_init(), which bounds clay to the overridable AKGL_UI_* ceilings, checks Clay_MinMemorySize() against a static BSS arena (no malloc), and refuses with both numbers in the message when it does not fit. Measured: 812544 bytes at the default 1024 elements / 4096 measured words, against a 1 MiB arena. clay's void-callback layout errors are logged as they happen and the first is stashed for the error protocol to raise later. AKGL_ERR_UI joins the status band before AKGL_ERR_LIMIT, named in akgl_error_init. New `ui` test suite covers validation, the init/shutdown lifecycle, and arena exhaustion via the akgl_ui_arena_limit test hook (same contract as akgl_ccd_arena_set_limit). clay.h is installed beside semver.h, its licence beside libccd's, and the headers suite proves akgl/ui.h self-contained -- clay.h compiles clean under -Wall. 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 10:53:11 -04:00
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")
Vendor clay and bring the UI subsystem up: arena, lifecycle, status band clay v0.14 (deps/clay, zlib licence) supplies the layout engine for the new akgl_ui subsystem. Sources-listed rather than add_subdirectory()'d, on the semver/libccd precedent: clay's CMakeLists declares no library target, builds its examples by default, and requires CMake 3.27 against this project's 3.10. src/ui_clay.c is the one CLAY_IMPLEMENTATION translation unit, compiled -w on the vendored-code terms but with clay's symbols deliberately exported -- consumers' CLAY() macros resolve against libakgl.so, and akgl/ui.h warns them never to link a second clay. The subsystem is runtime-optional the way collision is: always compiled in, inert until akgl_ui_init(), which bounds clay to the overridable AKGL_UI_* ceilings, checks Clay_MinMemorySize() against a static BSS arena (no malloc), and refuses with both numbers in the message when it does not fit. Measured: 812544 bytes at the default 1024 elements / 4096 measured words, against a 1 MiB arena. clay's void-callback layout errors are logged as they happen and the first is stashed for the error protocol to raise later. AKGL_ERR_UI joins the status band before AKGL_ERR_LIMIT, named in akgl_error_init. New `ui` test suite covers validation, the init/shutdown lifecycle, and arena exhaustion via the akgl_ui_arena_limit test hook (same contract as akgl_ccd_arena_set_limit). clay.h is installed beside semver.h, its licence beside libccd's, and the headers suite proves akgl/ui.h self-contained -- clay.h compiles clean under -Wall. 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 10:53:11 -04:00
install(FILES "deps/clay/LICENSE.md" DESTINATION "share/doc/akgl/" RENAME "LICENSE.clay")
Namespace every exported symbol, and bump to 0.5.0 Closes internal-consistency items 1 through 6, 12 and 13. Every include guard is _AKGL_<FILE>_H_, every in-project header include is angled, and every exported function, type and global carries the akgl_ prefix. This is an ABI break; the soname goes to libakgl.so.0.5. TODO.md carries the full rename table. The renames were driven by renaming each declaration and letting the compiler find the uses, not by pattern substitution: renderer, physics and camera are also parameter and struct-member names, and a sed would have rewritten map->physics and every akgl_RenderBackend *renderer parameter without a word. Item 4 turned out not to be cosmetic. The library exported a global called renderer and tests/character.c defined an SDL_Renderer *renderer of its own; the executable's definition preempted the library's, akgl_sprite_load_json read a SDL_Renderer * through an akgl_RenderBackend *, and every texture load in that suite failed. The suite reported success anyway, because libakerror's unhandled-error handler ends in exit(errctx->status), exit keeps only the low byte, and AKGL_ERR_SDL is exactly 256. So character had been green while running one of its four tests, and every suite in the tree was unable to fail on the most common status in a library built on SDL. Both are fixed. tests/testutil.h gains TEST_TRAP_UNHANDLED_ERRORS(), which collapses any status a byte cannot carry onto 1, and every suite installs it. character binds a real backend with akgl_render_2d_bind. Its fourth test then runs for the first time and fails on a defect it has asserted all along, so akgl_heap_release_character now walks state_sprites with AKGL_ITERATOR_OP_RELEASE and destroys the property set before zeroing the slot -- TODO.md Defects item 21 and half of Carried over item 1. AKGL_TIME_ONESEC_MS said "one second in milliseconds" and held 1000000, so akgl_game_state_lock waited roughly sixteen minutes rather than one second. It is AKGL_TIME_ONEMS_NS now, the budget is its own named constant, and tests/game.c holds the mutex from a second thread to assert the wait -- the contended path had no coverage at all. Headers are self-contained and it is enforced: AKGL_PUBLIC_HEADERS drives both install() and a generated translation unit per header, so a header that ships is a header that is checked. Writing that found registry.h, which used SDL_PropertiesID in eight declarations and included no SDL header. 23/23 suites pass, memcheck is clean, reindent --check is clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:32:21 -04:00
foreach(header IN LISTS AKGL_PUBLIC_HEADERS)
install(FILES "include/akgl/${header}.h" DESTINATION "include/akgl/")
endforeach()
install(FILES ${GAMECONTROLLERDB_H} DESTINATION "include/akgl/")