Files
libakgl/tests/physics_sim.c
Andrew Kesterson e4aa6a5084
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 20s
libakgl CI Build / performance (push) Failing after 19s
libakgl CI Build / memory_check (push) Failing after 15s
libakgl CI Build / mutation_test (push) Failing after 17s
Take libakerror 2.0.1 and drop the workaround it makes obsolete
Every libakgl test suite could report success while failing. libakerror's
default unhandled-error handler ended in exit(errctx->status), an exit
status is one byte wide, and libakgl's band starts at 256 -- so
AKGL_ERR_SDL, the most common failure a library built on SDL can have,
exited 0 and CTest recorded a pass. tests/character.c aborted at its
second of four tests on a bad renderer and was green for months.

0.5.0 worked around that here with TEST_TRAP_UNHANDLED_ERRORS() in
tests/testutil.h, and TODO.md ended the entry saying any consumer's
suites have the same problem and it was worth raising upstream. It was.
2.0.1 fixes it at the source: akerr_exit() owns the mapping and the
default handler calls it, so 0 exits 0, 1 through 255 exit themselves,
and anything else exits AKERR_EXIT_STATUS_UNREPRESENTABLE (125). The
trap and its 21 call sites are gone.

Verified by putting the original failure back rather than by reading the
release notes: a FAIL_BREAK(AKGL_ERR_SDL) in tests/character.c's main
exits 125 and CTest reports a failure. A standalone consumer raising the
same status unhandled exits 125 where it exited 0 before.

tests/actor.c installs its own handler and called exit(errctx->status)
from it, which is the same defect one layer up. It calls akerr_exit()
now.

2.0.0 also makes the error pool and the status registry thread safe,
which libakgl needs more than it knew: audio_stream_callback raises
error contexts on SDL's audio thread. With an unlocked pool that
callback and the main thread could scan AKERR_ARRAY_ERROR at the same
time and be handed the same slot. The comment there says so.

This is a hard dependency floor, not a preference. 2.0.0 moved
__akerr_last_ignored to thread-local storage and made akerr_next_error()
return a context that already holds its reference, and both expand at
libakgl's call sites -- and at a consumer's, because akerror.h is part
of libakgl's public interface. Mixing headers and libraries across that
line double-counts every reference and never returns a pool slot. The
soname moved to libakerror.so.2; include/akgl/error.h now also feature-
tests AKERR_EXIT_STATUS_UNREPRESENTABLE, which is the narrowest probe
for 2.0.1 since libakerror publishes no version macro.

0.7.0 for that reason: libakgl's own ABI is unchanged, but the one it
re-exports through its headers is not.

TODO.md records the pkg-config gap this makes sharper -- akgl.pc names
no dependencies at all, so nothing tells a pkg-config consumer which
libakerror it needs.

Clean build, 26/26 ctest, memcheck clean, warning-clean at -Wall
-Werror. libakgl.so.0.7 links libakerror.so.2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8T5FAYXE8HEJqFLCYwNNc
2026-08-01 13:05:43 -04:00

649 lines
24 KiB
C

