Files
libakgl/tests/physics_sim.c

854 lines
30 KiB
C
Raw Normal View History

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
2026-08-01 12:10:53 -04:00
/**
* @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
Report the failures libakstdlib's wrappers were already catching Updates deps/libakstdlib to 669b2b3. That commit is a TODO entry rather than new code, so the interesting part is what libakgl was not yet calling: it links libakstdlib and uses ten of its wrappers while calling the raw libc function at a good many more sites. Four of those were carrying a real defect. akgl_game_save checked every write and threw away the close. Every write goes through aksl_fwrite, and for a record this size all of them land in stdio's buffer -- nothing reaches the device until the close flushes it, so a full disk, an exceeded quota or an NFS server going away is reported only there. The function returned success over a savegame that was never written. aksl_fclose surfaces it. tests/game.c reaches the path through /dev/full, which accepts writes and fails at the flush; against the unfixed code the test reports "expected a failure, got success" with ENOSPC. The close has to drop the FILE * before the status is examined, because fclose(3) disassociates the stream either way and CLEANUP would close it again. Written the other way round it aborted in glibc with "double free detected in tcache" the first time a close actually failed, which is how that ordering was found. akgl_game_load's end-of-file check used fgetc(3), which spells "end of file" and "the read failed" with the same EOF -- a disk error there read as a clean end and passed the check it was meant to fail. aksl_fgetc tells them apart. Extracted to require_at_eof, because inverting the sense of a call inline needs a nested ATTEMPT whose break binds to the wrong switch. Two snprintf sites were truncating under a silenced -Wformat-truncation. The compiler was right about both. The tileset one handed the shortened path to IMG_LoadTexture, so a length error was reported as a missing file, with SDL naming a path the caller never wrote. Both use aksl_snprintf now and the suppression is gone; nothing in the tree uses those macros any more. tests/tilemap.c covers the join, and against the unfixed code it gets AKGL_ERR_SDL where it wants AKERR_OUTOFBOUNDS. The two src/game.c strncpy sites the fixed-width copy sweep missed -- the savegame name field and the libversion stamp -- use aksl_strncpy and sizeof rather than a repeated 32. Also makes tests/physics_sim.c deterministic. It placed gravity_time at "now - dt" and let the real clock supply the step, so a machine busy enough to deschedule the process between that store and the SDL_GetTicksNS() inside simulate got a longer step. It went red once, under a parallel ctest. It now sets max_timestep to the step it wants and gravity_time to zero, so the bound supplies the step and the scheduler cannot reach it. TODO.md records what is deliberately not adopted: the pure-arithmetic wrappers, which can only fail on NULL and which the tree already spells two ways, and the collections, which the registries do not want because SDL owns the property-set lifetime. AGENTS.md has the rule and the fclose ordering trap. 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
2026-08-01 12:36:36 -04:00
* 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.
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
2026-08-01 12:10:53 -04:00
*/
#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>
Call collide from the simulation, after the move rather than before it The collide slot has existed unused since the backend was written. It is wired in now, and its signature changes from (self, a1, a2) to (self, actor, dt): the old shape had nowhere to put a normal, a depth, or a piece of map geometry, and no answer to which of the two actors it was supposed to be resolving. **Resolution runs after `move`.** That is the decision everything else follows from. A resolver called from movementlogicfunc runs before gravity, drag and the move, so it has to predict where the actor will end up -- which means re-implementing the integrator's arithmetic in the game, and being wrong whenever the integrator changes. examples/sidescroller/collision.c carries forty lines of exactly that, and says so. Only `move` is subdivided. Gravity, drag, the thrust integration and the speed ellipse all still run once against the whole dt, and sum(subdt) is dt, so an actor with nothing to collide with takes one sub-step of exactly dt -- `dt/1.0f` is dt exactly in IEEE-754 -- and follows the arithmetic path it always did. That is what lets the integrator stay untouched and every recorded physics number stay valid, and tests/physics.c and tests/physics_sim.c pass unchanged with no world attached. Collision is opt-in. A backend with no collision world costs one comparison per actor per step and does nothing else. Also here: - `self->gravity` was never null-checked and is dereferenced unconditionally, so a hand-built backend with only `move` filled in crashed rather than reporting. - Children are re-snapped in a post-pass, gated on collision being on. The in-loop snap uses whatever position the parent had when the child's slot came up in pool order, which is already sometimes a frame stale; that was harmless while a parent only moved by v*dt and is not once a parent can be pushed out of a wall mid-step. - tests/physics.c's deliberate tripwire has been visited. It asserted that arcade collide always raised AKERR_API, with a comment saying it existed so that implementing collision would have to come back here. What replaces it asserts the opt-in contract instead. - physics_sim.c's hard-coded "%d of 7 simulations" is derived now. A literal goes stale the first time somebody adds a simulation, which is this commit. Three new whole-motion simulations, and the third took two attempts to make honest: - Landing and resting: 0.0000 px of drift over 120 steps after settling. - Walking after landing: 107 px in a second, which is the symptom a player sees when a resting actor is caught on the ground it is standing on. - A fast fall not tunnelling. The first version used gravity, so the step size grew every frame and the actor happened to land *inside* the floor rather than stepping over it -- it passed with sub-stepping disabled and proved nothing. It uses a constant 1200 px/s now, so every step is exactly 60 pixels against a 32-pixel window in which an overlap exists, and the samples miss it cleanly. With sub-stepping the actor stops at y=288; without it, y=1300. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:05:09 -04:00
#include <akgl/collision.h>
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
2026-08-01 12:10:53 -04:00
#include <akgl/physics.h>
Call collide from the simulation, after the move rather than before it The collide slot has existed unused since the backend was written. It is wired in now, and its signature changes from (self, a1, a2) to (self, actor, dt): the old shape had nowhere to put a normal, a depth, or a piece of map geometry, and no answer to which of the two actors it was supposed to be resolving. **Resolution runs after `move`.** That is the decision everything else follows from. A resolver called from movementlogicfunc runs before gravity, drag and the move, so it has to predict where the actor will end up -- which means re-implementing the integrator's arithmetic in the game, and being wrong whenever the integrator changes. examples/sidescroller/collision.c carries forty lines of exactly that, and says so. Only `move` is subdivided. Gravity, drag, the thrust integration and the speed ellipse all still run once against the whole dt, and sum(subdt) is dt, so an actor with nothing to collide with takes one sub-step of exactly dt -- `dt/1.0f` is dt exactly in IEEE-754 -- and follows the arithmetic path it always did. That is what lets the integrator stay untouched and every recorded physics number stay valid, and tests/physics.c and tests/physics_sim.c pass unchanged with no world attached. Collision is opt-in. A backend with no collision world costs one comparison per actor per step and does nothing else. Also here: - `self->gravity` was never null-checked and is dereferenced unconditionally, so a hand-built backend with only `move` filled in crashed rather than reporting. - Children are re-snapped in a post-pass, gated on collision being on. The in-loop snap uses whatever position the parent had when the child's slot came up in pool order, which is already sometimes a frame stale; that was harmless while a parent only moved by v*dt and is not once a parent can be pushed out of a wall mid-step. - tests/physics.c's deliberate tripwire has been visited. It asserted that arcade collide always raised AKERR_API, with a comment saying it existed so that implementing collision would have to come back here. What replaces it asserts the opt-in contract instead. - physics_sim.c's hard-coded "%d of 7 simulations" is derived now. A literal goes stale the first time somebody adds a simulation, which is this commit. Three new whole-motion simulations, and the third took two attempts to make honest: - Landing and resting: 0.0000 px of drift over 120 steps after settling. - Walking after landing: 107 px in a second, which is the symptom a player sees when a resting actor is caught on the ground it is standing on. - A fast fall not tunnelling. The first version used gravity, so the step size grew every frame and the actor happened to land *inside* the floor rather than stepping over it -- it passed with sub-stepping disabled and proved nothing. It uses a constant 1200 px/s now, so every step is exactly 60 pixels against a 32-pixel window in which an overlap exists, and the samples miss it cleanly. With sub-stepping the actor stops at y=288; without it, y=1300. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:05:09 -04:00
#include <akgl/tilemap.h>
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
2026-08-01 12:10:53 -04:00
#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.
*
Report the failures libakstdlib's wrappers were already catching Updates deps/libakstdlib to 669b2b3. That commit is a TODO entry rather than new code, so the interesting part is what libakgl was not yet calling: it links libakstdlib and uses ten of its wrappers while calling the raw libc function at a good many more sites. Four of those were carrying a real defect. akgl_game_save checked every write and threw away the close. Every write goes through aksl_fwrite, and for a record this size all of them land in stdio's buffer -- nothing reaches the device until the close flushes it, so a full disk, an exceeded quota or an NFS server going away is reported only there. The function returned success over a savegame that was never written. aksl_fclose surfaces it. tests/game.c reaches the path through /dev/full, which accepts writes and fails at the flush; against the unfixed code the test reports "expected a failure, got success" with ENOSPC. The close has to drop the FILE * before the status is examined, because fclose(3) disassociates the stream either way and CLEANUP would close it again. Written the other way round it aborted in glibc with "double free detected in tcache" the first time a close actually failed, which is how that ordering was found. akgl_game_load's end-of-file check used fgetc(3), which spells "end of file" and "the read failed" with the same EOF -- a disk error there read as a clean end and passed the check it was meant to fail. aksl_fgetc tells them apart. Extracted to require_at_eof, because inverting the sense of a call inline needs a nested ATTEMPT whose break binds to the wrong switch. Two snprintf sites were truncating under a silenced -Wformat-truncation. The compiler was right about both. The tileset one handed the shortened path to IMG_LoadTexture, so a length error was reported as a missing file, with SDL naming a path the caller never wrote. Both use aksl_snprintf now and the suppression is gone; nothing in the tree uses those macros any more. tests/tilemap.c covers the join, and against the unfixed code it gets AKGL_ERR_SDL where it wants AKERR_OUTOFBOUNDS. The two src/game.c strncpy sites the fixed-width copy sweep missed -- the savegame name field and the libversion stamp -- use aksl_strncpy and sizeof rather than a repeated 32. Also makes tests/physics_sim.c deterministic. It placed gravity_time at "now - dt" and let the real clock supply the step, so a machine busy enough to deschedule the process between that store and the SDL_GetTicksNS() inside simulate got a longer step. It went red once, under a parallel ctest. It now sets max_timestep to the step it wants and gravity_time to zero, so the bound supplies the step and the scheduler cannot reach it. TODO.md records what is deliberately not adopted: the pure-arithmetic wrappers, which can only fail on NULL and which the tree already spells two ways, and the collections, which the registries do not want because SDL owns the property-set lifetime. AGENTS.md has the rule and the fclose ordering trap. 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
2026-08-01 12:36:36 -04:00
* 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.
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
2026-08-01 12:10:53 -04:00
*/
static akerr_ErrorContext *sim_step(float32_t dt)
{
PREPARE_ERROR(errctx);
Report the failures libakstdlib's wrappers were already catching Updates deps/libakstdlib to 669b2b3. That commit is a TODO entry rather than new code, so the interesting part is what libakgl was not yet calling: it links libakstdlib and uses ten of its wrappers while calling the raw libc function at a good many more sites. Four of those were carrying a real defect. akgl_game_save checked every write and threw away the close. Every write goes through aksl_fwrite, and for a record this size all of them land in stdio's buffer -- nothing reaches the device until the close flushes it, so a full disk, an exceeded quota or an NFS server going away is reported only there. The function returned success over a savegame that was never written. aksl_fclose surfaces it. tests/game.c reaches the path through /dev/full, which accepts writes and fails at the flush; against the unfixed code the test reports "expected a failure, got success" with ENOSPC. The close has to drop the FILE * before the status is examined, because fclose(3) disassociates the stream either way and CLEANUP would close it again. Written the other way round it aborted in glibc with "double free detected in tcache" the first time a close actually failed, which is how that ordering was found. akgl_game_load's end-of-file check used fgetc(3), which spells "end of file" and "the read failed" with the same EOF -- a disk error there read as a clean end and passed the check it was meant to fail. aksl_fgetc tells them apart. Extracted to require_at_eof, because inverting the sense of a call inline needs a nested ATTEMPT whose break binds to the wrong switch. Two snprintf sites were truncating under a silenced -Wformat-truncation. The compiler was right about both. The tileset one handed the shortened path to IMG_LoadTexture, so a length error was reported as a missing file, with SDL naming a path the caller never wrote. Both use aksl_snprintf now and the suppression is gone; nothing in the tree uses those macros any more. tests/tilemap.c covers the join, and against the unfixed code it gets AKGL_ERR_SDL where it wants AKERR_OUTOFBOUNDS. The two src/game.c strncpy sites the fixed-width copy sweep missed -- the savegame name field and the libversion stamp -- use aksl_strncpy and sizeof rather than a repeated 32. Also makes tests/physics_sim.c deterministic. It placed gravity_time at "now - dt" and let the real clock supply the step, so a machine busy enough to deschedule the process between that store and the SDL_GetTicksNS() inside simulate got a longer step. It went red once, under a parallel ctest. It now sets max_timestep to the step it wants and gravity_time to zero, so the bound supplies the step and the scheduler cannot reach it. TODO.md records what is deliberately not adopted: the pure-arithmetic wrappers, which can only fail on NULL and which the tree already spells two ways, and the collections, which the registries do not want because SDL owns the property-set lifetime. AGENTS.md has the rule and the fclose ordering trap. 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
2026-08-01 12:36:36 -04:00
sim_physics.max_timestep = (float64_t)dt;
sim_physics.gravity_time = 0;
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
2026-08-01 12:10:53 -04:00
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;
}
Call collide from the simulation, after the move rather than before it The collide slot has existed unused since the backend was written. It is wired in now, and its signature changes from (self, a1, a2) to (self, actor, dt): the old shape had nowhere to put a normal, a depth, or a piece of map geometry, and no answer to which of the two actors it was supposed to be resolving. **Resolution runs after `move`.** That is the decision everything else follows from. A resolver called from movementlogicfunc runs before gravity, drag and the move, so it has to predict where the actor will end up -- which means re-implementing the integrator's arithmetic in the game, and being wrong whenever the integrator changes. examples/sidescroller/collision.c carries forty lines of exactly that, and says so. Only `move` is subdivided. Gravity, drag, the thrust integration and the speed ellipse all still run once against the whole dt, and sum(subdt) is dt, so an actor with nothing to collide with takes one sub-step of exactly dt -- `dt/1.0f` is dt exactly in IEEE-754 -- and follows the arithmetic path it always did. That is what lets the integrator stay untouched and every recorded physics number stay valid, and tests/physics.c and tests/physics_sim.c pass unchanged with no world attached. Collision is opt-in. A backend with no collision world costs one comparison per actor per step and does nothing else. Also here: - `self->gravity` was never null-checked and is dereferenced unconditionally, so a hand-built backend with only `move` filled in crashed rather than reporting. - Children are re-snapped in a post-pass, gated on collision being on. The in-loop snap uses whatever position the parent had when the child's slot came up in pool order, which is already sometimes a frame stale; that was harmless while a parent only moved by v*dt and is not once a parent can be pushed out of a wall mid-step. - tests/physics.c's deliberate tripwire has been visited. It asserted that arcade collide always raised AKERR_API, with a comment saying it existed so that implementing collision would have to come back here. What replaces it asserts the opt-in contract instead. - physics_sim.c's hard-coded "%d of 7 simulations" is derived now. A literal goes stale the first time somebody adds a simulation, which is this commit. Three new whole-motion simulations, and the third took two attempts to make honest: - Landing and resting: 0.0000 px of drift over 120 steps after settling. - Walking after landing: 107 px in a second, which is the symptom a player sees when a resting actor is caught on the ground it is standing on. - A fast fall not tunnelling. The first version used gravity, so the step size grew every frame and the actor happened to land *inside* the floor rather than stepping over it -- it passed with sub-stepping disabled and proved nothing. It uses a constant 1200 px/s now, so every step is exactly 60 pixels against a 32-pixel window in which an overlap exists, and the samples miss it cleanly. With sub-stepping the actor stops at y=288; without it, y=1300. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:05:09 -04:00
/**
* @brief A floor across the bottom, with a two-tile hole in it.
*
* Static, not local: sizeof(akgl_Tilemap) is about 26 MB and a local one
* overflows the stack before the first assertion runs.
*/
static akgl_Tilemap sim_map;
static akgl_CollisionWorld sim_world;
static akerr_ErrorContext *sim_collision_world(void)
{
int x = 0;
PREPARE_ERROR(errctx);
memset(&sim_map, 0x00, sizeof(akgl_Tilemap));
sim_map.tilewidth = 16;
sim_map.tileheight = 16;
sim_map.width = 40;
sim_map.height = 20;
sim_map.numlayers = 1;
sim_map.layers[0].type = AKGL_TILEMAP_LAYER_TYPE_TILES;
for ( x = 0; x < sim_map.width; x++ ) {
if ( (x == 20) || (x == 21) ) {
continue; // a hole to fall through
}
sim_map.layers[0].data[(19 * sim_map.width) + x] = 1;
}
sim_map.collidablelayers = 1u;
PASS(errctx, akgl_collision_world_init(&sim_world, NULL, 16.0f, 16.0f));
PASS(errctx, akgl_collision_bind_tilemap(&sim_world, &sim_map));
sim_physics.collision = &sim_world;
SUCCEED_RETURN(errctx);
}
/**
* @brief An actor dropped on a floor lands on it and stays there.
*
* The number that matters is the drift after it has settled. A resolver that
* zeroes the environmental term rather than accounting for the gravity the next
* step is about to add commits `gravity * dt^2` of penetration per step -- a
* quarter of a pixel at 60 Hz, invisible, and cumulative.
*/
static akerr_ErrorContext *test_sim_lands_and_rests(void)
{
akgl_Actor *actor = NULL;
akgl_CollisionShape shape;
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
float32_t settled = 0.0f;
float32_t drift = 0.0f;
int i = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
sim_arcade(900.0, 0.0, 0.0);
CATCH(errctx, sim_collision_world());
CATCH(errctx, sim_actor(&actor, "faller", 600.0f, 200.0f));
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
actor->shape = shape;
actor->shape_override = true;
actor->x = 32.0f;
actor->y = 100.0f;
for ( i = 0; i < 120; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
}
settled = actor->y;
for ( i = 0; i < 120; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
}
drift = actor->y - settled;
printf(" landed at y=%.3f, drift over 120 further steps %.4f px\n", settled, drift);
TEST_ASSERT(errctx, (settled < 304.0f),
"the actor came to rest at y=%f; the floor is at 304 and it is inside it",
settled);
TEST_ASSERT(errctx, (fabsf(drift) < 1.0f),
"a resting actor drifted %f px over 120 steps; it is sinking through the floor",
drift);
} CLEANUP {
sim_physics.collision = NULL;
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief And it can still walk once it has landed.
*
* The consequence a player sees. A quarter pixel of sink is invisible until the
* horizontal test starts reporting blocked wherever the actor stands, and then
* the character jerks backwards every time it tries to move.
*/
static akerr_ErrorContext *test_sim_walks_after_landing(void)
{
akgl_Actor *actor = NULL;
akgl_CollisionShape shape;
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
float32_t startx = 0.0f;
int i = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
sim_arcade(900.0, 0.0, 0.0);
CATCH(errctx, sim_collision_world());
CATCH(errctx, sim_actor(&actor, "walker", 600.0f, 120.0f));
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
actor->shape = shape;
actor->shape_override = true;
actor->x = 32.0f;
actor->y = 100.0f;
for ( i = 0; i < 120; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
}
startx = actor->x;
AKGL_BITMASK_ADD(actor->state, AKGL_ACTOR_STATE_MOVING_RIGHT);
for ( i = 0; i < 60; i++ ) {
CATCH(errctx, sim_step(SIM_DT));
}
printf(" walked from x=%.2f to x=%.2f in one second\n", startx, actor->x);
TEST_ASSERT(errctx, (actor->x > (startx + 30.0f)),
"an actor resting on the floor walked from %f to %f in a second; it is "
"caught on the ground it is standing on", startx, actor->x);
} CLEANUP {
sim_physics.collision = NULL;
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief A fast fall does not pass through a one-tile floor.
*
* At 900 px/s^2 and the default 0.05 s bound a single step covers about 30
* pixels -- nearly two 16-pixel tiles. Without sub-stepping the actor is above
* the floor at one sample and below it at the next, and no test between them
* ever sees an overlap.
*/
static akerr_ErrorContext *test_sim_fast_fall_does_not_tunnel(void)
{
akgl_Actor *actor = NULL;
akgl_CollisionShape shape;
SDL_FRect body = { .x = 0.0f, .y = 0.0f, .w = 16.0f, .h = 16.0f };
int i = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
/*
* Zero gravity and a constant 1200 px/s, so every step is exactly 60
* pixels and the arithmetic is the same every time. With gravity on, the
* step size grows and the actor lands *inside* the floor by luck rather
* than passing through it -- which made the first version of this test
* pass with sub-stepping disabled and prove nothing.
*
* 60 pixels a step against a 16-pixel floor and a 16-pixel actor: the
* window in which an overlap exists is 32 pixels wide, so from y=100 the
* samples land at 160, 220, 280 and 340 and step clean over it. Sub-
* stepping cuts that to 7.5 pixels, which cannot miss.
*/
sim_arcade(0.0, 0.0, 0.0);
CATCH(errctx, sim_collision_world());
CATCH(errctx, sim_actor(&actor, "bullet", 600.0f, 200.0f));
CATCH(errctx, akgl_collision_shape_box(&shape, &body, 0.0f));
actor->shape = shape;
actor->shape_override = true;
actor->x = 32.0f;
actor->y = 100.0f;
actor->ey = 1200.0f;
for ( i = 0; i < 20; i++ ) {
CATCH(errctx, sim_step(0.05f));
}
printf(" 60 px per step at a 16 px floor: ended at y=%.2f\n", actor->y);
TEST_ASSERT(errctx, (actor->y < 320.0f),
"an actor moving 60 px a step ended at y=%f, past the floor at 304; it "
"stepped straight over the 32 px window in which an overlap exists",
actor->y);
} CLEANUP {
sim_physics.collision = NULL;
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
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
2026-08-01 12:10:53 -04:00
int main(void)
{
PREPARE_ERROR(errctx);
int failures = 0;
Call collide from the simulation, after the move rather than before it The collide slot has existed unused since the backend was written. It is wired in now, and its signature changes from (self, a1, a2) to (self, actor, dt): the old shape had nowhere to put a normal, a depth, or a piece of map geometry, and no answer to which of the two actors it was supposed to be resolving. **Resolution runs after `move`.** That is the decision everything else follows from. A resolver called from movementlogicfunc runs before gravity, drag and the move, so it has to predict where the actor will end up -- which means re-implementing the integrator's arithmetic in the game, and being wrong whenever the integrator changes. examples/sidescroller/collision.c carries forty lines of exactly that, and says so. Only `move` is subdivided. Gravity, drag, the thrust integration and the speed ellipse all still run once against the whole dt, and sum(subdt) is dt, so an actor with nothing to collide with takes one sub-step of exactly dt -- `dt/1.0f` is dt exactly in IEEE-754 -- and follows the arithmetic path it always did. That is what lets the integrator stay untouched and every recorded physics number stay valid, and tests/physics.c and tests/physics_sim.c pass unchanged with no world attached. Collision is opt-in. A backend with no collision world costs one comparison per actor per step and does nothing else. Also here: - `self->gravity` was never null-checked and is dereferenced unconditionally, so a hand-built backend with only `move` filled in crashed rather than reporting. - Children are re-snapped in a post-pass, gated on collision being on. The in-loop snap uses whatever position the parent had when the child's slot came up in pool order, which is already sometimes a frame stale; that was harmless while a parent only moved by v*dt and is not once a parent can be pushed out of a wall mid-step. - tests/physics.c's deliberate tripwire has been visited. It asserted that arcade collide always raised AKERR_API, with a comment saying it existed so that implementing collision would have to come back here. What replaces it asserts the opt-in contract instead. - physics_sim.c's hard-coded "%d of 7 simulations" is derived now. A literal goes stale the first time somebody adds a simulation, which is this commit. Three new whole-motion simulations, and the third took two attempts to make honest: - Landing and resting: 0.0000 px of drift over 120 steps after settling. - Walking after landing: 107 px in a second, which is the symptom a player sees when a resting actor is caught on the ground it is standing on. - A fast fall not tunnelling. The first version used gravity, so the step size grew every frame and the actor happened to land *inside* the floor rather than stepping over it -- it passed with sub-stepping disabled and proved nothing. It uses a constant 1200 px/s now, so every step is exactly 60 pixels against a 32-pixel window in which an overlap exists, and the samples miss it cleanly. With sub-stepping the actor stops at y=288; without it, y=1300. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:05:09 -04:00
int total = 0;
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
2026-08-01 12:10:53 -04:00
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);
Call collide from the simulation, after the move rather than before it The collide slot has existed unused since the backend was written. It is wired in now, and its signature changes from (self, a1, a2) to (self, actor, dt): the old shape had nowhere to put a normal, a depth, or a piece of map geometry, and no answer to which of the two actors it was supposed to be resolving. **Resolution runs after `move`.** That is the decision everything else follows from. A resolver called from movementlogicfunc runs before gravity, drag and the move, so it has to predict where the actor will end up -- which means re-implementing the integrator's arithmetic in the game, and being wrong whenever the integrator changes. examples/sidescroller/collision.c carries forty lines of exactly that, and says so. Only `move` is subdivided. Gravity, drag, the thrust integration and the speed ellipse all still run once against the whole dt, and sum(subdt) is dt, so an actor with nothing to collide with takes one sub-step of exactly dt -- `dt/1.0f` is dt exactly in IEEE-754 -- and follows the arithmetic path it always did. That is what lets the integrator stay untouched and every recorded physics number stay valid, and tests/physics.c and tests/physics_sim.c pass unchanged with no world attached. Collision is opt-in. A backend with no collision world costs one comparison per actor per step and does nothing else. Also here: - `self->gravity` was never null-checked and is dereferenced unconditionally, so a hand-built backend with only `move` filled in crashed rather than reporting. - Children are re-snapped in a post-pass, gated on collision being on. The in-loop snap uses whatever position the parent had when the child's slot came up in pool order, which is already sometimes a frame stale; that was harmless while a parent only moved by v*dt and is not once a parent can be pushed out of a wall mid-step. - tests/physics.c's deliberate tripwire has been visited. It asserted that arcade collide always raised AKERR_API, with a comment saying it existed so that implementing collision would have to come back here. What replaces it asserts the opt-in contract instead. - physics_sim.c's hard-coded "%d of 7 simulations" is derived now. A literal goes stale the first time somebody adds a simulation, which is this commit. Three new whole-motion simulations, and the third took two attempts to make honest: - Landing and resting: 0.0000 px of drift over 120 steps after settling. - Walking after landing: 107 px in a second, which is the symptom a player sees when a resting actor is caught on the ground it is standing on. - A fast fall not tunnelling. The first version used gravity, so the step size grew every frame and the actor happened to land *inside* the floor rather than stepping over it -- it passed with sub-stepping disabled and proved nothing. It uses a constant 1200 px/s now, so every step is exactly 60 pixels against a 32-pixel window in which an overlap exists, and the samples miss it cleanly. With sub-stepping the actor stops at y=288; without it, y=1300. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:05:09 -04:00
total = 7;
failures += sim_run("lands and rests", &test_sim_lands_and_rests);
failures += sim_run("walks after landing", &test_sim_walks_after_landing);
failures += sim_run("fast fall does not tunnel", &test_sim_fast_fall_does_not_tunnel);
total += 3;
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
2026-08-01 12:10:53 -04:00
Call collide from the simulation, after the move rather than before it The collide slot has existed unused since the backend was written. It is wired in now, and its signature changes from (self, a1, a2) to (self, actor, dt): the old shape had nowhere to put a normal, a depth, or a piece of map geometry, and no answer to which of the two actors it was supposed to be resolving. **Resolution runs after `move`.** That is the decision everything else follows from. A resolver called from movementlogicfunc runs before gravity, drag and the move, so it has to predict where the actor will end up -- which means re-implementing the integrator's arithmetic in the game, and being wrong whenever the integrator changes. examples/sidescroller/collision.c carries forty lines of exactly that, and says so. Only `move` is subdivided. Gravity, drag, the thrust integration and the speed ellipse all still run once against the whole dt, and sum(subdt) is dt, so an actor with nothing to collide with takes one sub-step of exactly dt -- `dt/1.0f` is dt exactly in IEEE-754 -- and follows the arithmetic path it always did. That is what lets the integrator stay untouched and every recorded physics number stay valid, and tests/physics.c and tests/physics_sim.c pass unchanged with no world attached. Collision is opt-in. A backend with no collision world costs one comparison per actor per step and does nothing else. Also here: - `self->gravity` was never null-checked and is dereferenced unconditionally, so a hand-built backend with only `move` filled in crashed rather than reporting. - Children are re-snapped in a post-pass, gated on collision being on. The in-loop snap uses whatever position the parent had when the child's slot came up in pool order, which is already sometimes a frame stale; that was harmless while a parent only moved by v*dt and is not once a parent can be pushed out of a wall mid-step. - tests/physics.c's deliberate tripwire has been visited. It asserted that arcade collide always raised AKERR_API, with a comment saying it existed so that implementing collision would have to come back here. What replaces it asserts the opt-in contract instead. - physics_sim.c's hard-coded "%d of 7 simulations" is derived now. A literal goes stale the first time somebody adds a simulation, which is this commit. Three new whole-motion simulations, and the third took two attempts to make honest: - Landing and resting: 0.0000 px of drift over 120 steps after settling. - Walking after landing: 107 px in a second, which is the symptom a player sees when a resting actor is caught on the ground it is standing on. - A fast fall not tunnelling. The first version used gravity, so the step size grew every frame and the actor happened to land *inside* the floor rather than stepping over it -- it passed with sub-stepping disabled and proved nothing. It uses a constant 1200 px/s now, so every step is exactly 60 pixels against a 32-pixel window in which an overlap exists, and the samples miss it cleanly. With sub-stepping the actor stops at y=288; without it, y=1300. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:05:09 -04:00
// Derived, not written out. The literal that used to be here went stale
// the first time a simulation was added, which is what a literal does.
printf("\n%d of %d simulations failed\n", failures, total);
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
2026-08-01 12:10:53 -04:00
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;
}