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>
This commit is contained in:
2026-08-01 23:29:05 -04:00
parent 6bb241cfb9
commit 46232a5ad1
9 changed files with 848 additions and 2 deletions

View File

@@ -200,6 +200,7 @@ set(AKGL_PUBLIC_HEADERS
assets
audio
character
collision_arena
controller
draw
error
@@ -255,11 +256,46 @@ add_custom_target(${AKGL_CONTROLLERDB_TARGET}
COMMENT "Fetching controller mappings and rewriting ${GAMECONTROLLERDB_H} ..."
)
# libccd supplies the collision narrowphase (GJK, EPA and MPR).
#
# Its sources are listed here rather than add_subdirectory()'d. Four things in
# deps/libccd/CMakeLists.txt make it unusable as a subproject, and none of them
# is ours to fix in a submodule:
#
# - project(libccd) declares no LANGUAGES, so it enables CXX and makes a C++
# toolchain a requirement of every build of libakgl. CI installs gcc.
# - it declares option(BUILD_SHARED_LIBS ... ON), a global cache variable, and
# calls include(CTest), which would register its own suites into ours and
# build the LGPL-licensed CU framework under src/testsuites/.
# - install(FILES "${CMAKE_BINARY_DIR}/ccd.pc") reads the *top level* binary
# dir while configure_file writes CMAKE_CURRENT_BINARY_DIR. Those are the
# same directory standalone and different ones embedded, so `cmake --install`
# fails on a missing file.
# - cmake_minimum_required(VERSION 2.8.11) warns on CMake 3.28 and is a hard
# error on CMake 4.
#
# Compiling the sources directly, the way deps/semver/semver.c already is, sidesteps
# all four and never configures the LGPL testsuites at all.
set(CCD_SINGLE 1)
configure_file(
${CMAKE_CURRENT_SOURCE_DIR}/deps/libccd/src/ccd/config.h.cmake.in
${CMAKE_CURRENT_BINARY_DIR}/ccd/config.h)
set(AKGL_CCD_SOURCES
deps/libccd/src/ccd.c
deps/libccd/src/mpr.c
deps/libccd/src/polytope.c
deps/libccd/src/support.c
deps/libccd/src/vec3.c)
# Add include directories
include_directories(${SDL3_INCLUDE_DIRS})
add_library(akgl SHARED
deps/semver/semver.c
${AKGL_CCD_SOURCES}
src/actor.c
src/collision.c
src/collision_arena.c
src/actor_state_string_names.c
src/audio.c
src/text.c
@@ -304,6 +340,38 @@ target_compile_options(akgl PRIVATE ${AKGL_WARNING_FLAGS})
# akerror or akstdlib -- those are separate targets.)
set_source_files_properties(deps/semver/semver.c PROPERTIES COMPILE_OPTIONS "-w")
# libccd is vendored on the same terms as semver: -w so a future bump cannot fail
# this build on a warning we do not own.
#
# src/ccd_arena_shim.h is what keeps the "libakgl does not call malloc at
# runtime" promise true with the whole of libccd compiled in. Injected with
# -include, it redirects both halves of libccd's allocation -- the
# __CCD_ALLOC_MEMORY macro every CCD_ALLOC* expands to, and the four bare free()
# calls -- at the arena in src/collision_arena.c. It applies to these five files
# and nothing else.
#
# The redirect lives in that header rather than in COMPILE_DEFINITIONS because
# CMake cannot carry a function-like macro through a -D: it dropped it silently,
# and libccd went on calling the C library while free() became a no-op. Do not
# move it back.
#
# CCD_STATIC_DEFINE plus -fvisibility=hidden keeps every ccd* symbol out of
# libakgl.so's dynamic table. That is not tidiness: AGENTS.md records a shipped
# defect where an exported `renderer` in this library was preempted by a
# same-named symbol in a test executable, and a game linking both libakgl and a
# system libccd would hit exactly that.
set_source_files_properties(${AKGL_CCD_SOURCES} PROPERTIES
COMPILE_OPTIONS "-w;-fvisibility=hidden;-include;${CMAKE_CURRENT_SOURCE_DIR}/src/ccd_arena_shim.h"
COMPILE_DEFINITIONS "CCD_STATIC_DEFINE")
# PRIVATE, so that ccd/*.h never reaches a consumer's include path. The `headers`
# suite compiles each public header as the first include of a translation unit
# with only include/ available, so a public header that pulled in <ccd/ccd.h>
# would fail there immediately.
target_include_directories(akgl PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/deps/libccd/src"
"${CMAKE_CURRENT_BINARY_DIR}")
add_library(akgl::akgl ALIAS akgl)
add_executable(charviewer util/charviewer.c)
@@ -316,6 +384,7 @@ set(AKGL_TEST_SUITES
audio
bitmasks
character
collision_arena
controller
draw
error