Files
libakgl/tests/physics.c
Andrew Kesterson 5e213061af Call collide from the simulation, after the move rather than before it
The collide slot has existed unused since the backend was written. It is wired
in now, and its signature changes from (self, a1, a2) to (self, actor, dt): the
old shape had nowhere to put a normal, a depth, or a piece of map geometry, and
no answer to which of the two actors it was supposed to be resolving.

**Resolution runs after `move`.** That is the decision everything else follows
from. A resolver called from movementlogicfunc runs before gravity, drag and the
move, so it has to predict where the actor will end up -- which means
re-implementing the integrator's arithmetic in the game, and being wrong
whenever the integrator changes. examples/sidescroller/collision.c carries forty
lines of exactly that, and says so.

Only `move` is subdivided. Gravity, drag, the thrust integration and the speed
ellipse all still run once against the whole dt, and sum(subdt) is dt, so an
actor with nothing to collide with takes one sub-step of exactly dt -- `dt/1.0f`
is dt exactly in IEEE-754 -- and follows the arithmetic path it always did. That
is what lets the integrator stay untouched and every recorded physics number
stay valid, and tests/physics.c and tests/physics_sim.c pass unchanged with no
world attached.

Collision is opt-in. A backend with no collision world costs one comparison per
actor per step and does nothing else.

Also here:

- `self->gravity` was never null-checked and is dereferenced unconditionally, so
  a hand-built backend with only `move` filled in crashed rather than reporting.
- Children are re-snapped in a post-pass, gated on collision being on. The
  in-loop snap uses whatever position the parent had when the child's slot came
  up in pool order, which is already sometimes a frame stale; that was harmless
  while a parent only moved by v*dt and is not once a parent can be pushed out
  of a wall mid-step.
- tests/physics.c's deliberate tripwire has been visited. It asserted that
  arcade collide always raised AKERR_API, with a comment saying it existed so
  that implementing collision would have to come back here. What replaces it
  asserts the opt-in contract instead.
- physics_sim.c's hard-coded "%d of 7 simulations" is derived now. A literal
  goes stale the first time somebody adds a simulation, which is this commit.

Three new whole-motion simulations, and the third took two attempts to make
honest:

- Landing and resting: 0.0000 px of drift over 120 steps after settling.
- Walking after landing: 107 px in a second, which is the symptom a player sees
  when a resting actor is caught on the ground it is standing on.
- A fast fall not tunnelling. The first version used gravity, so the step size
  grew every frame and the actor happened to land *inside* the floor rather than
  stepping over it -- it passed with sub-stepping disabled and proved nothing.
  It uses a constant 1200 px/s now, so every step is exactly 60 pixels against a
  32-pixel window in which an overlap exists, and the samples miss it cleanly.
  With sub-stepping the actor stops at y=288; without it, y=1300.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:05:09 -04:00

829 lines
33 KiB
C

