/** * @file physics.c * @brief Unit tests for the physics backends and the simulation loop. * * None of these tests need a renderer or a window. The simulation loop walks * akgl_heap_actors directly, so each test rebuilds the actor heap rather than relying * on state left behind by an earlier one. */ #include #include #include #include #include #include #include #include #include #include #include #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"); // 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"); } 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")); // Over-thrust on one axis alone clamps to that axis's max speed, // preserving sign. This is the case the old per-axis clamp got right, // and it has to keep working. actor->sx = 50.0f; actor->sy = 40.0f; actor->sz = 30.0f; actor->tx = 500.0f; actor->ty = 0.0f; actor->tz = 0.0f; TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with single-axis over-thrust"); TEST_ASSERT_FEQ(e, actor->tx, 50.0f, "tx clamped to %f, expected 50", actor->tx); actor->tx = -500.0f; actor->ty = 0.0f; actor->tz = 0.0f; TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with negative single-axis over-thrust"); TEST_ASSERT_FEQ(e, actor->tx, -50.0f, "tx clamped to %f, expected -50", actor->tx); actor->tx = 0.0f; actor->ty = -400.0f; actor->tz = 0.0f; TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with negative y over-thrust"); TEST_ASSERT_FEQ(e, actor->ty, -40.0f, "ty clamped to %f, expected -40", actor->ty); actor->tx = 0.0f; actor->ty = 0.0f; actor->tz = 300.0f; TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with z over-thrust"); TEST_ASSERT_FEQ(e, actor->tz, 30.0f, "tz clamped to %f, expected 30", actor->tz); // Over-thrust on several axes at once scales the *vector* back to the // ellipse the three max speeds describe, rather than clamping each axis // against its own. Clamping the axes independently let the corner of the // box through: an actor holding two directions travelled their diagonal, // 41% faster than either alone, which is the classic diagonal-run bug. // tests/physics_sim.c measures it (`top-down diagonal speed`). // // The two things that makes true are asserted here rather than the three // numbers that fall out of them: the direction asked for is preserved, // so every axis is scaled by the same factor, and the result lands // exactly on the ellipse. actor->tx = 500.0f; actor->ty = 400.0f; actor->tz = 300.0f; TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with over-thrust on every axis"); TEST_ASSERT_FEQ(e, actor->tx / actor->sx, actor->ty / actor->sy, "x and y were scaled differently (%f vs %f), so the direction moved", actor->tx / actor->sx, actor->ty / actor->sy); TEST_ASSERT_FEQ(e, actor->ty / actor->sy, actor->tz / actor->sz, "y and z were scaled differently (%f vs %f), so the direction moved", actor->ty / actor->sy, actor->tz / actor->sz); TEST_ASSERT_FEQ(e, ((actor->tx / actor->sx) * (actor->tx / actor->sx)) + ((actor->ty / actor->sy) * (actor->ty / actor->sy)) + ((actor->tz / actor->sz) * (actor->tz / actor->sz)), 1.0f, "capped thrust (%f, %f, %f) is not on the max-speed ellipse", actor->tx, actor->ty, actor->tz); // Signs survive the scaling. actor->tx = -500.0f; actor->ty = -400.0f; actor->tz = -300.0f; TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with negative over-thrust on every axis"); TEST_ASSERT(e, (actor->tx < 0.0f) && (actor->ty < 0.0f) && (actor->tz < 0.0f), "capping negative thrust changed a sign: (%f, %f, %f)", actor->tx, actor->ty, actor->tz); // An axis whose max speed is zero cannot be thrust along at all, and it // stays out of the magnitude -- dividing by it would not end well. actor->sz = 0.0f; actor->tx = 10.0f; actor->ty = 0.0f; actor->tz = 300.0f; TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with a zero max speed"); TEST_ASSERT_FEQ(e, actor->tz, 0.0f, "thrust survived a zero max speed as %f", actor->tz); TEST_ASSERT_FEQ(e, actor->tx, 10.0f, "a zero max speed on z disturbed in-range x thrust (%f)", actor->tx); actor->sz = 30.0f; // Thrust inside the limit is left alone. actor->tx = 10.0f; actor->ty = -10.0f; actor->tz = 5.0f; TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with in-range thrust"); TEST_ASSERT_FEQ(e, actor->tx, 10.0f, "in-range tx changed to %f", actor->tx); TEST_ASSERT_FEQ(e, actor->ty, -10.0f, "in-range ty changed to %f", actor->ty); TEST_ASSERT_FEQ(e, actor->tz, 5.0f, "in-range tz changed to %f", actor->tz); // Velocity is the sum of environmental force and thrust. actor->ex = 3.0f; actor->ey = 4.0f; actor->ez = 5.0f; actor->tx = 1.0f; actor->ty = 2.0f; actor->tz = 3.0f; TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate velocity composition"); TEST_ASSERT_FEQ(e, actor->vx, 4.0f, "vx composed to %f, expected ex 3 + tx 1", actor->vx); TEST_ASSERT_FEQ(e, actor->vy, 6.0f, "vy composed to %f, expected ey 4 + ty 2", actor->vy); TEST_ASSERT_FEQ(e, actor->vz, 8.0f, "vz composed to %f, expected ez 5 + tz 3", actor->vz); } CLEANUP { } PROCESS(e) { } FINISH(e, true); SUCCEED_RETURN(e); } akerr_ErrorContext *test_physics_simulate_drag(void) { PREPARE_ERROR(e); akgl_PhysicsBackend backend; akgl_Actor *actor = NULL; akgl_Character *basechar = NULL; ATTEMPT { CATCH(e, reset_sim_heap()); memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); CATCH(e, akgl_physics_init_null(&backend)); CATCH(e, make_sim_actor(&actor, &basechar, "dragged")); // Zero drag leaves environmental velocity untouched. actor->ex = 100.0f; actor->ey = 100.0f; actor->ez = 100.0f; actor->sx = 1000.0f; actor->sy = 1000.0f; actor->sz = 1000.0f; TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with zero drag"); TEST_ASSERT_FEQ(e, actor->ex, 100.0f, "zero drag changed ex to %f", actor->ex); TEST_ASSERT_FEQ(e, actor->ey, 100.0f, "zero drag changed ey to %f", actor->ey); TEST_ASSERT_FEQ(e, actor->ez, 100.0f, "zero drag changed ez to %f", actor->ez); // Nonzero drag bleeds off environmental velocity in proportion to dt. // The simulator derives dt from the wall clock, so assert the direction // and bounds of the change rather than an exact figure. backend.drag_x = 0.5; backend.drag_y = 0.5; backend.drag_z = 0.5; backend.gravity_time = SDL_GetTicksNS(); SDL_Delay(20); TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with drag applied"); TEST_ASSERT(e, actor->ex < 100.0f && actor->ex > 0.0f, "drag left ex at %f, expected a value between 0 and 100", actor->ex); TEST_ASSERT(e, actor->ey < 100.0f && actor->ey > 0.0f, "drag left ey at %f, expected a value between 0 and 100", actor->ey); TEST_ASSERT(e, actor->ez < 100.0f && actor->ez > 0.0f, "drag left ez at %f, expected a value between 0 and 100", actor->ez); } CLEANUP { } PROCESS(e) { } FINISH(e, true); SUCCEED_RETURN(e); } akerr_ErrorContext *test_physics_simulate_movement_states(void) { PREPARE_ERROR(e); akgl_PhysicsBackend backend; akgl_Actor *actor = NULL; akgl_Character *basechar = NULL; ATTEMPT { CATCH(e, reset_sim_heap()); memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); CATCH(e, akgl_physics_init_null(&backend)); CATCH(e, make_sim_actor(&actor, &basechar, "mover")); actor->sx = 1000.0f; actor->sy = 1000.0f; actor->sz = 1000.0f; actor->ax = 10.0f; actor->ay = 20.0f; // An idle actor accumulates no thrust on either axis. actor->state = 0; actor->tx = 0; actor->ty = 0; backend.gravity_time = SDL_GetTicksNS(); SDL_Delay(10); TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate an idle actor"); TEST_ASSERT_FEQ(e, actor->tx, 0.0f, "idle actor accumulated tx %f", actor->tx); TEST_ASSERT_FEQ(e, actor->ty, 0.0f, "idle actor accumulated ty %f", actor->ty); // Moving horizontally accumulates thrust on X only. actor->state = AKGL_ACTOR_STATE_MOVING_LEFT; actor->tx = 0; actor->ty = 0; backend.gravity_time = SDL_GetTicksNS(); SDL_Delay(10); TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate a horizontally moving actor"); TEST_ASSERT(e, actor->tx > 0.0f, "MOVING_LEFT accumulated tx %f, expected a positive value", actor->tx); TEST_ASSERT_FEQ(e, actor->ty, 0.0f, "MOVING_LEFT leaked thrust into ty (%f)", actor->ty); // Moving vertically accumulates thrust on Y only. actor->state = AKGL_ACTOR_STATE_MOVING_DOWN; actor->tx = 0; actor->ty = 0; backend.gravity_time = SDL_GetTicksNS(); SDL_Delay(10); TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate a vertically moving actor"); TEST_ASSERT(e, actor->ty > 0.0f, "MOVING_DOWN accumulated ty %f, expected a positive value", actor->ty); TEST_ASSERT_FEQ(e, actor->tx, 0.0f, "MOVING_DOWN leaked thrust into tx (%f)", actor->tx); // The right and up states drive the same accumulators. actor->state = (AKGL_ACTOR_STATE_MOVING_RIGHT | AKGL_ACTOR_STATE_MOVING_UP); actor->tx = 0; actor->ty = 0; backend.gravity_time = SDL_GetTicksNS(); SDL_Delay(10); TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate a diagonally moving actor"); TEST_ASSERT(e, actor->tx > 0.0f, "MOVING_RIGHT accumulated tx %f, expected a positive value", actor->tx); TEST_ASSERT(e, actor->ty > 0.0f, "MOVING_UP accumulated ty %f, expected a positive value", actor->ty); } CLEANUP { } PROCESS(e) { } FINISH(e, true); SUCCEED_RETURN(e); } akerr_ErrorContext *test_physics_simulate_logic_interrupt(void) { PREPARE_ERROR(e); akgl_PhysicsBackend backend; akgl_Actor *first = NULL; akgl_Actor *second = NULL; akgl_Character *char1 = NULL; akgl_Character *char2 = NULL; ATTEMPT { CATCH(e, reset_sim_heap()); memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); CATCH(e, akgl_physics_init_null(&backend)); CATCH(e, make_sim_actor(&first, &char1, "interrupter")); CATCH(e, make_sim_actor(&second, &char2, "follower")); first->movementlogicfunc = &stub_movement_interrupt; // AKGL_ERR_LOGICINTERRUPT means "skip me this frame", not "abort the frame", // so the second actor must still be reached. stub_movement_calls = 0; TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate must swallow AKGL_ERR_LOGICINTERRUPT"); TEST_ASSERT(e, stub_movement_calls == 2, "an interrupting actor stopped the loop after %d of 2 actors", stub_movement_calls); // An unrelated error is not swallowed. CATCH(e, reset_sim_heap()); CATCH(e, make_sim_actor(&first, &char1, "failer")); first->movementlogicfunc = &stub_movement_error; TEST_EXPECT_STATUS(e, AKERR_VALUE, akgl_physics_simulate(&backend, NULL), "simulate must propagate a non-interrupt movement error"); } CLEANUP { } PROCESS(e) { } FINISH(e, true); SUCCEED_RETURN(e); } akerr_ErrorContext *test_physics_simulate_nullpointers(void) { PREPARE_ERROR(e); akgl_PhysicsBackend backend; ATTEMPT { CATCH(e, reset_sim_heap()); // A backend with no move function cannot simulate. memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_simulate(&backend, NULL), "simulate with no move function installed"); // A NULL backend must be reported, not dereferenced. TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_simulate(NULL, NULL), "simulate with NULL self"); } CLEANUP { } PROCESS(e) { } FINISH(e, true); SUCCEED_RETURN(e); } int main(void) { PREPARE_ERROR(errctx); SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy"); SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy"); ATTEMPT { CATCH(errctx, akgl_error_init()); TEST_TRAP_UNHANDLED_ERRORS(); 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); }