Files
libakgl/tests/physics.c
Andrew Kesterson 22162db2da Add physics, heap, json_helpers, game, and actor test suites
Raise line coverage from 39.6 to 61.8 percent with four new suites and an
extension to the actor suite, and register every suite through a single
CMake list so a new test file cannot be left out of the coverage fixture.
Give the test targets a build-tree RPATH and prepend the build tree to
LD_LIBRARY_PATH for CTest, so a developer with a previously installed
libakgl.so exercises the library that was just compiled.

Fix six defects the new tests exposed:

- akgl_physics_simulate read self->gravity_time before its NULL check, so a
  NULL backend crashed instead of reporting AKERR_NULLPOINTER.
- akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose
  inside the PROCESS switch. An ordinary save never flushed or closed its
  stream and produced an empty file.
- akgl_game_save_actors wrote each name table terminator from the address of
  a single char, emitting stack contents into the save file and a sentinel
  the loader could not recognize.
- akgl_game_load_objectnamemap used CATCH directly inside while(1), where the
  break leaves the loop rather than propagating, so a truncated name table
  loaded as a successful game.
- akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no
  NULL check, unlike their left and right counterparts.
- akgl_actor_logic_movement checked actor twice instead of checking
  actor->basechar before dereferencing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00

748 lines
29 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
* HEAP_ACTOR 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, &reference), "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, &reference),
"null collide with NULL self");
} 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);
}
akerr_ErrorContext *test_physics_arcade_collide_unimplemented(void)
{
PREPARE_ERROR(e);
akgl_PhysicsBackend backend;
akgl_Actor a1;
akgl_Actor a2;
ATTEMPT {
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
memset(&a1, 0x00, sizeof(akgl_Actor));
memset(&a2, 0x00, sizeof(akgl_Actor));
// Pins the stub so that implementing collision has to update this test.
TEST_EXPECT_STATUS(e, AKERR_API, akgl_physics_arcade_collide(&backend, &a1, &a2),
"arcade collide is still a stub");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_collide(NULL, &a1, &a2),
"arcade collide with NULL self");
} 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"));
// Thrust beyond the actor's max speed clamps to +sx, preserving sign.
actor->sx = 50.0f; actor->sy = 40.0f; actor->sz = 30.0f;
actor->tx = 500.0f; actor->ty = 400.0f; actor->tz = 300.0f;
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with positive over-thrust");
TEST_ASSERT_FEQ(e, actor->tx, 50.0f, "tx clamped to %f, expected 50", actor->tx);
TEST_ASSERT_FEQ(e, actor->ty, 40.0f, "ty clamped to %f, expected 40", actor->ty);
TEST_ASSERT_FEQ(e, actor->tz, 30.0f, "tz clamped to %f, expected 30", actor->tz);
// Negative over-thrust clamps to -sx.
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");
TEST_ASSERT_FEQ(e, actor->tx, -50.0f, "tx clamped to %f, expected -50", actor->tx);
TEST_ASSERT_FEQ(e, actor->ty, -40.0f, "ty clamped to %f, expected -40", actor->ty);
TEST_ASSERT_FEQ(e, actor->tz, -30.0f, "tz clamped to %f, expected -30", actor->tz);
// 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_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_unimplemented());
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);
}