Pool collision proxies, and tie one to the life of its actor

A proxy is what the broad phase will index: an actor's shape, where it is, and
the bounds that follow from those. It is a sixth heap layer, two per actor --
one for the actor itself and headroom for static geometry a game registers that
is not tile-aligned. Solid *tiles* deliberately get none: the broad phase will
read those out of the tilemap's own array, because one proxy per tile is tens of
megabytes to index data that is already a grid.

The pool follows the existing convention rather than improving on it.
akgl_heap_next_collision_proxy finds a free slot and does not claim it;
akgl_collision_proxy_initialize claims it. That is TODO.md item 8's asymmetry,
and it is kept on purpose: an acquire abandoned before initialization leaks
nothing, because the slot still reads as free, and a sixth pool with its own
rule would be worse than one defect with five instances. Fixing item 8 means
fixing all five together with every call site audited, and that is its own
change.

The proxy holds a *copy* of the shape rather than a pointer. An actor's own
logic may rewrite its shape mid-step -- a character crouching, a projectile
arming -- and a broad phase whose bounds came from a shape that has since
changed is a bug that only appears when two things happen in the same frame.

akgl_heap_release_actor now releases the actor's proxy. Without it the proxy
holds a borrowed `owner` into a slot that has just been zeroed, so the broad
phase keeps a registration whose owner reads as a free actor, and the next
contact against it is a collision with nothing. Verified by removing the release
and watching test_proxy_dies_with_its_actor go red.

The tests pin the convention as well as the behaviour: that two acquires with no
initialize between them return the same slot is asserted, not merely tolerated,
so that changing the convention has to change a test that says why it exists.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 23:56:21 -04:00
parent 9056ff06d6
commit d0b057f47a
6 changed files with 331 additions and 0 deletions

View File

@@ -181,3 +181,39 @@ akerr_ErrorContext *akgl_collision_shape_interacts(akgl_CollisionShape *self, ak
*dest = ((other->layermask & self->collidemask) != 0);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_collision_proxy_initialize(akgl_CollisionProxy *obj, struct akgl_Actor *owner, akgl_CollisionShape *shape, float32_t x, float32_t y, float32_t z)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL proxy reference");
FAIL_ZERO_RETURN(errctx, shape, AKERR_NULLPOINTER, "NULL shape reference");
memset(obj, 0x00, sizeof(akgl_CollisionProxy));
obj->owner = owner;
/*
* The reference is taken here and not in akgl_heap_next_collision_proxy,
* matching four of the five pools that came before. Until this runs the slot
* still reads as free -- which is also the property that keeps an acquire
* abandoned before this point from leaking the slot.
*/
obj->refcount = 1;
PASS(errctx, akgl_collision_proxy_sync(obj, shape, x, y, z));
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_collision_proxy_sync(akgl_CollisionProxy *obj, akgl_CollisionShape *shape, float32_t x, float32_t y, float32_t z)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL proxy reference");
FAIL_ZERO_RETURN(errctx, shape, AKERR_NULLPOINTER, "NULL shape reference");
// A copy, not a pointer. See the note on akgl_CollisionProxy.
obj->shape = *shape;
obj->x = x;
obj->y = y;
obj->z = z;
PASS(errctx, akgl_collision_shape_bounds(&obj->shape, x, y, &obj->bounds));
SUCCEED_RETURN(errctx);
}