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
|
|
|
/**
|
|
|
|
|
* @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
|
Namespace every exported symbol, and bump to 0.5.0
Closes internal-consistency items 1 through 6, 12 and 13. Every include guard
is _AKGL_<FILE>_H_, every in-project header include is angled, and every
exported function, type and global carries the akgl_ prefix. This is an ABI
break; the soname goes to libakgl.so.0.5. TODO.md carries the full rename
table.
The renames were driven by renaming each declaration and letting the compiler
find the uses, not by pattern substitution: renderer, physics and camera are
also parameter and struct-member names, and a sed would have rewritten
map->physics and every akgl_RenderBackend *renderer parameter without a word.
Item 4 turned out not to be cosmetic. The library exported a global called
renderer and tests/character.c defined an SDL_Renderer *renderer of its own;
the executable's definition preempted the library's, akgl_sprite_load_json
read a SDL_Renderer * through an akgl_RenderBackend *, and every texture load
in that suite failed. The suite reported success anyway, because libakerror's
unhandled-error handler ends in exit(errctx->status), exit keeps only the low
byte, and AKGL_ERR_SDL is exactly 256. So character had been green while
running one of its four tests, and every suite in the tree was unable to fail
on the most common status in a library built on SDL.
Both are fixed. tests/testutil.h gains TEST_TRAP_UNHANDLED_ERRORS(), which
collapses any status a byte cannot carry onto 1, and every suite installs it.
character binds a real backend with akgl_render_2d_bind. Its fourth test then
runs for the first time and fails on a defect it has asserted all along, so
akgl_heap_release_character now walks state_sprites with
AKGL_ITERATOR_OP_RELEASE and destroys the property set before zeroing the slot
-- TODO.md Defects item 21 and half of Carried over item 1.
AKGL_TIME_ONESEC_MS said "one second in milliseconds" and held 1000000, so
akgl_game_state_lock waited roughly sixteen minutes rather than one second. It
is AKGL_TIME_ONEMS_NS now, the budget is its own named constant, and
tests/game.c holds the mutex from a second thread to assert the wait -- the
contended path had no coverage at all.
Headers are self-contained and it is enforced: AKGL_PUBLIC_HEADERS drives both
install() and a generated translation unit per header, so a header that ships
is a header that is checked. Writing that found registry.h, which used
SDL_PropertiesID in eight declarations and included no SDL header.
23/23 suites pass, memcheck is clean, reindent --check is clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:32:21 -04:00
|
|
|
* akgl_heap_actors directly, so each test rebuilds the actor heap rather than relying
|
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
|
|
|
* 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");
|
Stop returning past CLEANUP, and validate the arguments every sibling validates
Closes internal-consistency items 16 and 17.
Ten *_RETURN macros sat inside ATTEMPT blocks, which return past CLEANUP and
skip every release in it. The one that mattered was the success path of
akgl_get_json_tilemap_property: it leaked two of the string pool's 256 entries
on every lookup that *found* what it was asked for, and a map load does that
several times per layer. tests/tilemap.c now runs each of its three paths --
found, absent, wrong type -- twice the pool size and asserts the pool is where
it started. Against the old code that test does not merely fail, it segfaults,
which is Defects item 30 seen from the outside: pool exhaustion arriving as a
NULL strncpy rather than as AKGL_ERR_HEAP.
Two of the conversions needed more than swapping the macro. In
akgl_get_json_tilemap_property a plain break would have fallen through to the
"property not found" FAIL_RETURN after FINISH, reporting a miss for something
found, so the success path sets a flag. In akgl_collide_rectangles the eight
early exits are followed by `*collide = false;`, which would have overwritten
the hit that broke out; each corner test writes the flag itself, so that line
is gone rather than moved. The same function also released its scratch string
once per loop iteration while continuing to use it, so the slot was free while
still live -- one claim now covers the whole scan.
akgl_controller_default is the other behavioural one: its SUCCEED_RETURN was
the last statement in the ATTEMPT block, so the path that falls out of FINISH
reached the closing brace of a non-void function.
scripts/check_error_protocol.py keeps both rules enforced -- a *_RETURN inside
ATTEMPT, and a return out of a HANDLE block -- as the error_protocol test.
Neither produces a compiler diagnostic and neither fails a test run until the
pool it drains is empty, which is why both have already shipped once.
For item 17: the eight typed JSON accessors that validated their container and
then wrote through dest unconditionally now check key and dest as the two
string accessors always did; the null physics backend checks its actors like
the arcade one; and akgl_render_2d_frame_start, _frame_end and _shutdown check
self, which the first two read straight through. tests/renderer.c calls all
three with NULL, which segfaulted before.
25/25 pass, memcheck clean, reindent --check clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:56:10 -04:00
|
|
|
|
|
|
|
|
// 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, &reference),
|
|
|
|
|
"null collide with a NULL first actor");
|
|
|
|
|
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_collide(&backend, &actor, NULL),
|
|
|
|
|
"null collide with a NULL second actor");
|
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
|
|
|
} 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"));
|
|
|
|
|
|
Fix three arcade physics defects the unit tests could not see
tests/physics_sim.c runs the arcade backend the way a game does -- a
Mario-esque jump and fall, Zelda-style top-down walking, and a run
reversed at full speed -- and prints what the actor actually did.
tests/physics.c already checked that akgl_physics_simulate does the
arithmetic it claims. Every one of these got past it.
The first step was however long the level took to load. gravity_time was
never initialized and dt was unbounded, so a 250 ms load produced a
250 ms step: 100 px of fall where a 60 Hz frame under the same gravity
is 0.44 px, straight through whatever was underneath. Both initializers
seed the clock, and simulate bounds dt to max_timestep -- a new
physics.max_timestep property, default 0.05 s, read like gravity and
drag. Zero disables the bound. The field replaces the dead timer_gravity,
so the struct is the same size.
Releasing a vertical key cancelled gravity. The _off handlers zeroed ay,
ey, ty and vy together, and ey is where the arcade backend accumulates
gravity -- tapping down mid-jump stopped the character in the air. They
clear ax/tx (or ay/ty) and nothing else now. Velocity was never theirs
to clear either: simulate recomputes v as e + t every step.
Diagonal movement was 41% too fast. Thrust was capped per axis, so an
actor holding two directions got both caps at once and travelled their
diagonal. It is capped as a vector against the sx/sy/sz ellipse, which
also keeps a character whose horizontal and vertical top speeds differ
moving at the ratio it asked for. An axis with a zero max speed stays
out of the magnitude and is forced to zero, as the old clamp did.
Each fix was checked by reverting it and confirming the simulation goes
red: 100.1 px, vy 0.0 after a down tap, and 141% diagonal respectively.
tests/actor.c and tests/physics.c pinned the old behaviour in both
places and now assert the new contract.
Bumped to 0.6.0: akgl_PhysicsBackend changed. TODO.md records the four
things the simulations found and this does not fix -- no terminal
velocity, no deceleration on release, Euler's frame-rate dependence, and
a drag coefficient large enough to invert velocity.
26/26 ctest, memcheck clean, warning-clean at -Wall -Werror.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8T5FAYXE8HEJqFLCYwNNc
2026-08-01 12:10:53 -04:00
|
|
|
// 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.
|
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
|
|
|
actor->sx = 50.0f; actor->sy = 40.0f; actor->sz = 30.0f;
|
Fix three arcade physics defects the unit tests could not see
tests/physics_sim.c runs the arcade backend the way a game does -- a
Mario-esque jump and fall, Zelda-style top-down walking, and a run
reversed at full speed -- and prints what the actor actually did.
tests/physics.c already checked that akgl_physics_simulate does the
arithmetic it claims. Every one of these got past it.
The first step was however long the level took to load. gravity_time was
never initialized and dt was unbounded, so a 250 ms load produced a
250 ms step: 100 px of fall where a 60 Hz frame under the same gravity
is 0.44 px, straight through whatever was underneath. Both initializers
seed the clock, and simulate bounds dt to max_timestep -- a new
physics.max_timestep property, default 0.05 s, read like gravity and
drag. Zero disables the bound. The field replaces the dead timer_gravity,
so the struct is the same size.
Releasing a vertical key cancelled gravity. The _off handlers zeroed ay,
ey, ty and vy together, and ey is where the arcade backend accumulates
gravity -- tapping down mid-jump stopped the character in the air. They
clear ax/tx (or ay/ty) and nothing else now. Velocity was never theirs
to clear either: simulate recomputes v as e + t every step.
Diagonal movement was 41% too fast. Thrust was capped per axis, so an
actor holding two directions got both caps at once and travelled their
diagonal. It is capped as a vector against the sx/sy/sz ellipse, which
also keeps a character whose horizontal and vertical top speeds differ
moving at the ratio it asked for. An axis with a zero max speed stays
out of the magnitude and is forced to zero, as the old clamp did.
Each fix was checked by reverting it and confirming the simulation goes
red: 100.1 px, vy 0.0 after a down tap, and 141% diagonal respectively.
tests/actor.c and tests/physics.c pinned the old behaviour in both
places and now assert the new contract.
Bumped to 0.6.0: akgl_PhysicsBackend changed. TODO.md records the four
things the simulations found and this does not fix -- no terminal
velocity, no deceleration on release, Euler's frame-rate dependence, and
a drag coefficient large enough to invert velocity.
26/26 ctest, memcheck clean, warning-clean at -Wall -Werror.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8T5FAYXE8HEJqFLCYwNNc
2026-08-01 12:10:53 -04:00
|
|
|
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");
|
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
|
|
|
TEST_ASSERT_FEQ(e, actor->tx, 50.0f, "tx clamped to %f, expected 50", actor->tx);
|
|
|
|
|
|
Fix three arcade physics defects the unit tests could not see
tests/physics_sim.c runs the arcade backend the way a game does -- a
Mario-esque jump and fall, Zelda-style top-down walking, and a run
reversed at full speed -- and prints what the actor actually did.
tests/physics.c already checked that akgl_physics_simulate does the
arithmetic it claims. Every one of these got past it.
The first step was however long the level took to load. gravity_time was
never initialized and dt was unbounded, so a 250 ms load produced a
250 ms step: 100 px of fall where a 60 Hz frame under the same gravity
is 0.44 px, straight through whatever was underneath. Both initializers
seed the clock, and simulate bounds dt to max_timestep -- a new
physics.max_timestep property, default 0.05 s, read like gravity and
drag. Zero disables the bound. The field replaces the dead timer_gravity,
so the struct is the same size.
Releasing a vertical key cancelled gravity. The _off handlers zeroed ay,
ey, ty and vy together, and ey is where the arcade backend accumulates
gravity -- tapping down mid-jump stopped the character in the air. They
clear ax/tx (or ay/ty) and nothing else now. Velocity was never theirs
to clear either: simulate recomputes v as e + t every step.
Diagonal movement was 41% too fast. Thrust was capped per axis, so an
actor holding two directions got both caps at once and travelled their
diagonal. It is capped as a vector against the sx/sy/sz ellipse, which
also keeps a character whose horizontal and vertical top speeds differ
moving at the ratio it asked for. An axis with a zero max speed stays
out of the magnitude and is forced to zero, as the old clamp did.
Each fix was checked by reverting it and confirming the simulation goes
red: 100.1 px, vy 0.0 after a down tap, and 141% diagonal respectively.
tests/actor.c and tests/physics.c pinned the old behaviour in both
places and now assert the new contract.
Bumped to 0.6.0: akgl_PhysicsBackend changed. TODO.md records the four
things the simulations found and this does not fix -- no terminal
velocity, no deceleration on release, Euler's frame-rate dependence, and
a drag coefficient large enough to invert velocity.
26/26 ctest, memcheck clean, warning-clean at -Wall -Werror.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8T5FAYXE8HEJqFLCYwNNc
2026-08-01 12:10:53 -04:00
|
|
|
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");
|
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
|
|
|
TEST_ASSERT_FEQ(e, actor->tx, -50.0f, "tx clamped to %f, expected -50", actor->tx);
|
Fix three arcade physics defects the unit tests could not see
tests/physics_sim.c runs the arcade backend the way a game does -- a
Mario-esque jump and fall, Zelda-style top-down walking, and a run
reversed at full speed -- and prints what the actor actually did.
tests/physics.c already checked that akgl_physics_simulate does the
arithmetic it claims. Every one of these got past it.
The first step was however long the level took to load. gravity_time was
never initialized and dt was unbounded, so a 250 ms load produced a
250 ms step: 100 px of fall where a 60 Hz frame under the same gravity
is 0.44 px, straight through whatever was underneath. Both initializers
seed the clock, and simulate bounds dt to max_timestep -- a new
physics.max_timestep property, default 0.05 s, read like gravity and
drag. Zero disables the bound. The field replaces the dead timer_gravity,
so the struct is the same size.
Releasing a vertical key cancelled gravity. The _off handlers zeroed ay,
ey, ty and vy together, and ey is where the arcade backend accumulates
gravity -- tapping down mid-jump stopped the character in the air. They
clear ax/tx (or ay/ty) and nothing else now. Velocity was never theirs
to clear either: simulate recomputes v as e + t every step.
Diagonal movement was 41% too fast. Thrust was capped per axis, so an
actor holding two directions got both caps at once and travelled their
diagonal. It is capped as a vector against the sx/sy/sz ellipse, which
also keeps a character whose horizontal and vertical top speeds differ
moving at the ratio it asked for. An axis with a zero max speed stays
out of the magnitude and is forced to zero, as the old clamp did.
Each fix was checked by reverting it and confirming the simulation goes
red: 100.1 px, vy 0.0 after a down tap, and 141% diagonal respectively.
tests/actor.c and tests/physics.c pinned the old behaviour in both
places and now assert the new contract.
Bumped to 0.6.0: akgl_PhysicsBackend changed. TODO.md records the four
things the simulations found and this does not fix -- no terminal
velocity, no deceleration on release, Euler's frame-rate dependence, and
a drag coefficient large enough to invert velocity.
26/26 ctest, memcheck clean, warning-clean at -Wall -Werror.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8T5FAYXE8HEJqFLCYwNNc
2026-08-01 12:10:53 -04:00
|
|
|
|
|
|
|
|
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");
|
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
|
|
|
TEST_ASSERT_FEQ(e, actor->ty, -40.0f, "ty clamped to %f, expected -40", actor->ty);
|
Fix three arcade physics defects the unit tests could not see
tests/physics_sim.c runs the arcade backend the way a game does -- a
Mario-esque jump and fall, Zelda-style top-down walking, and a run
reversed at full speed -- and prints what the actor actually did.
tests/physics.c already checked that akgl_physics_simulate does the
arithmetic it claims. Every one of these got past it.
The first step was however long the level took to load. gravity_time was
never initialized and dt was unbounded, so a 250 ms load produced a
250 ms step: 100 px of fall where a 60 Hz frame under the same gravity
is 0.44 px, straight through whatever was underneath. Both initializers
seed the clock, and simulate bounds dt to max_timestep -- a new
physics.max_timestep property, default 0.05 s, read like gravity and
drag. Zero disables the bound. The field replaces the dead timer_gravity,
so the struct is the same size.
Releasing a vertical key cancelled gravity. The _off handlers zeroed ay,
ey, ty and vy together, and ey is where the arcade backend accumulates
gravity -- tapping down mid-jump stopped the character in the air. They
clear ax/tx (or ay/ty) and nothing else now. Velocity was never theirs
to clear either: simulate recomputes v as e + t every step.
Diagonal movement was 41% too fast. Thrust was capped per axis, so an
actor holding two directions got both caps at once and travelled their
diagonal. It is capped as a vector against the sx/sy/sz ellipse, which
also keeps a character whose horizontal and vertical top speeds differ
moving at the ratio it asked for. An axis with a zero max speed stays
out of the magnitude and is forced to zero, as the old clamp did.
Each fix was checked by reverting it and confirming the simulation goes
red: 100.1 px, vy 0.0 after a down tap, and 141% diagonal respectively.
tests/actor.c and tests/physics.c pinned the old behaviour in both
places and now assert the new contract.
Bumped to 0.6.0: akgl_PhysicsBackend changed. TODO.md records the four
things the simulations found and this does not fix -- no terminal
velocity, no deceleration on release, Euler's frame-rate dependence, and
a drag coefficient large enough to invert velocity.
26/26 ctest, memcheck clean, warning-clean at -Wall -Werror.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8T5FAYXE8HEJqFLCYwNNc
2026-08-01 12:10:53 -04:00
|
|
|
|
|
|
|
|
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;
|
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
|
|
|
|
|
|
|
|
// 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 {
|
Migrate to the libakerror 1.0.0 status registry
libakerror 1.0.0 replaced the consumer-sized status-name array with a
private registry and made status-code ownership explicit and enforced.
AKERR_MAX_ERR_VALUE and __AKERR_ERROR_NAMES are gone, and the registry
entry points raise akerr_ErrorContext * instead of returning int. See
deps/libakerror/UPGRADING.md.
The break was not only source-level. libakgl's codes sat at
AKERR_LAST_ERRNO_VALUE + 18 through + 22, and 1.0.0 claimed exactly
those five offsets for its own AKERR_STATUS_* registry codes, so every
AKGL_ERR_* was aliasing a libakerror status. HANDLE(e,
AKGL_ERR_LOGICINTERRUPT) at physics.c:222 would have swallowed a
foreign-name refusal.
- Move the band to AKERR_FIRST_CONSUMER_STATUS (256) as fixed offsets,
so a libc that grows an errno cannot move the codes, and add
AKGL_ERR_OWNER, AKGL_ERR_LIMIT and AKGL_ERR_COUNT to describe it.
- Reserve the range and register the names through the owned entry
points, PASS-ing each: these are AKERR_NOIGNORE, and the old
akerr_name_for_status calls discarded failure silently.
- Drop the AKERR_MAX_ERR_VALUE=256 compile definition.
- Guard on AKERR_FIRST_CONSUMER_STATUS in include/akgl/error.h, which
now includes <akerror.h> so the guard is reliable. The embedded build
is fine, but the find_package path can pick up a stale installed
header, and 1.0.0 has an soname, so that pairing is an ABI mismatch
rather than a compile problem. Same guard libakstdlib already carries.
Registration also moves out of akgl_heap_init into a new akgl_error_init
in src/error.c. It was in the heap pool's initializer only because that
was the first thing akgl_game_init called, and the upgrade turned five
fire-and-forget name calls into a library-wide ownership claim that can
fail. That placement was hiding a defect: game.c raises AKGL_ERR_SDL
when SDL_CreateMutex fails, five lines before akgl_heap_init ran, so the
earliest error path in the library was guaranteed to print "Unknown
Error". akgl_error_init is now the first statement in akgl_game_init.
Callers that drive subsystems directly must call akgl_error_init first;
it is idempotent, so ordering it precisely is not required. The eleven
test suites that relied on akgl_heap_init to name their statuses now
call it explicitly, or their failure messages would have degraded to
"Unknown Error".
Add tests/error.c: assert every code reads back its registered name,
that the name table and AKGL_ERR_COUNT agree, that a foreign owner is
refused with AKERR_STATUS_NAME_FOREIGN and AKERR_STATUS_RANGE_OVERLAP,
and that repeating the init is a no-op. That last one is a live
constraint, not a triviality -- libakerror treats only an identical
reservation as a repeat, so a subset or superset raises.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:20:28 -04:00
|
|
|
CATCH(errctx, akgl_error_init());
|
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
|
|
|
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);
|
|
|
|
|
}
|