/**
* @file physics_sim.c
* @brief Whole-motion simulations of the arcade backend, in the shapes games actually use.
*
* `tests/physics.c` checks the pieces: that gravity accelerates, that drag
* sheds, that the cap caps. Every one of those passes while the motion a player
* would feel is still wrong, because feel lives in how the pieces compose over
* a few hundred frames.
*
* Three shapes, chosen because they are the three a 2D game almost always
* needs:
*
* 1. **Side-on jump and fall.** Gravity down, an upward impulse, land. The
* question is whether the arc is an arc.
* 2. **Top-down walk.** No gravity, four-way input, stop on release. The
* question is whether stopping and diagonals behave.
* 3. **Sudden reversal.** Full speed one way, then the other way. The question
* is whether the turn takes a believable amount of time.
*
* Every case runs at a fixed 60 Hz step and records the trajectory, so a
* failure says *what the motion did*, not merely that a number was wrong.
*
* @note akgl_physics_simulate reads SDL_GetTicksNS() itself, so it cannot be
* handed a dt. It can be *bounded* to one, though: sim_step sets
* `max_timestep` to the step it wants and `gravity_time` to zero, so the
* measured interval is always longer than the bound and every step is
* exactly `max_timestep`. That is deterministic under any load, which
* matters -- the first version placed `gravity_time` at `now - dt` and
* let the real clock supply the step, and a machine busy enough to
* deschedule the process between that store and the SDL_GetTicksNS()
* inside simulate produced a longer step and a red suite. It failed
* exactly once, under a parallel ctest, which is the worst way to find
* out. That the engine cannot be stepped exactly is itself a finding;
* see TODO.md.
*/
#include <SDL3/SDL.h>
#include <stdio.h>
#include <string.h>
#include <akerror.h>
#include <akgl/error.h>
#include <akgl/actor.h>
#include <akgl/character.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/physics.h>
#include <akgl/registry.h>
#include <akgl/sprite.h>
#include "testutil.h"
/** @brief One simulation step, in seconds. 60 Hz, the rate the budgets assume. */
#define SIM_DT (1.0f / 60.0f)
/** @brief How many steps a case may run before it is called a hang. */
#define SIM_MAX_STEPS 1200
/**
* @brief How long a host is assumed to spend loading before its first frame.
*
* Short enough to keep the suite quick, long enough to be far outside a frame.
* A real level load is longer, which only makes the effect larger.
*/
#define SIM_LOAD_TIME_MS 250
/** @brief The backend under test. Reconfigured per case rather than shared. */
static akgl_PhysicsBackend sim_physics;
/** @brief The character the simulated actor borrows its speed and acceleration from. */
static akgl_Character sim_character;
/**
* @brief Advance the simulation by exactly one step of @p dt seconds.
*
* akgl_physics_simulate measures dt as `SDL_GetTicksNS() - self->gravity_time`
* and then bounds it by `max_timestep`. Zeroing `gravity_time` makes the
* measured interval the whole uptime of the process, which is always past the
* bound, so the step the simulation takes is `max_timestep` and nothing else.
* The scheduler cannot get in the way of that, which the earlier `now - dt`
* version could not say.
*/
static akerr_ErrorContext *sim_step(float32_t dt)
{
PREPARE_ERROR(errctx);
sim_physics.max_timestep = (float64_t)dt;
sim_physics.gravity_time = 0;
PASS(errctx, sim_physics.simulate(&sim_physics, NULL));
SUCCEED_RETURN(errctx);
}
/** @brief Build a fresh actor bound to a character with the given accel and top speed. */
static akerr_ErrorContext *sim_actor(akgl_Actor **dest, char *name,
float32_t accel, float32_t topspeed)
{
PREPARE_ERROR(errctx);
memset(&sim_character, 0x00, sizeof(akgl_Character));
sim_character.ax = accel;
sim_character.ay = accel;
sim_character.sx = topspeed;
sim_character.sy = topspeed;
PASS(errctx, akgl_heap_next_actor(dest));
PASS(errctx, akgl_actor_initialize(*dest, name));
(*dest)->basechar = &sim_character;
(*dest)->x = 0.0f;
(*dest)->y = 0.0f;
SUCCEED_RETURN(errctx);
}
/** @brief Zero the backend and point it at the arcade implementation. */
static void sim_arcade(float64_t gravity_y, float64_t drag_x, float64_t drag_y)
{
memset(&sim_physics, 0x00, sizeof(akgl_PhysicsBackend));
sim_physics.simulate = &akgl_physics_simulate;
sim_physics.gravity = &akgl_physics_arcade_gravity;
sim_physics.collide = &akgl_physics_arcade_collide;
sim_physics.move = &akgl_physics_arcade_move;
sim_physics.gravity_y = gravity_y;
sim_physics.drag_x = drag_x;
sim_physics.drag_y = drag_y;
}
/** @brief A synthetic key-up, for driving the release handlers the way input does. */
static void sim_keyevent(SDL_Event *event, bool pressed)
{
memset(event, 0x00, sizeof(SDL_Event));
event->type = pressed ? SDL_EVENT_KEY_DOWN : SDL_EVENT_KEY_UP;
}
/**
* @brief Mario-esque: gravity, an upward impulse, and a landing.
*
* A side-on platformer jump is the arc of one impulse against constant
* acceleration. `libakgl` has no jump entry point, so a game does what this
* does: drive the environmental accumulator directly and let
* akgl_physics_arcade_gravity pull it back.
*
* The assertions are the ones that hold for *any* believable jump, not for one
* particular tuning: it must go up, it must come back, the apex must be in the
* middle rather than at an end, and rise and fall must take about the same time
* under constant gravity.
*/
akerr_ErrorContext *test_sim_jump_and_fall(void)
{
PREPARE_ERROR(errctx);
akgl_Actor *actor = NULL;
float32_t apex_y = 0.0f;
int apex_step = 0;
int landed_step = 0;
int i = 0;
ATTEMPT {
// 1600 px/s^2 down and a 600 px/s launch: about a 0.75 s hop, which is
// roughly what a platformer of this era feels like.
sim_arcade(1600.0, 0.0, 0.0);
CATCH(errctx, sim_actor(&actor, "jumper", 900.0f, 220.0f));
// The impulse. y grows downward, so up is negative.
actor->ey = -600.0f;
for ( i = 0; i < SIM_MAX_STEPS; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
if ( actor->y < apex_y ) {
apex_y = actor->y;
apex_step = i;
}
// Back to the ground it started on.
if ( (i > 0) && (actor->y >= 0.0f) ) {
landed_step = i;
break;
}
}
TEST_ASSERT(errctx, landed_step > 0,
"the jumper never came back down in %d steps (y is %f, vy %f)",
SIM_MAX_STEPS, actor->y, actor->vy);
TEST_ASSERT(errctx, apex_y < -1.0f,
"the jump did not leave the ground: apex was y=%f", apex_y);
// A constant-gravity arc is symmetric: the apex sits at the midpoint of
// the airtime, within a step or two of rounding.
TEST_ASSERT(errctx,
(apex_step > ((landed_step / 2) - 3)) && (apex_step < ((landed_step / 2) + 3)),
"apex at step %d of %d airborne steps; a constant-gravity arc peaks at the middle",
apex_step, landed_step);
printf(" jump: apex %.1f px at step %d, landed at step %d (%.2f s airborne)\n",
-apex_y, apex_step, landed_step, (float)landed_step * SIM_DT);
} CLEANUP {
if ( actor != NULL ) {
IGNORE(akgl_heap_release_actor(actor));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief Mario-esque, part two: steering in mid-air must not cancel the fall.
*
* Every platformer lets you steer while airborne, which means pressing and
* releasing a horizontal key during a jump. Releasing must not disturb the
* vertical arc: horizontal input and gravity are different axes.
*/
akerr_ErrorContext *test_sim_air_control_preserves_the_arc(void)
{
PREPARE_ERROR(errctx);
akgl_Actor *plain = NULL;
akgl_Actor *steered = NULL;
SDL_Event event;
float32_t plain_y = 0.0f;
float32_t steered_y = 0.0f;
int i = 0;
ATTEMPT {
sim_arcade(1600.0, 0.0, 0.0);
CATCH(errctx, sim_actor(&plain, "plain", 900.0f, 220.0f));
// sim_actor rewrites the shared character; both actors share it, which
// is what a game does with two of the same enemy.
CATCH(errctx, sim_actor(&steered, "steered", 900.0f, 220.0f));
plain->basechar = &sim_character;
plain->ey = -600.0f;
steered->ey = -600.0f;
// Both jump. The steered one taps right for ten frames, then lets go,
// while both are still in the air.
sim_keyevent(&event, true);
CATCH(errctx, akgl_actor_cmhf_right_on(steered, &event));
for ( i = 0; i < 10; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
}
sim_keyevent(&event, false);
CATCH(errctx, akgl_actor_cmhf_right_off(steered, &event));
for ( i = 0; i < 10; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
}
plain_y = plain->y;
steered_y = steered->y;
printf(" air control: plain y=%.1f vy=%.1f | steered y=%.1f vy=%.1f x=%.1f\n",
plain_y, plain->vy, steered_y, steered->vy, steered->x);
TEST_ASSERT(errctx, steered->x > 1.0f,
"steering right in mid-air moved the actor %f px", steered->x);
// The one that steered must be on the same vertical arc as the one that
// did not. Anything else means a horizontal key changed the fall.
TEST_ASSERT_FEQ(errctx, steered_y, plain_y,
"steering in mid-air moved the vertical arc: y=%f against %f, "
"a difference of %f px",
steered_y, plain_y, (steered_y - plain_y));
} CLEANUP {
if ( steered != NULL ) {
IGNORE(akgl_heap_release_actor(steered));
}
if ( plain != NULL ) {
IGNORE(akgl_heap_release_actor(plain));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief Zelda-style: top-down, no gravity, four-way walk that stops.
*
* The whole feel of a top-down game is in accelerate, hold, release, stop. The
* assertions here are that the walk reaches its stated top speed and no more,
* and that letting go actually stops the character rather than leaving them
* drifting.
*/
akerr_ErrorContext *test_sim_topdown_walk(void)
{
PREPARE_ERROR(errctx);
akgl_Actor *actor = NULL;
SDL_Event event;
float32_t speed_at_hold = 0.0f;
float32_t x_at_release = 0.0f;
float32_t drift = 0.0f;
int i = 0;
ATTEMPT {
// No gravity anywhere: this is a floor seen from above.
sim_arcade(0.0, 0.0, 0.0);
CATCH(errctx, sim_actor(&actor, "link", 900.0f, 220.0f));
sim_keyevent(&event, true);
CATCH(errctx, akgl_actor_cmhf_right_on(actor, &event));
for ( i = 0; i < 60; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
}
speed_at_hold = actor->vx;
TEST_ASSERT(errctx, speed_at_hold > 0.0f,
"a held right key produced vx=%f", speed_at_hold);
// The cap is on thrust, and with no environmental component in a
// top-down world vx is thrust, so it must not exceed the character's
// stated top speed.
TEST_ASSERT(errctx, speed_at_hold <= (sim_character.sx + 1.0f),
"walking reached vx=%f, above the character's top speed of %f",
speed_at_hold, sim_character.sx);
x_at_release = actor->x;
sim_keyevent(&event, false);
CATCH(errctx, akgl_actor_cmhf_right_off(actor, &event));
for ( i = 0; i < 60; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
}
drift = actor->x - x_at_release;
printf(" top-down: held vx=%.1f, drifted %.1f px in the second after release\n",
speed_at_hold, drift);
// A second after letting go, the character is stopped.
TEST_ASSERT_FEQ(errctx, actor->vx, 0.0f,
"a second after release vx is still %f", actor->vx);
} CLEANUP {
if ( actor != NULL ) {
IGNORE(akgl_heap_release_actor(actor));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief Zelda-style, part two: diagonal movement must not be faster.
*
* Holding two directions at once composes two axes that are each capped
* separately, so the resulting speed is the diagonal of the two caps rather
* than the cap. A character who walks 41% faster on the diagonal is the oldest
* bug in top-down movement.
*/
akerr_ErrorContext *test_sim_topdown_diagonal_speed(void)
{
PREPARE_ERROR(errctx);
akgl_Actor *actor = NULL;
SDL_Event event;
float32_t straight = 0.0f;
float32_t diagonal = 0.0f;
int i = 0;
ATTEMPT {
sim_arcade(0.0, 0.0, 0.0);
CATCH(errctx, sim_actor(&actor, "link_straight", 900.0f, 220.0f));
sim_keyevent(&event, true);
CATCH(errctx, akgl_actor_cmhf_right_on(actor, &event));
for ( i = 0; i < 60; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
}
straight = SDL_sqrtf((actor->vx * actor->vx) + (actor->vy * actor->vy));
IGNORE(akgl_heap_release_actor(actor));
actor = NULL;
CATCH(errctx, sim_actor(&actor, "link_diagonal", 900.0f, 220.0f));
sim_keyevent(&event, true);
// Right and down together. cmhf_right_on clears MOVING_ALL, so the
// down bit is set after it rather than before.
CATCH(errctx, akgl_actor_cmhf_right_on(actor, &event));
AKGL_BITMASK_ADD(actor->state, AKGL_ACTOR_STATE_MOVING_DOWN);
actor->ay = sim_character.ay;
for ( i = 0; i < 60; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
// Holding down means the down bit stays set; the movement logic
// rewrites ay from the character each step.
AKGL_BITMASK_ADD(actor->state, AKGL_ACTOR_STATE_MOVING_DOWN);
}
diagonal = SDL_sqrtf((actor->vx * actor->vx) + (actor->vy * actor->vy));
printf(" diagonal: straight %.1f px/s, diagonal %.1f px/s (%.0f%% of straight)\n",
straight, diagonal, (double)((diagonal / straight) * 100.0f));
TEST_ASSERT(errctx, straight > 0.0f, "the straight walk did not move");
// Within a few percent of the same speed in both directions.
TEST_ASSERT(errctx, (diagonal <= (straight * 1.05f)),
"walking diagonally is %.0f%% of the straight speed (%f against %f); "
"two independently capped axes compose to the diagonal of the caps",
(double)((diagonal / straight) * 100.0f), diagonal, straight);
} CLEANUP {
if ( actor != NULL ) {
IGNORE(akgl_heap_release_actor(actor));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief Full speed one way, then the other: the turn has to take a moment.
*
* A character at top speed who reverses instantly reads as weightless, and one
* who takes a second reads as stuck in treacle. With an acceleration of `a` and
* a top speed of `s`, a turn should take about `2s/a` seconds -- the time to
* shed the old speed plus the time to build the new one.
*/
akerr_ErrorContext *test_sim_sudden_reversal(void)
{
PREPARE_ERROR(errctx);
akgl_Actor *actor = NULL;
SDL_Event event;
float32_t topspeed = 0.0f;
float32_t expected_turn = 0.0f;
float32_t actual_turn = 0.0f;
int steps_to_reverse = 0;
int i = 0;
ATTEMPT {
sim_arcade(0.0, 0.0, 0.0);
CATCH(errctx, sim_actor(&actor, "runner", 900.0f, 220.0f));
// Up to speed going right.
sim_keyevent(&event, true);
CATCH(errctx, akgl_actor_cmhf_right_on(actor, &event));
for ( i = 0; i < 60; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
}
topspeed = actor->vx;
TEST_ASSERT(errctx, topspeed > 0.0f, "the runner never got up to speed (vx=%f)", topspeed);
// Now the other way, without letting go first -- which is what a player
// does, and which skips the release handler that zeroes everything.
CATCH(errctx, akgl_actor_cmhf_left_on(actor, &event));
for ( i = 0; i < SIM_MAX_STEPS; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
if ( actor->vx <= -topspeed ) {
steps_to_reverse = i + 1;
break;
}
}
TEST_ASSERT(errctx, steps_to_reverse > 0,
"the runner never reached full speed the other way (vx=%f after %d steps)",
actor->vx, SIM_MAX_STEPS);
actual_turn = (float32_t)steps_to_reverse * SIM_DT;
expected_turn = (2.0f * topspeed) / sim_character.ax;
printf(" reversal: %.3f s to turn around at %.0f px/s (2s/a predicts %.3f s)\n",
actual_turn, topspeed, expected_turn);
// A turn that takes no time at all means the velocity was assigned
// rather than accelerated.
TEST_ASSERT(errctx, steps_to_reverse > 1,
"the runner reversed from +%f to -%f in %d step(s)",
topspeed, topspeed, steps_to_reverse);
// And it should match the acceleration the character actually declares,
// within a couple of frames of rounding.
TEST_ASSERT(errctx,
(actual_turn > (expected_turn - (3.0f * SIM_DT))) &&
(actual_turn < (expected_turn + (3.0f * SIM_DT))),
"turning took %.3f s; the character's acceleration predicts %.3f s",
actual_turn, expected_turn);
} CLEANUP {
if ( actor != NULL ) {
IGNORE(akgl_heap_release_actor(actor));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief The first step must not launch the world.
*
* `gravity_time` is the timestamp the step's dt is measured from, and nothing
* initialises it -- akgl_physics_init_arcade sets every other field and leaves
* this one at whatever the backend's storage held, which for the default
* backend is zero. The first dt is then the whole time since SDL started.
*
* A game that spends a second loading before its first frame gets a one-second
* step, and with gravity that is a fall of several hundred pixels between the
* first frame and the second.
*/
akerr_ErrorContext *test_sim_first_step_is_not_a_leap(void)
{
PREPARE_ERROR(errctx);
akgl_Actor *actor = NULL;
float32_t worstcase = 0.0f;
ATTEMPT {
sim_arcade(1600.0, 0.0, 0.0);
CATCH(errctx, sim_actor(&actor, "faller", 900.0f, 220.0f));
// Exactly what a host does: initialize the backend, then start calling
// simulate. Nothing here sets gravity_time, because there is no
// documented way for a caller to.
CATCH(errctx, akgl_physics_init_arcade(&sim_physics));
sim_physics.gravity_y = 1600.0;
// A host does not call simulate() microseconds after configuring the
// physics; it loads a level first, and *then* starts its frame loop. dt
// is measured from gravity_time, so that load lands in the first step
// however gravity_time was initialized.
SDL_Delay(SIM_LOAD_TIME_MS);
CATCH(errctx, sim_physics.simulate(&sim_physics, NULL));
printf(" first step: y=%.1f vy=%.1f after one simulate() from a fresh backend\n",
actor->y, actor->vy);
// One 60 Hz step of 1600 px/s^2 is 0.44 px. Anything past a few pixels
// means dt was not a frame -- and the same happens on any hitch a
// running game takes: a level load, a breakpoint, a dragged window, an
// alt-tab. Initializing gravity_time is necessary but does not cover
// this on its own; the step has to be bounded.
//
// The bound this asserts is the worst case the backend still permits,
// derived rather than written down: one step of at most max_timestep
// accelerates to g*dt and then travels that for dt, so g*dt^2 -- 4 px
// under this gravity at the default 0.05 s. Not 100 px, which is what a
// 250 ms load bought before the step was bounded.
worstcase = (float32_t)(1600.0 * AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP * AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP);
TEST_ASSERT(errctx, (actor->y <= worstcase) && (actor->y > -worstcase),
"one step from a freshly initialized backend moved the actor %f px "
"after a %d ms load; a 60 Hz frame under this gravity is 0.44 px "
"and a step bounded to %.2f s cannot exceed %f px",
actor->y, SIM_LOAD_TIME_MS, AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP, worstcase);
} CLEANUP {
if ( actor != NULL ) {
IGNORE(akgl_heap_release_actor(actor));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief Releasing a vertical key mid-air must not cancel gravity.
*
* akgl_actor_cmhf_up_off and _down_off zero `ay`, `ey`, `ty` and `vy`. `ey` is
* the environmental accumulator -- the field gravity has been building up all
* fall -- and it belongs to the world, not to the key.
*
* A platformer that maps down to crouch or fast-fall, or up to look up or climb,
* releases those keys in mid-air constantly. Every release parks the actor in
* the air with zero vertical velocity and starts the fall again from nothing.
*/
akerr_ErrorContext *test_sim_vertical_key_release_keeps_gravity(void)
{
PREPARE_ERROR(errctx);
akgl_Actor *plain = NULL;
akgl_Actor *crouched = NULL;
SDL_Event event;
int i = 0;
ATTEMPT {
sim_arcade(1600.0, 0.0, 0.0);
CATCH(errctx, sim_actor(&plain, "faller_plain", 900.0f, 220.0f));
CATCH(errctx, sim_actor(&crouched, "faller_crouch", 900.0f, 220.0f));
plain->basechar = &sim_character;
// Both fall from rest for a third of a second.
for ( i = 0; i < 20; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
}
TEST_ASSERT(errctx, plain->vy > 100.0f,
"the plain faller is only doing %f px/s after 20 steps of 1600 px/s^2",
plain->vy);
// One of them taps down and lets go, the way a fast-fall or a crouch
// does. The fall is the world's, not the key's.
sim_keyevent(&event, true);
CATCH(errctx, akgl_actor_cmhf_down_on(crouched, &event));
CATCH(errctx, sim_step(SIM_DT));
sim_keyevent(&event, false);
CATCH(errctx, akgl_actor_cmhf_down_off(crouched, &event));
printf(" vertical release: plain vy=%.1f | after a down tap vy=%.1f\n",
plain->vy, crouched->vy);
TEST_ASSERT(errctx, crouched->vy > (plain->vy * 0.5f),
"releasing the down key left vy at %f while the untouched faller is at "
"%f; the release handler zeroed the gravity accumulator",
crouched->vy, plain->vy);
} CLEANUP {
if ( crouched != NULL ) {
IGNORE(akgl_heap_release_actor(crouched));
}
if ( plain != NULL ) {
IGNORE(akgl_heap_release_actor(plain));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief Run one simulation, report it, and return 1 if it failed.
*
* The context is released here rather than propagated, so one bad case does not
* stop the rest from running.
*/
static int sim_run(const char *name, akerr_ErrorContext *(*simulation)(void))
{
akerr_ErrorContext *result = NULL;
printf("%s:\n", name);
result = simulation();
if ( result == NULL ) {
return 0;
}
result->handled = true;
result = akerr_release_error(result);
printf(" ^^ FAILED\n");
return 1;
}
int main(void)
{
PREPARE_ERROR(errctx);
int failures = 0;
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
ATTEMPT {
CATCH(errctx, akgl_error_init());
// SDL_GetTicksNS() counts from SDL's own initialization, and
// akgl_physics_simulate measures dt against it. Without this the clock
// epoch would be established by the first call inside the first
// simulation, every reading would be a handful of nanoseconds, and the
// first-step case could not reproduce what a real host sees.
if ( !SDL_Init(SDL_INIT_VIDEO) ) {
FAIL_BREAK(errctx, AKGL_ERR_SDL, "Couldn't initialize SDL: %s", SDL_GetError());
}
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_registry_init());
// Every case runs even when an earlier one fails. This suite exists to
// describe the motion, and stopping at the first bad number would hide
// the rest of the picture -- which is the whole picture.
failures += sim_run("first step is a frame", &test_sim_first_step_is_not_a_leap);
failures += sim_run("jump and fall", &test_sim_jump_and_fall);
failures += sim_run("air control keeps the arc", &test_sim_air_control_preserves_the_arc);
failures += sim_run("vertical release keeps gravity", &test_sim_vertical_key_release_keeps_gravity);
failures += sim_run("top-down walk", &test_sim_topdown_walk);
failures += sim_run("top-down diagonal speed", &test_sim_topdown_diagonal_speed);
failures += sim_run("sudden reversal", &test_sim_sudden_reversal);
printf("\n%d of 7 simulations failed\n", failures);
FAIL_NONZERO_BREAK(errctx, failures, AKGL_ERR_BEHAVIOR,
"%d physics simulations do not behave as a game needs", failures);
} CLEANUP {
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
return 0;
}