Files
libakgl/tests/collision_arena.c

288 lines
11 KiB
C
Raw Normal View History

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
/**
* @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 <stdio.h>
#include <string.h>
#include <akerror.h>
#include <akgl/collision_arena.h>
#include <akgl/error.h>
#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;
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
akerr_ErrorContext *inner = NULL;
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
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.
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
*
* The loop body cannot use CATCH: it reports by `break`ing, which would
* leave the loop rather than the ATTEMPT block and turn a failing query
* into a silently short run. Collect the failure and hand it over after.
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
*/
for ( i = 0; i < 64; i++ ) {
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
inner = akgl_collision_arena_selftest(1.0f, &hit, &depth);
if ( inner != NULL ) {
break;
}
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
}
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
CATCH(errctx, inner);
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
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;
}