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

@@ -29,6 +29,7 @@
#ifndef _AKGL_HEAP_H_
#define _AKGL_HEAP_H_
#include <akgl/collision.h>
#include <akgl/sprite.h>
#include <akgl/actor.h>
#include <akgl/character.h>
@@ -50,6 +51,18 @@
#ifndef AKGL_MAX_HEAP_STRING
#define AKGL_MAX_HEAP_STRING 256
#endif
/**
* @brief The collision proxy pool.
*
* Two per actor. One is the actor's own; the spare headroom is for static
* geometry a game registers that is not tile-aligned -- a slope, a Tiled object
* rectangle, a moving platform. Solid *tiles* need none of these: the broad
* phase reads them out of the tilemap's own array, because one proxy per tile
* would be tens of megabytes on a large map to index data that is already a grid.
*/
#ifndef AKGL_MAX_HEAP_COLLISION_PROXY
#define AKGL_MAX_HEAP_COLLISION_PROXY (AKGL_MAX_HEAP_ACTOR * 2)
#endif
/** @brief The actor pool. Public so the render and physics sweeps can walk it directly instead of going through the registry. */
extern akgl_Actor akgl_heap_actors[AKGL_MAX_HEAP_ACTOR];
@@ -61,6 +74,8 @@ extern akgl_SpriteSheet akgl_heap_spritesheets[AKGL_MAX_HEAP_SPRITESHEET];
extern akgl_Character akgl_heap_characters[AKGL_MAX_HEAP_CHARACTER];
/** @brief The string pool. Every entry is PATH_MAX bytes, so this is the largest of the five by a wide margin. */
extern akgl_String akgl_heap_strings[AKGL_MAX_HEAP_STRING];
/** @brief The collision proxy pool. Only the library allocates from it. */
extern akgl_CollisionProxy akgl_heap_collision_proxies[AKGL_MAX_HEAP_COLLISION_PROXY];
/**
* @brief Zero every pool, marking every slot free.
@@ -218,4 +233,31 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_release_character(akgl_Character *p
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_release_string(akgl_String *ptr);
/**
* @brief Find a free collision proxy slot.
*
* Does **not** take the reference, matching four of the five pools above.
* akgl_collision_proxy_initialize takes it; write the two adjacent, because
* until it runs the slot still reads as free and the next acquire hands out the
* same pointer.
*
* @param dest Receives the proxy. Required.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKGL_ERR_HEAP If every slot is in use. That normally means proxies are
* not being released rather than that the pool is too small.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_next_collision_proxy(akgl_CollisionProxy **dest);
/**
* @brief Give a collision proxy back.
*
* Decrements, and at zero clears the slot. A proxy holds no external resource
* and appears in no registry, so there is nothing else to unwind.
*
* @param ptr The proxy. Required.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p ptr is `NULL`.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_release_collision_proxy(akgl_CollisionProxy *ptr);
#endif //_AKGL_HEAP_H_