5 Commits

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8T5FAYXE8HEJqFLCYwNNc
2026-08-01 13:05:43 -04:00
1b90f2ebd5 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
147ab7dc78 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
d55778e625 Document the checks, the copy rule and the trap that made a test lie
Four gaps in the maintenance guide, each one something a maintainer will hit.

The two enforced checks were never named in it. api_surface
(scripts/check_api_surface.sh) and error_protocol
(scripts/check_error_protocol.py) fail builds and appear in ctest output, so the
guide now says what each one asserts and why -- including that the API check
strips comments before looking, because four of the nineteen symbols it found
were mentioned in header prose and that is not the same as being declared.

A new "Copying Into Fixed-Width Fields" section: never strncpy or memcpy into
one of this library's fixed-width fields, use aksl_strncpy, bound n at
sizeof(dest) - 1 to keep the truncate-not-reject contract, and know the one
place the NUL padding is load-bearing. Ten sites had the unterminated form and
one had the overreading memcpy form, and every name in this library is a
registry key.

The testing section gains the error-context ownership trap. TEST_EXPECT_* release
whatever the statement returns, and some functions return the context they were
given -- so a CLEANUP that also releases it double-releases, and a
double-released context corrupts the failure rather than reporting it. A test
written that way passes against broken code. That is not hypothetical; it is
what the first AKERR_OUTOFBOUNDS test did.

Also recorded: what ctest actually runs beyond the C suites, the gcovr
positional search path and why it must stay, and the surviving GCOV returncode 5
trap. Plus rebuild.log to .gitignore -- it had been sitting in git status long
enough to stop being noticed, which is the state a genuinely unexpected file
needs to stand out from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:19:00 -04:00
4d80664cd9 Build clean under -Wall, with -Werror in CI only
TODO.md item 37 said the cast sweep buys nothing until the build turns on the
warnings those casts suppress. This is that precondition, and the argument
turned out to be right with evidence: -Wall found three genuine signedness
mismatches in sprite.c, where uint32_t width, height and speed were passed
straight to akgl_get_json_integer_value(..., int *). A cast would have silenced
all three. Fixing them properly also turned up that speed is scaled by a million
into a 32-bit field, so anything past 4294 ms overflowed rather than being held.

The other real finding was ten -Wstringop-truncation warnings, every one a
fixed-width strncpy. Those leave a name field unterminated when the input fills
it -- and those fields are registry keys, handed to strcmp and SDL property
calls that do not stop at the field. Same overread class fixed twice already
this release. All ten now use aksl_strncpy from akstdlib, which always
terminates and never NUL-pads, bounded to size - 1 so an over-long name still
truncates rather than being refused. staticstring.h documented the old strncpy
semantics explicitly and has been rewritten to match.

Two sites in game.c are deliberately left on strncpy: both stage into a
pre-zeroed buffer for the savegame's fixed-width fields, where the NUL padding
is part of the format. Neither is flagged.

sprite.c also had a memcpy of a full 128-byte field from a NUL-terminated
string, which reads past the end of any shorter name. Not flagged by anything;
found while converting its neighbours.

-Werror is an option, default OFF, on only in the cmake_build and performance CI
jobs. libakgl is consumed with add_subdirectory -- akbasic does exactly that --
so a target-level -Werror turns every diagnostic a newer compiler invents into a
broken build for somebody else. The warning set is not even stable across build
types here: an -O2 build reports ten warnings that -O0 and -fsyntax-only do not,
because they need the middle end. The two jobs that set it are one of each.

-Wextra is deliberately not adopted. It adds 22 findings, 17 inherent to the
design: 13 -Wunused-parameter from backend vtables and SDL callbacks that must
match a signature, and 4 -Wimplicit-fallthrough from libakerror's own
PROCESS/HANDLE/HANDLE_GROUP, which fall through by design. Filed upstream as
deps/libakerror TODO item 8; the submodule bump carries it.

deps/semver/semver.c is listed directly in add_library, so the target's PRIVATE
options reach it. Exempted with -w so a future semver update cannot fail this
build -- verified by forcing -Wextra -Werror and watching our files fail while
semver compiled clean.

Verified: RelWithDebInfo, Debug and coverage builds all clean under -Werror;
-Werror actually fails on a planted unused variable; an embedded consumer with
AKGL_WERROR unset still configures and builds. 25/25 pass, memcheck clean,
reindent --check, check_api_surface and check_error_protocol clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 09:18:52 -04:00
45 changed files with 1692 additions and 241 deletions

View File

@@ -24,7 +24,8 @@ jobs:
run: |
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Debug \
-DAKGL_COVERAGE=ON
-DAKGL_COVERAGE=ON \
-DAKGL_WERROR=ON
cmake --build build --parallel
- name: Build API documentation
run: doxygen Doxyfile
@@ -90,7 +91,8 @@ jobs:
- name: Configure and build
run: |
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=RelWithDebInfo
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DAKGL_WERROR=ON
cmake --build build --parallel
# --verbose rather than --output-on-failure: a passing benchmark's output
# is the entire point of running it, and --output-on-failure prints

5
.gitignore vendored
View File

@@ -5,6 +5,11 @@ build*/
.aider*
*~
# rebuild.sh captures its cmake output here. A build artifact, not a record --
# it sat untracked in `git status` long enough to become invisible, which is the
# state a genuinely unexpected untracked file needs to stand out from.
rebuild.log
# Generated by configure_file into the build tree. include/ precedes the build
# tree on the include path, so a stray copy here would silently shadow the real
# one and pin every consumer to whatever version it was generated at.

244
AGENTS.md
View File

