Files
libakgl/tests/partition.c

692 lines
25 KiB
C
Raw Normal View History

Add a pluggable broad phase, and the incremental uniform grid behind it akgl_Partitioner is a record of function pointers and an initializer, the same shape as the render and physics backends. `move` is its own slot rather than remove-then-insert, and that is the whole design: a uniform grid's move is a comparison and a return when the proxy has not left the cells it was in, which is the steady state for a walking actor and the permanent state for a static one. Spelling it as remove-and-insert would turn the incremental grid into the rebuild-every-frame tree that PERFORMANCE.md already measured and rejected. The grid is a dense 128x128 array of cell heads over the world, cells keyed on tile size, with two intrusive chains per entry -- one through the cell so a query can walk it, one through the proxy so removal unlinks a whole span without searching. Everything is an int16_t index rather than a pointer, so the structure is relocatable and clearing it is a memset. A proxy covering more cells than AKGL_COLLISION_GRID_MAX_SPAN spills onto one chain every query walks, which is what makes the cell pool's ceiling provable rather than hopeful. tests/partition.c compares every query against a linear scan over the same proxies and asserts containment in one direction, not equality. That asymmetry is the contract: over-reporting costs a narrowphase call, under-reporting is a wall an actor walks through. The suite is a table over partitioners so the same assertions run against every implementation, which is what the second one landing later will be held to. Three deliberate breaks, and two of them were not caught the first time: - Removing the min-cell rule so a shared-cell pair reports repeatedly: caught. - Dropping the unlink in `move`: **not caught**, initially. Stale entries do not produce wrong query answers, because the bounds re-check filters them out -- they leak the cell pool until the grid stops working, on a timescale no unit test reaches by accident. Counting live pool entries across 200 moves is the only thing that sees it, and now does: 505 entries against an expected 5. - Swapping floorf for a truncating cast: **not caught, and correctly so.** Everything below zero is clamped to cell 0 either way, so today the two are indistinguishable. The comment claiming truncation is a defect was overstating it and now says what is actually true: the clamp is the only thing hiding it, and a negative world origin or a relaxed clamp would make it real with no test standing in front of it. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:29:02 -04:00
/**
* @file partition.c
* @brief The broad phase, checked against brute force.
*
* A broad phase has one contract and it is asymmetric: it may report a pair that
* does not overlap, and it may never miss one that does. Over-reporting costs a
* narrowphase call; under-reporting is a wall an actor walks through, and it
* fails silently and intermittently, only for the positions where the structure
* happens to be wrong.
*
* So every query here is compared against a linear scan over the same proxies,
* and the assertion is containment in one direction and not equality. That is
* the only check that has any teeth: a broad phase can be tested against itself
* all day and agree with itself all day.
*
* The suite is written as a table over partitioners so the same assertions run
* against every implementation. A second implementation that is not held to the
* first one's contract is not pluggable, it is just present.
*/
#include <math.h>
#include <string.h>
#include <akerror.h>
#include <akgl/collision.h>
#include <akgl/error.h>
#include <akgl/heap.h>
Let a map say which layers are solid, and answer questions about them A tile layer with a `collidable` boolean custom property is collision geometry. Before this a game had to hard-code a layer index -- both examples do, because akgl_TilemapLayer does not retain the name Tiled wrote -- and that index changes the moment somebody reorders layers in the editor. Solid tiles are **not** given proxies. At the maximum map size that is a quarter of a million per layer, tens of megabytes of index to describe data that is already a dense grid sitting in the tilemap. The world keeps a borrowed pointer and reads layers[i].data[] over whatever cell range a query covers: nine array reads for a 32-pixel actor on 16-pixel tiles, nothing to build at level load, and nothing to maintain per frame. Static geometry that is not tile-aligned is still an ordinary proxy with AKGL_COLLISION_FLAG_STATIC; both mechanisms exist and tiles use the free one because there are a hundred thousand of them. Four public queries come with it, all of which answer without resolving: solid_at, box_blocked, query_box and settle. They are what a game reaches for when it wants to know rather than to be pushed -- a ledge probe ahead of a walking enemy, a check that a doorway is clear -- and the sidescroller cannot drop its hand-rolled collision without them. akgl_collision_settle is the one worth naming. Resolution stops a shape entering geometry and has nothing to say about one that began inside it: what it does instead is refuse every move, so an actor spawned in a wall is simply stuck. Level authors produce that constantly, so settling walks a shape up a tile at a time and refuses loudly rather than searching forever. Two defects found by writing the tests: - The fixture put an akgl_Tilemap on the stack and segfaulted before the first assertion. It is about 26 MB -- the layer and tileset arrays are sized for the worst case the format allows -- and tilemap.h says so. It is static now, with a comment saying why, because the next person to write a map fixture will reach for a local first as well. - The far-edge nudge used AKGL_COLLISION_EPSILON, which is 1e-6. That is a sensible tolerance on a unit vector and a meaningless one on a map coordinate: `float` carries about seven significant digits, so at a coordinate of 144 the smallest representable step is around 1.5e-5 and `144.0f - 1e-6f` is exactly 144.0f. The nudge did nothing, a box resting flush on the floor read as inside it, and every move it tried looked blocked -- an actor standing on the ground unable to walk. Two different quantities were sharing one constant; the tile one is now its own, at a thousandth of a pixel, which is what the sidescroller example independently arrived at. Both breaks verified: removing the nudge and ignoring the `collidable` bit each turn the suite red with the symptom named. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:38:12 -04:00
#include <akgl/tilemap.h>
Add a pluggable broad phase, and the incremental uniform grid behind it akgl_Partitioner is a record of function pointers and an initializer, the same shape as the render and physics backends. `move` is its own slot rather than remove-then-insert, and that is the whole design: a uniform grid's move is a comparison and a return when the proxy has not left the cells it was in, which is the steady state for a walking actor and the permanent state for a static one. Spelling it as remove-and-insert would turn the incremental grid into the rebuild-every-frame tree that PERFORMANCE.md already measured and rejected. The grid is a dense 128x128 array of cell heads over the world, cells keyed on tile size, with two intrusive chains per entry -- one through the cell so a query can walk it, one through the proxy so removal unlinks a whole span without searching. Everything is an int16_t index rather than a pointer, so the structure is relocatable and clearing it is a memset. A proxy covering more cells than AKGL_COLLISION_GRID_MAX_SPAN spills onto one chain every query walks, which is what makes the cell pool's ceiling provable rather than hopeful. tests/partition.c compares every query against a linear scan over the same proxies and asserts containment in one direction, not equality. That asymmetry is the contract: over-reporting costs a narrowphase call, under-reporting is a wall an actor walks through. The suite is a table over partitioners so the same assertions run against every implementation, which is what the second one landing later will be held to. Three deliberate breaks, and two of them were not caught the first time: - Removing the min-cell rule so a shared-cell pair reports repeatedly: caught. - Dropping the unlink in `move`: **not caught**, initially. Stale entries do not produce wrong query answers, because the bounds re-check filters them out -- they leak the cell pool until the grid stops working, on a timescale no unit test reaches by accident. Counting live pool entries across 200 moves is the only thing that sees it, and now does: 505 entries against an expected 5. - Swapping floorf for a truncating cast: **not caught, and correctly so.** Everything below zero is clamped to cell 0 either way, so today the two are indistinguishable. The comment claiming truncation is a defect was overstating it and now says what is actually true: the clamp is the only thing hiding it, and a negative world origin or a relaxed clamp would make it real with no test standing in front of it. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:29:02 -04:00
#include "testutil.h"
/** @brief Every partitioner the factory knows, so the same tests run over each. */
static char *partitioners[] = { "grid" };
/** @brief What a visit callback accumulates. */
typedef struct {
int16_t seen[AKGL_MAX_HEAP_COLLISION_PROXY];
int count;
} visit_record;
static akerr_ErrorContext *record_visit(akgl_CollisionProxy *proxy, void *data)
{
visit_record *rec = (visit_record *)data;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, proxy, AKERR_NULLPOINTER, "NULL proxy in a visit");
FAIL_ZERO_RETURN(errctx, rec, AKERR_NULLPOINTER, "NULL record in a visit");
if ( rec->count < AKGL_MAX_HEAP_COLLISION_PROXY ) {
rec->seen[rec->count] = (int16_t)(proxy - &akgl_heap_collision_proxies[0]);
rec->count += 1;
}
SUCCEED_RETURN(errctx);
}
/** @brief How many cell entries are currently claimed from the pool. */
static int live_cells(void)
{
int live = 0;
int i = 0;
for ( i = 0; i < AKGL_MAX_HEAP_COLLISION_CELL; i++ ) {
if ( akgl_heap_collision_cells[i].refcount != 0 ) {
live += 1;
}
}
return live;
}
/** @brief Was this proxy index reported? */
static bool sawit(visit_record *rec, int16_t idx)
{
int i = 0;
for ( i = 0; i < rec->count; i++ ) {
if ( rec->seen[i] == idx ) {
return true;
}
}
return false;
}
/** @brief Claim a proxy at a position and put it in the partitioner. */
static akerr_ErrorContext *spawn(akgl_CollisionWorld *world, akgl_CollisionShape *shape,
float32_t x, float32_t y, akgl_CollisionProxy **dest)
{
PREPARE_ERROR(errctx);
PASS(errctx, akgl_heap_next_collision_proxy(dest));
PASS(errctx, akgl_collision_proxy_initialize(*dest, NULL, shape, x, y, 0.0f));
PASS(errctx, world->partitioner.insert(&world->partitioner, *dest));
SUCCEED_RETURN(errctx);
}
/**
* @brief A query must report every proxy a linear scan would, and may report more.
*
* Positions are chosen to land on cell boundaries, span several cells, and sit
* outside the grid entirely, because those are the three places an index is
* wrong and a linear scan is not.
*/
akerr_ErrorContext *test_query_never_misses(char *name)
{
akgl_CollisionWorld world;
akgl_CollisionShape small;
akgl_CollisionShape big;
akgl_CollisionProxy *proxies[16];
visit_record rec;
SDL_FRect area;
SDL_FRect small_body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
SDL_FRect big_body = { .x = 0.0f, .y = 0.0f, .w = 96.0f, .h = 96.0f };
float32_t spots[][2] = {
{ 0.0f, 0.0f }, { 16.0f, 0.0f }, { 32.0f, 32.0f }, { 8.0f, 8.0f },
{ 100.0f, 100.0f }, { 1000.0f, 40.0f }, { -40.0f, -40.0f }, { 15.9f, 15.9f }
};
int spotcount = (int)(sizeof(spots) / sizeof(spots[0]));
bool missed = false;
float32_t missx = 0.0f;
float32_t missy = 0.0f;
int i = 0;
int q = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_collision_world_init(&world, name, 16.0f, 16.0f));
CATCH(errctx, akgl_collision_shape_box(&small, &small_body, 0.0f));
CATCH(errctx, akgl_collision_shape_box(&big, &big_body, 0.0f));
for ( i = 0; i < spotcount; i++ ) {
CATCH(errctx, spawn(&world, &small, spots[i][0], spots[i][1], &proxies[i]));
}
// One deliberately large enough to spill onto the oversized chain, which
// is a separate code path a query must not forget to walk.
CATCH(errctx, spawn(&world, &big, 200.0f, 200.0f, &proxies[spotcount]));
/*
* Sweep a query window across the world and compare against a linear
* scan every time. Recorded into a flag and asserted after the loops: a
* TEST_ASSERT in here reports by breaking, which would leave the loop
* rather than the ATTEMPT block and could not fail the test.
*/
for ( q = 0; q < 40; q++ ) {
area.x = (float32_t)((q * 13) % 300) - 50.0f;
area.y = (float32_t)((q * 7) % 300) - 50.0f;
area.w = 24.0f;
area.h = 24.0f;
memset(&rec, 0x00, sizeof(rec));
if ( world.partitioner.query(&world.partitioner, &area, AKGL_COLLISION_LAYER_ALL,
&record_visit, &rec) != NULL ) {
missed = true;
break;
}
for ( i = 0; i <= spotcount; i++ ) {
if ( !SDL_HasRectIntersectionFloat(&proxies[i]->bounds, &area) ) {
continue;
}
if ( !sawit(&rec, (int16_t)(proxies[i] - &akgl_heap_collision_proxies[0])) ) {
missed = true;
missx = area.x;
missy = area.y;
}
}
}
TEST_ASSERT(errctx, (missed == false),
"%s missed a proxy that overlaps the query at (%f, %f); a broad phase may "
"over-report and may never under-report", name, missx, missy);
// A query is allowed to report a proxy more than the once, but must not:
// a proxy spanning four cells appears on four chains.
area.x = 0.0f;
area.y = 0.0f;
area.w = 300.0f;
area.h = 300.0f;
memset(&rec, 0x00, sizeof(rec));
CATCH(errctx, world.partitioner.query(&world.partitioner, &area, AKGL_COLLISION_LAYER_ALL,
&record_visit, &rec));
TEST_ASSERT(errctx, (rec.count <= (spotcount + 1)),
"%s reported %d proxies where at most %d exist; a proxy spanning several "
"cells is being reported once per cell", name, rec.count, spotcount + 1);
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief The layer mask filters, and filters in the right direction.
*/
akerr_ErrorContext *test_query_respects_mask(char *name)
{
akgl_CollisionWorld world;
akgl_CollisionShape actor;
akgl_CollisionShape scenery;
akgl_CollisionProxy *a = NULL;
akgl_CollisionProxy *b = NULL;
visit_record rec;
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
SDL_FRect area = { .x = -50.0f, .y = -50.0f, .w = 200.0f, .h = 200.0f };
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_collision_world_init(&world, name, 16.0f, 16.0f));
CATCH(errctx, akgl_collision_shape_box(&actor, &body, 0.0f));
CATCH(errctx, akgl_collision_shape_box(&scenery, &body, 0.0f));
scenery.layermask = AKGL_COLLISION_LAYER_STATIC;
CATCH(errctx, spawn(&world, &actor, 0.0f, 0.0f, &a));
CATCH(errctx, spawn(&world, &scenery, 32.0f, 0.0f, &b));
memset(&rec, 0x00, sizeof(rec));
CATCH(errctx, world.partitioner.query(&world.partitioner, &area, AKGL_COLLISION_LAYER_STATIC,
&record_visit, &rec));
TEST_ASSERT(errctx, (rec.count == 1), "%s reported %d proxies on the static layer, expected 1",
name, rec.count);
TEST_ASSERT(errctx, sawit(&rec, (int16_t)(b - &akgl_heap_collision_proxies[0])),
"%s filtered out the static proxy and kept the actor", name);
memset(&rec, 0x00, sizeof(rec));
CATCH(errctx, world.partitioner.query(&world.partitioner, &area, AKGL_COLLISION_LAYER_NONE,
&record_visit, &rec));
TEST_ASSERT(errctx, (rec.count == 0), "%s reported %d proxies for an empty mask", name, rec.count);
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief Moving a proxy has to update the index, and moving it nowhere has not to.
*
* The first half is the correctness claim: a stale entry in the old cell is the
* classic incremental-structure bug, and it shows up as a collision with where
* something used to be. The second half is the *performance* claim the whole
* structure was chosen for, and it is asserted rather than assumed.
*/
akerr_ErrorContext *test_move_is_incremental(char *name)
{
akgl_CollisionWorld world;
akgl_CollisionShape shape;
akgl_CollisionProxy *proxy = NULL;
visit_record rec;
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 8.0f, .h = 8.0f };
SDL_FRect here = { .x = 0.0f, .y = 0.0f, .w = 8.0f, .h = 8.0f };
SDL_FRect there = { .x = 200.0f, .y = 200.0f, .w = 8.0f, .h = 8.0f };
int16_t firstbefore = 0;
int before = 0;
int i = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_collision_world_init(&world, name, 16.0f, 16.0f));
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
CATCH(errctx, spawn(&world, &shape, 0.0f, 0.0f, &proxy));
// Move it a long way and re-query both ends.
CATCH(errctx, akgl_collision_proxy_sync(proxy, &shape, 200.0f, 200.0f, 0.0f));
CATCH(errctx, world.partitioner.move(&world.partitioner, proxy));
memset(&rec, 0x00, sizeof(rec));
CATCH(errctx, world.partitioner.query(&world.partitioner, &here, AKGL_COLLISION_LAYER_ALL,
&record_visit, &rec));
TEST_ASSERT(errctx, (rec.count == 0),
"%s still reports a proxy where it used to be; the old cell entry was not "
"removed, and a game would collide with a ghost", name);
memset(&rec, 0x00, sizeof(rec));
CATCH(errctx, world.partitioner.query(&world.partitioner, &there, AKGL_COLLISION_LAYER_ALL,
&record_visit, &rec));
TEST_ASSERT(errctx, (rec.count == 1), "%s does not report the proxy where it now is", name);
/*
* A move within the cells already occupied must touch nothing. That is
* the property the uniform grid was chosen for over a tree, so it is
* checked rather than trusted: the proxy's chain head is the cheapest
* observable proof that no entry was released and re-claimed.
*/
firstbefore = proxy->first;
CATCH(errctx, akgl_collision_proxy_sync(proxy, &shape, 201.0f, 201.0f, 0.0f));
CATCH(errctx, world.partitioner.move(&world.partitioner, proxy));
TEST_ASSERT(errctx, (proxy->first == firstbefore),
"%s re-celled a proxy that did not leave its cells; the structure is not "
"incremental and the argument for choosing it does not hold", name);
/*
* Moving repeatedly must not accumulate index entries. A move that links
* into the new cells without unlinking the old ones answers *queries*
* correctly -- the bounds re-check filters the stale entries out -- so
* nothing above catches it. What it does instead is leak the cell pool
* until the grid stops working, on a timescale no unit test reaches by
* accident. Counting the pool is the only thing that sees it.
*/
before = live_cells();
for ( i = 0; i < 200; i++ ) {
CATCH(errctx, akgl_collision_proxy_sync(proxy, &shape,
(float32_t)((i * 37) % 400),
(float32_t)((i * 53) % 400), 0.0f));
CATCH(errctx, world.partitioner.move(&world.partitioner, proxy));
}
TEST_ASSERT(errctx, (live_cells() <= before),
"%s holds %d cell entries for one proxy after 200 moves, up from %d; the "
"old entries are not being unlinked and the pool is leaking",
name, live_cells(), before);
// Removing is idempotent, because a caller tearing down should not have
// to remember what it already dropped.
CATCH(errctx, world.partitioner.remove(&world.partitioner, proxy));
CATCH(errctx, world.partitioner.remove(&world.partitioner, proxy));
memset(&rec, 0x00, sizeof(rec));
CATCH(errctx, world.partitioner.query(&world.partitioner, &there, AKGL_COLLISION_LAYER_ALL,
&record_visit, &rec));
TEST_ASSERT(errctx, (rec.count == 0), "%s still reports a removed proxy", name);
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/** @brief What each_pair accumulates. */
typedef struct {
int count;
int selfpairs;
} pair_record;
static akerr_ErrorContext *record_pair(akgl_CollisionProxy *a, akgl_CollisionProxy *b, void *data)
{
pair_record *rec = (pair_record *)data;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, a, AKERR_NULLPOINTER, "NULL first proxy in a pair");
FAIL_ZERO_RETURN(errctx, b, AKERR_NULLPOINTER, "NULL second proxy in a pair");
FAIL_ZERO_RETURN(errctx, rec, AKERR_NULLPOINTER, "NULL record in a pair");
if ( a == b ) {
rec->selfpairs += 1;
}
rec->count += 1;
SUCCEED_RETURN(errctx);
}
/**
* @brief Every overlapping pair is reported, and none is reported twice.
*
* Two proxies sharing four cells is the case that produces four reports from a
* naive implementation, and four reports means four impulses for one contact.
*/
akerr_ErrorContext *test_each_pair_is_once(char *name)
{
akgl_CollisionWorld world;
akgl_CollisionShape shape;
akgl_CollisionProxy *proxies[4];
pair_record rec;
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 40.0f, .h = 40.0f };
int i = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_collision_world_init(&world, name, 16.0f, 16.0f));
// 40 pixels on a 16 pixel grid: each of these covers nine cells, so every
// pair among them shares several.
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
for ( i = 0; i < 4; i++ ) {
CATCH(errctx, spawn(&world, &shape, (float32_t)(i * 4), 0.0f, &proxies[i]));
}
memset(&rec, 0x00, sizeof(rec));
CATCH(errctx, world.partitioner.each_pair(&world.partitioner, &record_pair, &rec));
// Four mutually overlapping proxies are six distinct pairs.
TEST_ASSERT(errctx, (rec.selfpairs == 0), "%s paired a proxy with itself %d times",
name, rec.selfpairs);
TEST_ASSERT(errctx, (rec.count == 6),
"%s reported %d pairs among four mutually overlapping proxies, expected 6; "
"duplicates mean one contact resolved several times", name, rec.count);
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/** @brief An empty world answers nothing, and reset really empties it. */
akerr_ErrorContext *test_reset_and_empty(char *name)
{
akgl_CollisionWorld world;
akgl_CollisionShape shape;
akgl_CollisionProxy *proxy = NULL;
visit_record rec;
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
SDL_FRect area = { .x = -100.0f, .y = -100.0f, .w = 400.0f, .h = 400.0f };
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_collision_world_init(&world, name, 16.0f, 16.0f));
// The null hypothesis. A broken structure that reports nothing passes
// every other test in this file, so this one is not a formality.
memset(&rec, 0x00, sizeof(rec));
CATCH(errctx, world.partitioner.query(&world.partitioner, &area, AKGL_COLLISION_LAYER_ALL,
&record_visit, &rec));
TEST_ASSERT(errctx, (rec.count == 0), "%s reported %d proxies in an empty world",
name, rec.count);
CATCH(errctx, spawn(&world, &shape, 0.0f, 0.0f, &proxy));
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
CATCH(errctx, akgl_collision_proxy_sync(proxy, &shape, 0.0f, 0.0f, 0.0f));
CATCH(errctx, world.partitioner.move(&world.partitioner, proxy));
memset(&rec, 0x00, sizeof(rec));
CATCH(errctx, world.partitioner.query(&world.partitioner, &area, AKGL_COLLISION_LAYER_ALL,
&record_visit, &rec));
TEST_ASSERT(errctx, (rec.count == 1), "%s lost the only proxy in the world", name);
CATCH(errctx, world.partitioner.reset(&world.partitioner, &world));
memset(&rec, 0x00, sizeof(rec));
CATCH(errctx, world.partitioner.query(&world.partitioner, &area, AKGL_COLLISION_LAYER_ALL,
&record_visit, &rec));
TEST_ASSERT(errctx, (rec.count == 0), "%s still reports %d proxies after a reset",
name, rec.count);
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
Let a map say which layers are solid, and answer questions about them A tile layer with a `collidable` boolean custom property is collision geometry. Before this a game had to hard-code a layer index -- both examples do, because akgl_TilemapLayer does not retain the name Tiled wrote -- and that index changes the moment somebody reorders layers in the editor. Solid tiles are **not** given proxies. At the maximum map size that is a quarter of a million per layer, tens of megabytes of index to describe data that is already a dense grid sitting in the tilemap. The world keeps a borrowed pointer and reads layers[i].data[] over whatever cell range a query covers: nine array reads for a 32-pixel actor on 16-pixel tiles, nothing to build at level load, and nothing to maintain per frame. Static geometry that is not tile-aligned is still an ordinary proxy with AKGL_COLLISION_FLAG_STATIC; both mechanisms exist and tiles use the free one because there are a hundred thousand of them. Four public queries come with it, all of which answer without resolving: solid_at, box_blocked, query_box and settle. They are what a game reaches for when it wants to know rather than to be pushed -- a ledge probe ahead of a walking enemy, a check that a doorway is clear -- and the sidescroller cannot drop its hand-rolled collision without them. akgl_collision_settle is the one worth naming. Resolution stops a shape entering geometry and has nothing to say about one that began inside it: what it does instead is refuse every move, so an actor spawned in a wall is simply stuck. Level authors produce that constantly, so settling walks a shape up a tile at a time and refuses loudly rather than searching forever. Two defects found by writing the tests: - The fixture put an akgl_Tilemap on the stack and segfaulted before the first assertion. It is about 26 MB -- the layer and tileset arrays are sized for the worst case the format allows -- and tilemap.h says so. It is static now, with a comment saying why, because the next person to write a map fixture will reach for a local first as well. - The far-edge nudge used AKGL_COLLISION_EPSILON, which is 1e-6. That is a sensible tolerance on a unit vector and a meaningless one on a map coordinate: `float` carries about seven significant digits, so at a coordinate of 144 the smallest representable step is around 1.5e-5 and `144.0f - 1e-6f` is exactly 144.0f. The nudge did nothing, a box resting flush on the floor read as inside it, and every move it tried looked blocked -- an actor standing on the ground unable to walk. Two different quantities were sharing one constant; the tile one is now its own, at a thousandth of a pixel, which is what the sidescroller example independently arrived at. Both breaks verified: removing the nudge and ignoring the `collidable` bit each turn the suite red with the symptom named. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:38:12 -04:00
/**
* @brief A small map with a floor, a pillar, a gap and a one-tile corridor.
*
* Synthesized rather than loaded: collision reads `data[]`, `tilewidth`,
* `tileheight`, `width` and `height` and nothing else, so a fixture needs no
* image, no tileset and no loader. `tests/perf_render.c` builds its map the same
* way for the same reason.
*
* Every shape here is one the two example games actually hit -- a floor to rest
* on, a pillar to walk into, a gap to fall through, a corridor that blocks both
* axes at once.
*/
/*
* Static, not on the stack. sizeof(akgl_Tilemap) is about 26 MB -- the layer and
* tileset arrays are sized for the worst case the format allows -- so a local
* one overflows the stack before the first assertion runs. tilemap.h says so and
* this fixture was written wrong once anyway.
*/
static akgl_Tilemap fixture_map;
static void build_map(akgl_Tilemap *map)
{
int x = 0;
memset(map, 0x00, sizeof(akgl_Tilemap));
map->tilewidth = 16;
map->tileheight = 16;
map->width = 20;
map->height = 10;
map->numlayers = 2;
map->layers[0].type = AKGL_TILEMAP_LAYER_TYPE_TILES;
map->layers[1].type = AKGL_TILEMAP_LAYER_TYPE_TILES;
// Layer 0 is scenery and is not collidable. Layer 1 is terrain and is. If
// the two were confused, an actor would collide with the background.
for ( x = 0; x < map->width; x++ ) {
map->layers[0].data[(2 * map->width) + x] = 99;
}
// A floor along row 9, with a two-tile gap at columns 5 and 6.
for ( x = 0; x < map->width; x++ ) {
if ( (x == 5) || (x == 6) ) {
continue;
}
map->layers[1].data[(9 * map->width) + x] = 1;
}
// A one-tile pillar at column 12, rows 7 and 8.
map->layers[1].data[(7 * map->width) + 12] = 1;
map->layers[1].data[(8 * map->width) + 12] = 1;
map->collidablelayers = (1u << 1);
}
/**
* @brief Solid tiles block, non-collidable layers do not, and off-map is open.
*/
akerr_ErrorContext *test_tiles_are_geometry(char *name)
{
akgl_CollisionWorld world;
akgl_Tilemap *map = &fixture_map;
SDL_FRect box;
bool solid = false;
bool blocked = false;
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_collision_world_init(&world, name, 16.0f, 16.0f));
build_map(map);
CATCH(errctx, akgl_collision_bind_tilemap(&world, map));
TEST_ASSERT(errctx, (world.cellwidth == 16.0f),
"binding a map left the cell width at %f rather than the tile width",
world.cellwidth);
// On the floor.
CATCH(errctx, akgl_collision_solid_at(&world, 8.0f, 152.0f, &solid));
TEST_ASSERT(errctx, (solid == true), "%s does not see the floor tile at row 9", name);
// In the gap in the floor.
CATCH(errctx, akgl_collision_solid_at(&world, 88.0f, 152.0f, &solid));
TEST_ASSERT(errctx, (solid == false), "%s reports the gap in the floor as solid", name);
// On the scenery layer, which is not collidable. Getting this wrong
// means an actor colliding with the background.
CATCH(errctx, akgl_collision_solid_at(&world, 8.0f, 40.0f, &solid));
TEST_ASSERT(errctx, (solid == false),
"%s treats a non-collidable layer as solid; `collidable` is being ignored", name);
// Off the map is open, not solid. A pit has to be a pit.
CATCH(errctx, akgl_collision_solid_at(&world, -100.0f, -100.0f, &solid));
TEST_ASSERT(errctx, (solid == false), "%s reports off-map as solid", name);
CATCH(errctx, akgl_collision_solid_at(&world, 8.0f, 10000.0f, &solid));
TEST_ASSERT(errctx, (solid == false), "%s reports below the map as solid", name);
// A box resting exactly on the floor must not read as inside it. Without
// the epsilon on the far edge, an actor standing flush against geometry
// reads as overlapping it and every move it tries looks blocked.
box.x = 0.0f;
box.y = 128.0f;
box.w = 16.0f;
box.h = 16.0f;
CATCH(errctx, akgl_collision_box_blocked(&world, &box, AKGL_COLLISION_LAYER_ALL, &blocked));
TEST_ASSERT(errctx, (blocked == false),
"%s reports a box resting exactly on the floor as inside it; an actor "
"standing on the ground would be unable to move", name);
// One pixel lower and it is inside.
box.y = 129.0f;
CATCH(errctx, akgl_collision_box_blocked(&world, &box, AKGL_COLLISION_LAYER_ALL, &blocked));
TEST_ASSERT(errctx, (blocked == true), "%s does not see a box overlapping the floor", name);
// The pillar blocks horizontally.
box.x = 190.0f;
box.y = 112.0f;
CATCH(errctx, akgl_collision_box_blocked(&world, &box, AKGL_COLLISION_LAYER_ALL, &blocked));
TEST_ASSERT(errctx, (blocked == true), "%s does not see the pillar", name);
// Detaching the map leaves nothing solid.
CATCH(errctx, akgl_collision_bind_tilemap(&world, NULL));
CATCH(errctx, akgl_collision_solid_at(&world, 8.0f, 152.0f, &solid));
TEST_ASSERT(errctx, (solid == false), "%s still sees tiles after the map was detached", name);
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_collision_solid_at(NULL, 0.0f, 0.0f, &solid),
"a point query with no world");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_collision_box_blocked(&world, &box, 0, NULL),
"a box query with no destination");
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief Settling lifts a spawn out of the ground, and refuses when it cannot.
*/
akerr_ErrorContext *test_settle_lifts_a_spawn(char *name)
{
akgl_CollisionWorld world;
akgl_Tilemap *map = &fixture_map;
akgl_CollisionShape shape;
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
float32_t x = 0.0f;
float32_t y = 0.0f;
bool blocked = false;
SDL_FRect box;
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_collision_world_init(&world, name, 16.0f, 16.0f));
build_map(map);
CATCH(errctx, akgl_collision_bind_tilemap(&world, map));
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
// Placed straddling the floor, which is what an editor does when an
// object is dropped near a step.
x = 32.0f;
y = 140.0f;
CATCH(errctx, akgl_collision_settle(&world, &shape, &x, &y, 0));
TEST_ASSERT(errctx, (y < 140.0f), "%s did not lift a shape spawned inside the floor", name);
CATCH(errctx, akgl_collision_shape_bounds(&shape, x, y, &box));
CATCH(errctx, akgl_collision_box_blocked(&world, &box, AKGL_COLLISION_LAYER_STATIC, &blocked));
TEST_ASSERT(errctx, (blocked == false), "%s settled a shape that is still inside geometry", name);
// Already clear: settling must not move it at all.
x = 32.0f;
y = 40.0f;
CATCH(errctx, akgl_collision_settle(&world, &shape, &x, &y, 0));
TEST_ASSERT_FEQ(errctx, y, 40.0f, "%s moved a shape that was already clear, to %f", name, y);
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
Add a pluggable broad phase, and the incremental uniform grid behind it akgl_Partitioner is a record of function pointers and an initializer, the same shape as the render and physics backends. `move` is its own slot rather than remove-then-insert, and that is the whole design: a uniform grid's move is a comparison and a return when the proxy has not left the cells it was in, which is the steady state for a walking actor and the permanent state for a static one. Spelling it as remove-and-insert would turn the incremental grid into the rebuild-every-frame tree that PERFORMANCE.md already measured and rejected. The grid is a dense 128x128 array of cell heads over the world, cells keyed on tile size, with two intrusive chains per entry -- one through the cell so a query can walk it, one through the proxy so removal unlinks a whole span without searching. Everything is an int16_t index rather than a pointer, so the structure is relocatable and clearing it is a memset. A proxy covering more cells than AKGL_COLLISION_GRID_MAX_SPAN spills onto one chain every query walks, which is what makes the cell pool's ceiling provable rather than hopeful. tests/partition.c compares every query against a linear scan over the same proxies and asserts containment in one direction, not equality. That asymmetry is the contract: over-reporting costs a narrowphase call, under-reporting is a wall an actor walks through. The suite is a table over partitioners so the same assertions run against every implementation, which is what the second one landing later will be held to. Three deliberate breaks, and two of them were not caught the first time: - Removing the min-cell rule so a shared-cell pair reports repeatedly: caught. - Dropping the unlink in `move`: **not caught**, initially. Stale entries do not produce wrong query answers, because the bounds re-check filters them out -- they leak the cell pool until the grid stops working, on a timescale no unit test reaches by accident. Counting live pool entries across 200 moves is the only thing that sees it, and now does: 505 entries against an expected 5. - Swapping floorf for a truncating cast: **not caught, and correctly so.** Everything below zero is clamped to cell 0 either way, so today the two are indistinguishable. The comment claiming truncation is a defect was overstating it and now says what is actually true: the clamp is the only thing hiding it, and a negative world origin or a relaxed clamp would make it real with no test standing in front of it. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:29:02 -04:00
akerr_ErrorContext *test_world_and_factory(void)
{
akgl_CollisionWorld world;
akgl_Partitioner part;
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_collision_world_init(&world, NULL, 16.0f, 16.0f));
TEST_ASSERT(errctx, (strcmp(world.partitioner.name, "grid") == 0),
"a NULL partitioner name installed \"%s\" rather than the default",
world.partitioner.name);
TEST_ASSERT(errctx, ((world.flags & AKGL_COLLISION_TEST_PLANAR) != 0),
"a new world does not force planar normals; every game today is 2D");
CATCH(errctx, akgl_partitioner_factory(&part, "grid"));
TEST_ASSERT(errctx, (part.query != NULL), "the grid installed no query");
TEST_ASSERT(errctx, (part.move != NULL), "the grid installed no move");
TEST_EXPECT_STATUS(errctx, AKERR_KEY, akgl_partitioner_factory(&part, "quadtree"),
"a partitioner nobody wrote");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_partitioner_factory(NULL, "grid"),
"the factory with no destination");
TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_world_init(&world, NULL, 0.0f, 16.0f),
"a world with no cell width");
TEST_EXPECT_STATUS(errctx, AKERR_VALUE, akgl_collision_world_init(&world, NULL, 16.0f, -1.0f),
"a world with negative cell height");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_collision_world_init(NULL, NULL, 16.0f, 16.0f),
"a world with no destination");
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
int main(void)
{
akerr_ErrorContext *inner = NULL;
int i = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_error_init());
CATCH(errctx, test_world_and_factory());
/*
* Every partitioner is held to the same assertions. Collected rather
* than CATCHed inside the loop, because CATCH reports by breaking and a
* break here would leave the loop rather than the ATTEMPT block, so a
* failing partitioner would be silently skipped.
*/
for ( i = 0; i < (int)(sizeof(partitioners) / sizeof(partitioners[0])); i++ ) {
inner = test_query_never_misses(partitioners[i]);
if ( inner != NULL ) { break; }
inner = test_query_respects_mask(partitioners[i]);
if ( inner != NULL ) { break; }
inner = test_move_is_incremental(partitioners[i]);
if ( inner != NULL ) { break; }
inner = test_each_pair_is_once(partitioners[i]);
if ( inner != NULL ) { break; }
inner = test_reset_and_empty(partitioners[i]);
if ( inner != NULL ) { break; }
Let a map say which layers are solid, and answer questions about them A tile layer with a `collidable` boolean custom property is collision geometry. Before this a game had to hard-code a layer index -- both examples do, because akgl_TilemapLayer does not retain the name Tiled wrote -- and that index changes the moment somebody reorders layers in the editor. Solid tiles are **not** given proxies. At the maximum map size that is a quarter of a million per layer, tens of megabytes of index to describe data that is already a dense grid sitting in the tilemap. The world keeps a borrowed pointer and reads layers[i].data[] over whatever cell range a query covers: nine array reads for a 32-pixel actor on 16-pixel tiles, nothing to build at level load, and nothing to maintain per frame. Static geometry that is not tile-aligned is still an ordinary proxy with AKGL_COLLISION_FLAG_STATIC; both mechanisms exist and tiles use the free one because there are a hundred thousand of them. Four public queries come with it, all of which answer without resolving: solid_at, box_blocked, query_box and settle. They are what a game reaches for when it wants to know rather than to be pushed -- a ledge probe ahead of a walking enemy, a check that a doorway is clear -- and the sidescroller cannot drop its hand-rolled collision without them. akgl_collision_settle is the one worth naming. Resolution stops a shape entering geometry and has nothing to say about one that began inside it: what it does instead is refuse every move, so an actor spawned in a wall is simply stuck. Level authors produce that constantly, so settling walks a shape up a tile at a time and refuses loudly rather than searching forever. Two defects found by writing the tests: - The fixture put an akgl_Tilemap on the stack and segfaulted before the first assertion. It is about 26 MB -- the layer and tileset arrays are sized for the worst case the format allows -- and tilemap.h says so. It is static now, with a comment saying why, because the next person to write a map fixture will reach for a local first as well. - The far-edge nudge used AKGL_COLLISION_EPSILON, which is 1e-6. That is a sensible tolerance on a unit vector and a meaningless one on a map coordinate: `float` carries about seven significant digits, so at a coordinate of 144 the smallest representable step is around 1.5e-5 and `144.0f - 1e-6f` is exactly 144.0f. The nudge did nothing, a box resting flush on the floor read as inside it, and every move it tried looked blocked -- an actor standing on the ground unable to walk. Two different quantities were sharing one constant; the tile one is now its own, at a thousandth of a pixel, which is what the sidescroller example independently arrived at. Both breaks verified: removing the nudge and ignoring the `collidable` bit each turn the suite red with the symptom named. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:38:12 -04:00
inner = test_tiles_are_geometry(partitioners[i]);
if ( inner != NULL ) { break; }
inner = test_settle_lifts_a_spawn(partitioners[i]);
if ( inner != NULL ) { break; }
Add a pluggable broad phase, and the incremental uniform grid behind it akgl_Partitioner is a record of function pointers and an initializer, the same shape as the render and physics backends. `move` is its own slot rather than remove-then-insert, and that is the whole design: a uniform grid's move is a comparison and a return when the proxy has not left the cells it was in, which is the steady state for a walking actor and the permanent state for a static one. Spelling it as remove-and-insert would turn the incremental grid into the rebuild-every-frame tree that PERFORMANCE.md already measured and rejected. The grid is a dense 128x128 array of cell heads over the world, cells keyed on tile size, with two intrusive chains per entry -- one through the cell so a query can walk it, one through the proxy so removal unlinks a whole span without searching. Everything is an int16_t index rather than a pointer, so the structure is relocatable and clearing it is a memset. A proxy covering more cells than AKGL_COLLISION_GRID_MAX_SPAN spills onto one chain every query walks, which is what makes the cell pool's ceiling provable rather than hopeful. tests/partition.c compares every query against a linear scan over the same proxies and asserts containment in one direction, not equality. That asymmetry is the contract: over-reporting costs a narrowphase call, under-reporting is a wall an actor walks through. The suite is a table over partitioners so the same assertions run against every implementation, which is what the second one landing later will be held to. Three deliberate breaks, and two of them were not caught the first time: - Removing the min-cell rule so a shared-cell pair reports repeatedly: caught. - Dropping the unlink in `move`: **not caught**, initially. Stale entries do not produce wrong query answers, because the bounds re-check filters them out -- they leak the cell pool until the grid stops working, on a timescale no unit test reaches by accident. Counting live pool entries across 200 moves is the only thing that sees it, and now does: 505 entries against an expected 5. - Swapping floorf for a truncating cast: **not caught, and correctly so.** Everything below zero is clamped to cell 0 either way, so today the two are indistinguishable. The comment claiming truncation is a defect was overstating it and now says what is actually true: the clamp is the only thing hiding it, and a negative world origin or a relaxed clamp would make it real with no test standing in front of it. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:29:02 -04:00
}
CATCH(errctx, inner);
} CLEANUP {
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
return 0;
}