/**
* @file physics.c
* @brief Unit tests for the physics backends and the simulation loop.
*
* None of these tests need a renderer or a window. The simulation loop walks
* akgl_heap_actors directly, so each test rebuilds the actor heap rather than relying
* on state left behind by an earlier one.
*/
#include <SDL3/SDL.h>
#include <string.h>
#include <akerror.h>
#include <akgl/error.h>
#include <akgl/physics.h>
#include <akgl/actor.h>
#include <akgl/character.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/registry.h>
#include <akgl/staticstring.h>
#include "testutil.h"
/** @brief Records whether the stub movement logic ran, and with what dt. */
static int stub_movement_calls = 0;
static float32_t stub_movement_last_dt = 0;
/** @brief Movement logic stub that records its invocation and does nothing else. */
static akerr_ErrorContext *stub_movement_noop(akgl_Actor *actor, float32_t dt)
{
PREPARE_ERROR(e);
stub_movement_calls += 1;
stub_movement_last_dt = dt;
SUCCEED_RETURN(e);
}
/** @brief Movement logic stub that raises the interrupt the simulator must swallow. */
static akerr_ErrorContext *stub_movement_interrupt(akgl_Actor *actor, float32_t dt)
{
PREPARE_ERROR(e);
stub_movement_calls += 1;
FAIL_RETURN(e, AKGL_ERR_LOGICINTERRUPT, "deliberate interrupt from test stub");
}
/** @brief Movement logic stub that raises an error the simulator must propagate. */
static akerr_ErrorContext *stub_movement_error(akgl_Actor *actor, float32_t dt)
{
PREPARE_ERROR(e);
stub_movement_calls += 1;
FAIL_RETURN(e, AKERR_VALUE, "deliberate error from test stub");
}
/**
* @brief Build a character and an actor bound to it, ready for simulation.
*
* The actor is placed on the heap with a nonzero refcount and a movement logic
* stub, which is the minimum the simulation loop requires to process it.
*/
static akerr_ErrorContext *make_sim_actor(akgl_Actor **actor, akgl_Character **basechar, char *name)
{
PREPARE_ERROR(e);
ATTEMPT {
CATCH(e, akgl_heap_next_character(basechar));
CATCH(e, akgl_character_initialize(*basechar, name));
CATCH(e, akgl_heap_next_actor(actor));
CATCH(e, akgl_actor_initialize(*actor, name));
(*actor)->basechar = *basechar;
(*actor)->movementlogicfunc = &stub_movement_noop;
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/** @brief Reset the actor heap so each simulation test starts from a clean slate. */
static akerr_ErrorContext *reset_sim_heap(void)
{
PREPARE_ERROR(e);
ATTEMPT {
CATCH(e, akgl_registry_init_actor());
CATCH(e, akgl_registry_init_character());
CATCH(e, akgl_heap_init());
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
stub_movement_calls = 0;
stub_movement_last_dt = 0;
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_physics_init_null(void)
{
PREPARE_ERROR(e);
akgl_PhysicsBackend backend;
ATTEMPT {
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
TEST_EXPECT_OK(e, akgl_physics_init_null(&backend), "akgl_physics_init_null");
TEST_ASSERT(e, backend.gravity == &akgl_physics_null_gravity,
"init_null did not install the null gravity function");
TEST_ASSERT(e, backend.collide == &akgl_physics_null_collide,
"init_null did not install the null collide function");
TEST_ASSERT(e, backend.move == &akgl_physics_null_move,
"init_null did not install the null move function");
TEST_ASSERT(e, backend.simulate == &akgl_physics_simulate,
"init_null did not install the shared simulate function");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_init_null(NULL),
"akgl_physics_init_null(NULL)");
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_physics_null_backend_is_inert(void)
{
PREPARE_ERROR(e);
akgl_PhysicsBackend backend;
akgl_Actor actor;
akgl_Actor reference;
ATTEMPT {
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
memset(&actor, 0x00, sizeof(akgl_Actor));
TEST_EXPECT_OK(e, akgl_physics_init_null(&backend), "akgl_physics_init_null");
actor.x = 3.0f; actor.y = 5.0f; actor.z = 7.0f;
actor.vx = 11.0f; actor.vy = 13.0f; actor.vz = 17.0f;
actor.ex = 19.0f; actor.ey = 23.0f; actor.ez = 29.0f;
memcpy(&reference, &actor, sizeof(akgl_Actor));
TEST_EXPECT_OK(e, backend.gravity(&backend, &actor, 1.0f), "null gravity");
TEST_EXPECT_OK(e, backend.move(&backend, &actor, 1.0f), "null move");
TEST_EXPECT_OK(e, backend.collide(&backend, &actor, 1.0f), "null collide");
TEST_ASSERT(e, memcmp(&actor, &reference, sizeof(akgl_Actor)) == 0,
"the null backend modified the actor");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_gravity(NULL, &actor, 1.0f),
"null gravity with NULL self");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_move(NULL, &actor, 1.0f),
"null move with NULL self");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_collide(NULL, &actor, 1.0f),
"null collide with NULL self");
// The actor arguments too. The arcade backend has always checked these;
// the null one checked only `self`, so which pointers a caller could get
// away with passing depended on which backend it happened to have.
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_gravity(&backend, NULL, 1.0f),
"null gravity with a NULL actor");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_move(&backend, NULL, 1.0f),
"null move with a NULL actor");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_collide(&backend, NULL, 1.0f),
"null collide with a NULL actor");
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_physics_arcade_gravity(void)
{
PREPARE_ERROR(e);
akgl_PhysicsBackend backend;
akgl_Actor actor;
ATTEMPT {
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
// X gravity subtracts, because the X origin is screen-left.
memset(&actor, 0x00, sizeof(akgl_Actor));
backend.gravity_x = 10.0;
TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 0.5f), "arcade gravity x");
TEST_ASSERT_FEQ(e, actor.ex, -5.0f, "gravity_x 10 over dt 0.5 gave ex %f, expected -5", actor.ex);
TEST_ASSERT_FEQ(e, actor.ey, 0.0f, "gravity_x leaked into ey (%f)", actor.ey);
TEST_ASSERT_FEQ(e, actor.ez, 0.0f, "gravity_x leaked into ez (%f)", actor.ez);
// Y gravity adds, because the Y origin is down-screen.
memset(&actor, 0x00, sizeof(akgl_Actor));
backend.gravity_x = 0;
backend.gravity_y = 10.0;
TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 0.5f), "arcade gravity y");
TEST_ASSERT_FEQ(e, actor.ey, 5.0f, "gravity_y 10 over dt 0.5 gave ey %f, expected 5", actor.ey);
TEST_ASSERT_FEQ(e, actor.ex, 0.0f, "gravity_y leaked into ex (%f)", actor.ex);
// Z gravity subtracts, because the Z origin is behind the camera.
memset(&actor, 0x00, sizeof(akgl_Actor));
backend.gravity_y = 0;
backend.gravity_z = 4.0;
TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 0.25f), "arcade gravity z");
TEST_ASSERT_FEQ(e, actor.ez, -1.0f, "gravity_z 4 over dt 0.25 gave ez %f, expected -1", actor.ez);
// Negative gravity reverses each axis.
memset(&actor, 0x00, sizeof(akgl_Actor));
backend.gravity_x = -10.0;
backend.gravity_y = -10.0;
backend.gravity_z = -10.0;
TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 1.0f), "arcade gravity negative");
TEST_ASSERT_FEQ(e, actor.ex, 10.0f, "negative gravity_x gave ex %f, expected 10", actor.ex);
TEST_ASSERT_FEQ(e, actor.ey, -10.0f, "negative gravity_y gave ey %f, expected -10", actor.ey);
TEST_ASSERT_FEQ(e, actor.ez, 10.0f, "negative gravity_z gave ez %f, expected 10", actor.ez);
// A zero dt applies no force even when gravity is set.
memset(&actor, 0x00, sizeof(akgl_Actor));
backend.gravity_x = 10.0;
backend.gravity_y = 10.0;
backend.gravity_z = 10.0;
TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 0.0f), "arcade gravity dt 0");
TEST_ASSERT_FEQ(e, actor.ex, 0.0f, "dt 0 still moved ex (%f)", actor.ex);
TEST_ASSERT_FEQ(e, actor.ey, 0.0f, "dt 0 still moved ey (%f)", actor.ey);
TEST_ASSERT_FEQ(e, actor.ez, 0.0f, "dt 0 still moved ez (%f)", actor.ez);
// All-zero gravity leaves every axis alone.
memset(&actor, 0x00, sizeof(akgl_Actor));
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
actor.ex = 1.0f; actor.ey = 2.0f; actor.ez = 3.0f;
TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 1.0f), "arcade gravity all zero");
TEST_ASSERT_FEQ(e, actor.ex, 1.0f, "zero gravity_x changed ex (%f)", actor.ex);
TEST_ASSERT_FEQ(e, actor.ey, 2.0f, "zero gravity_y changed ey (%f)", actor.ey);
TEST_ASSERT_FEQ(e, actor.ez, 3.0f, "zero gravity_z changed ez (%f)", actor.ez);
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_gravity(NULL, &actor, 1.0f),
"arcade gravity with NULL self");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_gravity(&backend, NULL, 1.0f),
"arcade gravity with NULL actor");
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_physics_arcade_move(void)
{
PREPARE_ERROR(e);
akgl_PhysicsBackend backend;
akgl_Actor actor;
ATTEMPT {
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
memset(&actor, 0x00, sizeof(akgl_Actor));
actor.x = 100.0f; actor.y = 200.0f; actor.z = 300.0f;
actor.vx = 10.0f; actor.vy = -20.0f; actor.vz = 30.0f;
TEST_EXPECT_OK(e, akgl_physics_arcade_move(&backend, &actor, 0.5f), "arcade move");
TEST_ASSERT_FEQ(e, actor.x, 105.0f, "x moved to %f, expected 105", actor.x);
TEST_ASSERT_FEQ(e, actor.y, 190.0f, "y moved to %f, expected 190", actor.y);
TEST_ASSERT_FEQ(e, actor.z, 315.0f, "z moved to %f, expected 315", actor.z);
// dt of zero is a no-op regardless of velocity.
TEST_EXPECT_OK(e, akgl_physics_arcade_move(&backend, &actor, 0.0f), "arcade move dt 0");
TEST_ASSERT_FEQ(e, actor.x, 105.0f, "dt 0 moved x to %f", actor.x);
TEST_ASSERT_FEQ(e, actor.y, 190.0f, "dt 0 moved y to %f", actor.y);
TEST_ASSERT_FEQ(e, actor.z, 315.0f, "dt 0 moved z to %f", actor.z);
// Zero velocity is a no-op regardless of dt.
actor.vx = 0; actor.vy = 0; actor.vz = 0;
TEST_EXPECT_OK(e, akgl_physics_arcade_move(&backend, &actor, 10.0f), "arcade move zero velocity");
TEST_ASSERT_FEQ(e, actor.x, 105.0f, "zero velocity moved x to %f", actor.x);
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_move(NULL, &actor, 1.0f),
"arcade move with NULL self");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_move(&backend, NULL, 1.0f),
"arcade move with NULL actor");
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/**
* @brief The arcade backend resolves, and does nothing when there is no world.
*
* This function used to assert that akgl_physics_arcade_collide always raised
* AKERR_API, with a comment saying it existed so that implementing collision
* would have to come back here. It has, and this is that visit.
*
* What replaces it is the opt-in contract: a backend with no collision world
* attached returns success having changed nothing, which is what keeps every
* program written before collision existed running exactly as it did.
*/
akerr_ErrorContext *test_physics_arcade_collide(void)
{
PREPARE_ERROR(e);
akgl_PhysicsBackend backend;
akgl_Actor actor;
akgl_Actor reference;
ATTEMPT {
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
memset(&actor, 0x00, sizeof(akgl_Actor));
actor.x = 3.0f;
actor.y = 5.0f;
memcpy(&reference, &actor, sizeof(akgl_Actor));
// No world: success, and the actor is untouched.
TEST_EXPECT_OK(e, akgl_physics_arcade_collide(&backend, &actor, 1.0f),
"arcade collide with no collision world");
TEST_ASSERT(e, memcmp(&actor, &reference, sizeof(akgl_Actor)) == 0,
"arcade collide moved an actor with no collision world attached");
// Both pointers checked. The old stub returned AKERR_API before looking
// at either, so which arguments were validated depended on the backend.
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_collide(NULL, &actor, 1.0f),
"arcade collide with NULL self");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_collide(&backend, NULL, 1.0f),
"arcade collide with a NULL actor");
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_physics_factory(void)
{
PREPARE_ERROR(e);
akgl_PhysicsBackend backend;
akgl_String typestr;
ATTEMPT {
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
CATCH(e, akgl_string_initialize(&typestr, "null"));
TEST_EXPECT_OK(e, akgl_physics_factory(&backend, &typestr), "factory(\"null\")");
TEST_ASSERT(e, backend.move == &akgl_physics_null_move,
"factory(\"null\") did not install the null backend");
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
CATCH(e, akgl_string_initialize(&typestr, "arcade"));
TEST_EXPECT_OK(e, akgl_physics_factory(&backend, &typestr), "factory(\"arcade\")");
TEST_ASSERT(e, backend.move == &akgl_physics_arcade_move,
"factory(\"arcade\") did not install the arcade backend");
CATCH(e, akgl_string_initialize(&typestr, "newtonian"));
TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_physics_factory(&backend, &typestr),
"factory with an unknown backend name");
// An empty name must not match either prefix.
CATCH(e, akgl_string_initialize(&typestr, ""));
TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_physics_factory(&backend, &typestr),
"factory with an empty backend name");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_factory(NULL, &typestr),
"factory with NULL self");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_factory(&backend, NULL),
"factory with NULL type");
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_physics_init_arcade_properties(void)
{
PREPARE_ERROR(e);
akgl_PhysicsBackend backend;
ATTEMPT {
CATCH(e, akgl_registry_init_properties());
// With nothing configured, every environmental constant defaults to zero.
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
TEST_EXPECT_OK(e, akgl_physics_init_arcade(&backend), "init_arcade with no properties set");
TEST_ASSERT_FEQ(e, backend.gravity_y, 0.0f,
"unset physics.gravity.y defaulted to %f, expected 0", backend.gravity_y);
TEST_ASSERT_FEQ(e, backend.drag_x, 0.0f,
"unset physics.drag.x defaulted to %f, expected 0", backend.drag_x);
TEST_ASSERT(e, backend.gravity == &akgl_physics_arcade_gravity,
"init_arcade did not install the arcade gravity function");
TEST_ASSERT(e, backend.simulate == &akgl_physics_simulate,
"init_arcade did not install the shared simulate function");
// Configured values are read out of the property registry.
CATCH(e, akgl_set_property("physics.gravity.x", "1.5"));
CATCH(e, akgl_set_property("physics.gravity.y", "9.8"));
CATCH(e, akgl_set_property("physics.gravity.z", "2.25"));
CATCH(e, akgl_set_property("physics.drag.x", "0.5"));
CATCH(e, akgl_set_property("physics.drag.y", "0.25"));
CATCH(e, akgl_set_property("physics.drag.z", "0.125"));
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
TEST_EXPECT_OK(e, akgl_physics_init_arcade(&backend), "init_arcade with properties set");
TEST_ASSERT_FEQ(e, backend.gravity_x, 1.5f, "physics.gravity.x read as %f", backend.gravity_x);
TEST_ASSERT_FEQ(e, backend.gravity_y, 9.8f, "physics.gravity.y read as %f", backend.gravity_y);
TEST_ASSERT_FEQ(e, backend.gravity_z, 2.25f, "physics.gravity.z read as %f", backend.gravity_z);
TEST_ASSERT_FEQ(e, backend.drag_x, 0.5f, "physics.drag.x read as %f", backend.drag_x);
TEST_ASSERT_FEQ(e, backend.drag_y, 0.25f, "physics.drag.y read as %f", backend.drag_y);
TEST_ASSERT_FEQ(e, backend.drag_z, 0.125f, "physics.drag.z read as %f", backend.drag_z);
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_init_arcade(NULL),
"init_arcade with NULL self");
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_physics_simulate_skips_inactive(void)
{
PREPARE_ERROR(e);
akgl_PhysicsBackend backend;
akgl_Actor *actor = NULL;
akgl_Character *basechar = NULL;
ATTEMPT {
CATCH(e, reset_sim_heap());
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
CATCH(e, akgl_physics_init_null(&backend));
// A released actor (refcount 0) is skipped entirely.
CATCH(e, make_sim_actor(&actor, &basechar, "inactive"));
actor->refcount = 0;
stub_movement_calls = 0;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with a released actor");
TEST_ASSERT(e, stub_movement_calls == 0,
"simulate ran movement logic for an actor with refcount 0 (%d calls)",
stub_movement_calls);
// An actor with no base character is skipped.
CATCH(e, reset_sim_heap());
CATCH(e, make_sim_actor(&actor, &basechar, "nochar"));
actor->basechar = NULL;
stub_movement_calls = 0;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with a character-less actor");
TEST_ASSERT(e, stub_movement_calls == 0,
"simulate ran movement logic for an actor with no base character (%d calls)",
stub_movement_calls);
// A fully wired actor is processed.
CATCH(e, reset_sim_heap());
CATCH(e, make_sim_actor(&actor, &basechar, "active"));
stub_movement_calls = 0;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with an active actor");
TEST_ASSERT(e, stub_movement_calls == 1,
"simulate ran movement logic %d times for one active actor, expected 1",
stub_movement_calls);
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_physics_simulate_children_follow_parents(void)
{
PREPARE_ERROR(e);
akgl_PhysicsBackend backend;
akgl_Actor *parent = NULL;
akgl_Actor *child = NULL;
akgl_Character *basechar = NULL;
akgl_Character *childchar = NULL;
ATTEMPT {
CATCH(e, reset_sim_heap());
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
CATCH(e, akgl_physics_init_null(&backend));
CATCH(e, make_sim_actor(&parent, &basechar, "parent"));
CATCH(e, make_sim_actor(&child, &childchar, "child"));
parent->x = 100.0f; parent->y = 200.0f; parent->z = 300.0f;
// For a child, vx/vy/vz are read as a fixed offset from the parent.
child->vx = 5.0f; child->vy = -5.0f; child->vz = 2.0f;
child->x = -999.0f; child->y = -999.0f; child->z = -999.0f;
CATCH(e, akgl_actor_add_child(parent, child));
stub_movement_calls = 0;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with a parented child");
TEST_ASSERT_FEQ(e, child->x, 105.0f, "child x resolved to %f, expected parent 100 + offset 5", child->x);
TEST_ASSERT_FEQ(e, child->y, 195.0f, "child y resolved to %f, expected parent 200 - offset 5", child->y);
TEST_ASSERT_FEQ(e, child->z, 302.0f, "child z resolved to %f, expected parent 300 + offset 2", child->z);
// Only the parent goes through the movement logic; the child is positional only.
TEST_ASSERT(e, stub_movement_calls == 1,
"simulate ran movement logic %d times, expected 1 (parent only)",
stub_movement_calls);
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_physics_simulate_layer_mask(void)
{
PREPARE_ERROR(e);
akgl_PhysicsBackend backend;
akgl_Actor *layer0 = NULL;
akgl_Actor *layer3 = NULL;
akgl_Character *char0 = NULL;
akgl_Character *char3 = NULL;
akgl_Iterator opflags;
ATTEMPT {
CATCH(e, reset_sim_heap());
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
CATCH(e, akgl_physics_init_null(&backend));
CATCH(e, make_sim_actor(&layer0, &char0, "onlayer0"));
CATCH(e, make_sim_actor(&layer3, &char3, "onlayer3"));
layer0->layer = 0;
layer3->layer = 3;
// Masking to layer 3 processes only the layer 3 actor.
opflags.flags = AKGL_ITERATOR_OP_LAYERMASK;
opflags.layerid = 3;
stub_movement_calls = 0;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, &opflags), "simulate masked to layer 3");
TEST_ASSERT(e, stub_movement_calls == 1,
"layer mask 3 processed %d actors, expected 1", stub_movement_calls);
// Masking to a layer with no actors processes nothing.
opflags.layerid = 7;
stub_movement_calls = 0;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, &opflags), "simulate masked to an empty layer");
TEST_ASSERT(e, stub_movement_calls == 0,
"empty layer mask processed %d actors, expected 0", stub_movement_calls);
// Without the mask flag, layerid is ignored and both actors are processed.
opflags.flags = 0;
opflags.layerid = 7;
stub_movement_calls = 0;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, &opflags), "simulate with the layer mask off");
TEST_ASSERT(e, stub_movement_calls == 2,
"unmasked simulate processed %d actors, expected 2", stub_movement_calls);
// A NULL iterator selects the documented defaults, which mask nothing.
stub_movement_calls = 0;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with NULL opflags");
TEST_ASSERT(e, stub_movement_calls == 2,
"NULL opflags processed %d actors, expected 2", stub_movement_calls);
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_physics_simulate_thrust_and_clamp(void)
{
PREPARE_ERROR(e);
akgl_PhysicsBackend backend;
akgl_Actor *actor = NULL;
akgl_Character *basechar = NULL;
ATTEMPT {
CATCH(e, reset_sim_heap());
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
CATCH(e, akgl_physics_init_null(&backend));
CATCH(e, make_sim_actor(&actor, &basechar, "thruster"));
// Over-thrust on one axis alone clamps to that axis's max speed,
// preserving sign. This is the case the old per-axis clamp got right,
// and it has to keep working.
actor->sx = 50.0f; actor->sy = 40.0f; actor->sz = 30.0f;
actor->tx = 500.0f; actor->ty = 0.0f; actor->tz = 0.0f;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with single-axis over-thrust");
TEST_ASSERT_FEQ(e, actor->tx, 50.0f, "tx clamped to %f, expected 50", actor->tx);
actor->tx = -500.0f; actor->ty = 0.0f; actor->tz = 0.0f;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with negative single-axis over-thrust");
TEST_ASSERT_FEQ(e, actor->tx, -50.0f, "tx clamped to %f, expected -50", actor->tx);
actor->tx = 0.0f; actor->ty = -400.0f; actor->tz = 0.0f;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with negative y over-thrust");
TEST_ASSERT_FEQ(e, actor->ty, -40.0f, "ty clamped to %f, expected -40", actor->ty);
actor->tx = 0.0f; actor->ty = 0.0f; actor->tz = 300.0f;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with z over-thrust");
TEST_ASSERT_FEQ(e, actor->tz, 30.0f, "tz clamped to %f, expected 30", actor->tz);
// Over-thrust on several axes at once scales the *vector* back to the
// ellipse the three max speeds describe, rather than clamping each axis
// against its own. Clamping the axes independently let the corner of the
// box through: an actor holding two directions travelled their diagonal,
// 41% faster than either alone, which is the classic diagonal-run bug.
// tests/physics_sim.c measures it (`top-down diagonal speed`).
//
// The two things that makes true are asserted here rather than the three
// numbers that fall out of them: the direction asked for is preserved,
// so every axis is scaled by the same factor, and the result lands
// exactly on the ellipse.
actor->tx = 500.0f; actor->ty = 400.0f; actor->tz = 300.0f;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with over-thrust on every axis");
TEST_ASSERT_FEQ(e, actor->tx / actor->sx, actor->ty / actor->sy,
"x and y were scaled differently (%f vs %f), so the direction moved",
actor->tx / actor->sx, actor->ty / actor->sy);
TEST_ASSERT_FEQ(e, actor->ty / actor->sy, actor->tz / actor->sz,
"y and z were scaled differently (%f vs %f), so the direction moved",
actor->ty / actor->sy, actor->tz / actor->sz);
TEST_ASSERT_FEQ(e,
((actor->tx / actor->sx) * (actor->tx / actor->sx))
+ ((actor->ty / actor->sy) * (actor->ty / actor->sy))
+ ((actor->tz / actor->sz) * (actor->tz / actor->sz)),
1.0f,
"capped thrust (%f, %f, %f) is not on the max-speed ellipse",
actor->tx, actor->ty, actor->tz);
// Signs survive the scaling.
actor->tx = -500.0f; actor->ty = -400.0f; actor->tz = -300.0f;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with negative over-thrust on every axis");
TEST_ASSERT(e, (actor->tx < 0.0f) && (actor->ty < 0.0f) && (actor->tz < 0.0f),
"capping negative thrust changed a sign: (%f, %f, %f)",
actor->tx, actor->ty, actor->tz);
// An axis whose max speed is zero cannot be thrust along at all, and it
// stays out of the magnitude -- dividing by it would not end well.
actor->sz = 0.0f;
actor->tx = 10.0f; actor->ty = 0.0f; actor->tz = 300.0f;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with a zero max speed");
TEST_ASSERT_FEQ(e, actor->tz, 0.0f, "thrust survived a zero max speed as %f", actor->tz);
TEST_ASSERT_FEQ(e, actor->tx, 10.0f,
"a zero max speed on z disturbed in-range x thrust (%f)", actor->tx);
actor->sz = 30.0f;
// Thrust inside the limit is left alone.
actor->tx = 10.0f; actor->ty = -10.0f; actor->tz = 5.0f;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with in-range thrust");
TEST_ASSERT_FEQ(e, actor->tx, 10.0f, "in-range tx changed to %f", actor->tx);
TEST_ASSERT_FEQ(e, actor->ty, -10.0f, "in-range ty changed to %f", actor->ty);
TEST_ASSERT_FEQ(e, actor->tz, 5.0f, "in-range tz changed to %f", actor->tz);
// Velocity is the sum of environmental force and thrust.
actor->ex = 3.0f; actor->ey = 4.0f; actor->ez = 5.0f;
actor->tx = 1.0f; actor->ty = 2.0f; actor->tz = 3.0f;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate velocity composition");
TEST_ASSERT_FEQ(e, actor->vx, 4.0f, "vx composed to %f, expected ex 3 + tx 1", actor->vx);
TEST_ASSERT_FEQ(e, actor->vy, 6.0f, "vy composed to %f, expected ey 4 + ty 2", actor->vy);
TEST_ASSERT_FEQ(e, actor->vz, 8.0f, "vz composed to %f, expected ez 5 + tz 3", actor->vz);
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_physics_simulate_drag(void)
{
PREPARE_ERROR(e);
akgl_PhysicsBackend backend;
akgl_Actor *actor = NULL;
akgl_Character *basechar = NULL;
ATTEMPT {
CATCH(e, reset_sim_heap());
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
CATCH(e, akgl_physics_init_null(&backend));
CATCH(e, make_sim_actor(&actor, &basechar, "dragged"));
// Zero drag leaves environmental velocity untouched.
actor->ex = 100.0f; actor->ey = 100.0f; actor->ez = 100.0f;
actor->sx = 1000.0f; actor->sy = 1000.0f; actor->sz = 1000.0f;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with zero drag");
TEST_ASSERT_FEQ(e, actor->ex, 100.0f, "zero drag changed ex to %f", actor->ex);
TEST_ASSERT_FEQ(e, actor->ey, 100.0f, "zero drag changed ey to %f", actor->ey);
TEST_ASSERT_FEQ(e, actor->ez, 100.0f, "zero drag changed ez to %f", actor->ez);
// Nonzero drag bleeds off environmental velocity in proportion to dt.
// The simulator derives dt from the wall clock, so assert the direction
// and bounds of the change rather than an exact figure.
backend.drag_x = 0.5;
backend.drag_y = 0.5;
backend.drag_z = 0.5;
backend.gravity_time = SDL_GetTicksNS();
SDL_Delay(20);
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with drag applied");
TEST_ASSERT(e, actor->ex < 100.0f && actor->ex > 0.0f,
"drag left ex at %f, expected a value between 0 and 100", actor->ex);
TEST_ASSERT(e, actor->ey < 100.0f && actor->ey > 0.0f,
"drag left ey at %f, expected a value between 0 and 100", actor->ey);
TEST_ASSERT(e, actor->ez < 100.0f && actor->ez > 0.0f,
"drag left ez at %f, expected a value between 0 and 100", actor->ez);
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_physics_simulate_movement_states(void)
{
PREPARE_ERROR(e);
akgl_PhysicsBackend backend;
akgl_Actor *actor = NULL;
akgl_Character *basechar = NULL;
ATTEMPT {
CATCH(e, reset_sim_heap());
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
CATCH(e, akgl_physics_init_null(&backend));
CATCH(e, make_sim_actor(&actor, &basechar, "mover"));
actor->sx = 1000.0f; actor->sy = 1000.0f; actor->sz = 1000.0f;
actor->ax = 10.0f; actor->ay = 20.0f;
// An idle actor accumulates no thrust on either axis.
actor->state = 0;
actor->tx = 0; actor->ty = 0;
backend.gravity_time = SDL_GetTicksNS();
SDL_Delay(10);
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate an idle actor");
TEST_ASSERT_FEQ(e, actor->tx, 0.0f, "idle actor accumulated tx %f", actor->tx);
TEST_ASSERT_FEQ(e, actor->ty, 0.0f, "idle actor accumulated ty %f", actor->ty);
// Moving horizontally accumulates thrust on X only.
actor->state = AKGL_ACTOR_STATE_MOVING_LEFT;
actor->tx = 0; actor->ty = 0;
backend.gravity_time = SDL_GetTicksNS();
SDL_Delay(10);
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate a horizontally moving actor");
TEST_ASSERT(e, actor->tx > 0.0f, "MOVING_LEFT accumulated tx %f, expected a positive value", actor->tx);
TEST_ASSERT_FEQ(e, actor->ty, 0.0f, "MOVING_LEFT leaked thrust into ty (%f)", actor->ty);
// Moving vertically accumulates thrust on Y only.
actor->state = AKGL_ACTOR_STATE_MOVING_DOWN;
actor->tx = 0; actor->ty = 0;
backend.gravity_time = SDL_GetTicksNS();
SDL_Delay(10);
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate a vertically moving actor");
TEST_ASSERT(e, actor->ty > 0.0f, "MOVING_DOWN accumulated ty %f, expected a positive value", actor->ty);
TEST_ASSERT_FEQ(e, actor->tx, 0.0f, "MOVING_DOWN leaked thrust into tx (%f)", actor->tx);
// The right and up states drive the same accumulators.
actor->state = (AKGL_ACTOR_STATE_MOVING_RIGHT | AKGL_ACTOR_STATE_MOVING_UP);
actor->tx = 0; actor->ty = 0;
backend.gravity_time = SDL_GetTicksNS();
SDL_Delay(10);
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate a diagonally moving actor");
TEST_ASSERT(e, actor->tx > 0.0f, "MOVING_RIGHT accumulated tx %f, expected a positive value", actor->tx);
TEST_ASSERT(e, actor->ty > 0.0f, "MOVING_UP accumulated ty %f, expected a positive value", actor->ty);
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_physics_simulate_logic_interrupt(void)
{
PREPARE_ERROR(e);
akgl_PhysicsBackend backend;
akgl_Actor *first = NULL;
akgl_Actor *second = NULL;
akgl_Character *char1 = NULL;
akgl_Character *char2 = NULL;
ATTEMPT {
CATCH(e, reset_sim_heap());
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
CATCH(e, akgl_physics_init_null(&backend));
CATCH(e, make_sim_actor(&first, &char1, "interrupter"));
CATCH(e, make_sim_actor(&second, &char2, "follower"));
first->movementlogicfunc = &stub_movement_interrupt;
// AKGL_ERR_LOGICINTERRUPT means "skip me this frame", not "abort the frame",
// so the second actor must still be reached.
stub_movement_calls = 0;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL),
"simulate must swallow AKGL_ERR_LOGICINTERRUPT");
TEST_ASSERT(e, stub_movement_calls == 2,
"an interrupting actor stopped the loop after %d of 2 actors",
stub_movement_calls);
// An unrelated error is not swallowed.
CATCH(e, reset_sim_heap());
CATCH(e, make_sim_actor(&first, &char1, "failer"));
first->movementlogicfunc = &stub_movement_error;
TEST_EXPECT_STATUS(e, AKERR_VALUE, akgl_physics_simulate(&backend, NULL),
"simulate must propagate a non-interrupt movement error");
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_physics_simulate_nullpointers(void)
{
PREPARE_ERROR(e);
akgl_PhysicsBackend backend;
ATTEMPT {
CATCH(e, reset_sim_heap());
// A backend with no move function cannot simulate.
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_simulate(&backend, NULL),
"simulate with no move function installed");
// A NULL backend must be reported, not dereferenced.
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_simulate(NULL, NULL),
"simulate with NULL self");
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
int main(void)
{
PREPARE_ERROR(errctx);
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
ATTEMPT {
CATCH(errctx, akgl_error_init());
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_registry_init());
CATCH(errctx, akgl_registry_init_properties());
CATCH(errctx, test_physics_init_null());
CATCH(errctx, test_physics_null_backend_is_inert());
CATCH(errctx, test_physics_arcade_gravity());
CATCH(errctx, test_physics_arcade_move());
CATCH(errctx, test_physics_arcade_collide());
CATCH(errctx, test_physics_factory());
CATCH(errctx, test_physics_init_arcade_properties());
CATCH(errctx, test_physics_simulate_skips_inactive());
CATCH(errctx, test_physics_simulate_children_follow_parents());
CATCH(errctx, test_physics_simulate_layer_mask());
CATCH(errctx, test_physics_simulate_thrust_and_clamp());
CATCH(errctx, test_physics_simulate_drag());
CATCH(errctx, test_physics_simulate_movement_states());
CATCH(errctx, test_physics_simulate_logic_interrupt());
CATCH(errctx, test_physics_simulate_nullpointers());
} CLEANUP {
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
}