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:
62
src/ccd_arena_shim.h
Normal file
62
src/ccd_arena_shim.h
Normal file
@@ -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.
|
||||
*
|
||||
* **`<stdlib.h>` 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 `<stdlib.h>` 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 <stdlib.h>` 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 <stdlib.h>
|
||||
|
||||
/*
|
||||
* 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 <alloc.h>
|
||||
|
||||
#include <akgl/collision_arena.h>
|
||||
|
||||
#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
|
||||
132
src/collision.c
Normal file
132
src/collision.c
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @file collision.c
|
||||
* @brief The collision narrowphase, and the only place libccd is visible.
|
||||
*
|
||||
* Nothing else in the tree includes `<ccd/ccd.h>`. 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 <akerror.h>
|
||||
|
||||
#include <akgl/collision_arena.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/types.h>
|
||||
|
||||
#include <ccd/ccd.h>
|
||||
#include <ccd/quat.h>
|
||||
#include <ccd/vec3.h>
|
||||
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
142
src/collision_arena.c
Normal file
142
src/collision_arena.c
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @file collision_arena.c
|
||||
* @brief The bump allocator libccd allocates from. See akgl/collision_arena.h.
|
||||
*/
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include <akgl/collision_arena.h>
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user