Compare commits
5 Commits
b899f09fc8
...
e4aa6a5084
| Author | SHA1 | Date | |
|---|---|---|---|
|
e4aa6a5084
|
|||
|
1b90f2ebd5
|
|||
|
147ab7dc78
|
|||
|
d55778e625
|
|||
|
4d80664cd9
|
@@ -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
5
.gitignore
vendored
@@ -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
244
AGENTS.md
@@ -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, 1–255 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
|
||||
|
||||
|
||||
@@ -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
212
TODO.md
@@ -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.**
|
||||
|
||||
2
deps/libakerror
vendored
2
deps/libakerror
vendored
Submodule deps/libakerror updated: 5ff87908e7...5eaa956f50
2
deps/libakstdlib
vendored
2
deps/libakstdlib
vendored
Submodule deps/libakstdlib updated: f8425b8729...669b2b395f
@@ -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\"")
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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_
|
||||
|
||||
@@ -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);
|
||||
/**
|
||||
|
||||
17
src/actor.c
17
src/actor.c
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
98
src/game.c
98
src/game.c
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
52
src/sprite.c
52
src/sprite.c
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
29
src/util.c
29
src/util.c
@@ -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),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
49
tests/game.c
49
tests/game.c
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
648
tests/physics_sim.c
Normal 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;
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) ) { \
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user