@@ -81,6 +81,33 @@ Run mutation testing with `cmake --build build --target mutation`. For a quick s
Generate HTML and Cobertura coverage reports with `cmake -S . -B build-coverage -DAKGL_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug`, then build and run CTest. Reports are written to `build-coverage/coverage/`; this mode requires `gcovr` and GCC or Clang.
Both `gcovr` invocations take the build tree as an explicit positional search
path. Given none, gcovr searches `--root` -- the source directory, where
developers keep their build trees -- and `--object-directory` does not narrow
that. A second instrumented tree left in the source directory therefore used to
be folded into the report, and two trees describing different line numbers for
the same function failed `coverage_reset` before any test ran. Leave the
positional path alone.
A different trap survives: rebuilding an existing coverage tree after editing a
source leaves `.gcda` files describing the old object layout, and
`coverage_reset` -- whose whole job is to delete them -- fails with `GCOV
returncode was 5` first. `find build-coverage -name '*.gcda' -delete` clears it.
### What `ctest` runs
Beyond the C suites, three registered tests are not C programs and are worth
knowing about when one of them fails:
| Test | What it checks | Script |
|---|---|---|
| `api_surface` | Every exported `akgl_*` symbol is declared in a header | `scripts/check_api_surface.sh` |
| `error_protocol` | No `*_RETURN` inside `ATTEMPT`, no `return` out of `HANDLE` | `scripts/check_error_protocol.py` |
| `headers` | Every public header compiles as the first include of a translation unit | generated from `AKGL_PUBLIC_HEADERS` |
`api_surface` needs `nm` and the built library; it exits 2 and is reported as
skipped when it cannot run, rather than passing quietly.
## Continuous integration
`.gitea/workflows/ci.yaml` runs four jobs on every push, and each one exists
@@ -243,6 +270,48 @@ akerr_ErrorContext *akgl_actor_update(akgl_Actor *obj)
);
```
### Compiler warnings
**`-Wall` is on for every target this project owns** -- the library, the test
suites and `charviewer` -- and the tree builds clean under it at every
optimization level.
**`-Werror` is not on by default.** It is `option(AKGL_WERROR ... OFF)`, turned
on only by the `cmake_build` and `performance` CI jobs. Two reasons, and both
are measured rather than assumed:
- libakgl is consumed with `add_subdirectory()`; `akbasic` does exactly that. A
target-level `-Werror` makes every diagnostic a newer compiler invents into a
broken build for somebody else's project.
- The warning set is not stable across build types. An `-O2` build reports ten
`-Wstringop-truncation` warnings that `-O0` and `-fsyntax-only` do not,
because they need the middle end. The two CI jobs that set `AKGL_WERROR` are
deliberately one of each: coverage at `-O0`, performance at `-O2`.
If you are checking this by hand, **compile for real at the optimization level
being shipped.** `-fsyntax-only` reported 6 of the 16 findings in `src/`.
**`-Wextra` is deliberately not adopted.** It adds 22 findings, 17 of which are
inherent to the design rather than defects: 13 `-Wunused-parameter`, because a
backend vtable entry or an SDL callback must match a signature whether it reads
every argument or not, and 4 `-Wimplicit-fallthrough` from libakerror's own
`PROCESS`/`HANDLE`/`HANDLE_GROUP`, which fall through between `case` labels by
design. Adopting it today would mean disabling both permanently to gain 5
`-Wsign-compare`. The fallthrough half is filed upstream as
`deps/libakerror/TODO.md` item 8; revisit when that lands.
**Vendored code is exempt, not fixed.** `deps/semver/semver.c` is listed
directly in `add_library(akgl ...)`, so the target's `PRIVATE` options reach it;
`set_source_files_properties(... COMPILE_OPTIONS "-w")` keeps a future `semver`
update from failing this build. The other vendored projects are separate targets
and are unaffected either way.
**Never silence a warning with a cast.** Three `-Wpointer-sign` findings in
`src/sprite.c` were real signedness mismatches -- `uint32_t *` passed where an
`int *` was expected -- and a cast would have hidden every one. That is the
whole argument of `TODO.md` item 37. Read into a correctly typed local, check
the range, and assign.
### No repository-wide formatter
There is no `clang-format` or linter wired into the build, and `cc-mode`'s
@@ -320,6 +389,19 @@ because the failure mode is silent.
- Validate every pointer parameter before dereferencing it, including the ones a
sibling function happens not to check.
The first two rules are enforced by `scripts/check_error_protocol.py`, running
as the `error_protocol` test: it flags a `*_RETURN` inside an `ATTEMPT` block
and a `return` out of a `HANDLE` block. Both have shipped before. Neither
produces a compiler diagnostic, and neither fails a test run until the pool it
drains is empty, which is why a linter is the only thing that catches them.
It cannot see the third hazard -- a `CATCH` inside a loop nested in an `ATTEMPT`
block, where `break` binds to the loop. When a loop is the *entire* body of an
`ATTEMPT` that is harmless, because `CLEANUP`, `PROCESS` and `FINISH` still run;
anything after the loop has to account for it. `akgl_tilemap_load_layer_objects`
carries a comment saying exactly that, and `akgl_draw_background` uses a flag
instead for the same reason.
## API Surface
Every function with external linkage must be declared in a header, and every
@@ -342,8 +424,87 @@ unit is the only shape that proves anything: a second `#include` after the
first proves nothing about the second, because by then the first has dragged
its dependencies in.
The declaration half is enforced too, by `scripts/check_api_surface.sh` running
as the `api_surface` test. It reads the built library's dynamic symbol table,
strips comments out of every public header, and fails on an exported `akgl_*`
symbol that is declared nowhere. **Stripping the comments is the point**: four
of the nineteen symbols this found were *mentioned* in `controller.h` prose,
which is not the same as being declared, and is how they stayed missing.
Declare no-argument functions as `(void)`, not `()`.
## Copying Into Fixed-Width Fields
**Never `strncpy` or `memcpy` into one of this library's fixed-width fields.
Use `aksl_strncpy()` from `akstdlib.h`.**
```c
PASS(errctx, aksl_strncpy(obj->name, sizeof(obj->name), name, sizeof(obj->name) - 1));
```
`strncpy(dest, src, sizeof(dest))` leaves `dest` **unterminated** whenever the
source fills it, and `memcpy(dest, src, sizeof(dest))` reads past the end of any
source shorter than the field. Every name in this library is a registry key,
handed to `strcmp`, `realpath` and SDL property calls, none of which stop at the
field. Ten sites had the first problem and one had the second; that whole class
is the same one as the savegame overread and the `akgl_get_property` overread
before it.
`aksl_strncpy` always terminates and never NUL-pads. Two details matter:
- **Bound `n` at `sizeof(dest) - 1`.** It raises `AKERR_OUTOFBOUNDS` when the
bytes do not fit, and capping `n` is what preserves the "truncated, not
rejected" contract those headers document. Drop the cap only when you mean to
change that contract.
- **It does not pad.** Where the padding is load-bearing -- the savegame name
tables write a fixed-width field and the reader steps that many bytes --
`strncpy` into a pre-zeroed buffer is still correct. `write_name_field()` in
`src/game.c` is the one place that applies, and it says so.
`-Wstringop-truncation` catches the first form at `-O2`. Nothing catches the
`memcpy` form; that one was found by reading its neighbours.
## Reaching For libakstdlib
`deps/libakstdlib` wraps most of libc so a failure arrives as an
`akerr_ErrorContext *` with context instead of a return value the caller has to
remember to check. Use the wrapper **wherever the underlying call can actually
fail**, which in practice means anything touching a file, a format string, or a
fixed-width destination:
| Instead of | Use | Because |
|---|---|---|
| `fclose` | `aksl_fclose` | The flush happens here, so a full disk or an exceeded quota is reported *only* here |
| `fgetc` | `aksl_fgetc` | `fgetc(3)` returns `EOF` for both end-of-file and a read error; this raises `AKERR_EOF` and `AKERR_IO` separately |
| `snprintf` | `aksl_snprintf` | Truncation becomes `AKERR_OUTOFBOUNDS` naming both lengths, instead of a silently short string |
| `strncpy` into a fixed field | `aksl_strncpy` | See **Copying Into Fixed-Width Fields** |
| `fopen`/`fread`/`fwrite`/`realpath`/`atof`/`atoi` | the `aksl_` form | Already the convention in this tree |
**`fclose(3)` disassociates the stream whether or not it succeeds.** Drop the
`FILE *` before you look at the status, or `CLEANUP` closes it a second time:
```c
tmpfp = fp;
fp = NULL;
CATCH(errctx, aksl_fclose(tmpfp));
```
Written the other way round this aborts in glibc with `double free detected in
tcache`, and only on the path where a close actually fails -- which is to say,
never during development. `/dev/full` is how `tests/game.c` reaches it: writes
to that device are accepted and the `ENOSPC` surfaces at the flush.
**Do not convert the pure-arithmetic calls.** `memset`, `memcpy`, `strlen`,
`strcmp` and friends can only fail on a NULL argument, and the tree is
inconsistent about them already; see `TODO.md`, "libakstdlib wrappers not yet
adopted", before starting a sweep.
**Never silence `-Wformat-truncation`.** `include/akgl/error.h` still exports
`DISABLE_GCC_WARNING_FORMAT_TRUNCATION` and nothing uses it. Both sites that
did were genuinely truncating, and both reported the length problem as
something else -- one as a missing texture file, with SDL naming a path the
caller never wrote. `aksl_snprintf` is the answer.
## Fixing Defects
**Read what the code does before deciding what it should do.** Every rule below
@@ -410,13 +571,82 @@ cheap to check and expensive to assume.
Tests use simple executable return codes and are registered through CTest in `CMakeLists.txt`; there is no declared coverage threshold.
**Every suite's `main()` must call `TEST_TRAP_UNHANDLED_ERRORS()` immediately
after `akgl_error_init()`.** libakerror's default unhandled-error handler ends
in `exit(errctx->status)`, `exit` keeps only the low byte, and libakgl's status
band starts at 256 — so a suite that failed with `AKGL_ERR_SDL` exited 0 and
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/<feature>.c`, create a matching `test_<feature>` 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.
**Know who owns the error context an assertion consumes.** `TEST_EXPECT_OK`,
`TEST_EXPECT_STATUS` and `TEST_EXPECT_ANY_ERROR` all release whatever context
the statement returns. Some functions return *the context they were given* --
`akgl_get_json_with_default` hands its argument straight back when it does not
handle the status -- so a `CLEANUP` block that also releases that context
releases it twice, and a double-released context corrupts the failure instead of
reporting it. A test written that way **passes against the broken code**, which
is exactly what happened to the first draft of the `AKERR_OUTOFBOUNDS` test in
`tests/json_helpers.c`. When the call may hand the context back, take the result
into a local, `NULL` your own pointer, and release it yourself:
```c
defaulted = akgl_get_json_with_default(keyerr, &defval, &dest, sizeof(int));
keyerr = NULL; /* ownership passed either way */
if ( defaulted != NULL ) {
defaulted->handled = true;
defaulted = akerr_release_error(defaulted);
FAIL_BREAK(e, AKGL_ERR_BEHAVIOR, "...");
}
```
**Never `exit(3)` on an akerr status — call `akerr_exit()`.** An exit status is
one byte wide and libakgl's status band starts at 256, so `exit(AKGL_ERR_SDL)`
is a wait status of 0 and a shell sees a clean run. Every suite in this
directory once reported success on the most common failure a library built on
SDL can have: `tests/character.c` aborted at its second of four tests on a bad
renderer and was green until 0.5.0. `akerr_exit()` (libakerror 2.0.1) maps 0 to
0, 1255 to themselves, and anything else to
`AKERR_EXIT_STATUS_UNREPRESENTABLE` (125). The default unhandled-error handler
calls it, so an ordinary suite needs no trap of its own — `tests/testutil.h`
carried one until 0.6.0 and it is gone. A suite that installs its *own* handler
still has to call `akerr_exit()` from it; `tests/actor.c` does. Add focused tests as `tests/<feature>.c`, create a matching `test_<feature>` 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

View File

@@ -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.7.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
@@ -38,6 +38,32 @@ endif()
include(CTest)
option(AKGL_COVERAGE "Instrument libakgl and generate coverage reports with CTest" OFF)
# -Wall is always on for code this project owns. -Werror is not, and the
# distinction is deliberate.
#
# libakgl is consumed with add_subdirectory() -- akbasic does exactly that -- so
# a target-level -Werror makes every new compiler diagnostic a broken build for
# somebody else's project. The warning set is not even stable across build types
# here: an -O2 build reports ten -Wstringop-truncation warnings that -O0 and
# -fsyntax-only do not, because they need the middle end. A newer GCC or a
# switch to clang moves the line again.
#
# So the errors live in CI, where the compiler is pinned and a new warning is a
# task for the maintainer rather than an outage for a consumer.
option(AKGL_WERROR "Treat compiler warnings as errors. For CI; leave OFF when embedding." OFF)
set(AKGL_WARNING_FLAGS -Wall)
if(AKGL_WERROR)
list(APPEND AKGL_WARNING_FLAGS -Werror)
endif()
# -Wextra is deliberately not here. It adds 22 findings, 17 of which are
# inherent to the design rather than defects: 13 -Wunused-parameter, because a
# backend vtable entry or an SDL callback has to match a signature whether it
# reads every argument or not, and 4 -Wimplicit-fallthrough from libakerror's
# own PROCESS/HANDLE/HANDLE_GROUP, which fall through between case labels by
# design. Adopting it would mean disabling both permanently to gain 5
# -Wsign-compare. See deps/libakerror/TODO.md item 8.
if(AKGL_COVERAGE)
if(NOT CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
message(FATAL_ERROR "AKGL_COVERAGE requires GCC or Clang")
@@ -109,8 +135,17 @@ akgl_add_vendored_dependency(SDL3_image::SDL3_image deps/SDL_image)
akgl_add_vendored_dependency(SDL3_mixer::SDL3_mixer deps/SDL_mixer)
akgl_add_vendored_dependency(SDL3_ttf::SDL3_ttf deps/SDL_ttf)
# libakerror 1.0.0 sizes its own status-name registry; consumers no longer do.
# libakgl claims its status codes at runtime in akgl_heap_init() instead.
# libakerror sizes its own status-name registry; consumers no longer do. libakgl
# claims its status codes at runtime in akgl_heap_init() instead.
#
# The floor is 2.0.1, and it is a hard one. 2.0.0 moved __akerr_last_ignored to
# thread-local storage and made akerr_next_error() return a context that already
# holds its reference -- both of which expand at *libakgl's* call sites through
# IGNORE and the FAIL macros, and at a *consumer's* too, because akerror.h is
# part of libakgl's public interface. A tree that mixes headers and libraries
# across that line double-counts every reference and never returns a pool slot.
# The soname moved to libakerror.so.2 to stop it happening by accident; the
# #error in include/akgl/error.h catches a stale header in an install tree.
set(AKGL_SUPPRESS_DEPENDENCY_TESTS FALSE)
@@ -139,7 +174,8 @@ endif()
# No version here: libakerror ships no akerrorConfigVersion.cmake, so asking
# for one makes find_package reject every install. The floor is enforced by
# the #error in include/akgl/error.h instead, which feature-tests
# AKERR_FIRST_CONSUMER_STATUS.
# AKERR_FIRST_CONSUMER_STATUS and AKERR_EXIT_STATUS_UNREPRESENTABLE -- the
# latter arriving in 2.0.1, which is the version libakgl needs.
if(NOT TARGET akerror::akerror)
find_package(akerror REQUIRED)
endif()
@@ -261,6 +297,13 @@ set_target_properties(akgl PROPERTIES
SOVERSION ${AKGL_SOVERSION}
)
target_compile_options(akgl PRIVATE ${AKGL_WARNING_FLAGS})
# deps/semver/semver.c is vendored and listed directly in add_library(), so the
# target's PRIVATE options reach it. Exempt it: a future semver update must not
# be able to fail this build. (PRIVATE options do not reach SDL, jansson,
# akerror or akstdlib -- those are separate targets.)
set_source_files_properties(deps/semver/semver.c PROPERTIES COMPILE_OPTIONS "-w")
add_library(akgl::akgl ALIAS akgl)
add_executable(charviewer util/charviewer.c)
@@ -281,6 +324,7 @@ set(AKGL_TEST_SUITES
heap
json_helpers
physics
physics_sim
registry
renderer
sprite
@@ -475,9 +519,11 @@ target_link_libraries(akgl
foreach(suite IN LISTS AKGL_TEST_SUITES AKGL_PERF_SUITES)
target_link_libraries(akgl_test_${suite} PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm)
target_include_directories(akgl_test_${suite} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/tests")
target_compile_options(akgl_test_${suite} PRIVATE ${AKGL_WARNING_FLAGS})
endforeach()
target_link_libraries(charviewer PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm)
target_compile_options(charviewer PRIVATE ${AKGL_WARNING_FLAGS})
# When the vendored SDL satellite libraries are built in-tree they land in per-
# project subdirectories that are not on the loader's default search path, so a

212
TODO.md
View File

@@ -358,22 +358,25 @@ below.**
so `akgl_physics_init_arcade` and `akgl_render_2d_init` quietly ignored
their configuration. `tests/registry.c` sets a property and reads it back.
37. **Redundant casts obscure the code.** **Still open, and deliberately so.**
Roughly 180 pointer casts across `src/`, a large majority no-ops --
`(json_t *)json` where `json` is already `json_t *`, `(char *)&obj->name` on
an array that decays anyway -- densest in `src/tilemap.c` and
`src/character.c`.
37. **Redundant casts obscure the code.** **Still open -- but the precondition
it named is now met.** Roughly 180 pointer casts across `src/`, a large
majority no-ops, densest in `src/tilemap.c` and `src/character.c`.
The argument for fixing it is real: a no-op cast suppresses exactly the
conversion warning that would catch a mismatch. The argument for not doing
it *here* is that it is the largest and least mechanical change on this
list, it touches almost every line of the two files with the most
outstanding functional defects, and the benefit only arrives once the build
actually turns those warnings on -- which it does not today. Doing this
without `-Wall -Wextra` on the library target buys nothing but churn.
This item used to say the benefit only arrives once the build turns on the
warnings those casts suppress, and that doing it first buys nothing but
churn. `-Wall` is on now (see `AGENTS.md`, "Compiler warnings"), so the
sweep can proceed as its own commit.
**Do them together**, in that order: enable the warnings, see what they say,
then remove the casts that were hiding something and the ones that were not.
**The argument turned out to be right, with evidence.** Turning `-Wall` on
found three genuine signedness mismatches in `src/sprite.c`: `obj->width`,
`obj->height` and `obj->speed` are `uint32_t` and were being passed straight
to `akgl_get_json_integer_value(..., int *)`. A cast would have silenced all
three. They are fixed by reading into an `int`, range-checking, and
assigning -- which also turned up that `speed` is scaled by 1,000,000 into a
32-bit field, so anything past 4294 ms overflowed rather than being held.
When doing the sweep: remove a cast, rebuild, and read what the compiler
says. A cast that was load-bearing will say so.
38. **`struct`-qualified parameters in two definitions.** `akgl_physics_null_move`
and `akgl_physics_arcade_move` use the typedef like the other eight.
@@ -409,20 +412,25 @@ the symbol collision described in item 4 — and exited 0 because that status wa
enough that `TODO.md` recorded the suite as passing and wondered which change
had fixed it. Nothing had; it had stopped running.
**Fixed** in `tests/testutil.h`: `TEST_TRAP_UNHANDLED_ERRORS()` installs a
handler that collapses any status a byte cannot carry onto 1, and every suite's
`main()` calls it immediately after `akgl_error_init()`. `tests/error.c` calls
it after its first test instead, because that suite has no `akgl_error_init()`
in `main()` and libakerror's lazy `akerr_init()` would overwrite the handler.
**Fixed twice.** 0.5.0 worked around it here, with a
`TEST_TRAP_UNHANDLED_ERRORS()` in `tests/testutil.h` that installed a handler
collapsing any status a byte cannot carry onto 1. Once installed, no other suite
changed colour, so this was not masking anything beyond `character` — but it
could have been at any time, and nothing would have said so.
Once installed, no other suite changed colour, so this was not masking anything
beyond `character` — but it could have been at any time, and nothing would have
said so.
That entry ended "any consumer's test suites have this problem; that is worth
raising upstream", and it was. **libakerror 2.0.1 fixes it at the source**:
`akerr_exit()` owns the status-to-exit-code mapping and the default handler
calls it, so 0 exits 0, 1 through 255 exit themselves, and anything else exits
`AKERR_EXIT_STATUS_UNREPRESENTABLE` (125) rather than a low byte that is either
a lie or a claim of success. There is no wider `exit()` to reach for —
`_exit()`, `_Exit()`, `quick_exit()` and the raw `exit_group` syscall all
truncate the same way, and even `waitid()` reports the truncated value.
Worth knowing: the fix belongs here rather than in libakerror only because
`exit(status)` is a reasonable thing for a library to do when statuses fit in a
byte. libakerror's own band does. Consumers' bands start at 256 by construction,
so any consumer's test suites have this problem. That is worth raising upstream.
The workaround is gone as of 0.6.0, along with its 21 call sites. Verified by
putting the original failure back: a `FAIL_BREAK(AKGL_ERR_SDL)` in
`tests/character.c`'s `main` now exits 125 and CTest records a failure, where it
exited 0 and passed before.
## Coverage status
@@ -1858,6 +1866,158 @@ this library and told it is compatible.
outlived its own press, the code-point-boundary truncation, and both NULL arguments.
`src/controller.c` holds at 91%.
## Truncated registry keys can collide
Found while turning `-Wall` on. `akgl_actor_initialize` and
`akgl_character_initialize` document their name fields as "Truncated, not
rejected, if the source name is longer", and that is what they do -- the copy is
`aksl_strncpy` bounded to the field, so it always terminates now.
Termination was the overread half, and it is fixed. The other half is not: the
truncated name **is the registry key**. Two distinct 200-character names
truncate to the same 127-byte key, and the second `SDL_SetPointerProperty`
silently replaces the first. The objects are different; the registry cannot tell.
The same applies to `akgl_Sprite::name` (128), `akgl_SpriteSheet::name` (512)
and the tilemap's object and tileset names.
Whether that matters depends on whether long asset names are realistic, and 127
bytes is generous for a hand-written name in a JSON file. Recording it rather
than fixing it, because the fix is a contract change -- refuse an over-long name
with `AKERR_OUTOFBOUNDS` instead of truncating -- and every one of those headers
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.
## akgl.pc names no dependencies
`akgl.pc.in` carries `Libs: -L${libdir} -lakgl` and no `Requires:` or
`Requires.private:` line, so a consumer that finds libakgl through pkg-config is
told nothing about libakerror, libakstdlib, SDL3, SDL3_image, SDL3_mixer,
SDL3_ttf or jansson.
That has always been wrong, and libakerror 2.x makes it sharper: `akerror.h` is
part of libakgl's *public* interface -- `IGNORE` and the `FAIL_*` macros expand
in the consumer's own translation units and reference `__akerr_last_ignored`,
which is thread-local as of 2.0.0. A consumer that builds against whatever
`akerror.h` happens to be on its include path and links whatever `libakerror.so`
the loader finds can get a mismatch that pkg-config had every opportunity to
prevent and did not mention.
The fix is a `Requires:` line for the libraries whose headers libakgl's headers
include (akerror, akstdlib, SDL3) and `Requires.private:` for the rest, plus a
version floor of `akerror >= 2.0.1`. Not done here because it changes what
`pkg-config --libs akgl` emits for every existing consumer, and that wants to be
its own commit with an install-tree test behind it. The CMake path is not
affected -- `akglConfig.cmake` re-finds the targets.
## libakstdlib wrappers not yet adopted
`libakgl` links `libakstdlib` and uses ten of its wrappers. It calls the raw
libc function at a good many more sites. Adopting the four that were carrying a
real defect is done (see the 0.6.0 commit); this is what is left, and the reason
each is left.
**Adopted, for the record:** `aksl_fclose` (an unchecked close in
`akgl_game_save` lost buffered data silently), `aksl_fgetc` (`fgetc(3)` spells
EOF and a read error the same way), `aksl_snprintf` at two sites that were
truncating under a silenced `-Wformat-truncation`, and `aksl_strncpy` at the two
`src/game.c` sites the fixed-width copy sweep missed.
### The pure-arithmetic wrappers are deliberately not adopted
`memset` (38 sites), `strlen` (14), `strcmp` (9), `strncmp` (9), `memcpy` (6),
`memcmp` (2). Every one of these can only fail on a NULL argument, and at all
but a handful of those sites the argument is a stack object whose address cannot
be NULL. `aksl_memset` and `aksl_memcpy` are used twice each, which is the
inconsistency worth naming: there is no rule distinguishing those two sites from
the other 44.
Converting them costs an `errctx` check per line and buys a NULL check the
compiler already knows is redundant. Not converting them leaves the file mixing
two spellings of the same operation. **Pick one and write it down** -- the
recommendation is to convert only where the argument is a parameter or a heap
pointer, and say so in `AGENTS.md`, rather than either extreme.
### `DISABLE_GCC_WARNING_FORMAT_TRUNCATION` is now dead
`include/akgl/error.h` exports it and `RESTORE_GCC_WARNINGS`, and after the
`aksl_snprintf` conversion nothing in the tree uses either. They are public
macros, so removing them is an API change and is not done here. Both suppressed
sites turned out to be real truncations, which is the argument for deleting
rather than keeping them: the two times this library silenced that warning, the
compiler was right.
### No wrapper is used for the collections
`aksl_HashMap`, `aksl_List` and `aksl_TreeNode` exist and libakgl uses SDL
property sets for every registry instead. That is not an oversight -- the
registries hand `SDL_PropertiesID` values to callers and SDL owns the lifetime.
Recorded so nobody re-derives it.
## 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.**

View File

@@ -24,13 +24,34 @@
* See deps/libakerror/UPGRADING.md.
*/
#ifndef AKERR_FIRST_CONSUMER_STATUS
#error "libakgl requires libakerror >= 1.0.0: the akerror.h on the include path predates the status registry. Rebuild and reinstall libakerror."
#error "libakgl requires libakerror >= 2.0.1: the akerror.h on the include path predates the status registry. Rebuild and reinstall libakerror."
#endif
// 2.0.0 is an ABI break -- akerr_next_error() returns a context that already
// holds its reference, and __akerr_last_ignored is thread-local -- and both of
// those expand at *this* library's call sites through IGNORE and the FAIL
// macros. A libakgl built against a 1.x header and linked against 2.x
// double-counts every reference and never returns a slot to the pool. The
// soname moved to libakerror.so.2 so the two cannot be mixed by accident, but
// the header can still be stale in an install tree, so check it here too.
//
// AKERR_EXIT_STATUS_UNREPRESENTABLE arrived in 2.0.1 and is the narrowest thing
// to probe for: libakerror publishes no version macro.
#ifndef AKERR_EXIT_STATUS_UNREPRESENTABLE
#error "libakgl requires libakerror >= 2.0.1: the akerror.h on the include path predates akerr_exit(). Rebuild and reinstall libakerror."
#endif
// This macro is used to silence warnings on string concatenation operations that may fail.
// e.g., combining two element of PATH_MAX into a string buffer of AKGL_STRING_MAX_LENGTH.
// We have to draw a line in the sand somewhere or we will just let our buffers grow forever
// to keep the compiler happy.
// Silences -Wformat-truncation on a string concatenation that genuinely may not
// fit -- e.g. joining two PATH_MAX strings into one AKGL_MAX_STRING_LENGTH
// buffer. The line has to be drawn somewhere or the buffers grow forever to
// keep the compiler happy.
//
// **Nothing in libakgl uses these any more.** Both sites -- the path join in
// akgl_path_relative and the tileset image join in
// akgl_tilemap_load_layer_image -- now go through aksl_snprintf, which raises
// AKERR_OUTOFBOUNDS on truncation instead. That is the better answer: the
// compiler was right both times, and silencing it left the truncation
// happening and unreported. Kept because they are public and a consumer may
// have them; see TODO.md.
#define DISABLE_GCC_WARNING_FORMAT_TRUNCATION \
_Pragma("GCC diagnostic push") \
_Pragma("GCC diagnostic ignored \"-Wformat-truncation\"")

View File

@@ -208,6 +208,12 @@ void akgl_game_update_fps(void);
* @throws ENOENT, EACCES Or whatever else `fopen(3)` reports, with the path in
* the message.
* @throws AKERR_IO On a stream error or a short write.
* @throws ENOSPC, EDQUOT Or whatever else the close reports. A record this size
* fits entirely in stdio's buffer, so nothing reaches the device until
* the close flushes it and that is the only place a full disk, an
* exceeded quota or a server going away can be seen. The close is
* checked; success here means the bytes are on the device.
* @throws AKERR_OUTOFBOUNDS If a registered name does not fit its field.
*
* @note This is a partial implementation: the name tables are written but the
* objects themselves are not, so the file is not yet enough to restore a
@@ -236,6 +242,11 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_game_save(char *fpath);
* the running game, is not valid semver.
* @throws AKERR_API If the save file is from a different library version, game
* version, game name, or game URI.
* @throws AKERR_IO If anything remains in the file after the four name tables.
* The tables carry no length prefix and end at a zeroed sentinel, so a
* reader whose field widths disagree with the writer's stops early
* inside an entry and would otherwise report success with silently
* wrong maps. Trailing data is the symptom of that disagreement.
*
* @note Like akgl_game_save, this is partial: it rebuilds the pointer maps but
* does not yet read back any objects. The four `SDL_CreateProperties`

View File

@@ -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".

View File

@@ -27,16 +27,18 @@ typedef struct
/**
* @brief Set a pooled string's contents and mark the slot in use.
*
* Copies at most #AKGL_MAX_STRING_LENGTH bytes out of @p init, or zeroes the
* buffer when @p init is `NULL`, then sets `refcount` to 1. Callers normally
* reach this through akgl_heap_next_string rather than calling it directly.
* Copies @p init into the buffer, or zeroes it when @p init is `NULL`, then
* sets `refcount` to 1. Callers normally reach this through
* akgl_heap_next_string rather than calling it directly.
*
* @param obj The pooled string to (re)initialize. Required. Its previous
* contents are discarded without inspection.
* @param init Initial contents, NUL-terminated. Optional -- `NULL` zero-fills
* the buffer instead. An @p init longer than
* #AKGL_MAX_STRING_LENGTH is truncated *and left unterminated*,
* because this is `strncpy` semantics, not `strlcpy`.
* the buffer instead. An @p init that does not fit is truncated,
* and the result is **always NUL-terminated**. Until 0.5.0 this was
* `strncpy` at exactly the buffer size, which left an over-long
* string unterminated -- and a pooled string is handed to `strcmp`,
* `realpath` and SDL property calls, none of which stop at 4096.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p obj is `NULL`.
*
@@ -49,26 +51,27 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_string_initialize(akgl_String *obj, char
/**
* @brief Copy the contents of one pooled string into another.
*
* A bounded `strncpy` between two already-claimed pool slots. It copies bytes
* only: `refcount` is left alone, so @p dest keeps whatever pool state it had.
* A bounded copy between two already-claimed pool slots. It copies bytes only:
* `refcount` is left alone, so @p dest keeps whatever pool state it had.
*
* @param src Source string. Required. Read up to @p count bytes.
* @param dest Destination string. Required. Overwritten in place; the pool
* @param dest Destination string. Required. Overwritten in place; the pool
* slot must already have been claimed.
* @param count Maximum bytes to copy. 0 selects #AKGL_MAX_STRING_LENGTH, the
* whole buffer. A @p count shorter than the source truncates
* without writing a terminator; a @p count longer than the source
* zero-pads the remainder, per `strncpy`. A negative @p count, or
* one above #AKGL_MAX_STRING_LENGTH, is refused -- both buffers
* are exactly that long, so a larger count walked off the end of
* two pool slots at once.
* whole buffer. A @p count shorter than the source truncates, and
* the result is **always NUL-terminated**. The remainder is not
* zero-padded, so this no longer writes the full buffer for a
* short copy. A negative @p count, or one above
* #AKGL_MAX_STRING_LENGTH, is refused -- both buffers are exactly
* that long, so a larger count walked off the end of two pool
* slots at once.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p src or @p dest is `NULL`.
* @throws AKERR_OUTOFBOUNDS If @p count is negative or above
* #AKGL_MAX_STRING_LENGTH.
* @throws errno Whatever `errno` holds if `strncpy` returns something other
* than @p dest. In practice `strncpy` always returns its destination, so
* this path is unreachable rather than merely rare.
* @throws AKERR_VALUE, AKERR_OUTOFBOUNDS Whatever `aksl_strncpy` reports. The
* unreachable `errno` path this used to document went with the
* `strncpy` call it described.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_string_copy(akgl_String *src, akgl_String *dest, int count);
#endif //_AKGL_STATICSTRING_H_

View File

@@ -601,10 +601,10 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_tilemap_load_layer_object_actor(akgl_Til
* @throws AKGL_ERR_SDL If the image cannot be loaded. The message carries
* `SDL_GetError()`.
* @throws AKGL_ERR_HEAP If the string pool is exhausted.
*
* @note The joined path is truncated at
* #AKGL_TILEMAP_MAX_TILESET_FILENAME_SIZE without complaint, so an
* over-long path fails as a missing file rather than as a length error.
* @throws AKERR_OUTOFBOUNDS If `dirname` and the image name do not fit in
* #AKGL_TILEMAP_MAX_TILESET_FILENAME_SIZE when joined, naming both
* lengths. This used to truncate silently, so an over-long path failed
* as a missing file rather than as the length error it is.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_tilemap_load_layer_image(akgl_Tilemap *dest, json_t *root, int layerid, akgl_String *dirname);
/**

View File

@@ -7,6 +7,7 @@
#include <SDL3_image/SDL_image.h>
#include <string.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/physics.h>
#include <akgl/game.h>
@@ -24,7 +25,11 @@ akerr_ErrorContext *akgl_actor_initialize(akgl_Actor *obj, char *name)
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "akgl_actor_initialize received null name string pointer");
memset(obj, 0x00, sizeof(akgl_Actor));
strncpy((char *)obj->name, name, AKGL_ACTOR_MAX_NAME_LENGTH);
// aksl_strncpy always terminates; strncpy at exactly the field width does
// not, and this field is a registry key. n is one less than the field so an
// over-long name still truncates rather than being refused, which is the
// documented contract.
PASS(errctx, aksl_strncpy((char *)obj->name, sizeof(obj->name), name, sizeof(obj->name) - 1));
obj->curSpriteReversing = false;
obj->scale = 1.0;
obj->movement_controls_face = true;
@@ -349,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);
@@ -378,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);
@@ -407,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);
@@ -435,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);

View File

@@ -273,6 +273,14 @@ static void SDLCALL audio_stream_callback(void *userdata, SDL_AudioStream *strea
if ( errctx != NULL ) {
// There is nobody to return an error to on the audio thread, and
// refusing to write leaves SDL underrunning. Report and go quiet.
//
// Raising an error here at all is only safe from libakerror 2.0.0
// on. Before that the pool was unlocked, so this callback and the
// main thread could scan AKERR_ARRAY_ERROR at the same time and be
// handed the same slot -- two threads writing one context, and one
// of them releasing it out from under the other. 2.0.0 makes
// finding a free slot and claiming it one operation under a lock.
// libakgl requires it; see the guard in include/akgl/error.h.
LOG_ERROR_WITH_MESSAGE(errctx, "** AUDIO CALLBACK **");
errctx->handled = true;
errctx = akerr_release_error(errctx);

View File

@@ -6,6 +6,7 @@
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <akerror.h>
#include <akstdlib.h>
#include <string.h>
#include <jansson.h>
@@ -24,7 +25,9 @@ akerr_ErrorContext *akgl_character_initialize(akgl_Character *obj, char *name)
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL akgl_Character reference");
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "NULL name string pointer");
memset(obj, 0x00, sizeof(akgl_Character));
strncpy(obj->name, name, AKGL_CHARACTER_MAX_NAME_LENGTH);
// Always terminated: this is a registry key, and strncpy at exactly the
// field width leaves an over-long name unterminated.
PASS(errctx, aksl_strncpy(obj->name, sizeof(obj->name), name, sizeof(obj->name) - 1));
obj->state_sprites = SDL_CreateProperties();
FAIL_ZERO_RETURN(errctx, obj->state_sprites, AKERR_NULLPOINTER, "Unable to initialize SDL_PropertiesID for character state map");

View File

@@ -126,7 +126,7 @@ static akerr_ErrorContext *write_name_field(const char *name, size_t width, FILE
sizeof(field));
memset(&field, 0x00, sizeof(field));
strncpy((char *)&field, name, (width - 1));
PASS(errctx, aksl_strncpy((char *)&field, sizeof(field), name, (width - 1)));
PASS(errctx, write_exact((char *)&field, 1, width, fp));
SUCCEED_RETURN(errctx);
}
@@ -138,6 +138,48 @@ static akerr_ErrorContext *read_exact(void *ptr, size_t size, size_t nmemb, FILE
return aksl_fread(ptr, size, nmemb, fp, &transferred);
}
/**
* @brief Require that @p fp has nothing left in it.
*
* A helper rather than three lines at the call site, because "read a byte and
* succeed only if that fails in one specific way" inverts the sense of every
* other call in this file, and doing it inline needs a nested `ATTEMPT` whose
* `break` binds to the wrong `switch`.
*
* `fgetc(3)` is not used here. It spells "end of file" and "the read failed"
* with the same `EOF`, so a disk error at this point read as a clean end and
* passed the check it was supposed to fail. `aksl_fgetc` tells the two apart:
* #AKERR_EOF is the outcome this wants and is the only one swallowed.
*
* @param fp Open input stream. Required.
* @return `NULL` at end of file, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p fp is `NULL`.
* @throws AKERR_IO If a byte was read, naming it.
* @throws Whatever `aksl_fgetc` raises on a read error.
*/
static akerr_ErrorContext *require_at_eof(FILE *fp)
{
int trailing = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, fp, AKERR_NULLPOINTER, "NULL file pointer");
ATTEMPT {
CATCH(errctx, aksl_fgetc(fp, &trailing));
FAIL_BREAK(
errctx,
AKERR_IO,
"Savegame has data left after its name tables (first trailing byte 0x%02x); "
"a name field width does not match the writer's",
(trailing & 0xFF));
} CLEANUP {
} PROCESS(errctx) {
} HANDLE(errctx, AKERR_EOF) {
// The whole point: nothing left to read is what this asks for.
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
void akgl_game_lowfps(void)
{
SDL_Log("Low FPS! %d", akgl_game.fps);
@@ -152,7 +194,11 @@ akerr_ErrorContext *akgl_game_init(void)
// AKGL_ERR_* codes, and a code raised before its name is registered prints
// as "Unknown Error" in the stack trace the caller is left holding.
PASS(errctx, akgl_error_init());
strncpy((char *)&akgl_game.libversion, AKGL_VERSION, 32);
PASS(errctx, aksl_strncpy(
(char *)&akgl_game.libversion,
sizeof(akgl_game.libversion),
AKGL_VERSION,
sizeof(akgl_game.libversion) - 1));
akgl_game.gameStartTime = SDL_GetTicksNS();
akgl_game.lastIterTime = akgl_game.gameStartTime;
akgl_game.lastFPSTime = akgl_game.gameStartTime;
@@ -463,6 +509,7 @@ akerr_ErrorContext *akgl_game_save_actors(FILE *fp)
akerr_ErrorContext *akgl_game_save(char *fpath)
{
FILE *fp = NULL;
FILE *tmpfp = NULL;
PREPARE_ERROR(errctx);
ATTEMPT {
@@ -470,13 +517,34 @@ akerr_ErrorContext *akgl_game_save(char *fpath)
CATCH(errctx, aksl_fopen(fpath, "wb", &fp));
CATCH(errctx, write_exact(&akgl_game, 1, sizeof(akgl_Game), fp));
CATCH(errctx, akgl_game_save_actors(fp));
// Closed here rather than in CLEANUP, because on this path the close is
// part of the save. Every write above goes through aksl_fwrite and is
// checked; the flush that those writes were buffered for happens in the
// close, so a full disk, an exceeded quota or an NFS server going away
// is reported *only* here. Ignoring it means akgl_game_save returns
// success over a truncated savegame.
//
// The order matters: fclose(3) disassociates the stream whether or not
// it succeeds, so `fp` is dead either way and has to be dropped
// *before* the status is examined. Closing it again in CLEANUP is
// undefined behaviour that no wrapper can detect -- written the other
// way round, this aborted in glibc with "double free detected in
// tcache" the first time a close actually failed.
tmpfp = fp;
fp = NULL;
CATCH(errctx, aksl_fclose(tmpfp));
} CLEANUP {
// CLEANUP must precede PROCESS: with the two transposed, the fclose
// lands inside the PROCESS switch and only runs when an error context
// exists and reports success, so an ordinary save never flushed or
// closed its stream.
if ( fp != NULL )
fclose(fp);
//
// Only the failure path reaches this with a stream still open, and it
// is already carrying an error the caller needs more than a close
// status, so the descriptor is released without asking how it went.
if ( fp != NULL ) {
IGNORE(aksl_fclose(fp));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx); // SUCCEED_NORETURN if in main().
@@ -522,8 +590,8 @@ static akerr_ErrorContext *load_objectnamemap(FILE *fp, SDL_PropertiesID map, in
{
void *ptr = NULL;
char ptrstring[32];
int ptrstringlen = 0;
char objname[namelength];
int retval = 0;
bool done = false;
PREPARE_ERROR(errctx);
@@ -549,8 +617,13 @@ static akerr_ErrorContext *load_objectnamemap(FILE *fp, SDL_PropertiesID map, in
// SDL_Properties objects can only use string keys, so we can't use the
// old pointer as a key without first converting it to a string.
CATCH(errctx, aksl_memset((void *)&ptrstring, 0x00, 32));
snprintf((char *)&ptrstring, 32, "%p", ptr);
CATCH(errctx, aksl_memset((void *)&ptrstring, 0x00, sizeof(ptrstring)));
CATCH(errctx, aksl_snprintf(
&ptrstringlen,
(char *)&ptrstring,
sizeof(ptrstring),
"%p",
ptr));
SDL_SetPointerProperty(
map,
ptrstring,
@@ -656,16 +729,15 @@ akerr_ErrorContext *akgl_game_load(char *fpath)
//
// It also has to move once the objects themselves are written; the
// comment below is the marker for that.
FAIL_NONZERO_BREAK(
errctx,
(fgetc(fp) != EOF),
AKERR_IO,
"Savegame has data left after its name tables; a name field width does not match the writer's");
CATCH(errctx, require_at_eof(fp));
// Now that we have all of our pointer maps built, we can load the actual binary objects and reset their pointers
} CLEANUP {
// Nothing was written, so unlike akgl_game_save there is no buffered
// data a failed close could lose. Releasing the descriptor is all this
// has to do.
if ( fp != NULL ) {
fclose(fp);
IGNORE(aksl_fclose(fp));
}
} PROCESS(errctx) {
} FINISH(errctx, true);

View File

@@ -6,6 +6,7 @@
#include <jansson.h>
#include <string.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/json_helpers.h>
#include <akgl/game.h>
@@ -102,7 +103,11 @@ akerr_ErrorContext *akgl_get_json_string_value(json_t *obj, char *key, akgl_Stri
} PROCESS(errctx) {
} FINISH(errctx, true);
strncpy((char *)&(*dest)->data, json_string_value(value), AKGL_MAX_STRING_LENGTH);
PASS(errctx, aksl_strncpy(
(char *)&(*dest)->data,
sizeof((*dest)->data),
json_string_value(value),
sizeof((*dest)->data) - 1));
SUCCEED_RETURN(errctx);
}
@@ -164,7 +169,11 @@ akerr_ErrorContext *akgl_get_json_array_index_string(json_t *array, int index, a
} PROCESS(errctx) {
} FINISH(errctx, true);
strncpy((char *)&(*dest)->data, json_string_value(value), AKGL_MAX_STRING_LENGTH);
PASS(errctx, aksl_strncpy(
(char *)&(*dest)->data,
sizeof((*dest)->data),
json_string_value(value),
sizeof((*dest)->data) - 1));
SUCCEED_RETURN(errctx);
}

View File

@@ -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));
@@ -233,7 +281,6 @@ akerr_ErrorContext *akgl_physics_simulate(akgl_PhysicsBackend *self, akgl_Iterat
akerr_ErrorContext *akgl_physics_factory(akgl_PhysicsBackend *self, akgl_String *type)
{
uint32_t hashval;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
FAIL_ZERO_RETURN(errctx, type, AKERR_NULLPOINTER, "type");

View File

@@ -8,6 +8,7 @@
#include <string.h>
#include <jansson.h>
#include <akerror.h>
#include <akstdlib.h>
#include <libgen.h>
#include <akgl/game.h>
@@ -121,6 +122,7 @@ akerr_ErrorContext *akgl_sprite_load_json(char *filename)
int i = 0;
int framecount = 0;
int frameid = 0;
int jsonint = 0;
FAIL_ZERO_RETURN(errctx, filename, AKERR_NULLPOINTER, "Received null filename");
ATTEMPT {
@@ -128,7 +130,7 @@ akerr_ErrorContext *akgl_sprite_load_json(char *filename)
CATCH(errctx, akgl_heap_next_string(&filename_copy));
FAIL_NONZERO_BREAK(
errctx,
strlen(filename) >= AKGL_MAX_STRING_LENGTH,
(strlen(filename) >= AKGL_MAX_STRING_LENGTH),
AKERR_OUTOFBOUNDS,
"Sprite filename exceeds temporary string capacity"
);
@@ -155,10 +157,43 @@ akerr_ErrorContext *akgl_sprite_load_json(char *filename)
(akgl_SpriteSheet *)sheet)
);
CATCH(errctx, akgl_get_json_integer_value((json_t *)json, "width", &obj->width));
CATCH(errctx, akgl_get_json_integer_value((json_t *)json, "height", &obj->height));
CATCH(errctx, akgl_get_json_integer_value((json_t *)json, "speed", &obj->speed));
obj->speed = obj->speed * AKGL_TIME_ONEMS_NS;
// Read into an int and narrow deliberately. These three fields are
// uint32_t and the accessor takes an int *, so passing them directly was
// a signedness mismatch -- the kind -Wpointer-sign exists to catch, and
// the kind a cast would have hidden.
CATCH(errctx, akgl_get_json_integer_value((json_t *)json, "width", &jsonint));
FAIL_NONZERO_BREAK(
errctx,
(jsonint <= 0),
AKERR_VALUE,
"Sprite %s has width %d; a sprite must be at least one pixel wide",
(char *)&obj->name,
jsonint);
obj->width = (uint32_t)jsonint;
CATCH(errctx, akgl_get_json_integer_value((json_t *)json, "height", &jsonint));
FAIL_NONZERO_BREAK(
errctx,
(jsonint <= 0),
AKERR_VALUE,
"Sprite %s has height %d; a sprite must be at least one pixel high",
(char *)&obj->name,
jsonint);
obj->height = (uint32_t)jsonint;
// speed is milliseconds in the file and nanoseconds in the struct, and
// the struct field is 32 bits, so anything past this overflows on the
// multiply below rather than being held.
CATCH(errctx, akgl_get_json_integer_value((json_t *)json, "speed", &jsonint));
FAIL_NONZERO_BREAK(
errctx,
((jsonint < 0) || ((uint32_t)jsonint > (UINT32_MAX / AKGL_TIME_ONEMS_NS))),
AKERR_OUTOFBOUNDS,
"Sprite %s has a frame speed of %d ms; the range is 0 to %u",
(char *)&obj->name,
jsonint,
(UINT32_MAX / AKGL_TIME_ONEMS_NS));
obj->speed = ((uint32_t)jsonint) * AKGL_TIME_ONEMS_NS;
CATCH(errctx, akgl_get_json_boolean_value((json_t *)json, "loop", &obj->loop));
CATCH(errctx, akgl_get_json_boolean_value((json_t *)json, "loopReverse", &obj->loopReverse));
@@ -226,7 +261,10 @@ akerr_ErrorContext *akgl_sprite_initialize(akgl_Sprite *spr, char *name, akgl_Sp
FAIL_ZERO_RETURN(errctx, sheet, AKERR_NULLPOINTER, "Null spritesheet reference");
memset(spr, 0x00, sizeof(akgl_Sprite));
memcpy(spr->name, name, AKGL_SPRITE_MAX_NAME_LENGTH);
// Was a memcpy of the full field width from a NUL-terminated string, which
// reads past the end of any name shorter than the field. Copies only what
// is there now, and terminates.
PASS(errctx, aksl_strncpy(spr->name, sizeof(spr->name), name, sizeof(spr->name) - 1));
spr->sheet = sheet;
FAIL_ZERO_RETURN(
errctx,
@@ -251,7 +289,7 @@ akerr_ErrorContext *akgl_spritesheet_initialize(akgl_SpriteSheet *sheet, int spr
//CATCH(errctx, akgl_heap_next_string(&tmpstr));
//CATCH(errctx, akgl_string_initialize(tmpstr, NULL));
strncpy((char *)&sheet->name, filename, AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH);
CATCH(errctx, aksl_strncpy((char *)&sheet->name, sizeof(sheet->name), filename, sizeof(sheet->name) - 1));
sheet->texture = IMG_LoadTexture(akgl_renderer->sdl_renderer, filename);
FAIL_ZERO_BREAK(errctx, sheet->texture, AKGL_ERR_SDL, "Failed loading asset %s : %s", filename, SDL_GetError());

View File

@@ -4,15 +4,15 @@
*/
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/staticstring.h>
#include <errno.h>
akerr_ErrorContext *akgl_string_initialize(akgl_String *obj, char *init)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "Attempted to initialize NULL string reference");
if ( init != NULL ) {
strncpy((char *)&obj->data, init, AKGL_MAX_STRING_LENGTH);
PASS(errctx, aksl_strncpy((char *)&obj->data, sizeof(obj->data), init, sizeof(obj->data) - 1));
} else {
// sizeof(obj->data), not sizeof(akgl_String). `data` starts after the
// `refcount` in front of it, so zeroing the size of the whole struct
@@ -43,8 +43,6 @@ akerr_ErrorContext *akgl_string_copy(akgl_String *src, akgl_String *dest, int co
"Copy count %d is outside 0..%d",
count,
AKGL_MAX_STRING_LENGTH);
if ( (char *)dest->data != strncpy((char *)&dest->data, (char *)&src->data, count) ) {
FAIL_RETURN(errctx, errno, "strncpy");
}
PASS(errctx, aksl_strncpy((char *)&dest->data, sizeof(dest->data), (char *)&src->data, count));
SUCCEED_RETURN(errctx);
}

View File

@@ -149,14 +149,21 @@ akerr_ErrorContext *akgl_tilemap_load_tilesets_each(json_t *tileset, akgl_Tilema
PASS(errctx, akgl_get_json_string_value((json_t *)tileset, "name", &tmpstr));
PASS(errctx, akgl_heap_next_string(&tmppath));
ATTEMPT {
strncpy((char *)&dest->tilesets[tsidx].name,
(char *)&tmpstr->data,
AKGL_TILEMAP_MAX_TILESET_NAME_SIZE
);
CATCH(errctx, aksl_strncpy(
(char *)&dest->tilesets[tsidx].name,
sizeof(dest->tilesets[tsidx].name),
(char *)&tmpstr->data,
sizeof(dest->tilesets[tsidx].name) - 1
));
CATCH(errctx, akgl_get_json_string_value((json_t *)tileset, "image", &tmpstr));
CATCH(errctx, akgl_path_relative((char *)&dirname->data, (char *)&tmpstr->data, tmppath));
strncpy((char *)&dest->tilesets[tsidx].imagefilename, tmppath->data, AKGL_MAX_STRING_LENGTH);
CATCH(errctx, aksl_strncpy(
(char *)&dest->tilesets[tsidx].imagefilename,
sizeof(dest->tilesets[tsidx].imagefilename),
tmppath->data,
sizeof(dest->tilesets[tsidx].imagefilename) - 1
));
} CLEANUP {
IGNORE(akgl_heap_release_string(tmpstr));
IGNORE(akgl_heap_release_string(tmppath));
@@ -346,7 +353,12 @@ akerr_ErrorContext *akgl_tilemap_load_layer_objects(akgl_Tilemap *dest, json_t *
// non-NULL destination without taking another reference. Any other
// claim in between would have been handed the same string.
CATCH(errctx, akgl_get_json_string_value((json_t *)layerdatavalue, "name", &tmpstr));
strncpy((char *)curobj->name, tmpstr->data, AKGL_ACTOR_MAX_NAME_LENGTH);
CATCH(errctx, aksl_strncpy(
(char *)curobj->name,
sizeof(curobj->name),
tmpstr->data,
sizeof(curobj->name) - 1
));
CATCH(errctx, akgl_get_json_number_value((json_t *)layerdatavalue, "x", &curobj->x));
CATCH(errctx, akgl_get_json_number_value((json_t *)layerdatavalue, "y", &curobj->y));
CATCH(errctx, akgl_get_json_boolean_value((json_t *)layerdatavalue, "visible", &curobj->visible));
@@ -412,6 +424,7 @@ akerr_ErrorContext *akgl_tilemap_load_layer_image(akgl_Tilemap *dest, json_t *ro
PREPARE_ERROR(errctx);
akgl_String *tmpstr;
akgl_String *fpath;
int fpathlen = 0;
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination tilemap reference");
FAIL_ZERO_RETURN(errctx, root, AKERR_NULLPOINTER, "NULL tilemap root reference");
@@ -422,16 +435,23 @@ akerr_ErrorContext *akgl_tilemap_load_layer_image(akgl_Tilemap *dest, json_t *ro
CATCH(errctx, akgl_heap_next_string(&fpath));
CATCH(errctx, akgl_get_json_string_value(root, "image", &tmpstr));
DISABLE_GCC_WARNING_FORMAT_TRUNCATION
snprintf((char *)&fpath->data,
AKGL_TILEMAP_MAX_TILESET_FILENAME_SIZE,
"%s/%s",
dirname->data,
tmpstr->data
);
RESTORE_GCC_WARNINGS
// aksl_snprintf rather than snprintf(3) under a silenced
// -Wformat-truncation: the compiler was right that "%s/%s" of two
// AKGL_MAX_STRING_LENGTH strings does not fit, and silencing it left
// the truncation happening and unreported. What followed was
// IMG_LoadTexture on a path that was almost the right one, failing with
// SDL's "couldn't open" against a filename the caller never wrote.
// aksl_snprintf raises AKERR_OUTOFBOUNDS naming both lengths instead.
CATCH(errctx, aksl_snprintf(
&fpathlen,
(char *)&fpath->data,
AKGL_TILEMAP_MAX_TILESET_FILENAME_SIZE,
"%s/%s",
dirname->data,
tmpstr->data
));
dest->layers[layerid].texture = IMG_LoadTexture(akgl_renderer->sdl_renderer, (char *)fpath->data);
dest->layers[layerid].texture = IMG_LoadTexture(akgl_renderer->sdl_renderer, (char *)fpath->data);
FAIL_ZERO_BREAK(errctx, dest->layers[layerid].texture, AKGL_ERR_SDL, "%s", SDL_GetError());
dest->layers[layerid].width = dest->layers[layerid].texture->w;
dest->layers[layerid].height = dest->layers[layerid].texture->h;

View File

@@ -74,17 +74,15 @@ static akerr_ErrorContext *path_relative_root(char *root, char *path, akgl_Strin
if ( (rootlen + pathlen) >= AKGL_MAX_STRING_LENGTH ) {
FAIL_BREAK(errctx, AKERR_OUTOFBOUNDS, "Total path length (%d) is greater than maximum akgl_String length (%d)", (rootlen + pathlen), AKGL_MAX_STRING_LENGTH);
}
DISABLE_GCC_WARNING_FORMAT_TRUNCATION
CATCH(errctx, aksl_snprintf(
&count,
(char *)&pathbuf->data,
sizeof(pathbuf->data),
"%s/%s",
root,
path
));
RESTORE_GCC_WARNINGS
CATCH(errctx, aksl_realpath((char *)&pathbuf->data, (char *)&strbuf->data, sizeof(strbuf->data)));
CATCH(errctx, aksl_snprintf(
&count,
(char *)&pathbuf->data,
sizeof(pathbuf->data),
"%s/%s",
root,
path
));
CATCH(errctx, aksl_realpath((char *)&pathbuf->data, (char *)&strbuf->data, sizeof(strbuf->data)));
CATCH(errctx, akgl_string_copy(strbuf, dest, 0));
} CLEANUP {
IGNORE(akgl_heap_release_string(strbuf));
@@ -260,6 +258,7 @@ akerr_ErrorContext *akgl_render_and_compare(SDL_Texture *t1, SDL_Texture *t2, in
SDL_FRect dest = {.x = x, .y = y, .w = w, .h = h};
SDL_Rect read = {.x = x, .y = y, .w = w, .h = h};
akgl_String *tmpstring = NULL;
int count = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
@@ -273,7 +272,13 @@ akerr_ErrorContext *akgl_render_and_compare(SDL_Texture *t1, SDL_Texture *t2, in
FAIL_ZERO_BREAK(errctx, s1, AKGL_ERR_SDL, "Failed to read pixels from renderer");
if ( writeout != NULL ) {
snprintf((char *)&tmpstring->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), writeout);
CATCH(errctx, aksl_snprintf(
&count,
(char *)&tmpstring->data,
sizeof(tmpstring->data),
"%s%s",
SDL_GetBasePath(),
writeout));
FAIL_ZERO_BREAK(
errctx,
IMG_SavePNG(s1, (char *)&tmpstring->data),

View File

@@ -30,7 +30,9 @@ void handle_unhandled_error_noexit(akerr_ErrorContext *errctx)
return;
}
if ( UNHANDLED_ERROR_BEHAVIOR == UNHANDLED_ERROR_EXIT ) {
exit(errctx->status);
// akerr_exit rather than exit(3): a status past 255 does not survive a
// wait status, and this suite's are AKGL_* codes, which all are.
akerr_exit(errctx->status);
}
if ( UNHANDLED_ERROR_BEHAVIOR == UNHANDLED_ERROR_SET ) {
unhandled_error_context = errctx;
@@ -444,8 +446,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 +464,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 +490,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 +500,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 {
@@ -1021,7 +1039,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
CATCH(errctx, akgl_registry_init_actor());
CATCH(errctx, akgl_registry_init_sprite());
CATCH(errctx, akgl_registry_init_spritesheet());

View File

@@ -602,7 +602,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
FAIL_ZERO_BREAK(
errctx,
SDL_Init(SDL_INIT_AUDIO),

View File

@@ -286,7 +286,6 @@ int main(void)
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
akgl_renderer = &akgl_default_renderer;
SDL_SetAppMetadata("SDL3-GameTest", "0.1", "net.aklabs.sdl3-gametest");

View File

@@ -858,7 +858,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
FAIL_ZERO_BREAK(
errctx,
SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMEPAD),

View File

@@ -638,7 +638,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
akgl_renderer = &akgl_default_renderer;
FAIL_ZERO_BREAK(

View File

@@ -95,12 +95,9 @@ int main(void)
ATTEMPT {
// Unlike every other suite, this one has no akgl_error_init() in
// main() -- the first test is what brings the subsystem up. The trap
// therefore goes after it: libakerror installs its own default handler
// from the lazy akerr_init() inside that first call, and would
// overwrite anything set beforehand.
// main() -- the first test is what brings the subsystem up, and
// asserting that it does is the point of it.
CATCH(errctx, test_error_init_owns_the_status_band());
TEST_TRAP_UNHANDLED_ERRORS();
CATCH(errctx, test_error_init_is_idempotent());
} CLEANUP {
} PROCESS(errctx) {

View File

@@ -154,6 +154,53 @@ akerr_ErrorContext *test_game_save_roundtrip(void)
SUCCEED_RETURN(e);
}
/**
* @brief A save whose close fails must not report success.
*
* akgl_game_save checks every write, and every one of those writes lands in
* stdio's buffer -- for a record this size, nothing reaches the device until
* the close flushes it. So a full disk, an exceeded quota or a server going
* away is reported *only* by the close, and while that close was unchecked the
* function returned success over a savegame that was never written.
*
* `/dev/full` reproduces that exactly: writes to it are accepted, and the
* ENOSPC surfaces at the flush. Verified against the unfixed code, which
* reported a clean save.
*
* Linux-specific, so it skips rather than fails where the device is not there.
* That is the right trade for a test whose alternative is filling a real
* filesystem.
*/
akerr_ErrorContext *test_game_save_reports_a_failed_close(void)
{
PREPARE_ERROR(e);
char fullpath[] = "/dev/full";
FILE *probe = NULL;
ATTEMPT {
probe = fopen(fullpath, "wb");
if ( probe == NULL ) {
printf(" skipping the failed-close case: %s is not writable here\n", fullpath);
SUCCEED_BREAK(e);
}
fclose(probe);
probe = NULL;
CATCH(e, akgl_registry_init());
CATCH(e, akgl_heap_init());
set_game_identity();
TEST_EXPECT_ANY_ERROR(e, akgl_game_save((char *)&fullpath),
"saving to a device with no room");
} CLEANUP {
if ( probe != NULL ) {
fclose(probe);
}
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_game_load_rejects_foreign_saves(void)
{
PREPARE_ERROR(e);
@@ -728,7 +775,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_registry_init());
@@ -736,6 +782,7 @@ int main(void)
CATCH(errctx, test_game_load_versioncmp_mismatched());
CATCH(errctx, test_game_load_versioncmp_releases_semver());
CATCH(errctx, test_game_save_roundtrip());
CATCH(errctx, test_game_save_reports_a_failed_close());
CATCH(errctx, test_game_load_rejects_foreign_saves());
CATCH(errctx, test_game_save_load_nullpointers());
CATCH(errctx, test_game_load_truncated_table());

View File

@@ -362,7 +362,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_registry_init());

View File

@@ -528,7 +528,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_registry_init());
CATCH(errctx, load_fixture());

View File

@@ -901,7 +901,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_registry_init());
CATCH(errctx, akgl_registry_init_properties());

View File

@@ -752,7 +752,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
akgl_renderer = &akgl_default_renderer;
akgl_physics = &akgl_default_physics;
akgl_camera = &akgl_default_camera;

View File

@@ -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;
@@ -735,7 +784,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_registry_init());
CATCH(errctx, akgl_registry_init_properties());

648
tests/physics_sim.c Normal file
View File

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

View File

@@ -241,7 +241,6 @@ akerr_ErrorContext *test_actor_state_string_names(void)
akerr_ErrorContext *test_akgl_registry_init_is_repeatable(void)
{
PREPARE_ERROR(errctx);
SDL_PropertiesID first = 0;
akgl_String *readback = NULL;
ATTEMPT {
@@ -254,7 +253,6 @@ akerr_ErrorContext *test_akgl_registry_init_is_repeatable(void)
// the memcheck run's job, and `cmake --build build --target memcheck`
// gates on it. What is observable is that re-initializing really does
// replace: a key written before the call must be gone after it.
first = AKGL_REGISTRY_SPRITE;
SDL_SetNumberProperty(AKGL_REGISTRY_SPRITE, "akgl_test_sentinel", 1);
TEST_ASSERT(errctx,
SDL_GetNumberProperty(AKGL_REGISTRY_SPRITE, "akgl_test_sentinel", 0) == 1,
@@ -290,7 +288,6 @@ int main(void)
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
CATCH(errctx, test_akgl_registry_init_creation_failures());
CATCH(errctx, test_akgl_get_property_copies_only_the_value());
CATCH(errctx, test_actor_state_string_names());

View File

@@ -250,7 +250,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
memset(&bound, 0x00, sizeof(akgl_RenderBackend));
FAIL_ZERO_BREAK(

View File

@@ -242,7 +242,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
akgl_renderer = &akgl_default_renderer;
SDL_SetAppMetadata("SDL3-GameTest", "0.1", "net.aklabs.sdl3-gametest");

View File

@@ -241,7 +241,6 @@ int main(void)
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
printf("test_fresh_heap_gives_string ....\n");
test_fresh_heap_gives_strings();
reset_string_heap();

View File

@@ -43,40 +43,6 @@ static inline int test_string_pool_used(void)
return used;
}
/**
* @brief Exit with a status the shell can actually see.
*
* libakerror's default unhandled-error handler ends in `exit(errctx->status)`.
* `exit` keeps only the low byte of what it is given, and libakgl's own
* statuses start at `AKERR_FIRST_CONSUMER_STATUS`, which is 256 -- so
* #AKGL_ERR_SDL exits 0, and CTest records a pass. Every suite in this
* directory that failed for the most common reason in this library therefore
* reported success. `tests/character.c` did exactly that: it aborted at its
* second of four tests on a bad renderer and was green for months.
*
* This collapses any status that a byte cannot carry onto 1. It does not log --
* `FINISH_NORETURN` has already logged the context and its stack by the time it
* calls the handler.
*/
static inline void test_handler_unhandled_error(akerr_ErrorContext *errctx)
{
int status = ( errctx == NULL ) ? 1 : (errctx->status & 0xFF);
if ( status == 0 ) {
status = 1;
}
exit(status);
}
/**
* @brief Install test_handler_unhandled_error(). Call once, after akgl_error_init().
*
* akgl_error_init() reaches akerr_init(), which installs libakerror's default
* handler, so this has to come after it rather than before.
*/
#define TEST_TRAP_UNHANDLED_ERRORS() \
(akerr_handler_unhandled_error = &test_handler_unhandled_error)
/** @brief Fail the enclosing ATTEMPT block unless @p cond holds. */
#define TEST_ASSERT(e, cond, ...) \
if ( ! (cond) ) { \

View File

@@ -392,7 +392,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
memset(&testbackend, 0x00, sizeof(akgl_RenderBackend));
FAIL_ZERO_BREAK(
errctx,

View File

@@ -9,6 +9,7 @@
#include <akgl/tilemap.h>
#include <akgl/actor.h>
#include <akgl/game.h>
#include <akstdlib.h>
#include <akgl/json_helpers.h>
#include "testutil.h"
@@ -505,6 +506,52 @@ akerr_ErrorContext *test_akgl_tilemap_load_layer_tile(void)
SUCCEED_RETURN(errctx);
}
/**
* @brief A layer image path too long for the field must say so, not load the wrong file.
*
* The join is `"%s/%s"` of a directory and an image name into a
* #AKGL_TILEMAP_MAX_TILESET_FILENAME_SIZE buffer, and both inputs are
* #AKGL_MAX_STRING_LENGTH wide, so it can overflow. It used to be a raw
* `snprintf` under a silenced `-Wformat-truncation`: the path was quietly cut
* short and handed to `IMG_LoadTexture`, which failed with SDL's "couldn't
* open" against a filename the caller never wrote -- the length problem
* reported as a missing-file problem, pointing at the wrong thing.
*
* `aksl_snprintf` treats truncation as the error it is. The directory here is
* filled to capacity so the join cannot fit whatever the image name is.
*/
akerr_ErrorContext *test_akgl_tilemap_layer_image_path_too_long(void)
{
PREPARE_ERROR(errctx);
akgl_String *dirname = NULL;
json_t *layer = NULL;
json_error_t errdata;
ATTEMPT {
CATCH(errctx, akgl_heap_next_string(&dirname));
CATCH(errctx, aksl_memset((void *)&dirname->data, 'd', sizeof(dirname->data)));
dirname->data[sizeof(dirname->data) - 1] = '\0';
layer = json_loads("{\"image\": \"tiles.png\"}", 0, &errdata);
FAIL_ZERO_BREAK(errctx, layer, AKERR_VALUE, "Failed to build the test layer: %s", errdata.text);
TEST_EXPECT_STATUS(
errctx,
AKERR_OUTOFBOUNDS,
akgl_tilemap_load_layer_image(akgl_gamemap, layer, 0, dirname),
"joining an image name onto a directory that fills the field");
} CLEANUP {
if ( dirname != NULL ) {
IGNORE(akgl_heap_release_string(dirname));
}
if ( layer != NULL ) {
json_decref(layer);
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *test_akgl_tilemap_load_layers(void)
{
akgl_String *pathstr;
@@ -695,7 +742,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
akgl_gamemap = &akgl_default_gamemap;
akgl_renderer = &akgl_default_renderer;
SDL_SetAppMetadata("SDL3-GameTest", "0.1", "net.aklabs.sdl3-gametest");
@@ -720,6 +766,7 @@ int main(void)
CATCH(errctx, test_akgl_tilemap_compute_tileset_offsets());
CATCH(errctx, test_akgl_tilemap_load_layer_objects());
CATCH(errctx, test_akgl_tilemap_load_layer_tile());
CATCH(errctx, test_akgl_tilemap_layer_image_path_too_long());
CATCH(errctx, test_akgl_tilemap_load_layers());
CATCH(errctx, test_akgl_tilemap_load_tilesets());
CATCH(errctx, test_akgl_tilemap_load_bounds_fixed_tables());

View File

@@ -457,7 +457,6 @@ int main(void)
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
CATCH(errctx, test_akgl_rectangle_points_nullpointers());
CATCH(errctx, test_akgl_rectangle_points_math());
CATCH(errctx, test_akgl_collide_point_rectangle_nullpointers());

View File

@@ -99,7 +99,6 @@ int main(void)
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
CATCH(errctx, test_version_string_matches_components());
CATCH(errctx, test_version_linked_matches_compiled());
CATCH(errctx, test_version_at_least_boundaries());

View File

@@ -24,7 +24,6 @@ SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[])
{
PREPARE_ERROR(errctx);
akgl_Actor *actorptr = NULL;
int i = 0;
int gamepadids[32];
char *characterjson = NULL;
char pathbuf[4096];