Fix three arcade physics defects the unit tests could not see

tests/physics_sim.c runs the arcade backend the way a game does -- a
Mario-esque jump and fall, Zelda-style top-down walking, and a run
reversed at full speed -- and prints what the actor actually did.
tests/physics.c already checked that akgl_physics_simulate does the
arithmetic it claims. Every one of these got past it.

The first step was however long the level took to load. gravity_time was
never initialized and dt was unbounded, so a 250 ms load produced a
250 ms step: 100 px of fall where a 60 Hz frame under the same gravity
is 0.44 px, straight through whatever was underneath. Both initializers
seed the clock, and simulate bounds dt to max_timestep -- a new
physics.max_timestep property, default 0.05 s, read like gravity and
drag. Zero disables the bound. The field replaces the dead timer_gravity,
so the struct is the same size.

Releasing a vertical key cancelled gravity. The _off handlers zeroed ay,
ey, ty and vy together, and ey is where the arcade backend accumulates
gravity -- tapping down mid-jump stopped the character in the air. They
clear ax/tx (or ay/ty) and nothing else now. Velocity was never theirs
to clear either: simulate recomputes v as e + t every step.

Diagonal movement was 41% too fast. Thrust was capped per axis, so an
actor holding two directions got both caps at once and travelled their
diagonal. It is capped as a vector against the sx/sy/sz ellipse, which
also keeps a character whose horizontal and vertical top speeds differ
moving at the ratio it asked for. An axis with a zero max speed stays
out of the magnitude and is forced to zero, as the old clamp did.

Each fix was checked by reverting it and confirming the simulation goes
red: 100.1 px, vy 0.0 after a down tap, and 141% diagonal respectively.
tests/actor.c and tests/physics.c pinned the old behaviour in both
places and now assert the new contract.

Bumped to 0.6.0: akgl_PhysicsBackend changed. TODO.md records the four
things the simulations found and this does not fix -- no terminal
velocity, no deceleration on release, Euler's frame-rate dependence, and
a drag coefficient large enough to invert velocity.

26/26 ctest, memcheck clean, warning-clean at -Wall -Werror.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8T5FAYXE8HEJqFLCYwNNc
This commit is contained in:
2026-08-01 12:10:53 -04:00
parent d55778e625
commit 147ab7dc78
9 changed files with 931 additions and 55 deletions

View File

@@ -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 {