Files
libakgl/tests/partition.c
Andrew Kesterson 3a6569e384 Build the shape before spawning a proxy from it
test_reset_and_empty called spawn() with an uninitialized stack akgl_CollisionShape
and built it on the next line. akgl_collision_proxy_initialize copies the shape,
so grid_insert read uninitialized half-extents to work out which cells to file
the proxy under -- eighteen findings inside grid_cellrect, and the test still
passed because the proxy_sync two lines later overwrote all of it.

Found by scripts/memcheck.sh, which is the only thing that could have found it:
the assertion it makes is about the query afterwards, and the query answers
correctly either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 08:01:17 -04:00

704 lines
26 KiB
C

/**
* @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>
#include <akgl/tilemap.h>
#include "testutil.h"
/** @brief Every partitioner the factory knows, so the same tests run over each. */
static char *partitioners[] = { "grid", "bsp" };
/** @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.
*/
/*
* Only the grid claims to be incremental, and this is where that claim
* is checked rather than believed. The BSP is exempt by construction: it
* marks itself stale on any move and rebuilds, which is exactly the cost
* the grid was chosen to avoid and exactly why it is not the default.
*/
if ( strcmp(name, "grid") == 0 ) {
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);
// Build the shape before spawning from it, not after. Spawning first
// initializes the proxy from an uninitialized stack struct -- the sync
// below overwrites it, so the test still passed, and valgrind still
// reported eighteen uninitialised reads inside grid_cellrect.
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
CATCH(errctx, spawn(&world, &shape, 0.0f, 0.0f, &proxy));
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);
}
/**
* @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);
}
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; }
inner = test_tiles_are_geometry(partitioners[i]);
if ( inner != NULL ) { break; }
inner = test_settle_lifts_a_spawn(partitioners[i]);
if ( inner != NULL ) { break; }
}
CATCH(errctx, inner);
} CLEANUP {
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
return 0;
}