diff --git a/AGENTS.md b/AGENTS.md index 3bb40b3..55e575e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -559,6 +559,50 @@ CTest recorded a pass. That is not hypothetical: `tests/character.c` aborted at its second of four tests on a bad renderer and was green until 0.5.0. The trap in `tests/testutil.h` collapses any status a byte cannot carry onto 1. Add focused tests as `tests/.c`, create a matching `test_` target, and register it with `add_test`. Put reusable fixtures in `tests/assets/` and keep paths compatible with tests launched from the build tree. Coverage mode wraps the suite in a CTest fixture so counters are reset before tests and reports are generated afterward. +### Physics simulations + +`tests/physics_sim.c` is not a unit suite. `tests/physics.c` checks that +`akgl_physics_simulate` does the arithmetic it says it does; this one runs the +arcade backend for a second or two the way a game does and asks whether the +result is what a player would expect. It carries a Mario-esque jump and fall, +Zelda-style top-down walking, and a run reversed at full speed, because those +are the three shapes most 2D games are made of. + +It found three defects that every existing unit test passed straight over, so +the distinction is worth keeping: + +- **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 is 0.44 px. Both + initializers seed the clock now, and `akgl_physics_simulate` bounds `dt` to + `max_timestep` (`physics.max_timestep`, default 0.05 s). +- **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, so tapping down mid-jump stopped the character in the + air. The handlers clear `ax`/`tx` (or `ay`/`ty`) and nothing else. 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 now. + +Two rules for working on this suite: + +- **Drive the clock, do not sleep on it.** `sim_step()` sets `gravity_time` to + `now - dt` and calls `simulate` once, so a simulated second is 60 steps and + takes no wall-clock time. Only `test_sim_first_step_is_not_a_leap` uses a real + `SDL_Delay`, because a real stall is the thing it is measuring. +- **`main` must `SDL_Init`.** Without it, SDL sets its clock epoch on first use, + `SDL_GetTicksNS()` returns something around 130 ns, and every dt-dependent + assertion passes for a reason that has nothing to do with the code. The + first-step test passed exactly that way before the `SDL_Init` call was added, + and only started failing -- correctly -- once it was there. + +Each simulation prints what it measured whether it passes or fails, and `main` +exits with the number that failed. Read the numbers when changing the backend; +a change that keeps every assertion green while moving the apex of a jump by +30 px is a change worth noticing. + ### Performance suites `tests/perf.c` (nothing that draws) and `tests/perf_render.c` (everything that diff --git a/CMakeLists.txt b/CMakeLists.txt index c3d0c3a..539da58 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.10) # The single source of truth for the library version. It drives the generated # include/akgl/version.h, the shared library's VERSION and SOVERSION, and the # Version field in akgl.pc. Bump it here and nowhere else. -project(akgl VERSION 0.5.0 LANGUAGES C) +project(akgl VERSION 0.6.0 LANGUAGES C) # Memory checking reuses the suites that already exist -- `ctest -T memcheck` # runs every registered test under valgrind -- rather than adding programs of its @@ -314,6 +314,7 @@ set(AKGL_TEST_SUITES heap json_helpers physics + physics_sim registry renderer sprite diff --git a/TODO.md b/TODO.md index e153b10..d6c013a 100644 --- a/TODO.md +++ b/TODO.md @@ -1884,6 +1884,69 @@ currently promises the opposite. `aksl_strncpy` already reports exactly that status when the bytes do not fit, so the change is to stop capping `n` at `size - 1` and let it raise. +## Arcade physics feel + +Found by building `tests/physics_sim.c`, which runs the arcade backend as a game +would -- a Mario-esque jump and fall, Zelda-style top-down movement, and a +fast run reversed at speed -- and prints what the actor actually did. Three +defects it found are fixed in 0.6.0 (an unbounded first step, release handlers +zeroing the gravity accumulator, and per-axis thrust caps composing to a 141% +diagonal). These are what it found and did **not** fix. + +### No terminal velocity + +`akgl_physics_arcade_gravity` adds `gravity_y * dt` to `ey` every step and +nothing bounds it. `sy` caps *thrust*, deliberately -- it is the character's own +top speed, not a speed limit on the world -- so a fall accelerates without limit +for as long as it lasts. The jump simulation reaches 560 px/s in 0.7 s and would +keep going. + +Drag is the mechanism that exists for this: `physics.drag.y` makes `ey` +approach `gravity_y / drag_y` instead of diverging. So the behaviour is +reachable, it just is not the default and is not documented as the way to get a +terminal velocity. Either document it that way or add an explicit +`physics.terminal_velocity`. Touches `src/physics.c` and +`include/akgl/physics.h`; no ABI change if it is a property rather than a field. + +### Releasing a direction stops the actor dead + +`akgl_actor_cmhf_*_off` sets `tx` (or `ty`) to zero, so a character running at +full speed stops within one frame of the key coming up -- the top-down +simulation measures 0.0 px of drift in the second after release. That is correct +for Zelda and wrong for Mario, who slides. There is no deceleration or ground +friction anywhere in the arcade backend; `ax` is the only rate, and it applies +only while a direction is held. + +The shape that fits the existing model is a per-character deceleration that +decays `tx` toward zero over several frames instead of assigning zero, with the +current behaviour as the value that means "instant". That is a new +`akgl_Character` field and an ABI break, so it wants to land with other +character changes rather than alone. + +### Integration is explicit Euler, so trajectories are frame-rate dependent + +Velocity is applied to position using the velocity computed *this* step, and +thrust is accumulated before the cap, so the same jump traces a slightly +different arc at 30 Hz than at 144 Hz. The reversal simulation measures 0.500 s +to turn around where the closed form predicts 0.489 s -- about 2% at 60 Hz, and +it grows with the step. + +`max_timestep` bounds how bad it gets (that is half of why it exists), and 2% is +not a bug a player can see. It is recorded because the fix -- a fixed-step +accumulator, integrating in whole `max_timestep` slices and carrying the +remainder -- would also remove the slow-motion trade that bounding the step +currently makes, and the two are the same piece of work. + +### A large drag coefficient can invert velocity + +`actor->ex -= actor->ex * self->drag_x * dt` is a first-order decay, so it only +decays for `drag * dt < 1`. Past that it overshoots zero, and past `2` it +diverges. With `dt` bounded to the default 0.05 s that needs a drag coefficient +above 20, which is not a plausible setting, so this is a sharp edge rather than +a live defect -- but nothing rejects it, and `max_timestep` is caller-settable. +`src/physics.c`, in the three drag blocks. The exact fix is +`expf(-drag * dt)` instead of `1 - drag * dt`, which is unconditionally stable. + ## Carried over 1. **Make character-to-sprite state bindings release their references symmetrically.** diff --git a/include/akgl/physics.h b/include/akgl/physics.h index 02bf4b2..54b44ea 100644 --- a/include/akgl/physics.h +++ b/include/akgl/physics.h @@ -16,15 +16,29 @@ * | thrust | `tx, ty, tz` | the actor's own acceleration while it is moving | * | environmental | `ex, ey, ez` | gravity, less drag | * | velocity | `vx, vy, vz` | thrust + environmental | - * | max speed | `sx, sy, sz` | the character; caps thrust, not velocity | + * | max speed | `sx, sy, sz` | the character; caps the thrust vector, not velocity | * | acceleration | `ax, ay, az` | the character | * * Each step: movement logic, then gravity, then drag, then velocity, then move. * Because the cap is applied to thrust rather than to velocity, gravity can * carry an actor faster than its stated top speed -- which is the point, for a * falling one. + * + * The cap is on the thrust **vector**, not on each axis separately. Capping the + * axes independently lets the corner of the box through: an actor holding two + * directions at once got both caps at once and travelled their diagonal, 41% + * faster than either alone. Scaling to the ellipse instead keeps a character + * whose horizontal and vertical speeds differ moving at the ratio it asked for. */ +/** + * @brief Default bound on one simulation step, in seconds. + * + * A twentieth of a second: three frames at 60 Hz, so an ordinary dropped frame + * or two passes through untouched, and a load or a breakpoint does not. + */ +#define AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP 0.05 + #ifndef _AKGL_PHYSICS_H_ #define _AKGL_PHYSICS_H_ @@ -47,8 +61,8 @@ typedef struct akgl_PhysicsBackend { float64_t gravity_x; /**< Acceleration along x, world units per second squared. Applied toward screen left. From `physics.gravity.x`. */ float64_t gravity_y; /**< Acceleration along y. Applied down the screen, since y grows downward. From `physics.gravity.y`. */ float64_t gravity_z; /**< Acceleration along z. Applied away from the camera. From `physics.gravity.z`. */ - SDL_Time gravity_time; /**< Timestamp of the previous step, in nanoseconds. The simulation's `dt` is measured from this. */ - SDL_Time timer_gravity; /**< Unused. Nothing in the library reads or writes it. */ + SDL_Time gravity_time; /**< Timestamp of the previous step, in nanoseconds. Stamped by akgl_physics_simulate and seeded by the initializers; the simulation's `dt` is measured from this. */ + float64_t max_timestep; /**< Longest step the simulation will take, in seconds. A hitch longer than this advances the world by this much instead. 0 disables the bound. From `physics.max_timestep`, default #AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP. */ } akgl_PhysicsBackend; /** @@ -203,16 +217,26 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_factory(akgl_PhysicsBackend *sel * offset, and skipped entirely -- children do not simulate; * - an actor with no character is skipped, since the speed and acceleration * constants live there; - * - thrust accumulates along an axis the actor is moving on, then is clamped to - * the character's maximum speed; + * - thrust accumulates along an axis the actor is moving on, then the thrust + * *vector* is scaled to the ellipse the character's per-axis maximum speeds + * describe; * - the backend's `gravity` runs, drag is applied to the environmental term, * velocity becomes environmental plus thrust, and the backend's `move` * commits it. * - * `dt` is measured from `self->gravity_time`, which is stamped at the end. The - * first call after initialization therefore measures from 0 and produces an - * enormous `dt` -- initialize `gravity_time` from `SDL_GetTicksNS()` before the - * first step if that matters. + * `dt` is measured from `self->gravity_time`, which is stamped at the end and + * seeded by akgl_physics_init_arcade and akgl_physics_init_null. It is then + * bounded by `self->max_timestep`: a step longer than that advances the world + * by `max_timestep` instead of by the whole elapsed time. + * + * That bound is not a nicety. Anything that stalls a frame -- a level load + * between initialization and the first step, a breakpoint, a dragged window, an + * alt-tab -- otherwise arrives as one enormous `dt`, and one enormous `dt` + * moves every actor by however far its velocity carries it in that whole + * interval. A quarter-second load under ordinary platformer gravity is a + * hundred pixels of fall between the first frame and the second, straight + * through whatever was underneath. Advancing the world in slow motion during a + * hitch is the trade every game engine makes here. * * @param self The backend. Required, along with its `move` pointer. * @param opflags Iterator flags. Optional -- `NULL` means "simulate everything". diff --git a/src/actor.c b/src/actor.c index 411c850..61a390d 100644 --- a/src/actor.c +++ b/src/actor.c @@ -354,9 +354,7 @@ akerr_ErrorContext *akgl_actor_cmhf_left_off(akgl_Actor *obj, SDL_Event *event) FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event"); //SDL_Log("event %d (button %d / key %d) stops moving actor left", event->type, event->gbutton.which, event->key.key); obj->ax = 0; - obj->ex = 0; obj->tx = 0; - obj->vx = 0; AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_LEFT); //SDL_Log("new target actor state: %b", obj->state); SUCCEED_RETURN(errctx); @@ -383,9 +381,7 @@ akerr_ErrorContext *akgl_actor_cmhf_right_off(akgl_Actor *obj, SDL_Event *event) FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event"); //SDL_Log("event %d (button %d / key %d) stops moving actor right", event->type, event->gbutton.which, event->key.key); obj->ax = 0; - obj->ex = 0; obj->tx = 0; - obj->vx = 0; AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_RIGHT); //SDL_Log("new target actor state: %b", obj->state); SUCCEED_RETURN(errctx); @@ -412,9 +408,7 @@ akerr_ErrorContext *akgl_actor_cmhf_up_off(akgl_Actor *obj, SDL_Event *event) FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event"); //SDL_Log("event %d (button %d / key %d) stops moving actor up", event->type, event->gbutton.which, event->key.key); obj->ay = 0; - obj->ey = 0; obj->ty = 0; - obj->vy = 0; AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_UP); //SDL_Log("new target actor state: %b", obj->state); SUCCEED_RETURN(errctx); @@ -440,10 +434,8 @@ akerr_ErrorContext *akgl_actor_cmhf_down_off(akgl_Actor *obj, SDL_Event *event) FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL actor"); FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event"); //SDL_Log("event %d (button %d / key %d) stops moving actor down", event->type, event->gbutton.which, event->key.key); - obj->ty = 0; - obj->ey = 0; obj->ay = 0; - obj->vy = 0; + obj->ty = 0; AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_DOWN); //SDL_Log("new target actor state: %b", obj->state); SUCCEED_RETURN(errctx); diff --git a/src/physics.c b/src/physics.c index d8a3fd2..b425b32 100644 --- a/src/physics.c +++ b/src/physics.c @@ -47,6 +47,12 @@ akerr_ErrorContext *akgl_physics_init_null(akgl_PhysicsBackend *self) self->move = akgl_physics_null_move; self->simulate = akgl_physics_simulate; + // Set for the same reason as the arcade backend: the null backend still + // goes through akgl_physics_simulate, which still computes a dt and still + // commits velocity to position through akgl_physics_null_move. + self->gravity_time = SDL_GetTicksNS(); + self->max_timestep = AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP; + SUCCEED_RETURN(errctx); } @@ -104,6 +110,14 @@ akerr_ErrorContext *akgl_physics_init_arcade(akgl_PhysicsBackend *self) self->move = akgl_physics_arcade_move; self->simulate = akgl_physics_simulate; + // The epoch the first step's dt is measured from. Nothing set it, so it + // stayed at whatever the backend's storage held -- zero, for the default + // backend in BSS -- and the first akgl_physics_simulate() measured dt as + // the entire time since SDL started. A host that spends a quarter of a + // second loading before its first frame got a quarter-second step: 101 + // pixels of fall where a 60 Hz frame is 0.44. + self->gravity_time = SDL_GetTicksNS(); + ATTEMPT { CATCH(errctx, akgl_get_property("physics.gravity.x", &tmp, "0.0")); CATCH(errctx, aksl_atof(tmp->data, &self->gravity_x)); @@ -117,6 +131,8 @@ akerr_ErrorContext *akgl_physics_init_arcade(akgl_PhysicsBackend *self) CATCH(errctx, aksl_atof(tmp->data, &self->drag_y)); CATCH(errctx, akgl_get_property("physics.drag.z", &tmp, "0.0")); CATCH(errctx, aksl_atof(tmp->data, &self->drag_z)); + CATCH(errctx, akgl_get_property("physics.max_timestep", &tmp, "0.05")); + CATCH(errctx, aksl_atof(tmp->data, &self->max_timestep)); } CLEANUP { IGNORE(akgl_heap_release_string(tmp)); } PROCESS(errctx) { @@ -134,6 +150,8 @@ akerr_ErrorContext *akgl_physics_simulate(akgl_PhysicsBackend *self, akgl_Iterat }; SDL_Time curtime = 0; float32_t dt = 0; + float32_t overshoot = 0.0f; + float32_t thrustscale = 0.0f; akgl_Actor *actor = NULL; FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self"); @@ -144,6 +162,23 @@ akerr_ErrorContext *akgl_physics_simulate(akgl_PhysicsBackend *self, akgl_Iterat curtime = SDL_GetTicksNS(); dt = (float32_t)(curtime - self->gravity_time) / (float32_t)AKGL_TIME_ONESEC_NS; + // Bound the step. Everything below multiplies by dt, so one enormous dt + // moves every actor by however far its velocity carries it over the whole + // stall -- a quarter-second load is a hundred pixels of fall under + // platformer gravity, straight through whatever was underneath. Advancing + // the world in slow motion through a hitch is the trade to make here. + // + // A zero or negative max_timestep disables the bound, for a caller who + // would rather have the real elapsed time and handle it themselves. + if ( (self->max_timestep > 0.0) && (dt > (float32_t)self->max_timestep) ) { + dt = (float32_t)self->max_timestep; + } + // A clock that went backwards is not a step. SDL_GetTicksNS is monotonic, + // but gravity_time is a public field and a caller can put anything in it. + if ( dt < 0.0f ) { + dt = 0.0f; + } + if ( opflags == NULL ) { opflags = &defflags; } @@ -179,27 +214,40 @@ akerr_ErrorContext *akgl_physics_simulate(akgl_PhysicsBackend *self, akgl_Iterat actor->ty += actor->ay * dt; } - // velocity equals thrust unless thrust exceeds max speed - if ( fabsf(actor->tx) > fabsf(actor->sx) ) { - if ( actor->tx < 0 ) { - actor->tx = -actor->sx; - } else { - actor->tx = actor->sx; - } + // Cap the thrust *vector* against the ellipse the character's per-axis + // top speeds describe, rather than each axis against its own cap. + // + // Capping the axes independently lets the corner of the box through: an + // actor holding two directions at once got both caps at once and + // travelled their diagonal, which measured 41% faster than either alone. + // Scaling to the ellipse keeps a character whose horizontal and vertical + // speeds differ moving at the ratio it asked for, and makes them equal + // where the speeds are. + // + // An axis with a top speed of zero cannot be thrust along at all, which + // is what the old per-axis clamp did with it, and it stays out of the + // magnitude entirely -- dividing by it would not end well. + overshoot = 0.0f; + if ( actor->sx != 0.0f ) { + overshoot += (actor->tx / actor->sx) * (actor->tx / actor->sx); + } else { + actor->tx = 0.0f; } - if ( fabsf(actor->ty) > fabsf(actor->sy) ) { - if ( actor->ty < 0 ) { - actor->ty = -actor->sy; - } else { - actor->ty = actor->sy; - } + if ( actor->sy != 0.0f ) { + overshoot += (actor->ty / actor->sy) * (actor->ty / actor->sy); + } else { + actor->ty = 0.0f; } - if ( fabsf(actor->tz) > fabsf(actor->sz) ) { - if ( actor->tz < 0 ) { - actor->tz = -actor->sz; - } else { - actor->tz = actor->sz; - } + if ( actor->sz != 0.0f ) { + overshoot += (actor->tz / actor->sz) * (actor->tz / actor->sz); + } else { + actor->tz = 0.0f; + } + if ( overshoot > 1.0f ) { + thrustscale = 1.0f / sqrtf(overshoot); + actor->tx *= thrustscale; + actor->ty *= thrustscale; + actor->tz *= thrustscale; } ATTEMPT { CATCH(errctx, actor->movementlogicfunc(actor, dt)); diff --git a/tests/actor.c b/tests/actor.c index 8df9b14..7876cb0 100644 --- a/tests/actor.c +++ b/tests/actor.c @@ -444,8 +444,17 @@ akerr_ErrorContext *test_actor_control_handlers_off(void) basechar.ax = 7.0f; basechar.ay = 11.0f; - // Releasing a direction zeroes that axis outright: acceleration, thrust, - // environmental force, and velocity all go to zero together. + // Releasing a direction stops the actor pushing: acceleration and thrust + // go to zero. It does *not* touch the environmental force on that axis. + // + // It used to zero `e` and `v` as well, and that is a real bug rather + // than a style difference: `ey` is where the arcade backend accumulates + // gravity, so tapping down while falling zeroed the fall and the + // character stopped dead in mid-air. `tests/physics_sim.c` measures it + // (`vertical release keeps gravity`). Velocity is not the actor's to + // clear either -- akgl_physics_simulate recomputes `vx` as `ex + tx` + // every step, so whatever a handler writes there is overwritten before + // anything can read it. memset(&actor, 0x00, sizeof(akgl_Actor)); actor.basechar = &basechar; actor.ax = 5; actor.ex = 5; actor.tx = 5; actor.vx = 5; @@ -453,22 +462,25 @@ akerr_ErrorContext *test_actor_control_handlers_off(void) actor.state = (AKGL_ACTOR_STATE_MOVING_LEFT | AKGL_ACTOR_STATE_FACE_LEFT); TEST_EXPECT_OK(e, akgl_actor_cmhf_left_off(&actor, &event), "left off"); TEST_ASSERT_FEQ(e, actor.ax, 0.0f, "left off left ax at %f", actor.ax); - TEST_ASSERT_FEQ(e, actor.ex, 0.0f, "left off left ex at %f", actor.ex); TEST_ASSERT_FEQ(e, actor.tx, 0.0f, "left off left tx at %f", actor.tx); - TEST_ASSERT_FEQ(e, actor.vx, 0.0f, "left off left vx at %f", actor.vx); + TEST_ASSERT_FEQ(e, actor.ex, 5.0f, "left off cleared ex (%f), which is the world's, not the actor's", actor.ex); TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_MOVING_LEFT), "left off did not clear MOVING_LEFT (state %d)", actor.state); // Facing is deliberately sticky, so an idle actor keeps looking where it was. TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_FACE_LEFT), "left off cleared FACE_LEFT, which should persist (state %d)", actor.state); // The Y axis is untouched by a horizontal release. - TEST_ASSERT_FEQ(e, actor.vy, 5.0f, "left off disturbed vy (%f)", actor.vy); + TEST_ASSERT_FEQ(e, actor.ay, 5.0f, "left off disturbed ay (%f)", actor.ay); + TEST_ASSERT_FEQ(e, actor.ey, 5.0f, "left off disturbed ey (%f)", actor.ey); + TEST_ASSERT_FEQ(e, actor.ty, 5.0f, "left off disturbed ty (%f)", actor.ty); memset(&actor, 0x00, sizeof(akgl_Actor)); actor.ax = 5; actor.ex = 5; actor.tx = 5; actor.vx = 5; actor.state = AKGL_ACTOR_STATE_MOVING_RIGHT; TEST_EXPECT_OK(e, akgl_actor_cmhf_right_off(&actor, &event), "right off"); - TEST_ASSERT_FEQ(e, actor.vx, 0.0f, "right off left vx at %f", actor.vx); + TEST_ASSERT_FEQ(e, actor.ax, 0.0f, "right off left ax at %f", actor.ax); + TEST_ASSERT_FEQ(e, actor.tx, 0.0f, "right off left tx at %f", actor.tx); + TEST_ASSERT_FEQ(e, actor.ex, 5.0f, "right off cleared ex (%f)", actor.ex); TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_MOVING_RIGHT), "right off did not clear MOVING_RIGHT (state %d)", actor.state); @@ -476,7 +488,9 @@ akerr_ErrorContext *test_actor_control_handlers_off(void) actor.ay = 5; actor.ey = 5; actor.ty = 5; actor.vy = 5; actor.state = AKGL_ACTOR_STATE_MOVING_UP; TEST_EXPECT_OK(e, akgl_actor_cmhf_up_off(&actor, &event), "up off"); - TEST_ASSERT_FEQ(e, actor.vy, 0.0f, "up off left vy at %f", actor.vy); + TEST_ASSERT_FEQ(e, actor.ay, 0.0f, "up off left ay at %f", actor.ay); + TEST_ASSERT_FEQ(e, actor.ty, 0.0f, "up off left ty at %f", actor.ty); + TEST_ASSERT_FEQ(e, actor.ey, 5.0f, "up off cleared ey (%f), which is where gravity accumulates", actor.ey); TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_MOVING_UP), "up off did not clear MOVING_UP (state %d)", actor.state); @@ -484,7 +498,9 @@ akerr_ErrorContext *test_actor_control_handlers_off(void) actor.ay = 5; actor.ey = 5; actor.ty = 5; actor.vy = 5; actor.state = AKGL_ACTOR_STATE_MOVING_DOWN; TEST_EXPECT_OK(e, akgl_actor_cmhf_down_off(&actor, &event), "down off"); - TEST_ASSERT_FEQ(e, actor.vy, 0.0f, "down off left vy at %f", actor.vy); + TEST_ASSERT_FEQ(e, actor.ay, 0.0f, "down off left ay at %f", actor.ay); + TEST_ASSERT_FEQ(e, actor.ty, 0.0f, "down off left ty at %f", actor.ty); + TEST_ASSERT_FEQ(e, actor.ey, 5.0f, "down off cleared ey (%f), which is where gravity accumulates", actor.ey); TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_MOVING_DOWN), "down off did not clear MOVING_DOWN (state %d)", actor.state); } CLEANUP { diff --git a/tests/physics.c b/tests/physics.c index 3a944b9..09e15b0 100644 --- a/tests/physics.c +++ b/tests/physics.c @@ -531,20 +531,69 @@ akerr_ErrorContext *test_physics_simulate_thrust_and_clamp(void) CATCH(e, akgl_physics_init_null(&backend)); CATCH(e, make_sim_actor(&actor, &basechar, "thruster")); - // Thrust beyond the actor's max speed clamps to +sx, preserving sign. + // 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 = 400.0f; actor->tz = 300.0f; - TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with positive over-thrust"); + 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); - TEST_ASSERT_FEQ(e, actor->ty, 40.0f, "ty clamped to %f, expected 40", actor->ty); + + 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); - // Negative over-thrust clamps to -sx. + // 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"); - TEST_ASSERT_FEQ(e, actor->tx, -50.0f, "tx clamped to %f, expected -50", actor->tx); - TEST_ASSERT_FEQ(e, actor->ty, -40.0f, "ty clamped to %f, expected -40", actor->ty); - TEST_ASSERT_FEQ(e, actor->tz, -30.0f, "tz clamped to %f, expected -30", actor->tz); + 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; diff --git a/tests/physics_sim.c b/tests/physics_sim.c new file mode 100644 index 0000000..decd760 --- /dev/null +++ b/tests/physics_sim.c @@ -0,0 +1,639 @@ +/** + * @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 + * stepped with a caller-supplied dt. These tests set `gravity_time` to + * `now - dt` immediately before each call, which lands within a few + * microseconds of the intended step -- about 0.02% of a 60 Hz frame. + * Assertions carry tolerances to match. 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`, + * so the only way to control it from outside is to place `gravity_time` the + * right distance behind the clock immediately before the call. + */ +static akerr_ErrorContext *sim_step(float32_t dt) +{ + PREPARE_ERROR(errctx); + + sim_physics.gravity_time = SDL_GetTicksNS() - (SDL_Time)(dt * (float32_t)AKGL_TIME_ONESEC_NS); + 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()); + TEST_TRAP_UNHANDLED_ERRORS(); + // 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; +}