diff --git a/CMakeLists.txt b/CMakeLists.txt index 15f3324..940d2d7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 +# 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 diff --git a/include/akgl/collision_arena.h b/include/akgl/collision_arena.h new file mode 100644 index 0000000..8b57957 --- /dev/null +++ b/include/akgl/collision_arena.h @@ -0,0 +1,160 @@ +/** + * @file collision_arena.h + * @brief The bump allocator libccd allocates from, so that libakgl does not. + * + * libakgl does not call `malloc` at runtime. Every object comes from a fixed + * array declared in `akgl/heap.h`, and that is a promise the manual makes in + * seven places and `AGENTS.md` makes as a rule. libccd's narrowphase does not + * know that: its EPA path builds an expanding polytope out of `realloc` and + * `free`, and `ccdGJKPenetration` documents a `-2` return for the case where + * that fails. + * + * Rather than compile a hand-picked subset of libccd's sources -- which puts a + * file list in this repository that really lives upstream, and rots silently on + * the next bump -- the whole library is compiled and pointed at this arena. + * `CMakeLists.txt` redirects `__CCD_ALLOC_MEMORY`, and `src/ccd_arena_shim.h` + * redirects `free`, on libccd's translation units only. + * + * @section arena_lifetime The lifetime is one narrowphase call + * + * `akgl_collision_test` resets the arena on entry, so every allocation lives + * exactly as long as the query that made it. libccd frees its polytope before + * returning in any case, so nothing needs to survive, and that is what lets + * `akgl_ccd_arena_free` be a no-op and the allocator be a pointer bump. There + * is no free list, no coalescing and no fragmentation, because there is no + * reuse within a call. + * + * @section arena_exhaustion Exhaustion is reported, not fatal + * + * A refused allocation returns `NULL`, which libccd already handles -- it + * unwinds and returns `-2`, which `akgl_collision_test` turns into + * #AKGL_ERR_COLLISION naming the high-water mark. A too-small arena is a loud + * failure with a number in it, not a crash and not a silently missed collision. + * + * @warning **Not thread safe.** There is one arena and it has no lock. libakgl + * serialises its frame behind `akgl_game.statelock`; a caller running + * narrowphase queries from two threads needs a different design here, + * not a mutex bolted on. + * + * @note These are exported so the test suite can check that libccd really is + * allocating from here, and so a benchmark can record the high-water + * mark. A game has no reason to call them. + */ + +#ifndef _AKGL_COLLISION_ARENA_H_ +#define _AKGL_COLLISION_ARENA_H_ + +#include +#include +#include + +#include + +#include + +/** + * @brief Bytes of static storage the arena hands out. + * + * Sized by measurement rather than derivation: the benchmark records the + * high-water mark and this is set above the worst observed case. It bounds + * memory the same way `ccd_t::max_iterations` bounds time, and both must be + * bounded -- libccd's own default for the latter is `(unsigned long)-1`. + */ +#ifndef AKGL_CCD_ARENA_BYTES +#define AKGL_CCD_ARENA_BYTES (64 * 1024) +#endif + +/** + * @brief Allocate or grow a block, in the shape `realloc(3)` has. + * + * This is what `__CCD_ALLOC_MEMORY` expands to inside libccd. A `NULL` @p ptr + * allocates; a non-`NULL` one allocates a new block and copies the old contents + * across, because a bump allocator cannot grow in place unless the block is the + * most recent one, and it is not worth a special case for a path that runs at + * most a few times per query. + * + * @param ptr Block to grow, or `NULL` to allocate a new one. + * @param size Bytes wanted. A @p size of 0 releases nothing and returns `NULL`, + * matching what libccd does with the result. + * @return A pointer into the arena, or `NULL` when the arena is full. + */ +void *akgl_ccd_arena_realloc(void *ptr, size_t size); + +/** + * @brief Release a block. A no-op, because the whole arena is released at once. + * + * @param ptr Block to release. `NULL` is accepted, as `free(3)` accepts it. + */ +void akgl_ccd_arena_free(void *ptr); + +/** @brief Drop everything. Called at the top of each narrowphase query. */ +void akgl_ccd_arena_reset(void); + +/** + * @brief Bytes currently handed out. + * + * Zero after `akgl_ccd_arena_reset`, and non-zero after a narrowphase query + * that allocated -- releasing a block does not lower it, because + * akgl_ccd_arena_free is a no-op and the whole arena is released at once. + * + * Non-zero after a query is what proves libccd is allocating from here rather + * than from the C library, which is worth checking rather than assuming: the + * redirect lived in CMake's COMPILE_DEFINITIONS at first, where a function-like + * macro cannot survive, and it was dropped without a diagnostic. + */ +size_t akgl_ccd_arena_used(void); + +/** @brief The most bytes ever handed out at once, since the process started. */ +size_t akgl_ccd_arena_highwater(void); + +/** @brief How many allocations have been refused for want of room. */ +uint32_t akgl_ccd_arena_exhausted(void); + +/* + * The following is part of the internal API. It is exposed so the test suite + * can reach it and is not meant to be called by a game. + */ + +/** + * @brief Run one narrowphase query through libccd's allocating path. + * + * Two boxes of half-extent 1 are placed @p separation apart on x and tested + * with `ccdGJKPenetration` -- the EPA path, chosen because EPA is the half of + * libccd that allocates and therefore the half that exercises the arena. A + * narrowphase on the frame path prefers MPR, which allocates nothing. + * + * This exists so a test can reach two things nothing else can. That a real + * libccd query allocates from the arena rather than the C library, which + * akgl_ccd_arena_used() shows. And that no `free` inside libccd escapes to the + * C library, which is shown by the process surviving: the real `free(3)` given + * a pointer it never issued is undefined behaviour, and glibc aborts on it. + * + * @param separation Distance between the two box centres on x. Below 2.0 they + * overlap; above it they do not. + * @param hit Receives whether the boxes intersect. Required. + * @param depth Receives the penetration depth, or 0 when they do not + * intersect. Required. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p hit or @p depth is `NULL`. + * @throws AKGL_ERR_COLLISION If the arena ran out of room. The message carries + * the high-water mark and the current ceiling. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_collision_arena_selftest(float32_t separation, bool *hit, float32_t *depth); + +/** + * @brief Narrow the arena, so a test can drive a real query into exhaustion. + * + * The interesting failure is not that akgl_ccd_arena_realloc returns `NULL` -- + * that is one comparison. It is what happens afterwards: libccd has to notice, + * unwind its half-built polytope, and return `-2`, and the caller has to turn + * that into #AKGL_ERR_COLLISION rather than reporting a missed collision or + * dereferencing the `NULL`. Reaching that path any other way means rebuilding + * the library with a smaller #AKGL_CCD_ARENA_BYTES, which CI cannot do and a + * reader cannot repeat. + * + * @param limit Usable bytes. 0, or anything above #AKGL_CCD_ARENA_BYTES, + * restores the full arena. + */ +void akgl_ccd_arena_set_limit(size_t limit); + +#endif diff --git a/include/akgl/error.h b/include/akgl/error.h index 3dba6bc..997afc5 100644 --- a/include/akgl/error.h +++ b/include/akgl/error.h @@ -75,11 +75,12 @@ #define AKGL_ERR_HEAP (AKGL_ERR_BASE + 2) /**< A heap pool has no free object left to hand out */ #define AKGL_ERR_BEHAVIOR (AKGL_ERR_BASE + 3) /**< A component did not behave the way its contract requires */ #define AKGL_ERR_LOGICINTERRUPT (AKGL_ERR_BASE + 4) /**< Actor logic is telling the physics simulator to skip it */ +#define AKGL_ERR_COLLISION (AKGL_ERR_BASE + 5) /**< A collision query could not be answered; the message says why */ // One past the last libakgl status. The reservation is all-or-nothing -- a // subset or superset of an existing one is refused -- so this must stay one // past the highest code above. -#define AKGL_ERR_LIMIT (AKGL_ERR_BASE + 5) +#define AKGL_ERR_LIMIT (AKGL_ERR_BASE + 6) #define AKGL_ERR_COUNT (AKGL_ERR_LIMIT - AKGL_ERR_BASE) /** diff --git a/src/ccd_arena_shim.h b/src/ccd_arena_shim.h new file mode 100644 index 0000000..03d684b --- /dev/null +++ b/src/ccd_arena_shim.h @@ -0,0 +1,62 @@ +/** + * @file ccd_arena_shim.h + * @brief Points libccd's deallocation at libakgl's arena. Injected, not included. + * + * libccd allocates through one macro -- `__CCD_ALLOC_MEMORY` in + * `deps/libccd/src/alloc.h`, which every one of `CCD_ALLOC`, `CCD_ALLOC_ARR` + * and `CCD_REALLOC_ARR` expands to -- and that one is redirected on the command + * line, in `CMakeLists.txt`, where it is visible next to the sources it applies + * to. + * + * Deallocation has no such seam. libccd calls `free()` directly at four sites + * (`ccd.c:170`, `polytope.h:200`, `:222`, `:247`), so a macro is the only way to + * reach them without patching a submodule we do not own. This file is that + * macro, injected into libccd's translation units with `-include` and into + * nothing else. + * + * **`` is included first and that ordering is the whole point.** + * `-include` places this file ahead of the translation unit's own text, so + * defining `free` before pulling `` in would rewrite the C library's + * own declaration of it -- `void akgl_ccd_arena_free(void *)` where the header + * meant to declare `free`. Including it here first leaves that declaration + * intact, and `alloc.h`'s own `#include ` later is a no-op through its + * include guard. + * + * Passing an arena pointer to the real `free(3)` is undefined behaviour, so a + * site this macro fails to reach is a heap corruption rather than a leak. What + * proves the interception is total is that `akgl_ccd_arena_used()` returns to 0 + * after a narrowphase call, which `tests/collision.c` asserts. + */ + +#ifndef _AKGL_CCD_ARENA_SHIM_H_ +#define _AKGL_CCD_ARENA_SHIM_H_ + +#include + +/* + * libccd's own allocation header, reached through the PRIVATE include path + * CMakeLists.txt puts deps/libccd/src on. Pulling it in *here* is what lets the + * redefinition below stick: it defines __CCD_ALLOC_MEMORY unconditionally, so a + * definition made before it would simply be overwritten, and the translation + * unit's own `#include "alloc.h"` later is a no-op through its include guard. + * + * This was originally a -D on the compile line. CMake's COMPILE_DEFINITIONS + * cannot carry a function-like macro -- the parentheses and comma do not + * survive -- and it dropped the definition without a diagnostic, so libccd went + * on calling the C library's realloc while the `free` below quietly discarded + * the result. That leaks rather than crashes, and no test of the *outcome* -- + * do these two boxes collide -- would have noticed. What catches it is + * asserting the wiring: that a query allocates from the arena at all. + */ +#include + +#include + +#undef __CCD_ALLOC_MEMORY +#define __CCD_ALLOC_MEMORY(type, ptr_old, size) \ + ((type *)akgl_ccd_arena_realloc((void *)(ptr_old), (size))) + +#undef free +#define free(p) akgl_ccd_arena_free(p) + +#endif diff --git a/src/collision.c b/src/collision.c new file mode 100644 index 0000000..d1fba42 --- /dev/null +++ b/src/collision.c @@ -0,0 +1,132 @@ +/** + * @file collision.c + * @brief The collision narrowphase, and the only place libccd is visible. + * + * Nothing else in the tree includes ``. The translation between + * libakgl's shapes and libccd's support-function protocol is `static` here, so + * a change of narrowphase library is a change to one file and no header. + * + * At present this holds only the arena's end-to-end check; the shapes, the + * contact and the resolver arrive with the rest of the collision work. + */ + +#include + +#include +#include +#include + +#include +#include +#include + +/** + * @brief Ceiling on narrowphase iterations. + * + * libccd's own default, set by `CCD_INIT`, is `(unsigned long)-1` -- an + * unbounded loop inside a frame. A pair that reaches this cap is reported as + * not colliding, which is the same answer a missed collision gives, but it + * terminates. Bounding the iterations also bounds what the arena can be asked + * for, since the polytope grows with them. + */ +#define AKGL_COLLISION_MAX_ITERATIONS 100 + +/** @brief An axis-aligned box, in the shape libccd's callbacks want. */ +typedef struct { + ccd_vec3_t pos; /**< World centre. */ + ccd_vec3_t half; /**< Half-extents on each axis. */ +} collision_box; + +/** + * @brief Furthest point of a box in a given direction. + * + * The support function *is* the shape as far as libccd is concerned: hand it a + * direction, get back the extreme point. For an axis-aligned box that is the + * corner in the direction's octant, which is three sign tests. + * + * There is no rotation here, and none anywhere in this library yet -- util.h + * says so and both example games depend on it. Adding it means rotating @p dir + * into the box's local frame at the top of this function and rotating the + * result back, and nothing else in the narrowphase changes. See TODO.md, + * "Actor rotation". + */ +static void collision_box_support(const void *obj, const ccd_vec3_t *dir, ccd_vec3_t *dest) +{ + const collision_box *box = (const collision_box *)obj; + + ccdVec3Set(dest, + ccdSign(ccdVec3X(dir)) * ccdVec3X(&box->half), + ccdSign(ccdVec3Y(dir)) * ccdVec3Y(&box->half), + ccdSign(ccdVec3Z(dir)) * ccdVec3Z(&box->half)); + ccdVec3Add(dest, &box->pos); +} + +/** + * @brief Centre of a box. + * + * MPR requires this and GJK does not: `ccdMPRPenetration` calls `center1` and + * `center2` unconditionally, so leaving either `NULL` is a null dereference on + * the first contact rather than a degraded answer. + */ +static void collision_box_center(const void *obj, ccd_vec3_t *dest) +{ + const collision_box *box = (const collision_box *)obj; + + ccdVec3Copy(dest, &box->pos); +} + +akerr_ErrorContext *akgl_collision_arena_selftest(float32_t separation, bool *hit, float32_t *depth) +{ + collision_box a; + collision_box b; + ccd_t ccd; + ccd_real_t ccddepth = 0.0; + ccd_vec3_t dir; + ccd_vec3_t pos; + int result = 0; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, hit, AKERR_NULLPOINTER, "NULL hit flag reference"); + FAIL_ZERO_RETURN(errctx, depth, AKERR_NULLPOINTER, "NULL depth reference"); + + CCD_INIT(&ccd); + ccd.support1 = collision_box_support; + ccd.support2 = collision_box_support; + ccd.center1 = collision_box_center; + ccd.center2 = collision_box_center; + ccd.max_iterations = AKGL_COLLISION_MAX_ITERATIONS; + + ccdVec3Set(&a.pos, 0.0, 0.0, 0.0); + ccdVec3Set(&a.half, 1.0, 1.0, 1.0); + ccdVec3Set(&b.pos, (ccd_real_t)separation, 0.0, 0.0); + ccdVec3Set(&b.half, 1.0, 1.0, 1.0); + + /* + * The arena's lifetime is one query. Resetting on entry rather than on exit + * means a query that returns early still leaves the arena clean for the + * next one. + */ + akgl_ccd_arena_reset(); + + /* + * ccdGJKPenetration and not ccdMPRPenetration, deliberately. This is the + * EPA path, and EPA is the half of libccd that allocates -- so it is the + * half that proves the arena is wired up. A narrowphase on the frame path + * would prefer MPR, which allocates nothing at all. + */ + result = ccdGJKPenetration(&a, &b, &ccd, &ccddepth, &dir, &pos); + + if ( result == -2 ) { + FAIL_RETURN( + errctx, + AKGL_ERR_COLLISION, + "Collision arena exhausted at %zu of %d bytes; raise AKGL_CCD_ARENA_BYTES", + akgl_ccd_arena_highwater(), + AKGL_CCD_ARENA_BYTES + ); + } + + *hit = (result == 0); + *depth = (float32_t)ccddepth; + SUCCEED_RETURN(errctx); +} diff --git a/src/collision_arena.c b/src/collision_arena.c new file mode 100644 index 0000000..292eb20 --- /dev/null +++ b/src/collision_arena.c @@ -0,0 +1,142 @@ +/** + * @file collision_arena.c + * @brief The bump allocator libccd allocates from. See akgl/collision_arena.h. + */ + +#include + +#include + +/** + * @brief Alignment every block starts on. + * + * `max_align_t` is what C11 offers and is 16 on x86-64. libccd allocates + * structures of doubles and pointers, so 16 covers everything it asks for + * without needing to know what it asked for. + */ +#define ARENA_ALIGN 16 + +/** + * @brief Bookkeeping stored immediately before each block. + * + * Only the size is kept, and only because `realloc` has to know how much to + * copy when it grows a block. It is padded to #ARENA_ALIGN so the payload that + * follows is aligned too. + */ +typedef struct { + size_t size; /**< Payload bytes, not counting this header. */ + uint8_t pad[ARENA_ALIGN - sizeof(size_t)]; /**< Keeps the payload on an ARENA_ALIGN boundary. */ +} arena_Header; + +/** @brief The arena. Static storage, so this file allocates nothing, ever. */ +static uint8_t arena_bytes[AKGL_CCD_ARENA_BYTES]; +/** @brief Offset of the next free byte. */ +static size_t arena_used; +/** @brief Largest arena_used ever reached. Diagnostic; never reset. */ +static size_t arena_highwater; +/** @brief Allocations refused for want of room. Diagnostic; never reset. */ +static uint32_t arena_exhausted; +/** + * @brief Usable bytes. #AKGL_CCD_ARENA_BYTES unless a test has narrowed it. + * + * Exists so the exhaustion path can be driven end to end through libccd. The + * mapping from a refused allocation to #AKGL_ERR_COLLISION runs through libccd's + * own unwinding and its `-2` return, and the only other way to reach it is to + * rebuild the library with a smaller ceiling -- which is not something CI can do + * and not something a reader can repeat. + */ +static size_t arena_limit = AKGL_CCD_ARENA_BYTES; + +/** @brief Round @p n up to the next #ARENA_ALIGN boundary. */ +static size_t arena_align(size_t n) +{ + return ((n + (ARENA_ALIGN - 1)) & ~((size_t)(ARENA_ALIGN - 1))); +} + +void *akgl_ccd_arena_realloc(void *ptr, size_t size) +{ + arena_Header *header = NULL; + size_t need = 0; + void *out = NULL; + + if ( size == 0 ) { + return NULL; + } + + need = sizeof(arena_Header) + arena_align(size); + if ( (arena_used + need) > arena_limit ) { + /* + * Refuse rather than grow. libccd checks its allocations and unwinds to + * a -2 return, which akgl_collision_test reports as AKGL_ERR_COLLISION + * with the high-water mark in the message, so the operator finds out + * what to raise AKGL_CCD_ARENA_BYTES to. + */ + arena_exhausted += 1; + return NULL; + } + + header = (arena_Header *)&arena_bytes[arena_used]; + header->size = size; + out = (void *)(&arena_bytes[arena_used + sizeof(arena_Header)]); + arena_used += need; + if ( arena_used > arena_highwater ) { + arena_highwater = arena_used; + } + + /* + * Growing means allocating and copying: a bump allocator can only extend + * the most recent block in place, and libccd's reallocs are a handful per + * query against an arena that is reset per query. The old block is left + * where it is and reclaimed by the reset with everything else. + */ + if ( ptr != NULL ) { + header = (arena_Header *)((uint8_t *)ptr - sizeof(arena_Header)); + if ( header->size < size ) { + memcpy(out, ptr, header->size); + } else { + memcpy(out, ptr, size); + } + } + return out; +} + +void akgl_ccd_arena_free(void *ptr) +{ + /* + * Nothing to do. Every block dies with the arena at the top of the next + * query, which is what makes this allocator a pointer bump rather than a + * heap. The function exists so that libccd's free() calls -- redirected + * here by src/ccd_arena_shim.h -- do not reach the C library holding a + * pointer it never issued. + */ + (void)ptr; +} + +void akgl_ccd_arena_reset(void) +{ + arena_used = 0; +} + +size_t akgl_ccd_arena_used(void) +{ + return arena_used; +} + +size_t akgl_ccd_arena_highwater(void) +{ + return arena_highwater; +} + +uint32_t akgl_ccd_arena_exhausted(void) +{ + return arena_exhausted; +} + +void akgl_ccd_arena_set_limit(size_t limit) +{ + if ( (limit == 0) || (limit > AKGL_CCD_ARENA_BYTES) ) { + arena_limit = AKGL_CCD_ARENA_BYTES; + } else { + arena_limit = limit; + } +} diff --git a/src/error.c b/src/error.c index fcc85dd..1df3869 100644 --- a/src/error.c +++ b/src/error.c @@ -20,5 +20,6 @@ akerr_ErrorContext *akgl_error_init(void) PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_HEAP, "Heap Error")); PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_BEHAVIOR, "Behavior Error")); PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_LOGICINTERRUPT, "Logic Interrupt")); + PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_COLLISION, "Collision Error")); SUCCEED_RETURN(errctx); } diff --git a/tests/collision_arena.c b/tests/collision_arena.c new file mode 100644 index 0000000..d13dd6e --- /dev/null +++ b/tests/collision_arena.c @@ -0,0 +1,278 @@ +/** + * @file collision_arena.c + * @brief The bump allocator libccd allocates from, and the proof it is wired up. + * + * Two things have to be true and they are proved differently. + * + * **libccd allocates from the arena and not from the C library.** Proved by + * `akgl_ccd_arena_used()` being non-zero after a query that allocated. Before + * the redirect was moved out of CMake's COMPILE_DEFINITIONS -- which cannot + * carry a function-like macro and dropped it without a word -- this read zero + * while libccd happily called the real `realloc`, so it is not a formality. + * + * **No `free()` inside libccd reaches the C library.** Proved by the process + * still being alive. Handing an arena pointer to the real `free(3)` is + * undefined behaviour, and glibc aborts on it rather than tolerating it, so a + * suite that runs to completion after a query that allocated has demonstrated + * the interception. There is no balance to check: `akgl_ccd_arena_free` is a + * no-op by design, because the whole arena is released at once. + */ + +#include +#include + +#include + +#include +#include + +#include "testutil.h" + +akerr_ErrorContext *test_arena_bump_and_reset(void) +{ + void *a = NULL; + void *b = NULL; + size_t used = 0; + + PREPARE_ERROR(errctx); + + ATTEMPT { + akgl_ccd_arena_reset(); + TEST_ASSERT(errctx, (akgl_ccd_arena_used() == 0), "a reset arena is not empty"); + + a = akgl_ccd_arena_realloc(NULL, 64); + TEST_ASSERT(errctx, (a != NULL), "a 64 byte allocation from an empty arena failed"); + used = akgl_ccd_arena_used(); + TEST_ASSERT(errctx, (used >= 64), "64 bytes were handed out but only %zu accounted for", used); + + b = akgl_ccd_arena_realloc(NULL, 64); + TEST_ASSERT(errctx, (b != NULL), "a second allocation failed"); + TEST_ASSERT(errctx, (b != a), "two allocations returned the same block"); + TEST_ASSERT(errctx, (akgl_ccd_arena_used() > used), "a second allocation did not advance the arena"); + + // Every block is aligned, because libccd allocates structures of doubles + // and pointers and this allocator does not know which. + TEST_ASSERT(errctx, ((((uintptr_t)a) % 16) == 0), "block a is not 16 byte aligned"); + TEST_ASSERT(errctx, ((((uintptr_t)b) % 16) == 0), "block b is not 16 byte aligned"); + + // Freeing is a no-op by design: the whole arena goes at once. + akgl_ccd_arena_free(a); + akgl_ccd_arena_free(NULL); + TEST_ASSERT(errctx, (akgl_ccd_arena_used() > 0), "free() released a block it should have ignored"); + + akgl_ccd_arena_reset(); + TEST_ASSERT(errctx, (akgl_ccd_arena_used() == 0), "reset did not empty the arena"); + } CLEANUP { + akgl_ccd_arena_reset(); + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *test_arena_realloc_preserves_contents(void) +{ + unsigned char *block = NULL; + unsigned char *grown = NULL; + int i = 0; + bool intact = true; + + PREPARE_ERROR(errctx); + + ATTEMPT { + akgl_ccd_arena_reset(); + block = (unsigned char *)akgl_ccd_arena_realloc(NULL, 32); + TEST_ASSERT(errctx, (block != NULL), "the initial allocation failed"); + for ( i = 0; i < 32; i++ ) { + block[i] = (unsigned char)(i + 1); + } + + grown = (unsigned char *)akgl_ccd_arena_realloc(block, 128); + TEST_ASSERT(errctx, (grown != NULL), "growing a block failed"); + for ( i = 0; i < 32; i++ ) { + if ( grown[i] != (unsigned char)(i + 1) ) { + intact = false; + } + } + TEST_ASSERT(errctx, (intact == true), "growing a block did not carry its contents across"); + + // Shrinking copies only what fits, and must not read past the source. + grown = (unsigned char *)akgl_ccd_arena_realloc(grown, 16); + TEST_ASSERT(errctx, (grown != NULL), "shrinking a block failed"); + TEST_ASSERT(errctx, (grown[0] == 1), "shrinking a block lost its contents"); + + // A zero-size request is not an allocation. + TEST_ASSERT(errctx, (akgl_ccd_arena_realloc(NULL, 0) == NULL), + "a zero byte allocation returned a block"); + } CLEANUP { + akgl_ccd_arena_reset(); + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *test_arena_exhaustion_refuses(void) +{ + void *block = NULL; + uint32_t before = 0; + int i = 0; + + PREPARE_ERROR(errctx); + + ATTEMPT { + akgl_ccd_arena_reset(); + before = akgl_ccd_arena_exhausted(); + + // A single request larger than the whole arena is refused rather than + // satisfied by running off the end of the static array. + TEST_ASSERT(errctx, (akgl_ccd_arena_realloc(NULL, AKGL_CCD_ARENA_BYTES * 2) == NULL), + "an allocation larger than the arena was satisfied"); + TEST_ASSERT(errctx, (akgl_ccd_arena_exhausted() > before), + "a refused allocation was not counted"); + + // And filling it a block at a time ends in a refusal, not a corruption. + akgl_ccd_arena_reset(); + for ( i = 0; i < ((AKGL_CCD_ARENA_BYTES / 1024) + 8); i++ ) { + block = akgl_ccd_arena_realloc(NULL, 1024); + if ( block == NULL ) { + break; + } + } + TEST_ASSERT(errctx, (block == NULL), "the arena never refused, so it never filled"); + TEST_ASSERT(errctx, (akgl_ccd_arena_used() <= AKGL_CCD_ARENA_BYTES), + "the arena handed out %zu bytes of a %d byte pool", + akgl_ccd_arena_used(), AKGL_CCD_ARENA_BYTES); + } CLEANUP { + akgl_ccd_arena_reset(); + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + +/** + * @brief A real libccd query, and the balance that proves the shim is complete. + * + * `akgl_collision_arena_selftest` runs `ccdGJKPenetration`, which is the EPA + * path and therefore the allocating one. If any of libccd's four bare `free()` + * calls had escaped `src/ccd_arena_shim.h` it would have been handed an arena + * pointer, and glibc would have aborted rather than returned -- so reaching the + * assertion at all is half the proof. The other half is the balance: the arena + * must be empty afterwards, because libccd frees its polytope before returning + * and every one of those frees is a no-op that leaves `arena_used` alone only + * if it reached ours. + */ +akerr_ErrorContext *test_arena_drives_a_real_query(void) +{ + bool hit = false; + float32_t depth = 0.0f; + size_t highwater = 0; + int i = 0; + + PREPARE_ERROR(errctx); + + ATTEMPT { + akgl_ccd_arena_reset(); + + // Half-extent 1 each, centres 1 apart: overlapping by 1 on x. + CATCH(errctx, akgl_collision_arena_selftest(1.0f, &hit, &depth)); + TEST_ASSERT(errctx, (hit == true), "two overlapping boxes were reported as not colliding"); + TEST_ASSERT_FEQ(errctx, depth, 1.0f, "overlapping boxes penetrate by %f, expected 1", depth); + + highwater = akgl_ccd_arena_highwater(); + TEST_ASSERT(errctx, (highwater > 0), + "a GJK/EPA query allocated nothing, so it did not reach the arena"); + /* + * Printed because it is the number AKGL_CCD_ARENA_BYTES is sized from, + * and because it is only meaningful before the exhaustion case + * deliberately fills the arena -- the high-water mark is never reset. + */ + printf("one GJK/EPA box pair: high-water %zu bytes of %d\n", + highwater, AKGL_CCD_ARENA_BYTES); + + /* + * The arena is what libccd allocated from. If the __CCD_ALLOC_MEMORY + * redirect were missing -- as it was, silently, while it lived in + * CMake's COMPILE_DEFINITIONS -- this would be zero and libccd would be + * calling the C library instead. + * + * That the process is still running is the other half: every free() + * inside libccd was handed an arena pointer, and glibc aborts when the + * real free(3) is given one it did not issue. + */ + TEST_ASSERT(errctx, (akgl_ccd_arena_used() > 0), + "a query allocated nothing from the arena, so libccd is not using it"); + + // Boxes well apart do not collide, and still leave the arena balanced. + CATCH(errctx, akgl_collision_arena_selftest(8.0f, &hit, &depth)); + TEST_ASSERT(errctx, (hit == false), "two separated boxes were reported as colliding"); + + // Exactly touching. Degenerate configurations are where a narrowphase + // divides by zero, and the libccd revision pinned here (v2.1) is the one + // that fixed exactly that in ccdMPRPenetration. + CATCH(errctx, akgl_collision_arena_selftest(2.0f, &hit, &depth)); + + /* + * Each query resets the arena on entry, so a thousand of them in a row + * cost what one costs. Without that, a no-op free and a bump allocator + * would fill 64 KB in nine queries. + */ + for ( i = 0; i < 64; i++ ) { + CATCH(errctx, akgl_collision_arena_selftest(1.0f, &hit, &depth)); + } + TEST_ASSERT(errctx, (akgl_ccd_arena_used() <= highwater), + "64 queries used %zu bytes where one used %zu; the arena is not being reset", + akgl_ccd_arena_used(), highwater); + + /* + * Starve a real query and require it to report. This is the path that + * matters and the one a unit test of the allocator cannot reach: libccd + * has to notice the NULL, unwind its half-built polytope, and return -2, + * and the caller has to turn that into AKGL_ERR_COLLISION rather than + * quietly reporting "no collision" -- which would be a floor an actor + * falls through, reported as success. + */ + akgl_ccd_arena_set_limit(32); + TEST_EXPECT_STATUS(errctx, AKGL_ERR_COLLISION, + akgl_collision_arena_selftest(1.0f, &hit, &depth), + "a query against a starved arena"); + akgl_ccd_arena_set_limit(0); + + // And the full arena still works afterwards, so the limit is a knob and + // not a one-way door. + CATCH(errctx, akgl_collision_arena_selftest(1.0f, &hit, &depth)); + TEST_ASSERT(errctx, (hit == true), "the query failed after the arena was restored"); + + TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, + akgl_collision_arena_selftest(1.0f, NULL, &depth), + "the selftest with a NULL hit flag"); + TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, + akgl_collision_arena_selftest(1.0f, &hit, NULL), + "the selftest with a NULL depth"); + } CLEANUP { + akgl_ccd_arena_set_limit(0); + akgl_ccd_arena_reset(); + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + +int main(void) +{ + PREPARE_ERROR(errctx); + + ATTEMPT { + CATCH(errctx, akgl_error_init()); + CATCH(errctx, test_arena_bump_and_reset()); + CATCH(errctx, test_arena_realloc_preserves_contents()); + /* + * The real query runs before the exhaustion case, so the high-water mark + * it prints is what a narrowphase query actually costs rather than what + * a test deliberately filling the arena costs. + */ + CATCH(errctx, test_arena_drives_a_real_query()); + CATCH(errctx, test_arena_exhaustion_refuses()); + } CLEANUP { + } PROCESS(errctx) { + } FINISH_NORETURN(errctx); + + return 0; +} diff --git a/tests/error.c b/tests/error.c index 379cbab..046d863 100644 --- a/tests/error.c +++ b/tests/error.c @@ -32,7 +32,8 @@ akerr_ErrorContext *test_error_init_owns_the_status_band(void) { AKGL_ERR_REGISTRY, "Registry Error" }, { AKGL_ERR_HEAP, "Heap Error" }, { AKGL_ERR_BEHAVIOR, "Behavior Error" }, - { AKGL_ERR_LOGICINTERRUPT, "Logic Interrupt" } + { AKGL_ERR_LOGICINTERRUPT, "Logic Interrupt" }, + { AKGL_ERR_COLLISION, "Collision Error" } }; bool named = true; int i = 0;