/** * @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 #include #include #include #include #include #include #include #include #include #include #include #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; }