Compare commits
80 Commits
bugfix/phy
...
e4aa6a5084
| Author | SHA1 | Date | |
|---|---|---|---|
|
e4aa6a5084
|
|||
|
1b90f2ebd5
|
|||
|
147ab7dc78
|
|||
|
d55778e625
|
|||
|
4d80664cd9
|
|||
|
b899f09fc8
|
|||
|
9ec0b41460
|
|||
|
58b99e0477
|
|||
|
28fa929f6a
|
|||
|
947bc03601
|
|||
|
b0a4e36ded
|
|||
|
d9f0187ecf
|
|||
|
913834a3af
|
|||
|
3ee8c60491
|
|||
|
3f37d38f2d
|
|||
|
9c2f80bcb9
|
|||
|
8a90cbf275
|
|||
|
21d69192e3
|
|||
|
230278d303
|
|||
|
6b1cf437d3
|
|||
|
19530f6a97
|
|||
|
a60220d39b
|
|||
|
76ea04205c
|
|||
|
3a262bee54
|
|||
|
9924d74dcc
|
|||
|
93e8e4afa4
|
|||
|
5dfa49482c
|
|||
|
76c6240280
|
|||
|
fb58bb01b0
|
|||
|
80fdf2e098
|
|||
|
af304dc2f9
|
|||
|
8a920860c5
|
|||
|
bcd49fc5b1
|
|||
|
c6227545a6
|
|||
|
f35443e84d
|
|||
|
57bf1c7649
|
|||
|
a3eada1b3f
|
|||
|
34a076b851
|
|||
|
42b53dcb20
|
|||
|
ede3452c49
|
|||
|
18399f2726
|
|||
|
54df954ed6
|
|||
|
55675cc9de
|
|||
|
f0858b0d38
|
|||
|
582008a411
|
|||
|
1066ac716e
|
|||
|
996cacb10c
|
|||
|
42b60f725d
|
|||
|
4208d9d471
|
|||
|
f56f88710f
|
|||
|
2a3ca48d8f
|
|||
|
dba0f8db89
|
|||
|
1ddc64010a
|
|||
|
17e6e04c79
|
|||
|
5f03475e0f
|
|||
|
6dfe7487ae
|
|||
|
c2b16d3c18
|
|||
|
2be9831c0c
|
|||
|
c5a7b6053d
|
|||
|
9b124f2e27
|
|||
|
b014eb2360
|
|||
|
e423f9594e
|
|||
|
772f960865
|
|||
|
28bde4176d
|
|||
|
8d21d5c7dd
|
|||
|
bc782fbffe
|
|||
|
22162db2da
|
|||
|
6f6bd2d563
|
|||
|
ff88f48fa2
|
|||
|
549b27d3eb
|
|||
|
a2995e81df
|
|||
|
dc1bd0a798
|
|||
|
3854b33750
|
|||
|
cf9ebb206f
|
|||
|
74867ea82e
|
|||
|
4e510dd6d6
|
|||
|
dca03cb50d
|
|||
|
652ee4cdf3
|
|||
|
9fed59c4c8
|
|||
|
941eeb2493
|
16
.dir-locals.el
Normal file
16
.dir-locals.el
Normal file
@@ -0,0 +1,16 @@
|
||||
;;; Directory Local Variables -*- no-byte-compile: t -*-
|
||||
;;; See AGENTS.md -> "Coding Style" for the rationale.
|
||||
;;;
|
||||
;;; The canonical style is cc-mode "stroustrup" with tabs enabled: 4 columns per
|
||||
;;; level, tabs 8 columns wide, so depth 1 is four spaces, depth 2 is one tab,
|
||||
;;; depth 3 is a tab plus four spaces. A correctly formatted file is a fixed
|
||||
;;; point of `indent-region' under these settings.
|
||||
;;;
|
||||
;;; Reindent a whole file with C-x h C-M-\, or the tree with
|
||||
;;; `scripts/reindent.sh'.
|
||||
|
||||
((c-mode . ((c-file-style . "stroustrup")
|
||||
(indent-tabs-mode . t)
|
||||
(tab-width . 8)
|
||||
(fill-column . 100)
|
||||
(require-final-newline . t))))
|
||||
212
.gitea/workflows/ci.yaml
Normal file
212
.gitea/workflows/ci.yaml
Normal file
@@ -0,0 +1,212 @@
|
||||
name: libakgl CI Build
|
||||
run-name: ${{ gitea.actor }} libakgl test
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
cmake_build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y \
|
||||
cmake doxygen gcc gcovr pkg-config \
|
||||
libasound2-dev libfreetype-dev libharfbuzz-dev \
|
||||
libpng-dev libtiff-dev libwebp-dev \
|
||||
libudev-dev libx11-dev libxcursor-dev libxext-dev \
|
||||
libxfixes-dev libxi-dev libxrandr-dev libxrender-dev \
|
||||
libxss-dev libxtst-dev
|
||||
- name: Configure and build
|
||||
run: |
|
||||
cmake -S . -B build \
|
||||
-DCMAKE_BUILD_TYPE=Debug \
|
||||
-DAKGL_COVERAGE=ON \
|
||||
-DAKGL_WERROR=ON
|
||||
cmake --build build --parallel
|
||||
- name: Build API documentation
|
||||
run: doxygen Doxyfile
|
||||
# The perf suites run here too -- they are ordinary CTest tests -- but this
|
||||
# is a Debug build with coverage instrumentation, where a timing is a
|
||||
# measurement of gcov. AKGL_BENCH_SCALE cuts their iteration counts so
|
||||
# they contribute path coverage without spending ten minutes proving
|
||||
# nothing; benchutil.h already refuses to enforce budgets in an
|
||||
# unoptimized build. The performance job below is where the timings are
|
||||
# taken and the budgets are checked.
|
||||
- name: Test (JUnit)
|
||||
env:
|
||||
AKGL_BENCH_SCALE: '0.02'
|
||||
run: |
|
||||
export LD_LIBRARY_PATH="$PWD/build:$PWD/build/deps/SDL:$PWD/build/deps/SDL_image:$PWD/build/deps/SDL_mixer:$PWD/build/deps/SDL_ttf"
|
||||
ctest \
|
||||
--test-dir build \
|
||||
--output-on-failure \
|
||||
--output-junit "$(pwd)/ctest-junit.xml"
|
||||
- name: Publish test results
|
||||
if: always()
|
||||
uses: mikepenz/action-junit-report@v4
|
||||
with:
|
||||
report_paths: 'ctest-junit.xml'
|
||||
annotate_only: true
|
||||
detailed_summary: true
|
||||
include_passed: true
|
||||
fail_on_failure: 'true'
|
||||
- name: Upload coverage reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: code-coverage
|
||||
path: build/coverage/
|
||||
if-no-files-found: warn
|
||||
|
||||
# The benchmarks, in the only build where their numbers mean anything.
|
||||
# tests/benchutil.h enforces a budget per measurement only when it is compiled
|
||||
# optimized and running at full scale, which is exactly this job and no other:
|
||||
# the coverage job above is Debug, and the memory job below is under valgrind.
|
||||
# A budget is roughly ten times the baseline recorded in PERFORMANCE.md, which
|
||||
# is what makes a shared, slower runner viable -- it catches an algorithmic
|
||||
# regression and ignores a busy machine. If a budget still proves flaky here,
|
||||
# raise that one budget with a measurement behind it and re-record the
|
||||
# baseline; do not drop the gate.
|
||||
performance:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y \
|
||||
cmake gcc pkg-config \
|
||||
libasound2-dev libfreetype-dev libharfbuzz-dev \
|
||||
libpng-dev libtiff-dev libwebp-dev \
|
||||
libudev-dev libx11-dev libxcursor-dev libxext-dev \
|
||||
libxfixes-dev libxi-dev libxrandr-dev libxrender-dev \
|
||||
libxss-dev libxtst-dev
|
||||
- name: Configure and build
|
||||
run: |
|
||||
cmake -S . -B build \
|
||||
-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
|
||||
# nothing at all for a green run. pipefail because the step ends in a
|
||||
# pipe, whose status would otherwise be tee's -- always 0 -- and a blown
|
||||
# budget would be reported as a passing step.
|
||||
- name: Benchmarks (JUnit)
|
||||
run: |
|
||||
set -o pipefail
|
||||
export LD_LIBRARY_PATH="$PWD/build:$PWD/build/deps/SDL:$PWD/build/deps/SDL_image:$PWD/build/deps/SDL_mixer:$PWD/build/deps/SDL_ttf"
|
||||
ctest \
|
||||
--test-dir build \
|
||||
-L perf \
|
||||
--verbose \
|
||||
--output-junit "$(pwd)/perf-junit.xml" \
|
||||
2>&1 | tee "$(pwd)/perf-baseline.txt"
|
||||
- name: Publish benchmark results
|
||||
if: always()
|
||||
uses: mikepenz/action-junit-report@v4
|
||||
with:
|
||||
report_paths: 'perf-junit.xml'
|
||||
annotate_only: true
|
||||
detailed_summary: true
|
||||
include_passed: true
|
||||
fail_on_failure: 'true'
|
||||
# Kept whether the job passed or failed: the tables are the point. A run
|
||||
# that went red says which measurement moved, and a run that went green is
|
||||
# the next baseline if somebody re-records PERFORMANCE.md.
|
||||
- name: Upload benchmark tables
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: performance-baseline
|
||||
path: perf-baseline.txt
|
||||
if-no-files-found: warn
|
||||
|
||||
# Every suite under valgrind. No memory-check programs of their own: the perf
|
||||
# suites scale themselves down when they detect valgrind, which turns them
|
||||
# into the broadest path coverage in the tree at a cost this job can afford.
|
||||
#
|
||||
# This job gates. A definite leak, a read past the end of an allocation, or a
|
||||
# branch on uninitialised memory fails the build on the push that introduced
|
||||
# it -- not on the day somebody gets around to caring. The six findings this
|
||||
# job had on its first run were fixed to make that possible rather than
|
||||
# excused into a warning.
|
||||
memory_check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: Install memory-check dependencies
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y \
|
||||
cmake gcc pkg-config valgrind \
|
||||
libasound2-dev libfreetype-dev libharfbuzz-dev \
|
||||
libpng-dev libtiff-dev libwebp-dev \
|
||||
libudev-dev libx11-dev libxcursor-dev libxext-dev \
|
||||
libxfixes-dev libxi-dev libxrandr-dev libxrender-dev \
|
||||
libxss-dev libxtst-dev
|
||||
# RelWithDebInfo rather than Debug: valgrind needs the symbols, and -O0
|
||||
# would leave every inlined frame in the stacks it prints.
|
||||
- name: Configure and build
|
||||
run: |
|
||||
cmake -S . -B build \
|
||||
-DCMAKE_BUILD_TYPE=RelWithDebInfo
|
||||
cmake --build build --parallel
|
||||
# Every suite, character included. It was excluded from both this job
|
||||
# and the coverage job on the grounds that it failed deliberately -- it
|
||||
# did not. It was reporting success while running one of its four tests;
|
||||
# see TODO.md, "Test suites that could not fail".
|
||||
- name: Memory check
|
||||
run: scripts/memcheck.sh
|
||||
- name: Upload valgrind logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: valgrind-logs
|
||||
path: build/Testing/Temporary/MemoryChecker.*.log
|
||||
if-no-files-found: warn
|
||||
|
||||
mutation_test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: Install mutation-test dependencies
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y \
|
||||
cmake gcc pkg-config python3 \
|
||||
libasound2-dev libfreetype-dev libharfbuzz-dev \
|
||||
libpng-dev libtiff-dev libwebp-dev \
|
||||
libudev-dev libx11-dev libxcursor-dev libxext-dev \
|
||||
libxfixes-dev libxi-dev libxrandr-dev libxrender-dev \
|
||||
libxss-dev libxtst-dev
|
||||
# Keep CI bounded to a focused source file. Run the default target locally
|
||||
# for the complete libakgl-owned src/ tree.
|
||||
- name: Mutation testing
|
||||
run: |
|
||||
python3 scripts/mutation_test.py \
|
||||
--target src/staticstring.c \
|
||||
--junit mutation-junit.xml \
|
||||
--threshold 50
|
||||
- name: Publish mutation results
|
||||
if: always()
|
||||
uses: mikepenz/action-junit-report@v4
|
||||
with:
|
||||
report_paths: 'mutation-junit.xml'
|
||||
annotate_only: true
|
||||
detailed_summary: true
|
||||
include_passed: true
|
||||
fail_on_failure: 'false'
|
||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -1,3 +1,16 @@
|
||||
./build/*
|
||||
# A leading ./ is not a valid gitignore pattern, so "./build/*" matched nothing
|
||||
# and every out-of-tree build tree showed up as untracked. This also covers the
|
||||
# instrumented trees scripts/coverage.py and the AKGL_COVERAGE option create.
|
||||
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.
|
||||
include/akgl/version.h
|
||||
|
||||
4
.gitmodules
vendored
4
.gitmodules
vendored
@@ -15,10 +15,10 @@
|
||||
url = git@github.com:libsdl-org/SDL_ttf.git
|
||||
[submodule "deps/libsdlerror"]
|
||||
path = deps/libakerror
|
||||
url = https://source.home.aklabs.net/andrew/libsdlerror.git
|
||||
url = https://source.starfort.tech/andrew/libakerror.git
|
||||
[submodule "deps/libakstdlib"]
|
||||
path = deps/libakstdlib
|
||||
url = https://source.home.aklabs.net/andrew/libakstdlib.git
|
||||
url = https://source.starfort.tech/andrew/libakstdlib.git
|
||||
[submodule "deps/jansson"]
|
||||
path = deps/jansson
|
||||
url = git@github.com:akheron/jansson.git
|
||||
|
||||
687
AGENTS.md
Normal file
687
AGENTS.md
Normal file
@@ -0,0 +1,687 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
This is a C library intended to support the development of video games. Public C headers live in `include/akgl/`; keep declarations there aligned with their implementations in `src/`. Tests are standalone C programs under `tests/`, with JSON, image, and map fixtures in `tests/assets/`. The `util/` directory contains the `charviewer` utility and its sample assets. Third-party and companion libraries are vendored in `deps/`. Treat `build/` and generated `akgl.pc` files as build outputs rather than hand-maintained source; `include/akgl/SDL_GameControllerDB.h` is generated too, but is tracked on purpose — see below.
|
||||
|
||||
## Generated and Vendored Sources
|
||||
|
||||
### `include/akgl/SDL_GameControllerDB.h` is generated, and is tracked deliberately
|
||||
|
||||
`mkcontrollermappings.sh` regenerates this header by fetching the community
|
||||
controller database from `raw.githubusercontent.com`. **It is committed to the
|
||||
repository on purpose**: it is the offline fallback that keeps the library
|
||||
buildable if upstream is renamed, rate-limited, taken down, or simply
|
||||
unreachable from the build machine. Do not delete it, do not add it to
|
||||
`.gitignore`, and do not "clean up" the fact that a generated file is tracked.
|
||||
|
||||
Working rules:
|
||||
|
||||
- **Never hand-edit it.** It is machine-written; changes belong in
|
||||
`mkcontrollermappings.sh`.
|
||||
- **An ordinary build does not touch it.** Regeneration is
|
||||
`cmake --build build --target controllerdb`, and nothing else runs the script.
|
||||
Until 0.5.0 an `add_custom_command` re-ran it on every build, which made every
|
||||
build need network access and left the file modified with a fresh `$(date)`
|
||||
stamp and nothing of substance changed.
|
||||
- **Update it as a deliberate, standalone commit** when you actually want newer
|
||||
mappings, so the diff is reviewable and bisectable.
|
||||
- **A failed fetch cannot reach this file.** The script assembles the header in
|
||||
a temporary directory and moves it into place only after checking curl's exit
|
||||
status, a plausible minimum mapping count, and that no mapping contains a
|
||||
character that would break a C string literal. Any of those failing leaves the
|
||||
tracked copy untouched and exits non-zero.
|
||||
- Formatting tooling ignores it: `scripts/reindent.sh` and the pre-commit hook
|
||||
both skip it.
|
||||
|
||||
> **Fixed in 0.5.0.** The safety net used to be self-defeating:
|
||||
> `mkcontrollermappings.sh` had no `set -e` and never checked `curl`, so a
|
||||
> failed fetch wrote `AKGL_SDL_GAMECONTROLLER_DB_LEN 0` and an empty array
|
||||
> **over the good tracked copy, and exited 0** — and because CMake re-ran the
|
||||
> generator on every build, an offline build silently destroyed the very
|
||||
> fallback the file exists to provide. Both halves are closed: the script
|
||||
> refuses rather than overwrites, and a build does not run it.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
Configure an out-of-tree development build:
|
||||
|
||||
```sh
|
||||
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
|
||||
```
|
||||
|
||||
Build all library, utility, and test targets:
|
||||
|
||||
```sh
|
||||
cmake --build build --parallel
|
||||
```
|
||||
|
||||
Run the complete test suite with failure details:
|
||||
|
||||
```sh
|
||||
ctest --test-dir build --output-on-failure
|
||||
```
|
||||
|
||||
Run one test while iterating, for example `ctest --test-dir build -R sprite --output-on-failure`. The `rebuild.sh` script also installs into a developer-specific `/home/andrew/local` prefix and removes existing outputs; prefer the portable commands above unless that exact workflow is intended.
|
||||
|
||||
Run only the performance suites with `ctest --test-dir build -L perf --output-on-failure`, or leave them out of an ordinary run with `-LE perf`. They take about 30 seconds together, print a table of nanoseconds per operation, and fail only when a measurement exceeds a budget set at roughly ten times the recorded baseline. `AKGL_BENCH_SCALE` scales every iteration count — `AKGL_BENCH_SCALE=0.1 ctest --test-dir build -L perf` for a quick look — and below 1.0 the budgets are reported but not enforced. The recorded baseline and what it means are in `PERFORMANCE.md`.
|
||||
|
||||
Check for memory defects with `cmake --build build --target memcheck`, or
|
||||
`scripts/memcheck.sh` directly — it takes ctest's selection flags, so
|
||||
`scripts/memcheck.sh -R tilemap` and `scripts/memcheck.sh -LE perf` both work.
|
||||
It runs the suites that already exist under valgrind (`ctest -T memcheck`) with
|
||||
the headless drivers forced, and exits non-zero when valgrind finds a definite
|
||||
leak, an invalid access, or a read of uninitialised memory — which `ctest -T
|
||||
memcheck` on its own will not do. The whole run takes about thirty seconds
|
||||
because the perf suites scale themselves down under valgrind. Suppressions for
|
||||
third-party findings live in `scripts/valgrind.supp`; add one only when the
|
||||
finding genuinely cannot be fixed from this repository.
|
||||
|
||||
Run mutation testing with `cmake --build build --target mutation`. For a quick smoke run, use `scripts/mutation_test.py --target src/tilemap.c --max-mutants 10`; the harness mutates only a scratch copy and runs every suite.
|
||||
|
||||
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
|
||||
because the others cannot do its work:
|
||||
|
||||
| Job | Build | What it is for |
|
||||
|---|---|---|
|
||||
| `cmake_build` | Debug + `AKGL_COVERAGE=ON` | The unit suites and the coverage report. The perf suites run here too, at `AKGL_BENCH_SCALE=0.02`, for path coverage only — a timing taken under gcov is a measurement of gcov. |
|
||||
| `performance` | RelWithDebInfo | `ctest -L perf`. The only job where budgets are enforced, because `tests/benchutil.h` enforces them only when compiled optimized at full scale. Keeps the tables as an artifact. |
|
||||
| `memory_check` | RelWithDebInfo | `scripts/memcheck.sh`, every suite under valgrind. Gates: a definite leak, an invalid access or a branch on uninitialised memory fails the build. Keeps the valgrind logs as an artifact. |
|
||||
| `mutation_test` | Debug | One focused source file, so CI stays bounded. |
|
||||
|
||||
Every suite runs in every job. The `character` suite used to be excluded from
|
||||
the coverage and memory-check jobs on the grounds that it failed deliberately.
|
||||
It did not: it was reporting success while running one of its four tests, for
|
||||
two independent reasons, and both are fixed. See TODO.md, "Test suites that
|
||||
could not fail".
|
||||
|
||||
## Coding Style
|
||||
|
||||
The canonical style is Emacs `cc-mode` **`stroustrup`**, with tabs enabled. This
|
||||
is not advisory — new and edited code must match what `cc-mode` produces, so that
|
||||
reindenting a region never shows up as a diff.
|
||||
|
||||
### Emacs setup
|
||||
|
||||
`.dir-locals.el` in the repository root already applies the style to every
|
||||
`c-mode` buffer, so nothing needs configuring by hand:
|
||||
|
||||
```elisp
|
||||
((c-mode . ((c-file-style . "stroustrup")
|
||||
(indent-tabs-mode . t)
|
||||
(tab-width . 8)
|
||||
(fill-column . 100)
|
||||
(require-final-newline . t))))
|
||||
```
|
||||
|
||||
Interactively: `C-x h C-M-\` (`mark-whole-buffer` + `indent-region`) reindents
|
||||
the buffer. A correctly formatted file is a fixed point of that operation —
|
||||
reindenting must produce no change.
|
||||
|
||||
### Reindenting from the command line
|
||||
|
||||
```sh
|
||||
scripts/reindent.sh # reindent every tracked C source in place
|
||||
scripts/reindent.sh src/tilemap.c # reindent only the named files
|
||||
scripts/reindent.sh --check # list non-conforming files, change nothing
|
||||
```
|
||||
|
||||
`--check` exits 1 when something needs reindenting and 2 if Emacs is missing, so
|
||||
it is usable from CI. The script drives Emacs in batch mode through
|
||||
`scripts/reindent.el`, which reindents, normalises leading whitespace to the
|
||||
canonical tab/space mix, strips trailing whitespace, and ensures a final
|
||||
newline. `include/akgl/SDL_GameControllerDB.h` is generated and always skipped.
|
||||
|
||||
Note that `reindent.el` deliberately does **not** use Emacs' `tabify`: that
|
||||
function's `tabify-regexp` is `" [ \t]+"`, which is not anchored to the start of
|
||||
a line, so it rewrites runs of spaces anywhere and destroys the hand-aligned
|
||||
value columns in the bit-flag tables in `actor.h` and `iterator.h`. Only leading
|
||||
whitespace is ever rewritten.
|
||||
|
||||
### The pre-commit hook
|
||||
|
||||
`scripts/hooks/pre-commit` reindents staged C sources before the commit is
|
||||
written. Enable it once per clone:
|
||||
|
||||
```sh
|
||||
git config core.hooksPath scripts/hooks
|
||||
```
|
||||
|
||||
It inspects the *staged* content rather than the working tree, so what lands in
|
||||
the commit is what was checked. When a file needs reindenting it is fixed in the
|
||||
working tree and re-staged — but only if the index and working tree agree for
|
||||
that file. If they differ, re-staging would sweep unstaged work into the commit,
|
||||
so the hook stops and tells you to reindent and stage it yourself. It steps
|
||||
aside during a merge, and warns rather than blocking if Emacs is unavailable.
|
||||
`git commit --no-verify` bypasses it.
|
||||
|
||||
For reference, `cc-mode` defines the style as:
|
||||
|
||||
```elisp
|
||||
("stroustrup"
|
||||
(c-basic-offset . 4)
|
||||
(c-comment-only-line-offset . 0)
|
||||
(c-offsets-alist . ((statement-block-intro . +)
|
||||
(substatement-open . 0)
|
||||
(substatement-label . 0)
|
||||
(label . 0)
|
||||
(statement-cont . +))))
|
||||
```
|
||||
|
||||
### Indentation and whitespace
|
||||
|
||||
- **4 columns per level** (`c-basic-offset` 4).
|
||||
- **Tabs are 8 columns wide and are used for indentation** (`indent-tabs-mode`
|
||||
`t`, `tab-width` 8). Emacs emits the largest possible run of tabs and pads the
|
||||
remainder with spaces, which produces this ladder. Editors that expand tabs, or
|
||||
that assume a 4-column tab, will silently corrupt it:
|
||||
|
||||
| Depth | Columns | Bytes |
|
||||
|---|---|---|
|
||||
| 1 | 4 | 4 spaces |
|
||||
| 2 | 8 | `TAB` |
|
||||
| 3 | 12 | `TAB` + 4 spaces |
|
||||
| 4 | 16 | `TAB` `TAB` |
|
||||
| 5 | 20 | `TAB` `TAB` + 4 spaces |
|
||||
|
||||
- No trailing whitespace. Files end with a single newline.
|
||||
- `case` labels sit at the same column as their `switch` (`label . 0`).
|
||||
- Continuation lines of a wrapped expression indent one level (`statement-cont . +`).
|
||||
|
||||
The whole tree conforms: `scripts/reindent.sh --check` is clean, and the
|
||||
pre-commit hook keeps it that way. Convert a file to the canonical style in its
|
||||
own commit, not mixed into a behavioral change.
|
||||
|
||||
### Braces
|
||||
|
||||
- **Function bodies open on their own line, in column 0.**
|
||||
- **Control statements keep the brace on the same line**, one space before it.
|
||||
- **`else`, `else if`, and `while` of a `do`/`while` stay on the closing-brace
|
||||
line**: `} else {`, not a bare `else` on the next line. Note that this is a
|
||||
house convention rather than something `cc-mode` enforces — the `stroustrup`
|
||||
style governs indentation only and will not move a brace or an `else` for you.
|
||||
- **Always brace, even single-statement bodies.**
|
||||
|
||||
```c
|
||||
akerr_ErrorContext *akgl_actor_update(akgl_Actor *obj)
|
||||
{
|
||||
if ( obj->curSpriteFrameId == 0 ) {
|
||||
obj->curSpriteReversing = false;
|
||||
} else if ( obj->parent != NULL ) {
|
||||
obj->curSpriteFrameId -= 1;
|
||||
} else {
|
||||
obj->curSpriteFrameId += 1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Spacing
|
||||
|
||||
- **Spaces inside control-flow parentheses**: `if ( x == y ) {`, `while ( done == false ) {`,
|
||||
`for ( i = 0; i < len; i++ ) {`. This is the dominant existing convention
|
||||
(140 sites to 7) and `cc-mode` will not add or remove it.
|
||||
- No space between a function name and its argument list, at both call and
|
||||
definition sites, and no padding inside call parentheses: `SDL_Log("x %d", n)`.
|
||||
- Binary operators and assignment are surrounded by single spaces; unary
|
||||
operators are not separated from their operand.
|
||||
- **The pointer `*` binds to the identifier**, not the type: `char *name`,
|
||||
`akgl_Actor **dest`. Never `char* name`.
|
||||
- When a call is too long for one line, put each argument on its own line
|
||||
indented one level past the callee, with the closing `)` on its own line. This
|
||||
is the established shape for the `FAIL_*` and `CATCH` macros:
|
||||
|
||||
```c
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
SDL_SetPointerProperty(AKGL_REGISTRY_ACTOR, name, (void *)obj),
|
||||
AKERR_KEY,
|
||||
"Unable to add actor to registry"
|
||||
);
|
||||
```
|
||||
|
||||
### 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
|
||||
indentation engine is not exactly reproducible by `clang-format`. Do not
|
||||
introduce one without discussion, and do not reformat code you are not otherwise
|
||||
changing — unrelated whitespace churn makes review harder and is the reason the
|
||||
style drifted in the first place.
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- **Public functions**: `akgl_<subsystem>_<verb>`, all lower snake_case —
|
||||
`akgl_actor_set_character`, `akgl_heap_next_string`. No camelCase, and never
|
||||
embed a type name (`akgl_Actor_cmhf_left_on` is wrong; it should be
|
||||
`akgl_actor_cmhf_left_on`).
|
||||
- **Public types**: `akgl_TypeName` — `akgl_Actor`, `akgl_SpriteSheet`,
|
||||
`akgl_PhysicsBackend`. Every type exported from a header takes the prefix.
|
||||
`point` and `RectanglePoints` shipped bare until 0.5.0; they are
|
||||
`akgl_Point` and `akgl_RectanglePoints` now, and neither is precedent.
|
||||
- **Constants and macros**: `AKGL_UPPER_SNAKE_CASE`. A constant belongs to the
|
||||
subsystem it describes: a character limit is `AKGL_CHARACTER_MAX_*`, not
|
||||
`AKGL_SPRITE_MAX_CHARACTER_*`. Name a constant for what its value *is* — a
|
||||
nanoseconds-per-millisecond scale factor is `AKGL_TIME_ONEMS_NS`.
|
||||
- **Exported globals** take the `akgl_` prefix like any other public symbol. Do
|
||||
not add bare names (`renderer`, `camera`, `window`), leading-underscore names
|
||||
(reserved at file scope), or SCREAMING_SNAKE names for mutable objects —
|
||||
`AKGL_UPPER_SNAKE_CASE` is for constants.
|
||||
|
||||
This one is not cosmetic and there is a scar to prove it. Until 0.5.0 the
|
||||
library exported `renderer`, and `tests/character.c` defined a
|
||||
`SDL_Renderer *renderer` of its own. Both had external linkage and the same
|
||||
spelling, so the executable's definition preempted the shared library's: the
|
||||
library read a `SDL_Renderer *` through an `akgl_RenderBackend *`, every
|
||||
texture load in that suite failed, and the suite reported success anyway. A
|
||||
consuming game with a variable called `renderer` would have hit the same
|
||||
thing with no test to notice.
|
||||
- **`static` helpers drop the `akgl_` prefix**, which exists only to avoid
|
||||
external collisions.
|
||||
- **Include guards**: `_AKGL_<FILE>_H_`, matching the file name. Guard names
|
||||
without the project prefix risk colliding with system headers.
|
||||
- **Parameter names must match between the declaration and the definition** —
|
||||
Doxygen publishes the header spelling. Conventional names: `dest` for an
|
||||
output parameter (not `dst`), `self` for a backend receiver, `obj` for the
|
||||
instance being initialized or inspected, `e` for an incoming error context to
|
||||
be inspected.
|
||||
- **The local error context is named `errctx`** (92 sites to 45). New code uses
|
||||
`errctx`; convert `e` when you touch a function for other reasons.
|
||||
- **Header/source pairs are named by feature**: `include/akgl/sprite.h` and
|
||||
`src/sprite.c`.
|
||||
|
||||
## Error-Handling Protocol
|
||||
|
||||
The `akerror` control-flow macros have a shape that must be followed exactly,
|
||||
because the failure mode is silent.
|
||||
|
||||
- Inside an `ATTEMPT` block use the **`_BREAK`** variants (`FAIL_ZERO_BREAK`,
|
||||
`FAIL_NONZERO_BREAK`, `FAIL_BREAK`) and `CATCH`. Outside it use the
|
||||
**`_RETURN`** variants.
|
||||
- **Never use a `*_RETURN` macro inside an `ATTEMPT` block.** It returns past the
|
||||
`CLEANUP` block, so every release, `fclose`, and free in `CLEANUP` is skipped.
|
||||
This has already caused a heap-string leak on the success path of
|
||||
`akgl_get_json_tilemap_property`.
|
||||
- `CLEANUP` must precede `PROCESS`. Transposing them moves the cleanup body into
|
||||
the `PROCESS` switch, where it runs only when an error context exists.
|
||||
- `CATCH` reports failure by `break`ing, which binds to the innermost enclosing
|
||||
loop or `switch`. A `CATCH` written directly inside a `while` exits the loop
|
||||
rather than the function — put the `ATTEMPT` block inside the loop.
|
||||
- **Never `return` from inside a `HANDLE` block either.** `FINISH` ends with
|
||||
`RELEASE_ERROR`, so leaving before it means the handled context is never given
|
||||
back to `AKERR_ARRAY_ERROR` — one leaked slot per call, and the 129th call
|
||||
aborts the process with "Unable to pull an error context from the array!".
|
||||
`SUCCEED_RETURN` is safe because it releases first; a bare `return`, or a
|
||||
`return f(...)` that tail-calls the fallback, is not. Set a flag in the
|
||||
`HANDLE` block and act on it after `FINISH`. This has already cost a
|
||||
process-killing leak in `akgl_path_relative`; see TODO.md, "Performance".
|
||||
- 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
|
||||
declaration must have a definition. A non-`static` function that appears in no
|
||||
header is still in the ABI but is unreachable by callers; a declaration with no
|
||||
definition is a link error for anyone compiling against the header alone. If a
|
||||
function is exposed only so tests can reach it, declare it under the existing
|
||||
"part of the internal API" comment block in the relevant header.
|
||||
|
||||
Headers must be self-contained: include what you use, so that a translation unit
|
||||
including a single `akgl` header compiles without a particular include order.
|
||||
Within the project use the angled form, `#include <akgl/sibling.h>`.
|
||||
|
||||
This is enforced. `AKGL_PUBLIC_HEADERS` in `CMakeLists.txt` lists every header
|
||||
installed into `include/akgl/`, and drives two things from that one list: what
|
||||
`install()` ships, and a generated translation unit per header — each including
|
||||
exactly that header and nothing before it — linked into the `headers` suite. A
|
||||
new header is covered by adding it to that list. One header per translation
|
||||
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
|
||||
is here because it was broken in this repository, and every one of them was
|
||||
cheap to check and expensive to assume.
|
||||
|
||||
- **Check the surrounding code before designing around a hazard.** The savegame
|
||||
name-width fix first grew four `AKGL_GAME_SAVE_*_NAME_WIDTH` aliases, so the
|
||||
on-disk format's field widths could diverge from the object model's. They were
|
||||
deleted: `akgl_game_load` compares the save's `libversion` against
|
||||
`AKGL_VERSION` and refuses a mismatch **in the same function, sixteen lines
|
||||
above the tables being edited**, so the divergence they defended against
|
||||
cannot happen quietly. An
|
||||
abstraction defending an impossible case is worse than none, because the next
|
||||
reader has to work out what it is for.
|
||||
|
||||
- **Abstract when the second consumer is real, not before.** A `#define` with
|
||||
one use, a wrapper with one caller, a parameter only ever passed one value:
|
||||
delete it and inline the thing. Those four aliases had exactly one use per
|
||||
side and each expanded to exactly one existing constant.
|
||||
|
||||
- **A test that has not failed has not been tested.** Break the fix, watch the
|
||||
new test go red, put the fix back. Two tests in the 0.5.0 defect work passed
|
||||
against the unfixed library on the first attempt:
|
||||
|
||||
- the savegame roundtrip asserted the load *succeeded*, and a reader stepping
|
||||
the wrong field width does not fail — it finds a run of zeros mid-entry,
|
||||
stops early, and reports success with silently wrong maps;
|
||||
- the `akgl_get_json_with_default` test used `TEST_EXPECT_OK`, which releases
|
||||
whatever context the statement returns — and that function hands *back* the
|
||||
context it was given when it does not handle the status, so the `CLEANUP`
|
||||
block released it a second time and the double release corrupted the
|
||||
failure instead of reporting it.
|
||||
|
||||
Neither was discovered by reasoning. Both were discovered by reverting the fix
|
||||
and being surprised.
|
||||
|
||||
- **Pick the test case that distinguishes the two behaviours, not the obvious
|
||||
one.** `!AKGL_BITMASK_HAS(mask, bit)` misparses to `!(mask & bit) == bit`
|
||||
without the parentheses — but for a bit that *is* set that is `0 == bit`,
|
||||
false, which is the same answer the correct parse gives. It only diverges for
|
||||
a bit that is **not** set whose value is **not** 1. The obvious test passes
|
||||
either way.
|
||||
|
||||
- **Do not trust a comment, a TODO entry, or a CI exclusion that states a
|
||||
premise.** Verify it. `tests/character.c` was excluded from two CI jobs and
|
||||
from the mutation harness because it "fails deliberately". It did not: it was
|
||||
exiting 0 while running one of its four tests. `TODO.md` carried eleven
|
||||
entries describing code that had already changed. A premise nobody has
|
||||
re-checked is where the next defect is hiding.
|
||||
|
||||
- **When a measurement moves, say what you measured.** Absolute benchmark
|
||||
numbers drift with the machine. The `akgl_game_update` fix is recorded as the
|
||||
*gap* between two rows of the same run — 92 µs before, noise after — because
|
||||
a later run read every row 15% high, including rows nothing had touched. Do
|
||||
not re-baseline a whole table to record one change.
|
||||
|
||||
## Rules
|
||||
|
||||
- Add yourself (agent program name, model name and version) as a co-author on every commit message
|
||||
- Avoid dynamic memory allocation. Use the `akgl_heap_*` methods to retrieve necessary objects at runtime, and to release them when done. If the akgl heap facilities don't provide the kind of object needed, create a new heap layer to support that type of object.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Tests use simple executable return codes and are registered through CTest in `CMakeLists.txt`; there is no declared coverage threshold.
|
||||
|
||||
**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
|
||||
|
||||
`tests/perf.c` (nothing that draws) and `tests/perf_render.c` (everything that
|
||||
does) are benchmarks, built and registered like any other suite but listed in
|
||||
`AKGL_PERF_SUITES` so they carry the `perf` label and a longer timeout. The
|
||||
harness is `tests/benchutil.h`. Three rules keep the numbers honest, and all
|
||||
three were learned by getting them wrong first:
|
||||
|
||||
- **Nothing that checks an error goes inside the clock.** `PASS` and `CATCH`
|
||||
call `akerr_valid_error_address`, which walks `AKERR_ARRAY_ERROR` — more work
|
||||
than several of the calls being measured. Use `BENCH_LOOP`, which stashes the
|
||||
context and stops at the first failure, and hand it to `PASS` afterwards.
|
||||
- **Flush the renderer before stopping the clock.** SDL batches: a `draw_*` call
|
||||
queues a command and returns. `BENCH_FLUSH_STOP` in `tests/perf_render.c` is
|
||||
there because the first version of that suite measured queueing, reported a
|
||||
tilemap frame 250 times faster than it is, and paid the real cost at teardown
|
||||
inside `SDL_DestroyTexture`.
|
||||
- **A drawing benchmark needs a raw-SDL control that does the same pixel work.**
|
||||
Without one there is no way to separate what libakgl costs from what the
|
||||
rasterizer costs, and the answer is not the one you would guess — see
|
||||
`PERFORMANCE.md`.
|
||||
|
||||
Budgets are per-operation ceilings at roughly ten times the recorded baseline;
|
||||
they are enforced only in an optimized build at full scale. When a change moves
|
||||
a number for a good reason, re-record the baseline in `PERFORMANCE.md` in the
|
||||
same commit and say why it moved.
|
||||
|
||||
These two suites are also the memory-check vehicle. `tests/benchutil.h` detects
|
||||
valgrind from `LD_PRELOAD` and drops the scale to
|
||||
`AKGL_BENCH_VALGRIND_SCALE`, so the same binaries walk every path once instead
|
||||
of a hundred thousand times, and the timings a checked run prints are labelled
|
||||
as meaningless. **Do not add memory-check suites**: a new path worth checking
|
||||
belongs in a benchmark, where it gets both.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
Recent commits use concise, imperative summaries such as `Fix a scale bug...` and often explain related changes in one sentence. Keep each commit scoped and describe user-visible behavior. Pull requests should summarize the change, identify affected modules, list the CMake/CTest commands run, and link relevant issues. Include screenshots only for rendering, map, or `charviewer` changes.
|
||||
646
CMakeLists.txt
646
CMakeLists.txt
@@ -1,57 +1,258 @@
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
project(akgl LANGUAGES C)
|
||||
# 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.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
|
||||
# own. The perf suites carry most of the weight there: they are the only things
|
||||
# in the tree that load assets, draw a scene, and run a frame loop in one
|
||||
# process, and tests/benchutil.h drops their iteration counts by three orders of
|
||||
# magnitude when it finds itself under valgrind, which turns a benchmark into
|
||||
# exactly the broad, once-through path coverage a leak check wants.
|
||||
#
|
||||
# These have to be set before include(CTest): that is what writes them into
|
||||
# DartConfiguration.tcl, and a memcheck run reads them from there.
|
||||
find_program(MEMORYCHECK_COMMAND valgrind)
|
||||
# Set with FORCE, but only when empty. include(CTest) declares both of these as
|
||||
# empty cache entries, so a build tree configured before this block existed has
|
||||
# them already and a plain set(... CACHE ...) would be ignored -- the values
|
||||
# would silently never reach DartConfiguration.tcl. Guarding on emptiness still
|
||||
# leaves a deliberate -DMEMORYCHECK_SUPPRESSIONS_FILE=... alone.
|
||||
if(NOT MEMORYCHECK_SUPPRESSIONS_FILE)
|
||||
set(MEMORYCHECK_SUPPRESSIONS_FILE
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/scripts/valgrind.supp"
|
||||
CACHE FILEPATH "Suppressions for third-party findings the memcheck run cannot fix" FORCE)
|
||||
endif()
|
||||
# Definite losses only. "Still reachable" is every global SDL and FreeType keeps
|
||||
# for the process lifetime and says nothing about libakgl; "possibly lost" is
|
||||
# dominated by interior pointers into pools and thread stacks. Neither is worth
|
||||
# the false positives.
|
||||
if(NOT MEMORYCHECK_COMMAND_OPTIONS)
|
||||
set(MEMORYCHECK_COMMAND_OPTIONS
|
||||
"--leak-check=full --show-leak-kinds=definite --errors-for-leak-kinds=definite --track-origins=yes --num-callers=25"
|
||||
CACHE STRING "Options passed to the memory checker" FORCE)
|
||||
endif()
|
||||
|
||||
include(CTest)
|
||||
option(AKGL_COVERAGE "Instrument libakgl and generate coverage reports with CTest" OFF)
|
||||
|
||||
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
|
||||
|
||||
add_subdirectory(deps/jansson EXCLUDE_FROM_ALL)
|
||||
add_subdirectory(deps/libakerror EXCLUDE_FROM_ALL)
|
||||
add_subdirectory(deps/libakstdlib EXCLUDE_FROM_ALL)
|
||||
add_subdirectory(deps/SDL EXCLUDE_FROM_ALL)
|
||||
add_subdirectory(deps/SDL_image EXCLUDE_FROM_ALL)
|
||||
add_subdirectory(deps/SDL_mixer EXCLUDE_FROM_ALL)
|
||||
add_subdirectory(deps/SDL_ttf EXCLUDE_FROM_ALL)
|
||||
|
||||
else()
|
||||
|
||||
find_package(PkgConfig REQUIRED)
|
||||
|
||||
if(NOT TARGET SDL3::SDL3)
|
||||
find_package(SDL3 REQUIRED)
|
||||
endif()
|
||||
if(NOT TARGET SDL3_image::SDL3_image)
|
||||
find_package(SDL3_image REQUIRED)
|
||||
endif()
|
||||
if(NOT TARGET SDL3_mixer::SDL3_mixer)
|
||||
find_package(SDL3_mixer REQUIRED)
|
||||
endif()
|
||||
if(NOT TARGET SDL3_ttf::SDL3_ttf)
|
||||
find_package(SDL3_ttf REQUIRED)
|
||||
endif()
|
||||
if(NOT TARGET akerror::akerror)
|
||||
find_package(akerror REQUIRED)
|
||||
endif()
|
||||
if(NOT TARGET akstdlib::akstdlib)
|
||||
find_package(akstdlib REQUIRED)
|
||||
endif()
|
||||
if(NOT TARGET jansson::jansson)
|
||||
find_package(jansson)
|
||||
endif()
|
||||
|
||||
# -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")
|
||||
endif()
|
||||
find_program(GCOVR_EXECUTABLE gcovr REQUIRED)
|
||||
endif()
|
||||
|
||||
# Vendored projects own their test suites. Suppress their CTest registration so
|
||||
# the suite that runs contains only the targets built here. The override is
|
||||
# lifted again below, before this project registers its own tests.
|
||||
#
|
||||
# **Only when this project is top-level, and that guard is load-bearing.** CMake
|
||||
# exposes an overridden command as `_name` and chains exactly one level deep: a
|
||||
# second override rebinds `_add_test` to the *first* override and the builtin
|
||||
# becomes unreachable to everybody, forever. So two projects in one tree cannot
|
||||
# both shadow add_test() -- whoever goes second breaks, and an embedding
|
||||
# consumer that shadows it first sees its own registrations recurse until CMake
|
||||
# stops at depth 1000.
|
||||
#
|
||||
# An embedded libakgl therefore leaves the override alone and lets its consumer
|
||||
# suppress what it does not want; the consumer has to have that machinery
|
||||
# anyway, for libakerror and libakstdlib. libakstdlib guards its own shadow the
|
||||
# same way and for the same reason.
|
||||
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
|
||||
set(AKGL_SUPPRESS_DEPENDENCY_TESTS TRUE)
|
||||
function(add_test)
|
||||
if(NOT AKGL_SUPPRESS_DEPENDENCY_TESTS)
|
||||
_add_test(${ARGV})
|
||||
endif()
|
||||
endfunction()
|
||||
function(set_tests_properties)
|
||||
if(NOT AKGL_SUPPRESS_DEPENDENCY_TESTS)
|
||||
_set_tests_properties(${ARGV})
|
||||
endif()
|
||||
endfunction()
|
||||
endif()
|
||||
|
||||
set(JANSSON_WITHOUT_TESTS ON CACHE BOOL "Do not build vendored Jansson tests" FORCE)
|
||||
set(JANSSON_EXAMPLES OFF CACHE BOOL "Do not build vendored Jansson examples" FORCE)
|
||||
set(JANSSON_BUILD_DOCS OFF CACHE BOOL "Do not build vendored Jansson docs" FORCE)
|
||||
|
||||
# Add one vendored dependency, if nobody has already declared it and the
|
||||
# submodule is actually checked out.
|
||||
#
|
||||
# A macro rather than a function on purpose: add_subdirectory() inside a
|
||||
# function runs with that function's variable scope, so every cache-ish variable
|
||||
# these projects set for their own subdirectories would be discarded on return.
|
||||
macro(akgl_add_vendored_dependency target dir)
|
||||
if(NOT TARGET ${target})
|
||||
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${dir}/CMakeLists.txt")
|
||||
add_subdirectory(${dir} EXCLUDE_FROM_ALL)
|
||||
set(AKGL_VENDORED_DEPENDENCIES TRUE)
|
||||
endif()
|
||||
endif()
|
||||
endmacro()
|
||||
|
||||
# Added on both paths, not just when this is the top-level project. Embedded
|
||||
# with add_subdirectory(), the submodules under deps/ are sitting right there --
|
||||
# the recursive clone the consumer just did put them there -- and refusing to
|
||||
# configure until SDL3 is installed system-wide is a failure with its own answer
|
||||
# three directories away. if(NOT TARGET ...) means a consumer that has already
|
||||
# declared one of these wins; the EXISTS check means a checkout without
|
||||
# submodules falls through to find_package below.
|
||||
akgl_add_vendored_dependency(jansson::jansson deps/jansson)
|
||||
akgl_add_vendored_dependency(akerror::akerror deps/libakerror)
|
||||
akgl_add_vendored_dependency(akstdlib::akstdlib deps/libakstdlib)
|
||||
akgl_add_vendored_dependency(SDL3::SDL3 deps/SDL)
|
||||
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 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)
|
||||
|
||||
# Anything the vendored block did not supply has to come from the system.
|
||||
if(NOT (TARGET SDL3::SDL3 AND TARGET SDL3_image::SDL3_image AND
|
||||
TARGET SDL3_mixer::SDL3_mixer AND TARGET SDL3_ttf::SDL3_ttf AND
|
||||
TARGET akerror::akerror AND TARGET akstdlib::akstdlib AND
|
||||
TARGET jansson::jansson))
|
||||
# Only needed to locate installed copies; a fully vendored build does not
|
||||
# require pkg-config to be present at all.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
endif()
|
||||
|
||||
if(NOT TARGET SDL3::SDL3)
|
||||
find_package(SDL3 REQUIRED)
|
||||
endif()
|
||||
if(NOT TARGET SDL3_image::SDL3_image)
|
||||
find_package(SDL3_image REQUIRED)
|
||||
endif()
|
||||
if(NOT TARGET SDL3_mixer::SDL3_mixer)
|
||||
find_package(SDL3_mixer REQUIRED)
|
||||
endif()
|
||||
if(NOT TARGET SDL3_ttf::SDL3_ttf)
|
||||
find_package(SDL3_ttf REQUIRED)
|
||||
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 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()
|
||||
# 0.2 rather than bare: libakstdlib 0.2.0 ships an akstdlibConfigVersion.cmake
|
||||
# with SameMinorVersion compatibility, mirroring its soname, so this accepts
|
||||
# any 0.2.x and refuses 0.3 and 1.0. Unversioned, this path would silently
|
||||
# accept an ABI-incompatible libakstdlib.
|
||||
if(NOT TARGET akstdlib::akstdlib)
|
||||
find_package(akstdlib 0.2 REQUIRED)
|
||||
endif()
|
||||
if(NOT TARGET jansson::jansson)
|
||||
find_package(jansson)
|
||||
endif()
|
||||
|
||||
# Every header installed into include/akgl/, by base name. This list is the
|
||||
# single source of truth for two things that must not drift apart: what gets
|
||||
# installed, and what the `headers` suite proves is self-contained. Adding a
|
||||
# header here is all a new one needs. SDL_GameControllerDB.h and the generated
|
||||
# version.h are installed separately -- neither is an API header.
|
||||
set(AKGL_PUBLIC_HEADERS
|
||||
actor
|
||||
assets
|
||||
audio
|
||||
character
|
||||
controller
|
||||
draw
|
||||
error
|
||||
game
|
||||
heap
|
||||
iterator
|
||||
json_helpers
|
||||
physics
|
||||
registry
|
||||
renderer
|
||||
sprite
|
||||
staticstring
|
||||
text
|
||||
tilemap
|
||||
types
|
||||
util
|
||||
)
|
||||
|
||||
set(GAMECONTROLLERDB_H "include/akgl/SDL_GameControllerDB.h")
|
||||
set(prefix ${CMAKE_INSTALL_PREFIX})
|
||||
set(exec_prefix "\${prefix}")
|
||||
set(libdir "\${exec_prefix}/lib")
|
||||
set(includedir "\${prefix}/include")
|
||||
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/akgl.pc.in ${CMAKE_CURRENT_BINARY_DIR}/akgl.pc @ONLY)
|
||||
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/include/akgl/version.h.in
|
||||
${CMAKE_CURRENT_BINARY_DIR}/include/akgl/version.h @ONLY)
|
||||
|
||||
add_custom_command(
|
||||
OUTPUT ${GAMECONTROLLERDB_H}
|
||||
# Tests use both relative paths and SDL_GetBasePath(), so stage fixtures beside
|
||||
# test executables in every out-of-tree build.
|
||||
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/tests/assets"
|
||||
DESTINATION "${CMAKE_CURRENT_BINARY_DIR}")
|
||||
|
||||
# Regenerating the controller database is a deliberate act, not part of a build.
|
||||
#
|
||||
# This used to be an add_custom_command whose OUTPUT was the *relative* path
|
||||
# "include/akgl/SDL_GameControllerDB.h", which CMake resolves against the binary
|
||||
# directory while the script writes to the source directory. The declared output
|
||||
# therefore never appeared, the command was permanently out of date, and every
|
||||
# build re-ran it -- so every build needed network access and left the tracked
|
||||
# header dirty with a fresh $(date) stamp.
|
||||
#
|
||||
# `cmake --build build --target controllerdb` when newer mappings are actually
|
||||
# wanted. The result is its own commit, per AGENTS.md.
|
||||
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
|
||||
set(AKGL_CONTROLLERDB_TARGET controllerdb)
|
||||
else()
|
||||
set(AKGL_CONTROLLERDB_TARGET akgl_controllerdb)
|
||||
endif()
|
||||
add_custom_target(${AKGL_CONTROLLERDB_TARGET}
|
||||
COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/mkcontrollermappings.sh ${CMAKE_CURRENT_SOURCE_DIR}
|
||||
COMMENT "Generating controller mappings ..."
|
||||
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
|
||||
USES_TERMINAL
|
||||
COMMENT "Fetching controller mappings and rewriting ${GAMECONTROLLERDB_H} ..."
|
||||
)
|
||||
|
||||
# Add include directories
|
||||
@@ -60,50 +261,250 @@ add_library(akgl SHARED
|
||||
deps/semver/semver.c
|
||||
src/actor.c
|
||||
src/actor_state_string_names.c
|
||||
src/audio.c
|
||||
src/text.c
|
||||
src/assets.c
|
||||
src/character.c
|
||||
src/draw.c
|
||||
src/error.c
|
||||
src/game.c
|
||||
src/controller.c
|
||||
src/heap.c
|
||||
src/json_helpers.c
|
||||
src/registry.c
|
||||
src/renderer.c
|
||||
src/physics.c
|
||||
src/sprite.c
|
||||
src/staticstring.c
|
||||
src/tilemap.c
|
||||
src/util.c
|
||||
${GAMECONTROLLERDB_H}
|
||||
src/version.c
|
||||
)
|
||||
|
||||
# While the major version is 0 the ABI is not stable across minor releases, so
|
||||
# the soname carries major.minor -- libakgl.so.0.1. A plain SOVERSION 0 would
|
||||
# claim 0.1.0 and 0.2.0 are interchangeable, which is exactly the silent
|
||||
# mispairing the soname is here to prevent. At 1.0.0 this becomes the major
|
||||
# alone, matching libakerror.
|
||||
if(PROJECT_VERSION_MAJOR EQUAL 0)
|
||||
set(AKGL_SOVERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}")
|
||||
else()
|
||||
set(AKGL_SOVERSION "${PROJECT_VERSION_MAJOR}")
|
||||
endif()
|
||||
|
||||
set_target_properties(akgl PROPERTIES
|
||||
VERSION ${PROJECT_VERSION}
|
||||
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)
|
||||
add_executable(test_actor tests/actor.c)
|
||||
add_executable(test_bitmasks tests/bitmasks.c)
|
||||
add_executable(test_character tests/character.c)
|
||||
add_executable(test_registry tests/registry.c)
|
||||
add_executable(test_sprite tests/sprite.c)
|
||||
add_executable(test_staticstring tests/staticstring.c)
|
||||
add_executable(test_tilemap tests/tilemap.c)
|
||||
add_executable(test_util tests/util.c)
|
||||
add_executable(test_semver_unit deps/semver/semver_unit.c)
|
||||
add_test(NAME actor COMMAND test_actor)
|
||||
add_test(NAME bitmasks COMMAND test_bitmasks)
|
||||
add_test(NAME character COMMAND test_character)
|
||||
add_test(NAME registry COMMAND test_registry)
|
||||
add_test(NAME sprite COMMAND test_sprite)
|
||||
add_test(NAME staticstring COMMAND test_staticstring)
|
||||
add_test(NAME tilemap COMMAND test_tilemap)
|
||||
add_test(NAME util COMMAND test_util)
|
||||
add_test(NAME semver_unit COMMAND test_semver_unit)
|
||||
add_executable(akgl_test_semver_unit deps/semver/semver_unit.c)
|
||||
|
||||
# Every suite here is a standalone C program named tests/<name>.c, built as
|
||||
# akgl_test_<name> and registered with CTest under <name>.
|
||||
set(AKGL_TEST_SUITES
|
||||
actor
|
||||
audio
|
||||
bitmasks
|
||||
character
|
||||
controller
|
||||
draw
|
||||
error
|
||||
game
|
||||
headers
|
||||
heap
|
||||
json_helpers
|
||||
physics
|
||||
physics_sim
|
||||
registry
|
||||
renderer
|
||||
sprite
|
||||
staticstring
|
||||
text
|
||||
tilemap
|
||||
util
|
||||
version
|
||||
)
|
||||
|
||||
# The performance suites are built and registered exactly like the unit suites,
|
||||
# but they are benchmarks: they drive hot paths for millions of iterations, print
|
||||
# a table of nanoseconds per operation, and fail only when a measurement exceeds
|
||||
# a budget set at roughly ten times the recorded baseline. They carry the `perf`
|
||||
# label, so `ctest -L perf` runs only them and `ctest -LE perf` leaves them out
|
||||
# of an ordinary run, and they get a much longer timeout for obvious reasons.
|
||||
#
|
||||
# Set AKGL_BENCH_SCALE in the environment to change how long they run --
|
||||
# `AKGL_BENCH_SCALE=0.1 ctest -L perf` for a tenth of the iterations. Below 1.0
|
||||
# the budgets are measured and reported but not enforced, because a short run is
|
||||
# a noisy one.
|
||||
set(AKGL_PERF_SUITES
|
||||
perf
|
||||
perf_render
|
||||
)
|
||||
|
||||
# The executables carry an akgl_ prefix but the CTest names do not: a vendored
|
||||
# dependency is free to ship its own tests/version.c, and libakstdlib now does.
|
||||
# Its target is created by add_subdirectory even though EXCLUDE_FROM_ALL keeps
|
||||
# it from being built, so an unprefixed test_version here is a configure error.
|
||||
foreach(suite IN LISTS AKGL_TEST_SUITES AKGL_PERF_SUITES)
|
||||
add_executable(akgl_test_${suite} tests/${suite}.c)
|
||||
add_test(NAME ${suite} COMMAND akgl_test_${suite})
|
||||
endforeach()
|
||||
|
||||
add_test(NAME semver_unit COMMAND akgl_test_semver_unit)
|
||||
|
||||
# Not a C program, so it is not in AKGL_TEST_SUITES: it reads the built library's
|
||||
# dynamic symbol table and every public header, and fails when a function with
|
||||
# external linkage is declared nowhere. That is an ABI question rather than a
|
||||
# behavioural one, and nineteen symbols had drifted out of the headers before
|
||||
# anything was checking.
|
||||
add_test(
|
||||
NAME api_surface
|
||||
COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/scripts/check_api_surface.sh
|
||||
$<TARGET_FILE:akgl>
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||
${CMAKE_CURRENT_BINARY_DIR}/include
|
||||
)
|
||||
set_tests_properties(api_surface PROPERTIES SKIP_RETURN_CODE 2)
|
||||
|
||||
# The two akerror control-flow rules whose failure mode is silent: a *_RETURN
|
||||
# inside an ATTEMPT block, and a return out of a HANDLE block. Neither produces
|
||||
# a compiler diagnostic and neither fails a test run until the pool it drains is
|
||||
# empty, which is why both have already shipped.
|
||||
#
|
||||
# The mutation target below wants the same interpreter; find_package caches, so
|
||||
# asking here as well costs nothing and keeps this block self-contained.
|
||||
find_package(Python3 COMPONENTS Interpreter)
|
||||
if(Python3_Interpreter_FOUND)
|
||||
add_test(
|
||||
NAME error_protocol
|
||||
COMMAND ${Python3_EXECUTABLE}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/scripts/check_error_protocol.py
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
)
|
||||
endif()
|
||||
|
||||
# One translation unit per public header, each including exactly that header and
|
||||
# nothing before it. This is the only shape that proves a header is
|
||||
# self-contained: a second #include in the same file proves nothing about the
|
||||
# second, because by then the first has dragged its dependencies in. Generated
|
||||
# rather than written by hand so the check cannot fall behind
|
||||
# AKGL_PUBLIC_HEADERS -- the failure being guarded against is a header nobody
|
||||
# remembered to cover.
|
||||
set(AKGL_HEADER_SELFTEST_SOURCES "")
|
||||
foreach(header IN LISTS AKGL_PUBLIC_HEADERS)
|
||||
set(generated "${CMAKE_CURRENT_BINARY_DIR}/tests/header_${header}.c")
|
||||
file(WRITE "${generated}"
|
||||
"/* Generated by CMakeLists.txt from AKGL_PUBLIC_HEADERS. Do not edit. */\n\
|
||||
#include <akgl/${header}.h>\n\
|
||||
\n\
|
||||
/* ISO C forbids an empty translation unit; the compile is the assertion. */\n\
|
||||
int akgl_header_selftest_${header}(void);\n\
|
||||
int akgl_header_selftest_${header}(void)\n\
|
||||
{\n\
|
||||
return 0;\n\
|
||||
}\n")
|
||||
list(APPEND AKGL_HEADER_SELFTEST_SOURCES "${generated}")
|
||||
endforeach()
|
||||
target_sources(akgl_test_headers PRIVATE ${AKGL_HEADER_SELFTEST_SOURCES})
|
||||
# Hand-written rather than generated: it checks a header's behaviour under a
|
||||
# caller-supplied override, which is not something the generated shape covers.
|
||||
target_sources(akgl_test_headers PRIVATE tests/header_pool_override.c)
|
||||
|
||||
# TIMEOUT is generous because CTest applies the same property to `ctest -T
|
||||
# memcheck`, where every suite runs under valgrind at something like twenty
|
||||
# times its normal cost. The unit suites finish in well under a second each
|
||||
# without it; the ceiling is there for the checked run, not the ordinary one.
|
||||
set_tests_properties(
|
||||
${AKGL_TEST_SUITES} semver_unit
|
||||
PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/tests" TIMEOUT 300
|
||||
)
|
||||
|
||||
set_tests_properties(
|
||||
${AKGL_PERF_SUITES}
|
||||
PROPERTIES
|
||||
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/tests"
|
||||
TIMEOUT 900
|
||||
LABELS perf
|
||||
)
|
||||
|
||||
# Specify include directories for the library's headers (if applicable)
|
||||
target_include_directories(akgl PUBLIC
|
||||
include/
|
||||
deps/semver/
|
||||
# akgl/version.h is generated by configure_file, so it lives in the build tree
|
||||
# rather than beside the headers it is included from.
|
||||
${CMAKE_CURRENT_BINARY_DIR}/include/
|
||||
)
|
||||
|
||||
if(AKGL_COVERAGE)
|
||||
target_compile_options(akgl PRIVATE --coverage -O0 -g)
|
||||
target_link_options(akgl PRIVATE --coverage)
|
||||
|
||||
set(AKGL_COVERAGE_DIR "${CMAKE_CURRENT_BINARY_DIR}/coverage")
|
||||
file(MAKE_DIRECTORY "${AKGL_COVERAGE_DIR}")
|
||||
|
||||
# Both gcovr invocations take the build tree as an explicit positional search
|
||||
# path, and neither passes --object-directory.
|
||||
#
|
||||
# gcovr looks for .gcda/.gcno under its search paths, and with none given it
|
||||
# searches --root -- the source directory, which is where developers keep
|
||||
# their build trees. --object-directory does *not* narrow that: per gcovr's
|
||||
# own help it only identifies "the path between gcda files and the directory
|
||||
# where the compiler was originally run". So every instrumented tree left
|
||||
# inside the source directory was folded into the report alongside the one
|
||||
# being measured, and with two trees describing different line numbers for the
|
||||
# same function, coverage_reset failed before any test ran:
|
||||
#
|
||||
# AssertionError: Got function akgl_game_lowfps on multiple lines: 45, 46
|
||||
#
|
||||
# The `build*/` entry in .gitignore hides those trees from git status, which
|
||||
# makes the state easy to get into and hard to notice.
|
||||
|
||||
add_test(
|
||||
NAME coverage_reset
|
||||
COMMAND ${GCOVR_EXECUTABLE}
|
||||
--root "${CMAKE_CURRENT_SOURCE_DIR}"
|
||||
--delete
|
||||
"${CMAKE_CURRENT_BINARY_DIR}"
|
||||
)
|
||||
set_tests_properties(coverage_reset PROPERTIES FIXTURES_SETUP akgl_coverage)
|
||||
# Any suite missing from this list runs outside the fixture and has its
|
||||
# counters discarded by coverage_reset. The perf suites are deliberately
|
||||
# outside it: an instrumented -O0 build measures gcov, not libakgl, and the
|
||||
# lines they cover are covered by the unit suites anyway.
|
||||
set_tests_properties(
|
||||
${AKGL_TEST_SUITES} semver_unit
|
||||
PROPERTIES FIXTURES_REQUIRED akgl_coverage
|
||||
)
|
||||
|
||||
add_test(
|
||||
NAME coverage_report
|
||||
COMMAND ${GCOVR_EXECUTABLE}
|
||||
--root "${CMAKE_CURRENT_SOURCE_DIR}"
|
||||
--filter "${CMAKE_CURRENT_SOURCE_DIR}/src/"
|
||||
--xml-pretty
|
||||
--xml "${AKGL_COVERAGE_DIR}/coverage.xml"
|
||||
--html-details "${AKGL_COVERAGE_DIR}/index.html"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}"
|
||||
)
|
||||
set_tests_properties(
|
||||
coverage_report
|
||||
PROPERTIES
|
||||
FIXTURES_CLEANUP akgl_coverage
|
||||
WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}"
|
||||
)
|
||||
endif()
|
||||
|
||||
target_link_libraries(akgl
|
||||
PUBLIC
|
||||
SDL3::SDL3
|
||||
@@ -115,36 +516,111 @@ target_link_libraries(akgl
|
||||
jansson::jansson
|
||||
)
|
||||
|
||||
target_link_libraries(test_actor PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm)
|
||||
target_link_libraries(test_bitmasks PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm)
|
||||
target_link_libraries(test_character PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm)
|
||||
target_link_libraries(test_registry PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm)
|
||||
target_link_libraries(test_sprite PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm)
|
||||
target_link_libraries(test_staticstring PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm)
|
||||
target_link_libraries(test_tilemap PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm)
|
||||
target_link_libraries(test_util PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm)
|
||||
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
|
||||
# freshly built test aborts before main() with "cannot open shared object file".
|
||||
# Bake those directories into the build-tree RPATH. Installed builds resolve the
|
||||
# same libraries through find_package and need no help, so this keys on whether
|
||||
# anything was actually vendored rather than on being the top-level project.
|
||||
if(AKGL_VENDORED_DEPENDENCIES)
|
||||
set(AKGL_VENDORED_RPATH
|
||||
"$<TARGET_FILE_DIR:SDL3::SDL3>"
|
||||
"$<TARGET_FILE_DIR:SDL3_image::SDL3_image>"
|
||||
"$<TARGET_FILE_DIR:SDL3_ttf::SDL3_ttf>"
|
||||
"$<TARGET_FILE_DIR:SDL3_mixer::SDL3_mixer>"
|
||||
"$<TARGET_FILE_DIR:akerror::akerror>"
|
||||
"$<TARGET_FILE_DIR:akstdlib::akstdlib>"
|
||||
)
|
||||
foreach(suite IN LISTS AKGL_TEST_SUITES AKGL_PERF_SUITES)
|
||||
set_target_properties(akgl_test_${suite} PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}")
|
||||
endforeach()
|
||||
set_target_properties(charviewer akgl PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}")
|
||||
|
||||
# RPATH alone is not enough: LD_LIBRARY_PATH is searched first, so a developer
|
||||
# who has previously run rebuild.sh has an installed libakgl.so ahead of the
|
||||
# one under test. Prepend the build tree for the CTest run so the suite always
|
||||
# exercises what was just compiled.
|
||||
set(AKGL_TEST_LIBPATH
|
||||
"${CMAKE_CURRENT_BINARY_DIR}"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/deps/SDL"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/deps/SDL_image"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/deps/SDL_ttf"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/deps/SDL_mixer"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/deps/libakerror"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/deps/libakstdlib"
|
||||
)
|
||||
if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22")
|
||||
set(AKGL_TEST_ENV_MOD "")
|
||||
foreach(dir IN LISTS AKGL_TEST_LIBPATH)
|
||||
list(APPEND AKGL_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}")
|
||||
endforeach()
|
||||
set_tests_properties(
|
||||
${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES}
|
||||
PROPERTIES ENVIRONMENT_MODIFICATION "${AKGL_TEST_ENV_MOD}"
|
||||
)
|
||||
else()
|
||||
string(REPLACE ";" ":" AKGL_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}")
|
||||
set_tests_properties(
|
||||
${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES}
|
||||
PROPERTIES ENVIRONMENT "LD_LIBRARY_PATH=${AKGL_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}"
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# Mutation testing copies the repository to scratch space, applies one small
|
||||
# source change at a time, and verifies that the passing tests detect it. The
|
||||
# intentionally failing character test is excluded by the harness.
|
||||
find_package(Python3 COMPONENTS Interpreter)
|
||||
if(Python3_FOUND)
|
||||
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
|
||||
set(AKGL_MUTATION_TARGET mutation)
|
||||
else()
|
||||
set(AKGL_MUTATION_TARGET akgl_mutation)
|
||||
endif()
|
||||
add_custom_target(${AKGL_MUTATION_TARGET}
|
||||
COMMAND ${Python3_EXECUTABLE}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/scripts/mutation_test.py
|
||||
--source-root ${CMAKE_CURRENT_SOURCE_DIR}
|
||||
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
|
||||
USES_TERMINAL
|
||||
COMMENT "Running mutation tests (breaks a scratch copy, expects tests to fail)"
|
||||
)
|
||||
endif()
|
||||
|
||||
# Memory checking runs the whole registered suite under valgrind. The wrapper
|
||||
# script exists because `ctest -T memcheck` records defects and still exits 0,
|
||||
# which cannot gate anything; it also forces the headless drivers, so the vendor
|
||||
# GPU stack is never loaded and never has to be suppressed.
|
||||
if(MEMORYCHECK_COMMAND)
|
||||
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
|
||||
set(AKGL_MEMCHECK_TARGET memcheck)
|
||||
else()
|
||||
set(AKGL_MEMCHECK_TARGET akgl_memcheck)
|
||||
endif()
|
||||
add_custom_target(${AKGL_MEMCHECK_TARGET}
|
||||
COMMAND ${CMAKE_COMMAND} -E env
|
||||
AKGL_BUILD_DIR=${CMAKE_CURRENT_BINARY_DIR}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/scripts/memcheck.sh
|
||||
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
|
||||
USES_TERMINAL
|
||||
COMMENT "Running every test suite under valgrind (slow; the perf suites scale themselves down)"
|
||||
)
|
||||
endif()
|
||||
|
||||
set(main_lib_dest "lib/akgl-${MY_LIBRARY_VERSION}")
|
||||
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/akgl.pc DESTINATION "lib/pkgconfig/")
|
||||
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/include/akgl/version.h DESTINATION "include/akgl/")
|
||||
install(TARGETS akgl DESTINATION "lib/")
|
||||
install(FILES "deps/semver/semver.h" DESTINATION "include/")
|
||||
install(FILES "include/akgl/actor.h" DESTINATION "include/akgl/")
|
||||
install(FILES "include/akgl/types.h" DESTINATION "include/akgl/")
|
||||
install(FILES "include/akgl/text.h" DESTINATION "include/akgl/")
|
||||
install(FILES "include/akgl/assets.h" DESTINATION "include/akgl/")
|
||||
install(FILES "include/akgl/character.h" DESTINATION "include/akgl/")
|
||||
install(FILES "include/akgl/error.h" DESTINATION "include/akgl/")
|
||||
install(FILES "include/akgl/draw.h" DESTINATION "include/akgl/")
|
||||
install(FILES "include/akgl/game.h" DESTINATION "include/akgl/")
|
||||
install(FILES "include/akgl/controller.h" DESTINATION "include/akgl/")
|
||||
install(FILES "include/akgl/heap.h" DESTINATION "include/akgl/")
|
||||
install(FILES "include/akgl/iterator.h" DESTINATION "include/akgl/")
|
||||
install(FILES "include/akgl/json_helpers.h" DESTINATION "include/akgl/")
|
||||
install(FILES "include/akgl/registry.h" DESTINATION "include/akgl/")
|
||||
install(FILES "include/akgl/sprite.h" DESTINATION "include/akgl/")
|
||||
install(FILES "include/akgl/staticstring.h" DESTINATION "include/akgl/")
|
||||
install(FILES "include/akgl/tilemap.h" DESTINATION "include/akgl/")
|
||||
install(FILES "include/akgl/util.h" DESTINATION "include/akgl/")
|
||||
foreach(header IN LISTS AKGL_PUBLIC_HEADERS)
|
||||
install(FILES "include/akgl/${header}.h" DESTINATION "include/akgl/")
|
||||
endforeach()
|
||||
install(FILES ${GAMECONTROLLERDB_H} DESTINATION "include/akgl/")
|
||||
|
||||
18
Doxyfile
Normal file
18
Doxyfile
Normal file
@@ -0,0 +1,18 @@
|
||||
PROJECT_NAME = libakgl
|
||||
PROJECT_BRIEF = "Game development support library"
|
||||
OUTPUT_DIRECTORY = build/docs
|
||||
|
||||
INPUT = include/akgl src
|
||||
FILE_PATTERNS = *.h *.c
|
||||
RECURSIVE = YES
|
||||
EXCLUDE_PATTERNS = */SDL_GameControllerDB.h
|
||||
|
||||
EXTRACT_ALL = YES
|
||||
EXTRACT_STATIC = YES
|
||||
WARN_IF_UNDOCUMENTED = YES
|
||||
WARN_IF_DOC_ERROR = YES
|
||||
WARN_AS_ERROR = FAIL_ON_WARNINGS
|
||||
|
||||
GENERATE_HTML = YES
|
||||
GENERATE_LATEX = NO
|
||||
QUIET = YES
|
||||
366
PERFORMANCE.md
Normal file
366
PERFORMANCE.md
Normal file
@@ -0,0 +1,366 @@
|
||||
# libakgl performance baseline
|
||||
|
||||
This is where the library actually spends its time, measured rather than guessed.
|
||||
The numbers below are the first recorded baseline: libakgl 0.3.0, at commit
|
||||
`f35443e` plus the perf suites themselves. Everything here is reproducible with
|
||||
two commands, and the suites that produced it are checked in as
|
||||
`tests/perf.c` and `tests/perf_render.c`.
|
||||
|
||||
Read it in this order if you only want the short version: **the frame budget** is
|
||||
the part that matters and **what the numbers say** is the argument. The six
|
||||
defects the suites turned up on the way — two of them process-killing — are in
|
||||
`TODO.md` under **Performance**, along with the targets these numbers are
|
||||
measured against.
|
||||
|
||||
## Reproducing it
|
||||
|
||||
```sh
|
||||
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
|
||||
cmake --build build --parallel
|
||||
ctest --test-dir build -L perf --output-on-failure
|
||||
```
|
||||
|
||||
Both suites print a table and exit non-zero if any measurement blew its budget.
|
||||
They are ordinary CTest tests, so `ctest --test-dir build` runs them along with
|
||||
everything else; `ctest --test-dir build -LE perf` leaves them out when you only
|
||||
want the unit suites. `AKGL_BENCH_SCALE` scales every iteration count —
|
||||
`AKGL_BENCH_SCALE=0.1` for a quick look, `10` for a long one. Below 1.0 the
|
||||
budgets are reported but not enforced, because a short run is a noisy one.
|
||||
|
||||
The whole thing takes about 30 seconds: 4 s for `perf`, 25 s for `perf_render`.
|
||||
|
||||
## The machine
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| CPU | AMD Ryzen 5 7535HS, 6 cores / 12 threads, 4.6 GHz max |
|
||||
| Memory | 62 GiB |
|
||||
| OS | Linux 6.8.0-136-generic |
|
||||
| Compiler | gcc 13.3.0, `-DCMAKE_BUILD_TYPE=RelWithDebInfo` (`-O2 -g`) |
|
||||
| SDL | vendored SDL 3.4.8, `dummy` video driver, `software` renderer |
|
||||
| Target | 640x480, offscreen |
|
||||
|
||||
One machine, one build type, one afternoon. Treat the absolute numbers as this
|
||||
laptop's and the *ratios* as the library's.
|
||||
|
||||
## How it is measured
|
||||
|
||||
- **Best of five.** Each benchmark runs five times and the harness keeps the
|
||||
fastest. The mean is the wrong statistic on a shared machine: every source of
|
||||
noise makes a run slower and none makes it faster.
|
||||
- **No error checking inside the clock.** `PASS` and `CATCH` call
|
||||
`akerr_valid_error_address`, which walks `AKERR_ARRAY_ERROR` — more work than
|
||||
several of the calls being measured. The timed loop stashes the context and
|
||||
checks it after the clock stops.
|
||||
- **The renderer is flushed inside the measurement.** SDL batches: a `draw_*`
|
||||
call queues a command and returns. The first version of this suite measured
|
||||
*queueing* and reported a tilemap frame at 65 µs; the deferred work then took
|
||||
114 seconds to come out at teardown, inside `SDL_DestroyTexture`. Every
|
||||
drawing benchmark now flushes before it stops the clock, and the numbers below
|
||||
are 250x larger and true.
|
||||
- **SDL's log output goes to a sink that discards it.** The library logs on
|
||||
paths this suite calls hundreds of thousands of times; timing a write to a
|
||||
terminal measures the terminal. What the *formatting* costs is measured
|
||||
deliberately, by the pair of actor-spawn benchmarks.
|
||||
- **Budgets are set at roughly 10x the measured baseline.** Loose enough that a
|
||||
busy machine does not turn CI red, tight enough that a linear scan becoming
|
||||
quadratic cannot hide. They are enforced only in an optimized build at full
|
||||
scale — a coverage build measures gcov, not libakgl.
|
||||
|
||||
**The renderer caveat, stated once and loudly.** These draw benchmarks run
|
||||
against SDL's *software* renderer. No shipped game does that. What a software
|
||||
renderer buys is that all the work stays in this process where it can be timed,
|
||||
and that the per-blit cost is at least honest about how much pixel traffic was
|
||||
asked for. Read every drawing number as *a count of work libakgl asked for*, not
|
||||
as a frame rate anyone would ship. That is exactly why the raw-SDL control rows
|
||||
exist: they do the same pixel work with none of the library in the path, so the
|
||||
difference between the two is libakgl's share, and that part *does* carry over
|
||||
to a GPU backend.
|
||||
|
||||
## Static footprint
|
||||
|
||||
libakgl does not call `malloc`. Every one of these arrays exists from process
|
||||
start whether the game uses one slot or all of them.
|
||||
|
||||
| Pool | Slots | Bytes each | Total |
|
||||
|---|---:|---:|---:|
|
||||
| `akgl_heap_actors` | 64 | 400 | 25,600 |
|
||||
| `HEAP_SPRITE` | 1024 | 176 | 180,224 |
|
||||
| `HEAP_SPRITESHEET` | 1024 | 536 | 548,864 |
|
||||
| `HEAP_CHARACTER` | 256 | 184 | 47,104 |
|
||||
| `akgl_heap_strings` | 256 | 4,100 | 1,049,600 |
|
||||
| **pools, total** | | | **1,851,392** |
|
||||
| `akgl_Tilemap` (one, as `_akgl_gamemap`) | 1 | 26,388,008 | 26,388,008 |
|
||||
| — of which layers | 16 | 1,120,296 | 17,924,736 |
|
||||
| — of which tilesets | 16 | 528,944 | 8,463,104 |
|
||||
|
||||
**28 MB of BSS before `main` runs, and 94% of it is one tilemap.** A layer is
|
||||
512x512 `int` cells whether the map is 512x512 or 2x2, and a tileset carries a
|
||||
65,536-entry offset table whether the image holds 65,536 tiles or 1,728. This is
|
||||
not a hypothetical cost: zeroing that struct is 1.37 ms, and `akgl_tilemap_load`
|
||||
pays it before it has read a byte of the map file.
|
||||
|
||||
## Results: everything that does not draw
|
||||
|
||||
`tests/perf.c`, full scale. `ns/op` is the best of five runs; `ops/sec` is its
|
||||
reciprocal.
|
||||
|
||||
| Benchmark | Unit | ns/op | ops/sec |
|
||||
|---|---|---:|---:|
|
||||
| `heap_next_actor`, empty pool | call | 4.0 | 248,392,158 |
|
||||
| `heap_next_actor`, one slot left | call | 36.9 | 27,077,696 |
|
||||
| heap string claim + release cycle | cycle | 49.8 | 20,084,967 |
|
||||
| `heap_next_string`, empty pool | call | 3.9 | 255,059,748 |
|
||||
| `heap_next_string`, one slot left | call | 250.9 | 3,986,007 |
|
||||
| `heap_release_string`, 4 KiB wipe | call | 47.2 | 21,191,918 |
|
||||
| `heap_init`, all five pools | call | 45,141 | 22,153 |
|
||||
| actor spawn + release, library logging on | actor | 202.3 | 4,942,862 |
|
||||
| actor spawn + release, logging suppressed | actor | 162.5 | 6,153,324 |
|
||||
| `akgl_actor_set_character`, registry lookup | call | 39.3 | 25,438,092 |
|
||||
| `akgl_set_property` | call | 96.9 | 10,316,516 |
|
||||
| `akgl_get_property`, 4 KiB copy | call | 85.3 | 11,722,134 |
|
||||
| `akgl_character_sprite_get`, state to sprite | call | 37.4 | 26,734,605 |
|
||||
| `akgl_actor_update`, animation advancing | actor | 68.4 | 14,609,892 |
|
||||
| `akgl_actor_update`, no sprite for state | actor | 616.5 | 1,621,937 |
|
||||
| `akgl_physics_simulate`, 64 live actors | frame | 1,216.8 | 821,841 |
|
||||
| `akgl_physics_simulate`, empty pool | frame | 63.9 | 15,650,101 |
|
||||
| logic frame: 64 updates + simulate | frame | 5,763.1 | 173,517 |
|
||||
| `akgl_rectangle_points` | call | 4.0 | 248,412,026 |
|
||||
| `akgl_collide_rectangles`, overlapping | call | 24.9 | 40,181,108 |
|
||||
| `akgl_collide_rectangles`, disjoint | call | 57.9 | 17,261,906 |
|
||||
| all-pairs collision sweep, 64 actors (2016 pairs) | sweep | 115,023.6 | 8,694 |
|
||||
| `akgl_string_initialize` | call | 32.1 | 31,197,432 |
|
||||
| `akgl_string_copy`, full length | call | 32.2 | 31,075,004 |
|
||||
| `json_load_file`, small document | load | 11,338.8 | 88,193 |
|
||||
| `akgl_get_json_string_value` | call | 40.7 | 24,552,958 |
|
||||
| `akgl_get_json_integer_value` | call | 13.8 | 72,704,562 |
|
||||
| `akgl_path_relative`, `realpath` on an existing file | call | 3,481.5 | 287,232 |
|
||||
|
||||
## Results: everything that draws
|
||||
|
||||
`tests/perf_render.c`, full scale, 640x480 software renderer. The two **control**
|
||||
rows are raw `SDL_RenderTexture` loops with no libakgl in the path.
|
||||
|
||||
| Benchmark | Unit | ns/op | ops/sec |
|
||||
|---|---|---:|---:|
|
||||
| `frame_start` + `frame_end` (clear + present) | frame | 23,161.7 | 43,175 |
|
||||
| `akgl_draw_point` | call | 132.5 | 7,547,141 |
|
||||
| `akgl_draw_line`, screen diagonal | call | 605.9 | 1,650,375 |
|
||||
| `akgl_draw_rect`, 200x150 outline | call | 520.8 | 1,920,055 |
|
||||
| `akgl_draw_filled_rect`, 200x150 | call | 43,695.1 | 22,886 |
|
||||
| `akgl_draw_circle`, radius 64 | call | 2,516.2 | 397,423 |
|
||||
| `akgl_draw_copy_region`, 64x64 readback | call | 954.7 | 1,047,471 |
|
||||
| `akgl_draw_paste_region`, 64x64 upload | call | 5,352.1 | 186,841 |
|
||||
| `akgl_draw_flood_fill`, full 640x480 target | call | 1,967,295.6 | 508 |
|
||||
| `akgl_text_measure`, 15 characters | call | 37.3 | 26,804,657 |
|
||||
| `akgl_text_rendertextat`, 15 characters | call | 12,601.7 | 79,354 |
|
||||
| `akgl_sprite_load_json`, sheet already loaded | load | 17,011.3 | 58,784 |
|
||||
| `akgl_character_load_json`, two mappings | load | 14,506.8 | 68,933 |
|
||||
| `akgl_tilemap_load` + release, fixture map | load | 11,881,190.8 | 84 |
|
||||
| zeroing one `akgl_Tilemap` | call | 1,372,396.7 | 729 |
|
||||
| `akgl_tilemap_compute_tileset_offsets`, 1728 tiles | call | 2,240.8 | 446,264 |
|
||||
| `akgl_tilemap_draw`, 40x30 tiles, 1 tileset | frame | 16,260,566.6 | 61 |
|
||||
| `akgl_tilemap_draw`, 40x30 tiles, 8 tilesets | frame | 16,395,182.4 | 61 |
|
||||
| **control**: raw SDL blits, one source tile | frame | 471,676.6 | 2,120 |
|
||||
| **control**: raw SDL blits, map order | frame | 16,229,501.0 | 62 |
|
||||
| `akgl_actor_render`, on camera | actor | 2,992.0 | 334,220 |
|
||||
| `draw_world`, 1200 tiles + 64 actors | frame | 16,484,493.6 | 61 |
|
||||
| `akgl_game_update`, full frame | frame | 16,576,902.2 | 60 |
|
||||
|
||||
The `akgl_game_update` row is from before the sixteen-times-per-actor defect was
|
||||
fixed in 0.5.0; see below. It sat about 92 µs above `draw_world` in the same
|
||||
run, and now sits level with it. The absolute figures in this table have not
|
||||
been re-taken -- a later run on this machine read every row about 15% high,
|
||||
including rows nothing has touched, so the table is a record of one machine
|
||||
state and the honest measurement of that fix is the *gap* between two rows of
|
||||
the same run, not a new absolute.
|
||||
|
||||
## The frame budget
|
||||
|
||||
At 60 fps a frame is 16.67 ms. Here is where it goes for a 640x480 game with a
|
||||
full screen of 16-pixel tiles and 64 actors:
|
||||
|
||||
| Part of the frame | Cost | Share of 16.67 ms |
|
||||
|---|---:|---:|
|
||||
| Logic: 64 actor updates + one physics sweep | 0.006 ms | 0.03% |
|
||||
| All-pairs collision over 64 actors, if you do it | 0.115 ms | 0.7% |
|
||||
| Clear + present | 0.023 ms | 0.1% |
|
||||
| 64 actor renders (48x48 blits) | 0.191 ms | 1.1% |
|
||||
| 1200 tile blits | 16.26 ms | 97.6% |
|
||||
| Six lines of HUD text | 0.076 ms | 0.5% |
|
||||
|
||||
**Everything libakgl decides is free. The pixels are the whole frame.** Every
|
||||
piece of bookkeeping this library does — pool scans, registry lookups, state-to-
|
||||
sprite mapping, physics, the error-context machinery — adds up to well under 1%
|
||||
of a frame that is 97% software rasterization. On a GPU backend those blits get
|
||||
cheap and libakgl's own share rises, which is exactly why the per-operation
|
||||
numbers above matter more than the frame totals.
|
||||
|
||||
## What the numbers say
|
||||
|
||||
### The tilemap draw is SDL, not libakgl — and I can prove it
|
||||
|
||||
`akgl_tilemap_draw` takes 16.26 ms for a 1200-tile screen. A raw
|
||||
`SDL_RenderTexture` loop issuing the *same 1200 blits from the same scattered
|
||||
source tiles* takes 16.23 ms. The library's own per-tile work — the bounds
|
||||
arithmetic, the tileset scan, the offset-table lookup, the backend indirection,
|
||||
the error macros — is **0.03 ms per frame, under 0.2%**.
|
||||
|
||||
That took three attempts to measure honestly. A control that walked the sheet
|
||||
sequentially said libakgl cost 67%; a control that blitted one source tile over
|
||||
and over said it cost 3400%. Both were wrong, and both were wrong the same way:
|
||||
they changed the *memory access pattern of the source texture* rather than
|
||||
isolating the library. A 16x16 tile read from a random place in a 768x576 sheet
|
||||
costs about 13 µs on this software rasterizer; the same tile read from cache
|
||||
costs 0.4 µs. That factor of thirty is the whole story, and none of it is
|
||||
libakgl's.
|
||||
|
||||
Related: the `FIXME` in `src/tilemap.c` worrying that the per-tile tileset scan
|
||||
"is probably not very efficient" is, at eight tilesets, worth **0.8% of the
|
||||
frame** (16.40 ms vs 16.26 ms). It is a real O(tiles x tilesets) loop and it
|
||||
should still be fixed, but it is not where the time is, and nobody should
|
||||
reorganise the loader for it.
|
||||
|
||||
### The pools are linear scans, and only the string pool cares
|
||||
|
||||
Claiming an actor from an empty pool is 4.0 ns; claiming the last free slot is
|
||||
36.9 ns — nine times the cost, and still nothing.
|
||||
|
||||
The string pool is the exception, and it is instructive. Claiming from an empty
|
||||
string pool is 3.9 ns; claiming the *last free slot* is 250.9 ns, **64 times**
|
||||
slower. Same algorithm, same 256-ish entries. The difference is that each
|
||||
`akgl_String` is `PATH_MAX` + 4 bytes, so the scan touches one reference count
|
||||
every 4 KiB and takes a cache miss on every candidate. A pool of 256 strings is
|
||||
a megabyte, and walking it is walking a megabyte.
|
||||
|
||||
`akgl_heap_release_string` costs 47.2 ns because it `memset`s all 4,100 bytes
|
||||
whether the string held a path or one character. Same for `akgl_string_copy`
|
||||
(32.2 ns) and `akgl_get_property` (85.3 ns), which move `AKGL_MAX_STRING_LENGTH`
|
||||
bytes unconditionally. None of these is expensive in isolation; all of them are
|
||||
the same avoidable habit of paying for `PATH_MAX` when you used eleven bytes.
|
||||
|
||||
### Errors cost about ten times what success costs
|
||||
|
||||
`akgl_actor_update` on an actor whose character has a sprite for its state:
|
||||
68.4 ns. The same call on an actor whose character does *not*: **616.5 ns**.
|
||||
|
||||
That path is not an error in any meaningful sense — the library handles
|
||||
`AKERR_KEY` and carries on, and `akgl_actor_render` logs it and draws nothing.
|
||||
It is a normal condition on a partly authored character. But raising it means
|
||||
claiming a context out of `AKERR_ARRAY_ERROR`, formatting a message with
|
||||
`vsnprintf`, appending a stack-trace frame, walking the `PROCESS` switch, and
|
||||
releasing it again. Nine times the cost of the update it replaced.
|
||||
|
||||
The design conclusion is not "make errors cheaper". It is that a *routine*
|
||||
condition should not be reported as an error. A character that has no sprite for
|
||||
a state should answer that question with a boolean.
|
||||
|
||||
### `akgl_game_update` updated every actor sixteen times — fixed in 0.5.0
|
||||
|
||||
`akgl_game_update` looped over `AKGL_TILEMAP_MAX_LAYERS` with the actor sweep
|
||||
nested inside it, and never compared an actor's `layer` to the layer it was on.
|
||||
Every live actor's `updatefunc` ran **16 times per frame**: at 68.4 ns per
|
||||
update and 64 actors, 70 µs of work to do 4.4 µs of it — and, more to the
|
||||
point, every piece of a game's own per-frame actor logic running sixteen times.
|
||||
|
||||
The sweep is hoisted out of the layer loop. Updating an actor is not a per-layer
|
||||
operation; drawing is, and `akgl_render_2d_draw_world` walks the layers itself.
|
||||
`AKGL_ITERATOR_OP_LAYERMASK` is honoured now rather than ignored, so a caller
|
||||
that genuinely wants one layer can still ask for it and gets each of those
|
||||
actors once.
|
||||
|
||||
**How it was measured.** `tests/game.c` counts calls into a stub `updatefunc`
|
||||
and asserts exactly one per live actor per `akgl_game_update`; against the old
|
||||
code it reports 16. The timing side is the gap between the `akgl_game_update`
|
||||
and `draw_world` rows of the *same* benchmark run: 92 µs before, and across
|
||||
three runs afterwards -414 µs, -376 µs and +19 µs — noise in both directions.
|
||||
|
||||
It was invisible in the frame total because the tilemap blits are three orders
|
||||
of magnitude larger, which is exactly why it needed a counting test rather than
|
||||
a stopwatch. On a GPU backend, where a frame might be 2 ms, it was 3.5% of the
|
||||
frame doing nothing.
|
||||
|
||||
### Text has no cache at all
|
||||
|
||||
`akgl_text_rendertextat` is 12.6 µs for fifteen characters: it rasterizes the
|
||||
string, uploads it as a texture, blits it, and destroys the texture — every
|
||||
call, every frame, for a score that changes once a second. Measuring the string
|
||||
first with `akgl_text_measure` is 37.3 ns, i.e. free, which tells you the whole
|
||||
cost is the rasterize-and-upload.
|
||||
|
||||
Six HUD readouts is 76 µs a frame. That is fine at 60 fps on this machine and it
|
||||
is 4% of a 2 ms GPU frame. A one-line cache keyed on (font, string, colour)
|
||||
would take it to nothing, and it is the single clearest optimisation in the
|
||||
library.
|
||||
|
||||
### Loading is dominated by things that are not the file
|
||||
|
||||
A 2x2 fixture map with one tileset takes **11.9 ms** to load and release. Of
|
||||
that, 1.37 ms — 11.5% — is `memset`ing the 26 MB `akgl_Tilemap` before anything
|
||||
is read. Most of the rest is decoding the tileset PNG. The JSON is noise:
|
||||
parsing a small document is 11.3 µs, and the accessors are 14-41 ns each.
|
||||
|
||||
`akgl_path_relative` is 3.5 µs, because it is a `realpath(3)` syscall. A map
|
||||
naming twenty assets pays 70 µs. Also noise, but worth knowing it is a syscall
|
||||
and not a string operation.
|
||||
|
||||
### The collision helpers are fine; the missing broad phase is the problem
|
||||
|
||||
`akgl_collide_rectangles` is 24.9 ns when the rectangles overlap (it returns at
|
||||
the first corner that hits) and 57.9 ns when they do not (all eight corner tests
|
||||
run). Both are fine.
|
||||
|
||||
What the library does not provide is a broad phase, so a caller that wants
|
||||
collision writes the all-pairs loop: 2016 pairs for 64 actors, 115 µs a frame,
|
||||
0.7% of a 60 fps budget. That is affordable. It is also O(n²): raise
|
||||
`AKGL_MAX_HEAP_ACTOR` to 256 and the same loop is 32,640 pairs and 1.9 ms — over
|
||||
10% of the frame, for a game that has done nothing yet.
|
||||
|
||||
### Spawning is cheap, and a third of it is a log line
|
||||
|
||||
An actor spawn — pool claim, `memset`, registry insert, release — is 202.3 ns
|
||||
with the library's logging on and 162.5 ns with SDL's log priority raised so the
|
||||
message is never formatted. **20% of a spawn is formatting a log line nobody
|
||||
reads**, and that is with output going to a sink that throws it away; write it to
|
||||
a terminal and it is far worse. `akgl_actor_initialize` and
|
||||
`akgl_character_sprite_add` both log unconditionally at `INFO`.
|
||||
|
||||
## Defects these tests found
|
||||
|
||||
Six, and they are filed where defects live: `TODO.md`, under **Performance ->
|
||||
Defects the perf suites found**, items 28-33, each with its file, line,
|
||||
functional consequence, and what fixing it would touch. They are not repeated
|
||||
here.
|
||||
|
||||
Worth saying in this document, because it is the argument for having written the
|
||||
suites at all: stress testing breaks things unit tests do not reach. Two of the
|
||||
six are process-killing — an error-context leak that aborted on the 129th path
|
||||
resolution (fixed, with a regression test), and a pooled-string leak that turns
|
||||
into a segfault rather than an `AKGL_ERR_HEAP` around the 52nd map load. Neither
|
||||
is reachable by a suite that calls each function a handful of times, and neither
|
||||
had anything to do with speed. They came out of loops that ran the same call ten
|
||||
thousand times in one process, which is what a running game does and what
|
||||
nothing else in this tree did.
|
||||
|
||||
## What this report does not cover
|
||||
|
||||
- **One machine.** No ARM, no Raspberry Pi, no Windows, no macOS. The ratios
|
||||
should travel; the absolute numbers will not.
|
||||
- **No GPU backend.** Everything drawing-related is a software rasterizer under
|
||||
the dummy video driver. The share of a frame that belongs to libakgl is a
|
||||
floor, not an estimate.
|
||||
- **No audio.** `src/audio.c` is not benchmarked at all; the mixer under the
|
||||
dummy driver does not do the work a real device would.
|
||||
- **No controller input.** The event path is driven by SDL and has no gamepad to
|
||||
drive it under the dummy driver.
|
||||
- **No heap profiling beyond the static footprint.** Nothing here measures peak
|
||||
or steady-state heap usage. Correctness of the heap *is* covered now, by
|
||||
`cmake --build build --target memcheck`, which runs these same binaries under
|
||||
valgrind — `tests/benchutil.h` cuts the iteration counts by three orders of
|
||||
magnitude when it finds itself there, so a benchmark becomes a path-coverage
|
||||
program. That run found six more defects, including a leak of the parsed JSON
|
||||
document in every asset loader; they are in `TODO.md` under **Memory
|
||||
checking**. What is still missing is a profile: how much a running game
|
||||
actually holds at once, and how that grows over an hour.
|
||||
- **Single-threaded throughout.** The library is not thread-safe by design
|
||||
(`akgl_game_state_lock` guards one field), and nothing here tests contention.
|
||||
404
README.md
Normal file
404
README.md
Normal file
@@ -0,0 +1,404 @@
|
||||
## How do I initialize a game
|
||||
|
||||
Initialize the global game object with info about your game
|
||||
|
||||
```c
|
||||
strncpy((char *)&game.name, "sdl3-gametest", 256);
|
||||
strncpy((char *)&game.version, "0.0.1", 32);
|
||||
strncpy((char *)&game.uri, "net.aklabs.games.sdl3-gametest", 256);
|
||||
```
|
||||
|
||||
Call the game initialization routines and lock the game state for further initialization
|
||||
|
||||
```c
|
||||
PASS(e, akgl_game_init());
|
||||
PASS(e, akgl_game_state_lock());
|
||||
```
|
||||
|
||||
If you have a registry properties file, load it. If you don't have a properties file, use `akgl_set_property("prop_name", "prop_value")` to populate the required game properties.
|
||||
|
||||
```c
|
||||
PASS(e, akgl_registry_load_properties(YOUR_REGISTR_FILEPATH));
|
||||
```
|
||||
|
||||
Initialize your physics engine and renderer of choice
|
||||
|
||||
```c
|
||||
PASS(e, akgl_render_2d_init(akgl_renderer));
|
||||
PASS(e, akgl_physics_init_arcade(akgl_physics));
|
||||
```
|
||||
|
||||
Unlock the game state
|
||||
|
||||
```c
|
||||
PASS(e, akgl_game_state_unlock());
|
||||
```
|
||||
|
||||
## What is in a properties file (or, What properties must I set if I don't have one?)
|
||||
|
||||
```json
|
||||
{
|
||||
"properties": {
|
||||
"game.screenwidth": "640",
|
||||
"game.screenheight": "480",
|
||||
"physics.gravity.y": "1024.0",
|
||||
"physics.drag.y": "1.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Physics properties (gravity and drag along X, Y and Z) are optional and default to `0`.
|
||||
|
||||
## How do I update and render the game world in my main loop
|
||||
|
||||
In your game loop (or in your `SDL_AppIterate` method), lock the game state, call the game update function, and then unlock the game state
|
||||
|
||||
```c
|
||||
PASS(e, akgl_game_state_lock());
|
||||
PASS(e, akgl_renderer->frame_start(akgl_renderer));
|
||||
SDL_RenderClear(akgl_renderer->sdl_renderer);
|
||||
PASS(e, akgl_game_update(NULL));
|
||||
PASS(e, akgl_renderer->frame_end(akgl_renderer));
|
||||
PASS(e, akgl_game_state_unlock());
|
||||
```
|
||||
|
||||
## How do I get an actor on screen
|
||||
|
||||
Load a sprite for a character. Sprites are JSON documents describing 2D sprites, frames, and looping. Sprites are named, and sprite names must be unique.
|
||||
|
||||
```c
|
||||
PASS(e, akgl_sprite_load_json(SOME_FILENAME))
|
||||
```
|
||||
|
||||
Load a character from a JSON file. Characters map sprites to actor states and define physics characteristics like movement speed. Each character is named, and character names must be unique.
|
||||
|
||||
```c
|
||||
PASS(e, akgl_character_load_json(SOME_FILENAME))
|
||||
```
|
||||
|
||||
You don't strictly have to load sprites and characters from json files, you can initialize them yourself, it's just tedious work. Here's an example of initializing a 32x32 sprite from a spritesheet that uses the first 4 frames in a looping animation.
|
||||
|
||||
```c
|
||||
akgl_SpriteSheet *sheet;
|
||||
akgl_Sprite *sprite;
|
||||
akgl_Character *character;
|
||||
PASS(e, akgl_heap_next_spritesheet(&sheet);
|
||||
PASS(e, akgl_spritesheet_initialize(sheet, 32, 32, IMAGE_FILENAME));
|
||||
PASS(e, akgl_heap_next_sprite(&sprite));
|
||||
PASS(e, akgl_sprite_initialize(sprite, SPRITE_NAME, &sheet);
|
||||
sprite->frames = 4;
|
||||
sprite->frameids = [0, 1, 2, 3];
|
||||
sprite->width = 32;
|
||||
sprite->height = 32;
|
||||
sprite->speed = 1000;
|
||||
sprite->loop = true;
|
||||
strncpy((char *)&sprite->name, "SPRITE NAME", AKGL_SPRITE_MAX_NAME_LENGTH);
|
||||
PASS(e, akgl_heap_next_character(&character));
|
||||
PASS(e, akgl_character_initialize(&character, "CHAR NAME"));
|
||||
PASS(e, akgl_character_sprite_add(&character, &sprite, STATE_MASK));
|
||||
// Set the character acceleration and scale if desired
|
||||
character->ax = 64.0;
|
||||
character->ay = 64.0;
|
||||
character->sx = 2.0;
|
||||
character->sy = 2.0;
|
||||
```
|
||||
|
||||
Initialize an actor. Actors are named ("player", "Quest NPC", whatever) and names must be unique.
|
||||
|
||||
```c
|
||||
akgl_Actor *myactor = NULL;
|
||||
PASS(e, akgl_heap_next_actor(&myactor);
|
||||
PASS(e, akgl_actor_initialize(&myactor, "ACTOR_NAME"));
|
||||
```
|
||||
|
||||
Assign a character to the actor by looking up the `akgl_Character` from the AKGL registry and assign it.
|
||||
|
||||
```c
|
||||
myactor->basechar = SDL_GetPointerProperty(
|
||||
AKGL_REGISTRY_CHARACTER,
|
||||
"CHARACTER_NAME",
|
||||
NULL);
|
||||
FAIL_ZERO_BREAK(e, myactor->basechar, AKERR_REGISTRY, "Character missing");
|
||||
```
|
||||
|
||||
Give the actor a position and a state, and turn it visible.
|
||||
|
||||
```c
|
||||
myactor->state = 9AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_LEFT);
|
||||
myactor->x = 320;
|
||||
myactor->y = 240;
|
||||
myactor->visible = true;
|
||||
```
|
||||
|
||||
## What are in Sprite and Character files
|
||||
|
||||
Sprite files:
|
||||
|
||||
```json
|
||||
{
|
||||
"spritesheet": {
|
||||
"filename": "RELATIVE_IMAGE_FILE_REFERENCE",
|
||||
"frame_width": int,
|
||||
"frame_height": int
|
||||
},
|
||||
"name": "UNIQUE_SPRITE_NAME",
|
||||
"width": int,
|
||||
"height": int,
|
||||
"speed": int,
|
||||
"loop": boolean,
|
||||
"loopReverse": boolean,
|
||||
"frames": [
|
||||
int
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
* `frames` references the frame indexes in the spritesheet that should be used for this animation. Spritesheets are counted from the top left corner going to the right according to the spritesheet `frame_width` and `frame_height`.
|
||||
* `loop` says whether or not we should loop the animation
|
||||
* `loopReverse` says whether or not we should "bounce" the animation (when we reach the end of the frames, start counting back to the beginning, then count to the end, etc). Otherwise the frames are displayed from 0..n and then cycles back to 0.
|
||||
* `speed` is the number of milliseconds each frame in the animation should appear on the screen
|
||||
|
||||
Character files:
|
||||
|
||||
```c
|
||||
{
|
||||
"name": "UNIQUE_CHARACTER_NAME",
|
||||
"speedtime": 8,
|
||||
"speed_x": 0,
|
||||
"speed_y": 0,
|
||||
"acceleration_x": 0,
|
||||
"acceleration_y": 0,
|
||||
"sprite_mappings": [
|
||||
{
|
||||
"state": [
|
||||
"AKGL_ACTOR_STATE_ALIVE",
|
||||
"AKGL_ACTOR_STATE_FACE_UP",
|
||||
"AKGL_ACTOR_STATE_MOVING_UP"
|
||||
],
|
||||
"sprite": "menupointer"
|
||||
}[, ...]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
* `speedtime` appears to be legacy and unused.
|
||||
* `speed_[xy]` and `acceleration_[xy]` are physics parameters that specify the top speed and acceleration rate (in pixels per nanosecond) of the character in physics simulations. The effect of acceleration depends on the physics simulation being used at the time (which may or may not account for gravity, drag, etc).
|
||||
* `sprite_mappings` map a set of actor state flag bitmasks (assume everything in `state` is `OR`ed together) to a sprite name. The game engine uses this to automatically pick the correct sprite (by name) for a given set of state flags. You need one of these for every possible state the character may be used in.
|
||||
|
||||
## How do I load a tilemap from the filesystem and display it on screen with my actors
|
||||
|
||||
The engine ONLY supports TilED TMJ tilemaps with tileset external references. Load a tilemap into the global `akgl_Tilemap *gamemap` object.
|
||||
|
||||
```c
|
||||
PASS(e, akgl_tilemap_load(PATHSTRING, akgl_gamemap));
|
||||
```
|
||||
|
||||
Actors will be automatically populated from objects in the tilemap object layers. Actor state flags here must be expressed as an integer, you can't (yet) use the same array of strings that is used in character json files.
|
||||
|
||||
```json
|
||||
"objects":[
|
||||
{
|
||||
"gid":147,
|
||||
"height":16,
|
||||
"id":1,
|
||||
"name":"player",
|
||||
"properties":[
|
||||
{
|
||||
"name":"character",
|
||||
"type":"string",
|
||||
"value":"little guy"
|
||||
},
|
||||
{
|
||||
"name":"state",
|
||||
"type":"int",
|
||||
"value":24
|
||||
}],
|
||||
"rotation":0,
|
||||
"type":"actor",
|
||||
"visible":true,
|
||||
"width":16,
|
||||
"x":440.510088317656,
|
||||
"y":140.347239175702
|
||||
}[, ... ]
|
||||
```
|
||||
|
||||
Check if the tilemap wants to use its own physics, and if you want to allow that, override the global physics simulation
|
||||
|
||||
```c
|
||||
if ( akgl_gamemap->use_own_physics == true ) {
|
||||
akgl_physics = &akgl_gamemap->physics;
|
||||
}
|
||||
```
|
||||
|
||||
Tilemap physics specification follows. A map can specify its own physics properties (drag, gravity) without specifying a custom model.
|
||||
|
||||
```json
|
||||
"properties":[
|
||||
{
|
||||
"name":"physics.drag.y",
|
||||
"type":"float",
|
||||
"value":0
|
||||
},
|
||||
{
|
||||
"name":"physics.gravity.y",
|
||||
"type":"float",
|
||||
"value":0
|
||||
},
|
||||
{
|
||||
"name":"physics.model",
|
||||
"type":"string",
|
||||
"value":"arcade"
|
||||
}],
|
||||
```
|
||||
|
||||
The global `akgl_gamemap` object is automatically displayed if it is populated. Actors are drawn at the appropriate map layer depending on the actor's `layer` property (warning: this may be replaced with a `z` property soon.)
|
||||
|
||||
## How do I get the screen width and height
|
||||
|
||||
The most direct is to call `SDL_GetCurrentDisplayMode` to get the parameters from the returned `SDL_DisplayMode` structure (`->w` and `->h`).
|
||||
|
||||
The simplest way is to check the global `akgl_camera` object's `akgl_camera->w` and `akgl_camera->h` object. You may have more than one camera on a scene, and it's theoretically possible that the global camera object has been overriden and no longer represents the full screen.
|
||||
|
||||
The most reliable engine-centric way is to use `akgl_get_property` to get the property from the engine. Properties are read and stored as strings, so if you need to do these kinds of things a lot, cache the integer value somewhere.
|
||||
|
||||
```c
|
||||
akgl_String *width = NULL;
|
||||
int screenwidth = NULL;
|
||||
PASS(e, akgl_get_property("game.screenwidth", &width, "0"));
|
||||
PASS(e, aksl_atoi(width->data, &screenwidth));
|
||||
PASS(e, akgl_heap_release_string(width));
|
||||
```
|
||||
|
||||
|
||||
## Git hooks
|
||||
|
||||
The repository ships a `pre-commit` hook that keeps committed C sources in the
|
||||
project's canonical format (Emacs `cc-mode` "stroustrup"; see `AGENTS.md` for the
|
||||
full style guide). The hook lives in `scripts/hooks/` and is version controlled,
|
||||
but **Git configuration is not cloned**, so every clone has to be pointed at it
|
||||
once:
|
||||
|
||||
```sh
|
||||
git config core.hooksPath scripts/hooks
|
||||
```
|
||||
|
||||
Confirm it took effect:
|
||||
|
||||
```sh
|
||||
git rev-parse --git-path hooks # should print: scripts/hooks
|
||||
```
|
||||
|
||||
That is the whole installation. The hook is already committed with its
|
||||
executable bit set, so nothing needs `chmod`.
|
||||
|
||||
### What the hook does
|
||||
|
||||
On each commit it looks at the **staged** content of any added, copied, modified,
|
||||
or renamed `.c`/`.h` file under `src/`, `include/`, `tests/`, or `util/`, and
|
||||
reindents it if it does not already match the canonical style. Checking the
|
||||
staged content rather than the working tree means what lands in the commit is
|
||||
what was actually verified.
|
||||
|
||||
When a file needs reindenting, the hook fixes it in the working tree and
|
||||
re-stages it — but only when the index and working tree agree for that file. If
|
||||
they differ, you have staged part of a file with `git add -p`, and re-staging
|
||||
would sweep your unstaged work into the commit. Rather than do that silently the
|
||||
hook stops and prints the commands to run yourself:
|
||||
|
||||
```sh
|
||||
scripts/reindent.sh path/to/file.c
|
||||
git add path/to/file.c
|
||||
```
|
||||
|
||||
`include/akgl/SDL_GameControllerDB.h` is generated and always skipped.
|
||||
|
||||
### Requirements
|
||||
|
||||
The hook drives Emacs in batch mode, because `cc-mode`'s indentation engine is
|
||||
the definition of the style and `clang-format` cannot reproduce it exactly. If
|
||||
`emacs` is not on `PATH` the hook prints a warning and allows the commit — a
|
||||
hook that refuses to run without an optional tool only teaches people to reach
|
||||
for `--no-verify`. Install Emacs to get the check; without it, formatting is on
|
||||
you.
|
||||
|
||||
### Bypassing and uninstalling
|
||||
|
||||
Skip the hook for a single commit:
|
||||
|
||||
```sh
|
||||
git commit --no-verify
|
||||
```
|
||||
|
||||
Remove it entirely:
|
||||
|
||||
```sh
|
||||
git config --unset core.hooksPath
|
||||
```
|
||||
|
||||
### If you already have local hooks
|
||||
|
||||
`core.hooksPath` **replaces** the hooks directory outright — once it is set, Git
|
||||
stops reading `.git/hooks/` altogether, so any hooks you keep there will silently
|
||||
stop firing. If that matters, leave `core.hooksPath` unset and symlink just this
|
||||
one hook instead:
|
||||
|
||||
```sh
|
||||
ln -s ../../scripts/hooks/pre-commit .git/hooks/pre-commit
|
||||
```
|
||||
|
||||
### Formatting without the hook
|
||||
|
||||
The hook is a convenience, not the source of truth. The same check is available
|
||||
directly, and is what you would run in CI:
|
||||
|
||||
```sh
|
||||
scripts/reindent.sh --check # list non-conforming files; exit 1 if any
|
||||
scripts/reindent.sh # reindent every tracked C source in place
|
||||
scripts/reindent.sh src/game.c # reindent specific files
|
||||
```
|
||||
|
||||
`--check` exits `0` when everything conforms, `1` when a file needs reindenting,
|
||||
and `2` if Emacs is missing or the script cannot run — so a CI job that treats
|
||||
any non-zero status as failure will not mistake a broken toolchain for a clean
|
||||
tree.
|
||||
|
||||
## Mutation testing
|
||||
|
||||
The mutation harness makes one deliberate source-code change at a time in a scratch copy, then rebuilds and runs the passing CTest suite to measure whether tests detect the change. The known-failing `character` test is excluded by default.
|
||||
|
||||
```sh
|
||||
cmake --build build --target mutation
|
||||
scripts/mutation_test.py --target src/tilemap.c --list
|
||||
scripts/mutation_test.py --target src/tilemap.c --max-mutants 10
|
||||
scripts/mutation_test.py --threshold 40 --junit mutation-junit.xml
|
||||
```
|
||||
|
||||
The default run covers all libakgl-owned files under `src/`. Use repeated `--target` options to narrow the scope. A surviving mutant identifies behavior that the current tests do not verify; the script prints its file, line, operator, and exact edit. The real working tree is never mutated.
|
||||
|
||||
## Performance testing
|
||||
|
||||
`tests/perf.c` and `tests/perf_render.c` are benchmarks rather than unit tests: they drive the hot paths — pool acquire and release, the registry, the physics sweep, the per-actor update and render, the tilemap draw, text, and asset loading — and print what each one costs per operation.
|
||||
|
||||
```sh
|
||||
ctest --test-dir build -L perf --output-on-failure # benchmarks only, about 30 seconds
|
||||
ctest --test-dir build -LE perf # everything except the benchmarks
|
||||
AKGL_BENCH_SCALE=0.1 ctest --test-dir build -L perf # a tenth of the iterations
|
||||
```
|
||||
|
||||
Each measurement is the best of five runs and is held to a budget set at roughly ten times the recorded baseline, so a suite that turns red means an algorithmic regression rather than a busy machine. Budgets are enforced only in an optimized build at full scale; below `AKGL_BENCH_SCALE=1.0`, and in a coverage build, they are reported without failing.
|
||||
|
||||
`PERFORMANCE.md` carries the recorded baseline, the frame budget it adds up to, and what the numbers say — including the raw-SDL control rows that separate what libakgl costs from what the rasterizer costs. The six defects the stress tests turned up, and the targets the numbers are measured against, are in `TODO.md` under **Performance**.
|
||||
|
||||
## Memory checking
|
||||
|
||||
`memcheck` runs the suites that already exist under valgrind rather than adding suites of its own. The perf binaries carry most of it: they are the only programs in the tree that load assets, draw a scene, and run a frame loop in one process, and `tests/benchutil.h` cuts their iteration counts by three orders of magnitude when it detects valgrind — a benchmark walks one path a hundred thousand times, a leak check wants every path walked once.
|
||||
|
||||
```sh
|
||||
cmake --build build --target memcheck # everything, about 30 seconds
|
||||
scripts/memcheck.sh -R tilemap # one suite; ctest selection flags pass through
|
||||
scripts/memcheck.sh -LE perf # skip the benchmarks
|
||||
```
|
||||
|
||||
The run forces `SDL_VIDEO_DRIVER=dummy`, `SDL_RENDER_DRIVER=software` and `SDL_AUDIO_DRIVER=dummy` so the vendor GPU stack is never loaded — that removes thousands of unfixable findings inside the driver without suppressing anything. Definite leaks, invalid accesses and uninitialised reads are counted and set the exit status; "still reachable" is not, because that is what SDL and FreeType keep for the process lifetime. Suppressions for genuine third-party findings are in `scripts/valgrind.supp`.
|
||||
|
||||
What it found the first time it ran is in `TODO.md` under **Memory checking**.
|
||||
2
deps/libakerror
vendored
2
deps/libakerror
vendored
Submodule deps/libakerror updated: 4a09ca87fd...5eaa956f50
2
deps/libakstdlib
vendored
2
deps/libakstdlib
vendored
Submodule deps/libakstdlib updated: b9d703f48a...669b2b395f
@@ -1,9 +1,9 @@
|
||||
#ifndef _SDL_GAMECONTROLLERDB_H_
|
||||
#define _SDL_GAMECONTROLLERDB_H_
|
||||
#ifndef _AKGL_SDL_GAMECONTROLLERDB_H_
|
||||
#define _AKGL_SDL_GAMECONTROLLERDB_H_
|
||||
|
||||
// Taken from https://raw.githubusercontent.com/mdqinc/SDL_GameControllerDB/refs/heads/master/gamecontrollerdb.txt on Tue May 26 01:00:21 PM EDT 2026
|
||||
// Taken from https://raw.githubusercontent.com/mdqinc/SDL_GameControllerDB/refs/heads/master/gamecontrollerdb.txt on Sat Aug 1 07:34:43 AM EDT 2026
|
||||
|
||||
#define AKGL_SDL_GAMECONTROLLER_DB_LEN 2228
|
||||
#define AKGL_SDL_GAMECONTROLLER_DB_LEN 2255
|
||||
|
||||
const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"03000000300f00000a01000000000000,3 In 1 Conversion Box,a:b2,b:b1,back:b9,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b6,leftstick:b10,lefttrigger:b4,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b11,righttrigger:b5,rightx:a3,righty:a2,start:b8,x:b3,y:b0,platform:Windows",
|
||||
@@ -86,6 +86,7 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"03000000c82d00001b30000000000000,8BitDo Ultimate 2C,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:b8,leftx:a0,lefty:a1,paddle1:b5,paddle2:b2,rightshoulder:b7,rightstick:b14,righttrigger:b9,rightx:a3,righty:a4,start:b11,x:b3,y:b4,platform:Windows",
|
||||
"03000000c82d00001c30000000000000,8BitDo Ultimate 2C,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:b8,leftx:a0,lefty:a1,paddle1:b2,paddle2:b5,rightshoulder:b7,rightstick:b14,righttrigger:b9,rightx:a3,righty:a4,start:b11,x:b3,y:b4,platform:Windows",
|
||||
"03000000c82d00001d30000000000000,8BitDo Ultimate 2C,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:b8,leftx:a0,lefty:a1,paddle1:b5,paddle2:b2,rightshoulder:b7,rightstick:b14,righttrigger:b9,rightx:a3,righty:a4,start:b11,x:b3,y:b4,platform:Windows",
|
||||
"03000000c82d00000a31000000000000,8BitDo Ultimate 2C,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:b8,leftx:a0,lefty:a1,paddle1:b5,paddle2:b2,rightshoulder:b7,rightstick:b14,righttrigger:b9,rightx:a3,righty:a4,start:b11,x:b3,y:b4,platform:Windows",
|
||||
"03000000c82d00001530000000000000,8BitDo Ultimate C,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:b8,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:b9,rightx:a3,righty:a4,start:b11,x:b3,y:b4,platform:Windows",
|
||||
"03000000c82d00001630000000000000,8BitDo Ultimate C,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:b8,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:b9,rightx:a3,righty:a4,start:b11,x:b3,y:b4,platform:Windows",
|
||||
"03000000c82d00001730000000000000,8BitDo Ultimate C,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:b8,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:b9,rightx:a3,righty:a4,start:b11,x:b3,y:b4,platform:Windows",
|
||||
@@ -247,6 +248,7 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"03000000ac0500001a06000000000000,GameSir T3 2.02,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b15,leftshoulder:b6,leftstick:b13,lefttrigger:b8,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:b9,rightx:a3,righty:a4,start:b11,x:b3,y:b4,platform:Windows",
|
||||
"03000000373500000410000000000000,GameSir T4 Kaleid,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b4,leftstick:b8,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b9,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Windows",
|
||||
"03000000373500009410000000000000,GameSir Tegenaria Lite,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:b8,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:b9,rightx:a3,righty:a4,start:b11,x:b3,y:b4,platform:Windows",
|
||||
"03000000373500001611000000000000,GameSir X5 Lite,a:b0,b:b1,x:b3,y:b4,back:b10,guide:b12,start:b11,leftstick:b13,rightstick:b14,leftshoulder:b6,rightshoulder:b7,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:b8,righttrigger:b9,platform:Windows",
|
||||
"030000004c0e00001035000000000000,Gamester,a:b0,b:b1,back:b7,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b4,leftstick:b8,lefttrigger:b10,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b9,righttrigger:b11,rightx:a3,righty:a4,start:b6,x:b2,y:b3,platform:Windows",
|
||||
"030000000d0f00001110000000000000,GameStick Controller,a:b0,b:b1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b6,leftstick:b13,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,rightx:a2,righty:a5,start:b11,x:b3,y:b4,platform:Windows",
|
||||
"0300000047530000616d000000000000,GameStop,a:b0,b:b1,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b2,y:b3,platform:Windows",
|
||||
@@ -430,7 +432,7 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"03000000380700008084000000000000,Mad Catz TE2 PS4 Fightstick,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,lefttrigger:a3,leftx:a0,lefty:a1,rightshoulder:b5,righttrigger:a4,rightx:a2,righty:a5,start:b9,touchpad:b13,x:b0,y:b3,platform:Windows",
|
||||
"030000002a0600001024000000000000,Matricom,a:b0,b:b1,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a3,righty:a4,start:b9,x:b2,y:b3,platform:Windows",
|
||||
"030000009f000000adbb000000000000,MaxJoypad Virtual Controller,a:b1,b:b2,back:b9,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b4,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,righttrigger:b7,rightx:a2,righty:a3,start:b8,x:b3,y:b0,platform:Windows",
|
||||
"030000008f0e00001330000000000000,Mayflash Adapter,a:b1,b:b2,back:b8,dpdown:h0.8,dpleft:h0.2,dpright:h0.1,dpup:h0.4,leftshoulder:b6,lefttrigger:b8,leftx:a0,lefty:a1,rightshoulder:b7,rightx:a3~,righty:a2,start:b9,x:b0,y:b3,platform:Windows",
|
||||
"030000008f0e00001330000000000000,Mayflash N64 Adapter,a:b1,b:b2,back:b8,dpdown:h0.8,dpleft:h0.2,dpright:h0.1,dpup:h0.4,leftshoulder:b6,lefttrigger:b8,leftx:a0,lefty:a1,rightshoulder:b7,rightx:a3~,righty:a2,start:b9,x:b0,y:b3,platform:Windows",
|
||||
"03000000250900000128000000000000,Mayflash Arcade Stick,a:b1,b:b2,back:b8,leftshoulder:b0,lefttrigger:b4,leftx:a0,lefty:a1,rightshoulder:b3,righttrigger:b7,start:b9,x:b5,y:b6,platform:Windows",
|
||||
"03000000242f00003700000000000000,Mayflash F101,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,lefttrigger:b6,rightshoulder:b5,righttrigger:b7,start:b9,x:b0,y:b3,platform:Windows",
|
||||
"03000000790000003018000000000000,Mayflash F300 Arcade Joystick,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b4,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,righttrigger:b7,start:b9,x:b0,y:b3,platform:Windows",
|
||||
@@ -518,6 +520,7 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"030000006f0e00000901000000000000,PDP PS3 Versus Fighting,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,lefttrigger:b6,rightshoulder:b5,righttrigger:b7,start:b9,x:b0,y:b3,platform:Windows",
|
||||
"030000006f0e00008901000000000000,PDP Realmz Nintendo Switch Controller,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b0,y:b3,platform:Windows",
|
||||
"030000008f0e00004100000000000000,PlaySega,a:b1,b:b0,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,leftshoulder:b6,lefttrigger:b7,rightshoulder:b5,righttrigger:b2,start:b8,x:b4,y:b3,platform:Windows",
|
||||
"03000000d620000020a7000000000000,PowerA Advantage Nintendo Switch 2 Controller,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,misc1:b13,misc2:b16,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b0,y:b3,platform:Windows",
|
||||
"03000000d620000011a7000000000000,PowerA Core Controller,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,misc1:b13,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b0,y:b3,platform:Windows",
|
||||
"03000000dd62000015a7000000000000,PowerA Fusion Nintendo Switch Arcade Stick,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,misc1:b13,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b0,y:b3,platform:Windows",
|
||||
"03000000d620000012a7000000000000,PowerA Fusion Nintendo Switch Fight Pad,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,misc1:b13,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b0,y:b3,platform:Windows",
|
||||
@@ -550,7 +553,7 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"03000000250900000500000000000000,PS3 Controller,a:b2,b:b1,back:b9,dpdown:h0.8,dpleft:h0.4,dpright:h0.2,dpup:h0.1,leftshoulder:b6,leftstick:b10,lefttrigger:b4,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b11,righttrigger:b5,rightx:a2,righty:a3,start:b8,x:b0,y:b3,platform:Windows",
|
||||
"030000004c0500006802000000000000,PS3 Controller,a:b2,b:b1,back:b9,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b10,lefttrigger:a3~,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b11,righttrigger:a4~,rightx:a2,righty:a5,start:b8,x:b3,y:b0,platform:Windows",
|
||||
"030000004f1f00000800000000000000,PS3 Controller,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b4,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,righttrigger:b7,start:b9,x:b0,y:b3,platform:Windows",
|
||||
"03000000632500007505000000000000,PS3 Controller,a:b2,b:b1,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b3,y:b0,platform:Windows",
|
||||
"03000000632500007505000000000000,PS3 Controller,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b0,y:b3,platform:Windows",
|
||||
"03000000888800000803000000000000,PS3 Controller,a:b2,b:b1,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b9,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:b7,rightx:a3,righty:a4,start:b11,x:b3,y:b0,platform:Windows",
|
||||
"03000000888800000804000000000000,PS3 Controller,a:b14,b:b13,back:b0,dpdown:b6,dpleft:b7,dpright:b5,dpup:b4,guide:b16,leftshoulder:b10,leftstick:b1,lefttrigger:b8,leftx:a0,lefty:a1,rightshoulder:b11,rightstick:b2,righttrigger:b9,rightx:a2,righty:a3,start:b3,x:b15,y:b12,platform:Windows",
|
||||
"030000008f0e00000300000000000000,PS3 Controller,a:b2,b:b1,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a5,start:b9,x:b3,y:b0,platform:Windows",
|
||||
@@ -640,7 +643,8 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"03000000921200004547000000000000,Retro Bit Sega Genesis Adapter,a:b0,b:b1,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,lefttrigger:b7,rightshoulder:b5,righttrigger:b2,start:b6,x:b3,y:b4,platform:Windows",
|
||||
"03000000790000001100000000000000,Retro Controller,a:b1,b:b2,back:b8,dpdown:+a4,dpleft:-a3,dpright:+a3,dpup:-a4,leftshoulder:b6,lefttrigger:b7,rightshoulder:b4,righttrigger:b5,start:b9,x:b0,y:b3,platform:Windows",
|
||||
"03000000830500006020000000000000,Retro Controller,a:b0,b:b1,back:b6,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,leftshoulder:b4,lefttrigger:b5,rightshoulder:b8,righttrigger:b9,start:b7,x:b2,y:b3,platform:Windows",
|
||||
"03000000632500007805000000000000,Retro Fighters Controller,a:b2,b:b1,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b3,y:b0,platform:Windows",
|
||||
"03000000632500007805000000000000,Retro Fighters D6,a:b2,b:b1,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,lefttrigger:b6,leftx:a0,lefty:a1,misc1:b10,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b3,y:b0,platform:Windows",
|
||||
"03000000790000001600000000000000,Retro Fighters D6,a:b1,b:b2,back:b8,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,leftshoulder:b4,lefttrigger:b6,rightshoulder:b5,righttrigger:b7,start:b9,x:b0,y:b3,platform:Windows",
|
||||
"0300000003040000c197000000000000,Retrode Adapter,a:b0,b:b4,back:b2,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,leftshoulder:b6,rightshoulder:b7,start:b3,x:b1,y:b5,platform:Windows",
|
||||
"03000000bd12000013d0000000000000,Retrolink Sega Saturn Classic Controller,a:b0,b:b1,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,leftshoulder:b5,lefttrigger:b6,rightshoulder:b2,righttrigger:b7,start:b8,x:b3,y:b4,platform:Windows",
|
||||
"03000000bd12000015d0000000000000,Retrolink SNES Controller,a:b2,b:b1,back:b8,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,leftshoulder:b4,rightshoulder:b5,start:b9,x:b3,y:b0,platform:Windows",
|
||||
@@ -687,7 +691,7 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"03000000a306000020f6000000000000,Saitek PS2700 PlayStation Controller,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a4,start:b9,x:b0,y:b3,platform:Windows",
|
||||
"03000000300f00001101000000000000,Saitek Rumble,a:b2,b:b3,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b10,lefttrigger:b5,leftx:a0,lefty:a1,rightshoulder:b6,rightstick:b11,righttrigger:b7,rightx:a3,righty:a2,start:b9,x:b0,y:b1,platform:Windows",
|
||||
"03000000e804000000a0000000000000,Samsung EIGP20,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b6,leftx:a0,lefty:a1,rightshoulder:b7,rightx:a3,righty:a4,start:b11,x:b3,y:b4,platform:Windows",
|
||||
"03000000bd12000010d1000000000000,Sanwa 4Button,a:b0,b:b1,x:b2,y:b3,dpup:-a1,dpdown:+a1,dpleft:-a0,dpright:+a0,platform:Windows",
|
||||
"03000000bd12000010d1000000000000,Sanwa 4Button,a:b0,b:b1,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,x:b2,y:b3,platform:Windows",
|
||||
"03000000c01100000252000000000000,Sanwa Easy Grip,a:b0,b:b1,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b6,lefttrigger:b7,leftx:a0,lefty:a1,rightshoulder:b5,righttrigger:b2,start:b9,x:b3,y:b4,platform:Windows",
|
||||
"03000000c01100004350000000000000,Sanwa Micro Grip P3,a:b1,b:b0,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b4,leftstick:b10,lefttrigger:b5,leftx:a0,lefty:a1,rightshoulder:b6,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,x:b3,y:b2,platform:Windows",
|
||||
"03000000411200004550000000000000,Sanwa Micro Grip Pro,a:b0,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b4,leftstick:b10,lefttrigger:b5,leftx:a0,lefty:a1,rightshoulder:b6,rightstick:b11,righttrigger:b7,rightx:a1,righty:a2,start:b9,x:b1,y:b3,platform:Windows",
|
||||
@@ -701,14 +705,14 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"030000004f04000028b3000000000000,Score A,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a5,start:b9,x:b0,y:b3,platform:Windows",
|
||||
"03000000952e00002577000000000000,Scuf PS4 Controller,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:a3,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:a4,rightx:a2,righty:a5,start:b9,touchpad:b13,x:b0,y:b3,platform:Windows",
|
||||
"03000000a30c00002500000000000000,Sega Genesis Mini 3B Controller,a:b2,b:b1,dpdown:+a4,dpleft:-a3,dpright:+a3,dpup:-a4,righttrigger:b5,start:b9,platform:Windows",
|
||||
"03000000a30c00002400000000000000,Sega Mega Drive Mini 6B Controller,a:b2,b:b1,dpdown:+a4,dpleft:-a3,dpright:+a3,dpup:-a4,rightshoulder:b4,righttrigger:b5,leftshoulder:b6,lefttrigger:b7,back:b8,start:b9,x:b3,y:b0,platform:Windows",
|
||||
"03000000a30c00002400000000000000,Sega Mega Drive Mini 6B Controller,a:b2,b:b1,back:b8,dpdown:+a4,dpleft:-a3,dpright:+a3,dpup:-a4,leftshoulder:b6,lefttrigger:b7,rightshoulder:b4,righttrigger:b5,start:b9,x:b3,y:b0,platform:Windows",
|
||||
"03000000d804000086e6000000000000,Sega Multi Controller,a:b0,b:b1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:a2,lefttrigger:a3,leftx:a0,lefty:a1,rightshoulder:b5,righttrigger:b2,start:b8,x:b3,y:b4,platform:Windows",
|
||||
"0300000000050000289b000000000000,Sega Saturn Adapter,a:b1,b:b2,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,leftshoulder:b6,lefttrigger:b4,rightshoulder:b7,righttrigger:b5,start:b9,x:b0,y:b3,platform:Windows",
|
||||
"0300000000f000000800000000000000,Sega Saturn Controller,a:b1,b:b2,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,rightshoulder:b7,righttrigger:b3,start:b0,x:b5,y:b6,platform:Windows",
|
||||
"03000000730700000601000000000000,Sega Saturn Controller,a:b0,b:b1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b6,lefttrigger:b7,leftx:a0,lefty:a1,rightshoulder:b5,righttrigger:b2,start:b9,x:b3,y:b4,platform:Windows",
|
||||
"03000000b40400000a01000000000000,Sega Saturn Controller,a:b0,b:b1,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,leftshoulder:b6,lefttrigger:b7,rightshoulder:b5,righttrigger:b2,start:b8,x:b3,y:b4,platform:Windows",
|
||||
"030000003b07000004a1000000000000,SFX,a:b0,b:b2,back:b7,leftshoulder:b6,lefttrigger:b4,leftx:a0,lefty:a1,rightshoulder:b9,righttrigger:b5,start:b8,x:b1,y:b3,platform:Windows",
|
||||
"03000000632500002705000000000000,ShanWan Q36,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b6,lefttrigger:b8,rightshoulder:b7,righttrigger:b9,start:b11,x:b3,y:b4,platform:Windows",
|
||||
"03000000632500002705000000000000,ShanWan Gamepad,+lefty:+a2,-lefty:-a1,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b6,leftstick:b13,lefttrigger:b8,leftx:a0,rightshoulder:b7,rightstick:b14,righttrigger:b9,start:b11,x:b3,y:b4,platform:Windows",
|
||||
"03000000f82100001900000000000000,Shogun Bros Chameleon X1,a:b2,b:b1,back:b9,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b6,lefttrigger:b4,leftx:a0,lefty:a1,rightshoulder:b7,righttrigger:b5,rightx:a2,righty:a3,start:b8,x:b3,y:b0,platform:Windows",
|
||||
"03000000120c00001c1e000000000000,SnakeByte 4S PS4 Controller,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:a3,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:a4,rightx:a2,righty:a5,start:b9,touchpad:b13,x:b0,y:b3,platform:Windows",
|
||||
"03000000140300000918000000000000,SNES Controller,a:b0,b:b1,back:b6,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,leftshoulder:b4,rightshoulder:b5,start:b7,x:b2,y:b3,platform:Windows",
|
||||
@@ -737,6 +741,7 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"03000000120c0000160e000000000000,Steel Play Metaltech PS4 Controller,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:a3,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:a4,rightx:a2,righty:a5,start:b9,touchpad:b13,x:b0,y:b3,platform:Windows",
|
||||
"03000000110100001914000000000000,SteelSeries,a:b0,b:b1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftstick:b13,lefttrigger:b6,leftx:a0,lefty:a1,rightstick:b14,righttrigger:b7,rightx:a3,righty:a4,start:b11,x:b3,y:b4,platform:Windows",
|
||||
"03000000381000001214000000000000,SteelSeries Free,a:b0,b:b1,back:b12,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b6,leftx:a0,lefty:a1,rightshoulder:b7,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Windows",
|
||||
"03000000381000004114000000000000,SteelSeries Nimbus Cloud,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b15,leftshoulder:b6,leftstick:b13,lefttrigger:b8,leftx:a0,lefty:a1,paddle1:b5,paddle2:b2,rightshoulder:b7,rightstick:b14,righttrigger:b9,rightx:a3,righty:a4,start:b11,x:b3,y:b4,platform:Windows",
|
||||
"03000000110100003114000000000000,SteelSeries Stratus Duo,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b6,leftstick:b13,lefttrigger:b8,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:b9,rightx:a3,righty:a4,start:b11,x:b3,y:b4,platform:Windows",
|
||||
"03000000381000003014000000000000,SteelSeries Stratus Duo,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b10,leftshoulder:b4,leftstick:b8,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b9,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Windows",
|
||||
"03000000381000003114000000000000,SteelSeries Stratus Duo,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b10,leftshoulder:b4,leftstick:b8,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b9,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Windows",
|
||||
@@ -780,6 +785,7 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"030000005f140000c501000000000000,Trust Gamepad,a:b2,b:b1,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b3,y:b0,platform:Windows",
|
||||
"03000000b80500000210000000000000,Trust Gamepad,a:b2,b:b1,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b3,y:b0,platform:Windows",
|
||||
"030000005f1400002a01000000000000,Trust Predator GM1200,a:b0,b:b1,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,leftshoulder:b6,lefttrigger:b7,rightshoulder:b5,righttrigger:b2,x:b3,y:b4,platform:Windows",
|
||||
"03000000580400000a10000000000000,Trust Sight Fighter,a:b0,b:b1,back:b5,leftshoulder:b6,lefttrigger:b8,leftx:a0,lefty:a1,rightshoulder:b7,righttrigger:b9,start:b2,x:b3,y:b4,platform:Windows",
|
||||
"030000004f04000087b6000000000000,TWCS Throttle,dpdown:b8,dpleft:b9,dpright:b7,dpup:b6,leftstick:b5,lefttrigger:-a5,leftx:a0,lefty:a1,righttrigger:+a5,platform:Windows",
|
||||
"03000000411200000450000000000000,Twin Shock,a:b2,b:b1,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b6,leftstick:b9,lefttrigger:b4,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b10,righttrigger:b5,rightx:a3,righty:a4,start:b11,x:b3,y:b0,platform:Windows",
|
||||
"03000000d90400000200000000000000,TwinShock PS2 Controller,a:b2,b:b1,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b6,leftstick:b10,lefttrigger:b4,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b11,righttrigger:b5,rightx:a3,righty:a2,start:b9,x:b3,y:b0,platform:Windows",
|
||||
@@ -803,6 +809,8 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"030000006f0e00000302000000000000,Victrix PS4 Pro Fightstick,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,lefttrigger:b6,rightshoulder:b5,righttrigger:b7,start:b9,touchpad:b13,x:b0,y:b3,platform:Windows",
|
||||
"030000006f0e00000702000000000000,Victrix PS4 Pro Fightstick,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:b6,rightshoulder:b5,righttrigger:b7,start:b9,touchpad:b13,x:b0,y:b3,platform:Windows",
|
||||
"0300000034120000adbe000000000000,vJoy Device,a:b0,b:b1,back:b15,dpdown:b6,dpleft:b7,dpright:b8,dpup:b5,guide:b16,leftshoulder:b9,leftstick:b13,lefttrigger:b11,leftx:a0,lefty:a1,rightshoulder:b10,rightstick:b14,righttrigger:b12,rightx:a3,righty:a4,start:b4,x:b2,y:b3,platform:Windows",
|
||||
"03000000d23800001010000000000000,Void Genesis,a:b0,b:b1,back:b16,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b6,leftshoulder:b2,leftstick:b9,lefttrigger:a3,leftx:a0,lefty:a1,misc1:b18,misc2:b21,misc3:b20,paddle1:b22,paddle2:b15,paddle3:b23,paddle4:b17,rightshoulder:b5,rightstick:b10,righttrigger:a4,rightx:a2,righty:a5,start:b19,x:b3,y:b4,platform:Windows",
|
||||
"03000000d23800001210000000000000,Void Genesis,a:b0,b:b1,back:b16,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b6,leftshoulder:b2,leftstick:b9,lefttrigger:a3,leftx:a0,lefty:a1,misc1:b18,misc2:b21,misc3:b20,paddle1:b22,paddle2:b15,paddle3:b23,paddle4:b17,rightshoulder:b5,rightstick:b10,righttrigger:a4,rightx:a2,righty:a5,start:b19,x:b3,y:b4,platform:Windows",
|
||||
"03000000120c0000ab57000000000000,Warrior Joypad JS083,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b4,leftstick:b10,lefttrigger:a3,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:a4,rightx:a2,righty:a5,start:b9,x:b0,y:b3,platform:Windows",
|
||||
"030000007e0500003003000000000000,Wii U Pro,a:b0,b:b1,back:b8,dpdown:b14,dpleft:b15,dpright:b16,dpup:b13,leftshoulder:b6,leftstick:b11,lefttrigger:b4,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b12,righttrigger:b5,rightx:a2,righty:a3,start:b9,x:b3,y:b2,platform:Windows",
|
||||
"0300000032150000030a000000000000,Wildcat,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b4,leftstick:b8,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b9,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Windows",
|
||||
@@ -911,7 +919,7 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"03000000c82d00000351000000010000,8BitDo SN30,a:b1,b:b0,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b6,leftx:a0,lefty:a1,rightshoulder:b7,rightx:a2,righty:a3,start:b11,x:b4,y:b3,platform:Mac OS X",
|
||||
"03000000c82d00001290000001000000,8BitDo SN30,a:b1,b:b0,back:b10,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,leftshoulder:b6,rightshoulder:b7,start:b11,x:b4,y:b3,platform:Mac OS X",
|
||||
"03000000c82d00004028000000010000,8BitDo SN30,a:b1,b:b0,back:b10,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,leftshoulder:b6,rightshoulder:b7,start:b11,x:b4,y:b3,platform:Mac OS X",
|
||||
"03000000c82d00000160000001000000,8BitDo SN30 Pro,a:b1,b:b0,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b6,leftstick:b13,lefttrigger:a4,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:a5,rightx:a2,righty:a3,start:b11,x:b4,y:b3,platform:Mac OS X",
|
||||
"03000000c82d00000160000001000000,8BitDo SN30 Pro,a:b1,b:b0,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b6,leftstick:b13,lefttrigger:b8,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:b9,rightx:a2,righty:a3,start:b11,x:b4,y:b3,platform:Mac OS X",
|
||||
"03000000c82d00000161000000010000,8BitDo SN30 Pro,a:b1,b:b0,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b2,leftshoulder:b6,leftstick:b13,lefttrigger:b8,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:b9,rightx:a2,righty:a3,start:b11,x:b4,y:b3,platform:Mac OS X",
|
||||
"03000000c82d00000260000001000000,8BitDo SN30 Pro Plus,a:b1,b:b0,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b2,leftshoulder:b6,leftstick:b13,lefttrigger:b8,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:b9,rightx:a2,righty:a3,start:b11,x:b4,y:b3,platform:Mac OS X",
|
||||
"03000000c82d00000261000000010000,8BitDo SN30 Pro Plus,a:b1,b:b0,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b2,leftshoulder:b6,leftstick:b13,lefttrigger:b8,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:b9,rightx:a2,righty:a3,start:b11,x:b4,y:b3,platform:Mac OS X",
|
||||
@@ -941,6 +949,7 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"03000000503200000110000045010000,Atari VCS Classic,a:b0,b:b1,back:b4,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b3,start:b2,platform:Mac OS X",
|
||||
"03000000503200000110000047010000,Atari VCS Classic Controller,a:b0,b:b1,back:b4,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b3,start:b2,platform:Mac OS X",
|
||||
"03000000503200000210000047010000,Atari VCS Modern Controller,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b9,leftshoulder:b4,leftstick:b6,lefttrigger:a5,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b7,righttrigger:a4,rightx:a2,righty:a3,start:b8,x:b2,y:b3,platform:Mac OS X",
|
||||
"03000000e30500008106000000010000,Austgame GameCube Adapter,a:b0,b:b2,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,lefttrigger:+a4,leftx:a0,lefty:a1,misc3:b5,misc4:b6,rightshoulder:b4,righttrigger:-a4,rightx:a2,righty:a3,start:b7,x:b1,y:b3,platform:Mac OS X",
|
||||
"030000008a3500000102000000010000,Backbone One,a:b0,b:b1,back:b16,dpdown:b11,dpleft:b13,dpright:b12,dpup:b10,guide:b17,leftshoulder:b4,leftstick:b8,lefttrigger:b6,leftx:a0,lefty:a1~,rightshoulder:b5,rightstick:b9,righttrigger:b7,rightx:a2,righty:a3~,start:b15,x:b2,y:b3,platform:Mac OS X",
|
||||
"030000008a3500000201000000010000,Backbone One,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Mac OS X",
|
||||
"030000008a3500000202000000010000,Backbone One,a:b0,b:b1,back:b16,dpdown:b11,dpleft:b13,dpright:b12,dpup:b10,guide:b17,leftshoulder:b4,leftstick:b8,lefttrigger:b6,leftx:a0,lefty:a1~,rightshoulder:b5,rightstick:b9,righttrigger:b7,rightx:a2,righty:a3~,start:b15,x:b2,y:b3,platform:Mac OS X",
|
||||
@@ -1118,6 +1127,7 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"03000000110100002014000000000000,SteelSeries Nimbus,a:b0,b:b1,dpdown:b9,dpleft:b11,dpright:b10,dpup:b8,guide:b12,leftshoulder:b4,lefttrigger:b6,leftx:a0,lefty:a1~,rightshoulder:b5,righttrigger:b7,rightx:a2,righty:a3~,x:b2,y:b3,platform:Mac OS X",
|
||||
"03000000110100002014000001000000,SteelSeries Nimbus,a:b0,b:b1,dpdown:b9,dpleft:b11,dpright:b10,dpup:b8,guide:b12,leftshoulder:b4,lefttrigger:b6,leftx:a0,lefty:a1~,rightshoulder:b5,righttrigger:b7,rightx:a2,righty:a3~,x:b2,y:b3,platform:Mac OS X",
|
||||
"03000000381000002014000001000000,SteelSeries Nimbus,a:b0,b:b1,dpdown:b9,dpleft:b11,dpright:b10,dpup:b8,guide:b12,leftshoulder:b4,lefttrigger:b6,leftx:a0,lefty:a1~,rightshoulder:b5,righttrigger:b7,rightx:a2,righty:a3~,x:b2,y:b3,platform:Mac OS X",
|
||||
"03000000381000004114000000010000,SteelSeries Nimbus Cloud,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b15,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,paddle1:b5,paddle2:b2,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Mac OS X",
|
||||
"05000000484944204465766963650000,SteelSeries Nimbus Plus,a:b0,b:b1,back:b15,dpdown:b11,dpleft:b13,dpright:b12,dpup:b10,guide:b16,leftshoulder:b4,leftstick:b8,lefttrigger:b6,leftx:a0,lefty:a1~,rightshoulder:b5,rightstick:b9,righttrigger:b7,rightx:a2,righty:a3~,start:b14,x:b2,y:b3,platform:Mac OS X",
|
||||
"050000004e696d6275732b0000000000,SteelSeries Nimbus Plus,a:b0,b:b1,back:b15,dpdown:b11,dpleft:b13,dpright:b12,dpup:b10,guide:b16,leftshoulder:b4,leftstick:b8,lefttrigger:b6,leftx:a0,lefty:a1~,rightshoulder:b5,rightstick:b9,righttrigger:b7,rightx:a2,righty:a3~,start:b14,x:b2,y:b3,platform:Mac OS X",
|
||||
"03000000381000003014000000000000,SteelSeries Stratus Duo,a:b0,b:b1,back:b9,dpdown:b12,dpleft:b13,dpright:b14,dpup:b11,guide:b10,leftshoulder:b4,leftstick:b6,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b7,righttrigger:a5,rightx:a3,righty:a4,start:b8,x:b2,y:b3,platform:Mac OS X",
|
||||
@@ -1249,12 +1259,16 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"03000000c82d00001230000011010000,8BitDo Ultimate,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,paddle1:b2,paddle2:b5,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"03000000c82d00001330000011010000,8BitDo Ultimate,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,misc1:b26,paddle1:b23,paddle2:b19,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"05000000c82d00001230000000010000,8BitDo Ultimate,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"03000000c82d00000b31000014010000,8BitDo Ultimate 2,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
"03000000c82d00001260000011010000,8BitDo Ultimate 2,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,paddle1:b17,paddle2:b16,paddle3:b2,paddle4:b5,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"05000000c82d00001260000001000000,8BitDo Ultimate 2,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,paddle1:b17,paddle2:b16,paddle3:b2,paddle4:b5,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"03000000c82d00000a31000014010000,8BitDo Ultimate 2C,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
"03000000c82d00001d30000011010000,8BitDo Ultimate 2C,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,paddle1:b5,paddle2:b2,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"05000000c82d00001b30000001000000,8BitDo Ultimate 2C,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,paddle1:b5,paddle2:b2,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"03000000c82d00001530000011010000,8BitDo Ultimate C,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"03000000c82d00001630000011010000,8BitDo Ultimate C,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"03000000c82d00001730000011010000,8BitDo Ultimate C,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"06000000c82d00000320000007010000,8BitDo Ultimate Wired Controller for Xbox,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
"03000000c82d00000121000011010000,8BitDo Xbox One SN30 Pro,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"05000000c82d00000121000000010000,8BitDo Xbox One SN30 Pro,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"05000000a00500003232000001000000,8BitDo Zero,a:b0,b:b1,back:b10,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,leftshoulder:b6,rightshoulder:b7,start:b11,x:b3,y:b4,platform:Linux",
|
||||
@@ -1276,6 +1290,7 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"03000000020500000913000010010000,Anbernic RG P01,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"03000000373500000710000010010000,Anbernic RG P01,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"05000000373500004610000001000000,Anbernic RG P01,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,misc1:b15,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"19000000010000000100000000010000,Anbernic RG28XX Controller,a:b4,b:b3,back:b9,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b11,leftshoulder:b7,leftstick:b12,misc1:b1,rightshoulder:b8,rightstick:b13,start:b10,x:b5,y:b6,platform:Linux",
|
||||
"03000000190e00000110000010010000,Aquaplus Piece,a:b1,b:b0,back:b3,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,start:b2,platform:Linux",
|
||||
"03000000790000003018000011010000,Arcade Fightstick F300,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,righttrigger:b7,start:b9,x:b0,y:b3,platform:Linux",
|
||||
"03000000a30c00002700000011010000,Astro City Mini,a:b2,b:b1,back:b8,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,rightshoulder:b4,righttrigger:b5,start:b9,x:b3,y:b0,platform:Linux",
|
||||
@@ -1310,6 +1325,7 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"05000000bc2000000055000001000000,Betop AX1 BFM,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:b8,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:b9,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"03000000bc2000006412000011010000,Betop Controller,a:b2,b:b1,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b30,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b3,y:b0,platform:Linux",
|
||||
"030000006b1400000209000011010000,Bigben,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b0,y:b3,platform:Linux",
|
||||
"030000006b1400000055000011010000,Bigben Interactive Gamepad,a:b0,b:b1,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b2,y:b3,platform:Linux",
|
||||
"03000000120c0000300e000011010000,Brook Audio Fighting Board PS3,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b0,y:b3,platform:Linux",
|
||||
"03000000120c0000310e000011010000,Brook Audio Fighting Board PS4,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:a3,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:a4,rightx:a2,righty:a5,start:b9,touchpad:b13,x:b0,y:b3,platform:Linux",
|
||||
"03000000120c0000200e000011010000,Brook Mars PS4 Controller,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:a3,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:a4,rightx:a2,righty:a5,start:b9,touchpad:b13,x:b0,y:b3,platform:Linux",
|
||||
@@ -1347,6 +1363,7 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"03000000558500001b06000010010000,GameSir G4 Pro,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b6,leftstick:b13,lefttrigger:b8,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:b9,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"05000000ac0500002d0200001b010000,GameSir G4s,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b33,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"03000000ac0500007a05000011010000,GameSir G5,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:b8,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b16,righttrigger:b9,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"03000000373500005810000001020000,GameSir K1,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,misc1:b15,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"03000000373500009710000001020000,GameSir Kaleid Flux,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,misc1:b15,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"03000000ac0500001a06000011010000,GameSir T3 2.02,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b15,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"03000000373500000410000044010000,GameSir T4 Kaleid,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
@@ -1446,6 +1463,7 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"03000000242f00008a00000011010000,JYS Adapter,a:b1,b:b4,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:b8,rightshoulder:b7,rightstick:b14,righttrigger:b9,rightx:a2,righty:a3,start:b11,x:b0,y:b3,platform:Linux",
|
||||
"03000000314100001935000000010000,LeadJoy Xeno Plus,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:+a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:+a5,rightx:a4,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
"030000006f0e00000103000000020000,Logic3 Controller,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
"030000006f0e000001f5000010010000,Logic3 Gamepad,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
"030000006d040000d1ca000000000000,Logitech Chillstream,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b0,y:b3,platform:Linux",
|
||||
"030000006d040000d1ca000011010000,Logitech Chillstream,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b0,y:b3,platform:Linux",
|
||||
"030000006d04000016c2000010010000,Logitech Dual Action,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b0,y:b3,platform:Linux",
|
||||
@@ -1536,10 +1554,10 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"03000000853200000706000012010000,Nacon GC100,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
"030000006b140000010c000010010000,Nacon GC400ES,a:b0,b:b1,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b2,y:b3,platform:Linux",
|
||||
"05000000853200000503000000010000,Nacon MGX Pro,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"0300000085320000170d000011010000,Nacon Revolution 5 Pro,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:a3,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:a4,rightx:a2,righty:a5,start:b9,touchpad:b13,x:b0,y:b3,platform:Linux",
|
||||
"0300000085320000190d000011010000,Nacon Revolution 5 Pro,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:a3,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:a4,rightx:a2,righty:a5,start:b9,touchpad:b13,x:b0,y:b3,platform:Linux",
|
||||
"0300000085320000180d000011010000,Nacon Revolution 5 Pro,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:a3,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:a4,rightx:a2,righty:a5,start:b9,x:b0,y:b3,platform:Linux",
|
||||
"0300000085320000160d000011010000,Nacon Revolution 5 Pro,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:a3,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:a4,rightx:a2,righty:a5,start:b9,touchpad:b13,x:b0,y:b3,platform:Linux",
|
||||
"0300000085320000170d000011010000,Nacon Revolution 5 Pro,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:a3,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:a4,rightx:a2,righty:a5,start:b9,touchpad:b13,x:b0,y:b3,platform:Linux",
|
||||
"0300000085320000180d000011010000,Nacon Revolution 5 Pro,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:a3,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:a4,rightx:a2,righty:a5,start:b9,x:b0,y:b3,platform:Linux",
|
||||
"0300000085320000190d000011010000,Nacon Revolution 5 Pro,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:a3,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:a4,rightx:a2,righty:a5,start:b9,touchpad:b13,x:b0,y:b3,platform:Linux",
|
||||
"030000000d0f00000900000010010000,Natec Genesis P44,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b0,y:b3,platform:Linux",
|
||||
"030000004f1f00000800000011010000,NeoGeo PS3,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,righttrigger:b7,start:b9,x:b0,y:b3,platform:Linux",
|
||||
"0300000092120000474e000000010000,NeoGeo X Arcade Stick,a:b0,b:b1,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,start:b9,x:b3,y:b2,platform:Linux",
|
||||
@@ -1618,6 +1636,7 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"03000000d62000000520000050010000,PowerA Xbox Series Controller,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
"03000000d62000000b20000001010000,PowerA Xbox Series Controller,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
"03000000d62000000f20000001010000,PowerA Xbox Series Controller,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b7,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
"03000000d62000006520000002010000,PowerA Xbox Series Controller,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
"030000006d040000d2ca000011010000,Precision Controller,a:b1,b:b2,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b0,y:b3,platform:Linux",
|
||||
"03000000250900000017000010010000,PS SS N64 Adapter,a:b1,b:b2,dpdown:b14,dpleft:b15,dpright:b13,dpup:b12,leftshoulder:b5,lefttrigger:b9,leftx:a0,lefty:a1,rightshoulder:b7,rightx:a2~,righty:a3,start:b8,platform:Linux",
|
||||
"03000000ff1100004133000010010000,PS2 Controller,a:b2,b:b1,back:b8,leftshoulder:b6,lefttrigger:b4,leftx:a0,lefty:a1,rightshoulder:b7,righttrigger:b5,start:b9,x:b3,y:b0,platform:Linux",
|
||||
@@ -1701,6 +1720,8 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"0300000032150000140a000001010000,Razer Wolverine Ultimate Xbox,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
"030000000d0f0000c100000010010000,Retro Bit Legacy16,a:b1,b:b2,back:b8,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,guide:b12,leftshoulder:b4,lefttrigger:b6,misc1:b13,rightshoulder:b5,righttrigger:b7,start:b9,x:b0,y:b3,platform:Linux",
|
||||
"03000000790000001100000010010000,Retro Controller,a:b1,b:b2,back:b8,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,leftshoulder:b6,lefttrigger:b7,rightshoulder:b4,righttrigger:b5,start:b9,x:b0,y:b3,platform:Linux",
|
||||
"03000000632500007805000011010000,Retro Fighters D6,a:b2,b:b1,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b3,y:b0,platform:Linux",
|
||||
"03000000790000001600000011010000,Retro Fighters D6,a:b1,b:b2,back:b8,leftshoulder:b4,lefttrigger:b6,leftx:a0,lefty:a1,misc1:b10,rightshoulder:b5,righttrigger:b7,start:b9,x:b0,y:b3,platform:Linux",
|
||||
"0300000003040000c197000011010000,Retrode Adapter,a:b0,b:b4,back:b2,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,leftshoulder:b6,rightshoulder:b7,start:b3,x:b1,y:b5,platform:Linux",
|
||||
"190000004b4800000111000000010000,RetroGame Joypad,a:b1,b:b0,back:b8,dpdown:b14,dpleft:b15,dpright:b16,dpup:b13,leftshoulder:b4,leftstick:b11,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b12,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b2,y:b3,platform:Linux",
|
||||
"0300000081170000990a000001010000,Retronic Adapter,a:b0,leftx:a0,lefty:a1,platform:Linux",
|
||||
@@ -1751,7 +1772,8 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"030000005e0400008e02000073050000,Speedlink Torid,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
"030000005e0400008e02000020200000,SpeedLink Xeox Pro Analog,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
"03000000de280000ff11000001000000,Steam Virtual Gamepad,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
"05000000381000001214000001000000,SteelSeries Free,a:b0,b:b1,x:b3,y:b4,back:b10,guide:b12,start:b11,leftstick:b13,rightstick:b14,leftshoulder:b6,rightshoulder:b7,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a2,righty:a3,lefttrigger:a2,righttrigger:a3,platform:Linux",
|
||||
"05000000381000001214000001000000,SteelSeries Free,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:a3,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"05000000381000004114000000010000,SteelSeries Nimbus Cloud,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b15,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,paddle1:b5,paddle2:b2,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"050000004e696d6275732b0000000000,SteelSeries Nimbus Plus,a:b0,b:b1,back:b10,guide:b11,leftshoulder:b4,leftstick:b8,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b9,righttrigger:b7,rightx:a2,righty:a3,start:b12,x:b2,y:b3,platform:Linux",
|
||||
"03000000381000003014000075010000,SteelSeries Stratus Duo,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
"03000000381000003114000075010000,SteelSeries Stratus Duo,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
@@ -1797,9 +1819,10 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"03000000e00d00000300000003000000,TRBot Virtual Joypad,a:b11,b:b12,back:b15,dpdown:b6,dpleft:b3,dpright:b4,dpup:b5,leftshoulder:b17,leftstick:b21,lefttrigger:a5,leftx:a0,lefty:a1,rightshoulder:b18,rightstick:b22,righttrigger:a2,rightx:a3,righty:a4,start:b16,x:b13,y:b14,platform:Linux",
|
||||
"03000000f00600000300000003000000,TRBot Virtual Joypad,a:b11,b:b12,back:b15,dpdown:b6,dpleft:b3,dpright:b4,dpup:b5,leftshoulder:b17,leftstick:b21,lefttrigger:a5,leftx:a0,lefty:a1,rightshoulder:b18,rightstick:b22,righttrigger:a2,rightx:a3,righty:a4,start:b16,x:b13,y:b14,platform:Linux",
|
||||
"030000005f140000c501000010010000,Trust Gamepad,a:b2,b:b1,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b3,y:b0,platform:Linux",
|
||||
"03000000f51000001370000000000000,Turtle Beach ReactR,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
"03000000f51000001370000000010000,Turtle Beach ReactR,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
"06000000f51000000870000003010000,Turtle Beach Recon,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
"03000000f51000001370000000010000,Turtle Beach ReactR,a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,platform:Linux",
|
||||
"03000000f51000001370000000000000,Turtle Beach ReactR,a:b0,b:b1,x:b2,y:b3,back:b6,guide:b8,start:b7,leftstick:b9,rightstick:b10,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a3,righty:a4,lefttrigger:a2,righttrigger:a5,platform:Linux",
|
||||
"05000000f51000008f01000001000000,Turtle Beach Rematch Nintendo Switch Controller,a:b0,b:b1,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b2,y:b3,platform:Linux",
|
||||
"03000000100800000100000010010000,Twin PlayStation Adapter,a:b2,b:b1,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b6,leftstick:b10,lefttrigger:b4,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b11,righttrigger:b5,rightx:a3,righty:a2,start:b9,x:b3,y:b0,platform:Linux",
|
||||
"03000000c0160000e105000010010000,Ultimate Atari Fight Stick,a:b1,b:b2,back:b9,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,rightshoulder:b4,righttrigger:b5,start:b8,x:b0,y:b3,platform:Linux",
|
||||
"03000000151900005678000010010000,Uniplay U6,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
@@ -1886,6 +1909,7 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"060000005e040000120b00000d050000,Xbox Series Controller,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,misc1:b11,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
"060000005e040000120b00000f050000,Xbox Series Controller,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,misc1:b11,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
"050000005e040000130b000022050000,Xbox Series X Controller,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,misc1:b15,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"050000005e040000130b000023050000,Xbox Series X Controller,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,misc1:b15,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,platform:Linux",
|
||||
"060000005e040000120b000011050000,Xbox Series X Controller,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,misc1:b11,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux",
|
||||
"03000000450c00002043000010010000,XEOX SL6556 BK,a:b0,b:b1,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b4,leftstick:b10,lefttrigger:b6,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b11,righttrigger:b7,rightx:a2,righty:a3,start:b9,x:b2,y:b3,platform:Linux",
|
||||
"05000000172700004431000029010000,XiaoMi Controller,a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b20,leftshoulder:b6,leftstick:b13,lefttrigger:a7,leftx:a0,lefty:a1,rightshoulder:b7,rightstick:b14,righttrigger:a6,rightx:a2,righty:a5,start:b11,x:b3,y:b4,platform:Linux",
|
||||
@@ -1970,6 +1994,7 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"66306331643531333230306437353936,8BitDo SN30 Pro Plus,a:b1,b:b0,back:b15,dpdown:b12,dpleft:b13,dpright:b14,dpup:b11,leftshoulder:b9,leftstick:b7,lefttrigger:b17,leftx:a0,lefty:a1,rightshoulder:b10,rightstick:b8,righttrigger:b18,rightx:a2,righty:a3,start:b6,x:b3,y:b2,platform:Android",
|
||||
"050000002028000009000000ffff3f00,8BitDo SNES30,a:b1,b:b0,back:b4,dpdown:b12,dpleft:b13,dpright:b14,dpup:b11,leftshoulder:b9,leftstick:b7,lefttrigger:a4,leftx:a0,lefty:a1,rightshoulder:b10,rightstick:b8,righttrigger:a5,rightx:a2,righty:a3,start:b6,x:b3,y:b2,platform:Android",
|
||||
"050000003512000020ab000000780f00,8BitDo SNES30,a:b21,b:b20,back:b30,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,leftshoulder:b26,rightshoulder:b27,start:b31,x:b24,y:b23,platform:Android",
|
||||
"05000000c82d00001b30000000000000,8BitDo Ultimate 2C Wireless,a:b10,b:b1,x:b3,y:b6,back:b16,guide:b19,start:b15,leftstick:b17,rightstick:b18,leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,leftx:a0,lefty:a1,rightx:a2,righty:a3,lefttrigger:b8,righttrigger:b9,platform:Android",
|
||||
"33666663316164653937326237613331,8BitDo Zero,a:b0,b:b1,back:b15,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,leftshoulder:b9,rightshoulder:b10,start:b6,x:b2,y:b3,platform:Android",
|
||||
"38426974646f205a65726f2047616d65,8BitDo Zero,a:b0,b:b1,back:b15,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,leftshoulder:b9,rightshoulder:b10,start:b6,x:b2,y:b3,platform:Android",
|
||||
"05000000c82d000018900000ffff0f00,8BitDo Zero 2,a:b1,b:b0,back:b4,dpdown:+a1,dpleft:-a0,dpright:+a0,dpup:-a1,leftshoulder:b9,rightshoulder:b10,start:b6,x:b3,y:b2,platform:Android",
|
||||
@@ -1991,6 +2016,7 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"30363230653635633863366338623265,Evo VR,a:b0,b:b1,dpdown:b12,dpleft:b13,dpright:b14,dpup:b11,leftx:a0,lefty:a1,x:b2,y:b3,platform:Android",
|
||||
"05000000b404000011240000dfff3f00,Flydigi Vader 2,a:b0,b:b1,back:b4,dpdown:b12,dpleft:b13,dpright:b14,dpup:b11,leftshoulder:b9,leftstick:b7,lefttrigger:a4,leftx:a0,lefty:a1,paddle1:b17,paddle2:b18,rightshoulder:b10,rightstick:b8,righttrigger:a5,rightx:a2,righty:a3,start:b6,x:b2,y:b3,platform:Android",
|
||||
"05000000bc20000000550000ffff3f00,GameSir G3w,a:b0,b:b1,back:b4,dpdown:b12,dpleft:b13,dpright:b14,dpup:b11,leftshoulder:b9,leftstick:b7,lefttrigger:a5,leftx:a0,lefty:a1,rightshoulder:b10,rightstick:b8,righttrigger:a4,rightx:a2,righty:a3,start:b6,x:b2,y:b3,platform:Android",
|
||||
"05000000373500001611000000000000,GameSir X5 Lite,a:b0,b:b1,x:b3,y:b6,leftshoulder:b4,rightshoulder:b5,lefttrigger:b8,righttrigger:b9,back:b16,start:b15,guide:b19,leftstick:b17,rightstick:b18,leftx:a0,lefty:a1,rightx:a2,righty:a3,dpup:h0.1,dpright:h0.2,dpdown:h0.4,dpleft:h0.8,platform:Android",
|
||||
"34323662653333636330306631326233,Google Nexus,a:b0,b:b1,back:b4,dpdown:b12,dpleft:b13,dpright:b14,dpup:b11,leftshoulder:b9,leftstick:b7,lefttrigger:a4,leftx:a0,lefty:a1,rightshoulder:b10,rightstick:b8,righttrigger:a5,rightx:a2,righty:a3,start:b6,x:b2,y:b3,platform:Android",
|
||||
"35383633353935396534393230616564,Google Stadia Controller,a:b0,b:b1,back:b15,dpdown:b12,dpleft:b13,dpright:b14,dpup:b11,leftshoulder:b9,leftstick:b7,lefttrigger:a4,leftx:a0,lefty:a1,rightshoulder:b10,rightstick:b8,righttrigger:a5,rightx:a2,righty:a3,start:b6,x:b2,y:b3,platform:Android",
|
||||
"476f6f676c65204c4c43205374616469,Google Stadia Controller,a:b0,b:b1,back:b15,dpdown:b12,dpleft:b13,dpright:b14,dpup:b11,guide:b5,leftshoulder:b9,leftstick:b7,lefttrigger:a4,leftx:a0,lefty:a1,rightshoulder:b10,rightstick:b8,righttrigger:a5,rightx:a2,righty:a3,start:b6,x:b2,y:b3,platform:Android",
|
||||
@@ -2013,6 +2039,7 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"5553422c322d6178697320382d627574,iBuffalo Super Famicom Controller,a:b1,b:b0,back:b9,dpdown:b12,dpleft:b13,dpright:b14,dpup:b11,leftshoulder:b17,rightshoulder:b18,start:b10,x:b3,y:b2,platform:Android",
|
||||
"64306137363261396266353433303531,InterAct GoPad,a:b24,b:b25,leftshoulder:b23,lefttrigger:b27,leftx:a0,lefty:a1,rightshoulder:b26,righttrigger:b28,x:b21,y:b22,platform:Android",
|
||||
"532e542e442e20496e74657261637420,InterAct HammerHead FX,a:b23,b:b24,back:b30,dpdown:b12,dpleft:b13,dpright:b14,dpup:b11,leftshoulder:b26,leftstick:b22,lefttrigger:b28,leftx:a0,lefty:a1,rightshoulder:b27,rightstick:b25,righttrigger:b29,rightx:a2,righty:a3,start:b31,x:b20,y:b21,platform:Android",
|
||||
"05000000300f00001201000000780f00,Jess Tech Dual Analog Pad,a:b22,b:b23,back:b28,dpdown:b12,dpleft:b13,dpright:b14,dpup:b11,leftshoulder:b24,leftstick:b30,lefttrigger:b25,leftx:a0,lefty:a1,rightshoulder:b26,rightstick:b31,righttrigger:b27,rightx:a3,righty:a2,start:b29,x:b20,y:b21,platform:Android",
|
||||
"65346535636333663931613264643164,Joy-Con,a:b21,b:b22,back:b29,dpdown:b12,dpleft:b13,dpright:b14,dpup:b11,leftshoulder:b25,lefttrigger:b27,leftx:a0,lefty:a1,rightshoulder:b26,righttrigger:b28,rightx:a2,righty:a3,start:b30,x:b23,y:b24,platform:Android",
|
||||
"33346566643039343630376565326335,Joy-Con (L),a:b0,b:b1,back:b7,dpdown:b12,dpleft:b13,dpright:b14,dpup:b11,leftshoulder:b3,leftstick:b15,rightshoulder:b20,start:b17,x:b19,y:b2,platform:Android",
|
||||
"35313531613435623366313835326238,Joy-Con (L),a:b0,b:b1,back:b7,dpdown:b12,dpleft:b13,dpright:b14,dpup:b11,leftshoulder:b3,leftstick:b15,rightshoulder:b20,start:b17,x:b19,y:b2,platform:Android",
|
||||
@@ -2235,4 +2262,4 @@ const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
"050000005e040000130b0000df870001,Xbox Series X Controller,a:b0,b:b1,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,leftshoulder:b4,leftstick:b6,lefttrigger:a2,leftx:a0,lefty:a1,misc1:b10,rightshoulder:b5,rightstick:b7,righttrigger:a5,rightx:a3,righty:a4,start:b9,x:b2,y:b3,platform:iOS",
|
||||
"050000005e040000130b0000ff870001,Xbox Series X Controller,a:b0,b:b1,back:b8,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b9,leftshoulder:b4,leftstick:b6,lefttrigger:a2,leftx:a0,lefty:a1,misc1:b11,rightshoulder:b5,rightstick:b7,righttrigger:a5,rightx:a3,righty:a4,start:b10,x:b2,y:b3,platform:iOS"
|
||||
};
|
||||
#endif // _SDL_GAMECONTROLLERDB_H_
|
||||
#endif // _AKGL_SDL_GAMECONTROLLERDB_H_
|
||||
|
||||
@@ -1,124 +1,455 @@
|
||||
/**
|
||||
* @file actor.h
|
||||
* @brief A live thing in the world: state bits, motion, hierarchy, and behaviour hooks.
|
||||
*
|
||||
* An actor is an instance; the akgl_Character it points at is its template. The
|
||||
* actor carries only what is unique to it -- where it is, which way it is
|
||||
* moving, which animation frame it is on -- and borrows speed, acceleration and
|
||||
* the state-to-sprite map from the character. That is what makes a hundred of
|
||||
* one kind of thing cheap.
|
||||
*
|
||||
* **State is a bitmask**, not an enum. An actor is facing left *and* moving left
|
||||
* *and* alive at the same time, and the whole combination is the key that
|
||||
* selects a sprite (see akgl_character_sprite_get). Adding a state means adding
|
||||
* a bit here *and* a name in `src/actor_state_string_names.c`, or character JSON
|
||||
* cannot refer to it.
|
||||
*
|
||||
* **Behaviour attaches as function pointers**, not by inheritance. Every actor
|
||||
* gets the library's default `updatefunc`, `renderfunc`, `facefunc`,
|
||||
* `movementlogicfunc`, `changeframefunc` and `addchild` from
|
||||
* akgl_actor_initialize; a game that wants a different flavour of anything
|
||||
* replaces the pointer on that one actor.
|
||||
*
|
||||
* **Motion is split four ways** -- thrust, environmental, acceleration, and the
|
||||
* velocity that is their sum -- so that a character's own walking and the
|
||||
* world's gravity and drag can be reasoned about separately. See physics.h for
|
||||
* the model and which fields mean what.
|
||||
*
|
||||
* Actors are pool objects (akgl_heap_next_actor) published in
|
||||
* #AKGL_REGISTRY_ACTOR under their name.
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_ACTOR_H_
|
||||
#define _AKGL_ACTOR_H_
|
||||
|
||||
#include <stdint.h>
|
||||
#include "types.h"
|
||||
#include "character.h"
|
||||
#include <akgl/types.h>
|
||||
#include <akgl/character.h>
|
||||
|
||||
// ---- LOW WORD STATUSES ----
|
||||
//
|
||||
// The trailing column is the bit pattern within its own 16-bit half -- the
|
||||
// low half here, the high half below -- not the full 32-bit value. Read as a
|
||||
// whole word it looks like bit 16 restarts at bit 0, which it does not.
|
||||
|
||||
#define AKGL_ACTOR_STATE_FACE_DOWN 1 << 0 // 1 0000 0000 0000 0001
|
||||
#define AKGL_ACTOR_STATE_FACE_LEFT 1 << 1 // 2 0000 0000 0000 0010
|
||||
#define AKGL_ACTOR_STATE_FACE_RIGHT 1 << 2 // 4 0000 0000 0000 0100
|
||||
#define AKGL_ACTOR_STATE_FACE_UP 1 << 3 // 8 0000 0000 0000 1000
|
||||
#define AKGL_ACTOR_STATE_ALIVE 1 << 4 // 16 0000 0000 0001 0000
|
||||
#define AKGL_ACTOR_STATE_DYING 1 << 5 // 32 0000 0000 0010 0000
|
||||
#define AKGL_ACTOR_STATE_DEAD 1 << 6 // 64 0000 0000 0100 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_LEFT 1 << 7 // 128 0000 0000 1000 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_RIGHT 1 << 8 // 256 0000 0001 0000 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_UP 1 << 9 // 512 0000 0010 0000 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_DOWN 1 << 10 // 1024 0000 0100 0000 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_IN 1 << 11 // 2048 0000 1000 0000 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_OUT 1 << 12 // 4096 0001 0000 0000 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_13 1 << 13 // 8192 0010 0000 0000 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_14 1 << 14 // 16384 0100 0000 0000 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_15 1 << 15 // 32768 1000 0000 0000 0000
|
||||
#define AKGL_ACTOR_STATE_FACE_DOWN (1 << 0) // 1 0000 0000 0000 0001
|
||||
#define AKGL_ACTOR_STATE_FACE_LEFT (1 << 1) // 2 0000 0000 0000 0010
|
||||
#define AKGL_ACTOR_STATE_FACE_RIGHT (1 << 2) // 4 0000 0000 0000 0100
|
||||
#define AKGL_ACTOR_STATE_FACE_UP (1 << 3) // 8 0000 0000 0000 1000
|
||||
#define AKGL_ACTOR_STATE_ALIVE (1 << 4) // 16 0000 0000 0001 0000
|
||||
#define AKGL_ACTOR_STATE_DYING (1 << 5) // 32 0000 0000 0010 0000
|
||||
#define AKGL_ACTOR_STATE_DEAD (1 << 6) // 64 0000 0000 0100 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_LEFT (1 << 7) // 128 0000 0000 1000 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_RIGHT (1 << 8) // 256 0000 0001 0000 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_UP (1 << 9) // 512 0000 0010 0000 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_DOWN (1 << 10) // 1024 0000 0100 0000 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_IN (1 << 11) // 2048 0000 1000 0000 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_OUT (1 << 12) // 4096 0001 0000 0000 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_13 (1 << 13) // 8192 0010 0000 0000 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_14 (1 << 14) // 16384 0100 0000 0000 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_15 (1 << 15) // 32768 1000 0000 0000 0000
|
||||
|
||||
// ----- HIGH WORD STATUSES -----
|
||||
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_16 1 << 16 // 65536 0000 0000 0000 0001
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_17 1 << 17 // 131072 0000 0000 0000 0010
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_18 1 << 18 // 262144 0000 0000 0000 0100
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_19 1 << 19 // 524288 0000 0000 0000 1000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_20 1 << 20 // 1048576 0000 0000 0001 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_21 1 << 21 // 2097152 0000 0000 0010 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_22 1 << 22 // 4194304 0000 0000 0100 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_23 1 << 23 // 8388608 0000 0000 1000 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_24 1 << 24 // 16777216 0000 0001 0000 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_25 1 << 25 // 33554432 0000 0010 0000 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_26 1 << 26 // 67108864 0000 0100 0000 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_27 1 << 27 // 134217728 0000 1000 0000 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_28 1 << 28 // 268435456 0001 0000 0000 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_29 1 << 29 // 536870912 0010 0000 0000 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_30 1 << 30 // 1073741824 0100 0000 0000 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_31 1 << 31 // 2147483648 1000 0000 0000 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_16 (1 << 16) // 65536 0000 0000 0000 0001
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_17 (1 << 17) // 131072 0000 0000 0000 0010
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_18 (1 << 18) // 262144 0000 0000 0000 0100
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_19 (1 << 19) // 524288 0000 0000 0000 1000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_20 (1 << 20) // 1048576 0000 0000 0001 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_21 (1 << 21) // 2097152 0000 0000 0010 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_22 (1 << 22) // 4194304 0000 0000 0100 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_23 (1 << 23) // 8388608 0000 0000 1000 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_24 (1 << 24) // 16777216 0000 0001 0000 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_25 (1 << 25) // 33554432 0000 0010 0000 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_26 (1 << 26) // 67108864 0000 0100 0000 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_27 (1 << 27) // 134217728 0000 1000 0000 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_28 (1 << 28) // 268435456 0001 0000 0000 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_29 (1 << 29) // 536870912 0010 0000 0000 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_30 (1 << 30) // 1073741824 0100 0000 0000 0000
|
||||
#define AKGL_ACTOR_STATE_UNDEFINED_31 (1 << 31) // 2147483648 1000 0000 0000 0000
|
||||
|
||||
/** @brief Bits in an actor's state word. Fixed by the width of `int32_t state`. */
|
||||
#define AKGL_ACTOR_MAX_STATES 32
|
||||
|
||||
// This is an array of strings equal to actor states from 1-32.
|
||||
// This is built by a utility script and not kept in git, see
|
||||
// the Makefile for lib_src/actor_state_string_names.c
|
||||
extern char *AKGL_ACTOR_STATE_STRING_NAMES[AKGL_ACTOR_MAX_STATES+1];
|
||||
/**
|
||||
* @brief Bit position -> state name, as text. Index `i` names the bit `1 << i`.
|
||||
*
|
||||
* akgl_registry_init_actor_state_strings builds
|
||||
* #AKGL_REGISTRY_ACTOR_STATE_STRINGS out of this, which is what lets character
|
||||
* JSON write `"AKGL_ACTOR_STATE_FACE_LEFT"` instead of `2`. A bit whose name
|
||||
* here does not match its `#define` above cannot be referred to from JSON at
|
||||
* all -- which was the case for `MOVING_IN` and `MOVING_OUT` until 0.5.0.
|
||||
*
|
||||
* Maintained by hand in `src/actor_state_string_names.c`, and exactly
|
||||
* #AKGL_ACTOR_MAX_STATES entries long: it was declared one longer than it was
|
||||
* defined until 0.5.0, so a consumer trusting the declared bound read past the
|
||||
* object. `tests/registry.c` checks every entry against its bit.
|
||||
*/
|
||||
extern char *AKGL_ACTOR_STATE_STRING_NAMES[AKGL_ACTOR_MAX_STATES];
|
||||
|
||||
/** @brief Every facing bit. Clear this before setting one, so an actor faces exactly one way. */
|
||||
#define AKGL_ACTOR_STATE_FACE_ALL (AKGL_ACTOR_STATE_FACE_DOWN | AKGL_ACTOR_STATE_FACE_LEFT | AKGL_ACTOR_STATE_FACE_RIGHT | AKGL_ACTOR_STATE_FACE_UP)
|
||||
/** @brief Every planar movement bit. The two depth bits, MOVING_IN and MOVING_OUT, are not in it. */
|
||||
#define AKGL_ACTOR_STATE_MOVING_ALL (AKGL_ACTOR_STATE_MOVING_LEFT | AKGL_ACTOR_STATE_MOVING_RIGHT | AKGL_ACTOR_STATE_MOVING_UP | AKGL_ACTOR_STATE_MOVING_DOWN)
|
||||
|
||||
/** @brief Longest actor name, including the terminator. Names are truncated, not rejected. */
|
||||
#define AKGL_ACTOR_MAX_NAME_LENGTH 128
|
||||
/** @brief Children one actor can carry. A child moves with its parent rather than simulating. */
|
||||
#define AKGL_ACTOR_MAX_CHILDREN 8
|
||||
|
||||
#define AKGL_MAX_HEAP_ACTOR 64
|
||||
|
||||
/** @brief Represents a live actor, including state, motion, hierarchy, and behavior callbacks. */
|
||||
typedef struct akgl_Actor {
|
||||
uint8_t refcount;
|
||||
char name[AKGL_ACTOR_MAX_NAME_LENGTH];
|
||||
akgl_Character *basechar;
|
||||
uint8_t curSpriteFrameId;
|
||||
SDL_Time curSpriteFrameTimer;
|
||||
bool curSpriteReversing;
|
||||
uint32_t layer;
|
||||
int32_t state;
|
||||
bool movement_controls_face;
|
||||
void *actorData;
|
||||
bool visible;
|
||||
SDL_Time movetimer;
|
||||
uint8_t refcount; /**< Pool bookkeeping; 0 means the slot is free. One reference per parent, plus one for the registry. */
|
||||
char name[AKGL_ACTOR_MAX_NAME_LENGTH]; /**< Registry key. Truncated, not rejected, if the source name is longer. */
|
||||
akgl_Character *basechar; /**< The template this actor instantiates. Borrowed; no reference is taken. Required before the actor can update, render, or simulate. */
|
||||
uint8_t curSpriteFrameId; /**< Index into the current sprite's `frameids`, not a frame number on the sheet. */
|
||||
SDL_Time curSpriteFrameTimer; /**< When the current frame was shown. The next frame is due once the sprite's `speed` has elapsed. */
|
||||
bool curSpriteReversing; /**< Walking the animation backwards, for a sprite with `loopReverse` set. */
|
||||
uint32_t layer; /**< Which tilemap layer the actor is drawn and simulated on. Set from the object layer it was placed in. */
|
||||
int32_t state; /**< The `AKGL_ACTOR_STATE_*` bitmask. The whole value is the key that selects a sprite. */
|
||||
bool movement_controls_face; /**< When set, the default `facefunc` turns the actor to face whichever way it is moving. Clear it for an actor that aims independently. */
|
||||
void *actorData; /**< The game's own per-actor data. Never read or freed by the library. */
|
||||
bool visible; /**< Whether to draw at all. An actor off-camera is skipped separately, so this is for deliberate hiding. */
|
||||
SDL_Time movetimer; /**< Unused. Nothing in the library reads or writes it. */
|
||||
// Velocity. Combined effect of all forces acting on the actor resulting
|
||||
// in energy along an axis.
|
||||
float32_t vx;
|
||||
float32_t vy;
|
||||
float32_t vz;
|
||||
// Gravity. Forces acting on the actor on a given axis due to the forces
|
||||
// of gravity and drag.
|
||||
float32_t gx;
|
||||
float32_t gy;
|
||||
float32_t gz;
|
||||
float32_t vx; /**< Velocity along x, world units per second. Recomputed each step as `ex + tx`; writing it directly is overwritten. */
|
||||
float32_t vy; /**< Velocity along y. On a child actor this is read as an offset from the parent instead. */
|
||||
float32_t vz; /**< Velocity along z. */
|
||||
// Environmental velocity. These are the forces acting on the actor by the
|
||||
// environment (such as gravity and atmospheric drag)
|
||||
float32_t ex; /**< Environmental velocity along x: what gravity and drag have done. Accumulates across steps. */
|
||||
float32_t ey; /**< Environmental velocity along y. This is what carries a falling actor. */
|
||||
float32_t ez; /**< Environmental velocity along z. */
|
||||
// Thrust. Energy originating only from the actor's own acceleration on a
|
||||
// given axis, before the effects of gravity and drag.
|
||||
float32_t tx;
|
||||
float32_t ty;
|
||||
float32_t tz;
|
||||
float32_t tx; /**< Thrust along x: the actor's own effort. Capped at `sx`, which is why gravity can outrun top speed and walking cannot. */
|
||||
float32_t ty; /**< Thrust along y, capped at `sy`. */
|
||||
float32_t tz; /**< Thrust along z, capped at `sz`. */
|
||||
// Acceleration. These are borrowed from the base character object.
|
||||
// A given axis resets to 0 when the actor stops moving in a given axis.
|
||||
float32_t ax; /**< Acceleration along x, signed by the direction of travel. Copied from the character each step by the default movement logic. */
|
||||
float32_t ay; /**< Acceleration along y, signed the same way. */
|
||||
float32_t az; /**< Acceleration along z. Not set by the default movement logic. */
|
||||
// Max speed. These are borrowed from the base character object.
|
||||
float32_t sx; /**< Maximum thrust along x, copied from the character. */
|
||||
float32_t sy; /**< Maximum thrust along y. */
|
||||
float32_t sz; /**< Maximum thrust along z. */
|
||||
// Position.
|
||||
float32_t x;
|
||||
float32_t y;
|
||||
float32_t z;
|
||||
float32_t scale;
|
||||
struct akgl_Actor *children[AKGL_ACTOR_MAX_CHILDREN];
|
||||
struct akgl_Actor *parent;
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*updatefunc)(struct akgl_Actor *obj);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*renderfunc)(struct akgl_Actor *obj);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*facefunc)(struct akgl_Actor *obj);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*movementlogicfunc)(struct akgl_Actor *obj, float32_t dt);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*changeframefunc)(struct akgl_Actor *obj, akgl_Sprite *curSprite, SDL_Time curtimems);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*addchild)(struct akgl_Actor *obj, struct akgl_Actor *child);
|
||||
float32_t x; /**< Position in map pixels. For a child, an offset from the parent. */
|
||||
float32_t y; /**< Position in map pixels. Also what the tilemap's perspective band scales against. */
|
||||
float32_t z; /**< Depth. Carried through the simulation but not used at draw time. */
|
||||
float32_t scale; /**< Draw scale, 1.0 for natural size. Written by akgl_tilemap_scale_actor, or forced to 1.0 when tilemap scaling is off. */
|
||||
struct akgl_Actor *children[AKGL_ACTOR_MAX_CHILDREN]; /**< Attached actors, `NULL` in unused slots. Each holds a reference and is released with the parent. */
|
||||
struct akgl_Actor *parent; /**< The actor this one is attached to, or `NULL`. A child is positioned relative to it and does not simulate on its own. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*updatefunc)(struct akgl_Actor *obj); /**< Per-frame logic: facing, then animation frame. Defaults to akgl_actor_update. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*renderfunc)(struct akgl_Actor *obj); /**< Per-frame draw. Defaults to akgl_actor_render. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*facefunc)(struct akgl_Actor *obj); /**< Chooses the facing bits. Defaults to akgl_actor_automatic_face. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*movementlogicfunc)(struct akgl_Actor *obj, float32_t dt); /**< Called by the physics step before gravity. Defaults to akgl_actor_logic_movement. May raise AKGL_ERR_LOGICINTERRUPT to skip the rest of the step. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*changeframefunc)(struct akgl_Actor *obj, akgl_Sprite *curSprite, SDL_Time curtimems); /**< Advances the animation. Defaults to akgl_actor_logic_changeframe. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*addchild)(struct akgl_Actor *obj, struct akgl_Actor *child); /**< Attaches a child. Defaults to akgl_actor_add_child. */
|
||||
} akgl_Actor;
|
||||
|
||||
/**
|
||||
* @brief Zero a pooled actor, name it, wire up its default behaviour, and register it.
|
||||
*
|
||||
* Sets `scale` to 1.0 and `movement_controls_face` to true, installs all six
|
||||
* default function pointers, publishes the actor in #AKGL_REGISTRY_ACTOR, and
|
||||
* takes the first reference. It does *not* set a character: the actor cannot
|
||||
* update, render, or simulate until akgl_actor_set_character has run.
|
||||
*
|
||||
* @param obj Pooled actor to initialize, normally from akgl_heap_next_actor.
|
||||
* Required. Any previous contents are discarded -- including its
|
||||
* child list, so release an actor rather than reinitializing it.
|
||||
* @param name Registry key, NUL-terminated. Required. Truncated at
|
||||
* #AKGL_ACTOR_MAX_NAME_LENGTH. An existing entry with the same name
|
||||
* is silently replaced, and the actor it displaced becomes
|
||||
* unreachable rather than being released.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or @p name is `NULL`.
|
||||
* @throws AKERR_KEY If the actor cannot be written into #AKGL_REGISTRY_ACTOR --
|
||||
* in practice, because akgl_registry_init has not run.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_actor_initialize(akgl_Actor *obj, char *name);
|
||||
/**
|
||||
* @brief Bind an actor to a registered character, and copy its top speeds.
|
||||
*
|
||||
* Looks the character up by name and takes its `sx`/`sy` as this actor's speed
|
||||
* limits, zeroing `ax` and `ay` so the actor starts from rest. The character is
|
||||
* borrowed, not referenced: releasing it out from under a live actor leaves a
|
||||
* dangling pointer.
|
||||
*
|
||||
* @param obj The actor to bind. Required.
|
||||
* @param basecharname Registry name of the character. Required. Rebinding an
|
||||
* actor mid-life is allowed, and is how a character swaps
|
||||
* its whole sprite set at once.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or @p basecharname is `NULL`, or if no
|
||||
* character is registered under that name. The last case is a lookup
|
||||
* miss reported with a pointer status rather than AKERR_KEY.
|
||||
*
|
||||
* @note `sz` is not copied and `az` is not zeroed, so an actor's depth speed
|
||||
* keeps whatever it had. The default movement logic re-copies all three
|
||||
* each step, which papers over it for anything that simulates.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_actor_set_character(akgl_Actor *obj, char *basecharname);
|
||||
/**
|
||||
* @brief Draw an actor's current animation frame, if it is on camera.
|
||||
*
|
||||
* Selects the sprite for the actor's current state, checks it against the
|
||||
* camera, and blits the frame at the actor's position translated into screen
|
||||
* space. A child actor is drawn at its parent's position plus its own, which is
|
||||
* what makes an offset mean an offset.
|
||||
*
|
||||
* Several things are skipped rather than reported, because a frame is not the
|
||||
* place to fail: an actor with no sprite for its state, one off camera, one with
|
||||
* `visible` clear, and one whose frame index has run past its current sprite
|
||||
* are all simply not drawn.
|
||||
*
|
||||
* @param obj The actor to draw. Required, along with its `basechar`.
|
||||
* @return `NULL` on success -- including every skipped case above -- otherwise
|
||||
* an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or `obj->basechar` is `NULL`.
|
||||
* @throws AKERR_* Whatever akgl_spritesheet_coords_for_frame or the renderer's
|
||||
* `draw_texture` raises.
|
||||
*
|
||||
* @note The destination height is computed from the sprite's *width*, so a
|
||||
* non-square sprite is drawn square.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_actor_render(akgl_Actor *obj);
|
||||
/**
|
||||
* @brief Per-frame logic: turn the actor to face its direction, then advance its animation.
|
||||
*
|
||||
* Calls the actor's `facefunc`, then selects the sprite for the resulting state
|
||||
* and calls `changeframefunc` if that sprite's frame time has elapsed. An actor
|
||||
* with no sprite registered for its current state is left alone and reported as
|
||||
* success -- states change faster than art gets drawn, and a missing sprite
|
||||
* should not stop the frame.
|
||||
*
|
||||
* @param obj The actor to update. Required, along with its `basechar`.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or `obj->basechar` is `NULL`.
|
||||
* @throws AKERR_* Whatever `facefunc` or `changeframefunc` raises. Note that an
|
||||
* AKERR_KEY from either is swallowed along with the missing-sprite case.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_actor_update(akgl_Actor *obj);
|
||||
/**
|
||||
* @brief The default `movementlogicfunc`: turn movement bits into signed acceleration.
|
||||
*
|
||||
* Re-copies the character's speed limits onto the actor -- so a change to the
|
||||
* character takes effect next step -- and sets `ax`/`ay` to plus or minus the
|
||||
* character's acceleration according to which movement bits are set. It does not
|
||||
* integrate anything; the physics backend does that.
|
||||
*
|
||||
* Opposing bits do not cancel: `MOVING_LEFT` wins over `MOVING_RIGHT` because it
|
||||
* is tested first. An actor with no movement bits keeps its previous
|
||||
* acceleration rather than being zeroed, which is why the input handlers clear
|
||||
* it themselves on key release.
|
||||
*
|
||||
* @param obj The actor to compute acceleration for. Required, along with its
|
||||
* `basechar`.
|
||||
* @param dt Seconds since the previous step. Accepted for the hook's signature;
|
||||
* this implementation does not use it, since it sets an acceleration
|
||||
* rather than integrating one.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or `obj->basechar` is `NULL`.
|
||||
*
|
||||
* @note A replacement for this hook may raise AKGL_ERR_LOGICINTERRUPT to tell
|
||||
* the physics step to skip the rest of this actor's tick. This
|
||||
* implementation never does.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_actor_logic_movement(akgl_Actor *obj, float32_t dt);
|
||||
/**
|
||||
* @brief The default `changeframefunc`: step to the next animation frame.
|
||||
*
|
||||
* Walks forward through the sprite's frames; at the last one it either wraps to
|
||||
* 0, or -- for a sprite with both `loop` and `loopReverse` -- turns round and
|
||||
* walks back down, turning again at frame 0. A sprite with `loop` clear also
|
||||
* wraps to 0 rather than holding the final frame.
|
||||
*
|
||||
* @param obj The actor whose frame index to advance. Required.
|
||||
* @param curSprite The sprite currently selected for the actor, whose frame
|
||||
* count and loop flags decide what happens at the end.
|
||||
* Required in practice, and **not** checked -- a `NULL` here is
|
||||
* a crash, not an error.
|
||||
* @param curtimems The current clock reading. Accepted for the hook's signature;
|
||||
* this implementation does not use it, because the caller has
|
||||
* already decided the frame is due.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_actor_logic_changeframe(akgl_Actor *obj, akgl_Sprite *curSprite, SDL_Time curtimems);
|
||||
/**
|
||||
* @brief The default `facefunc`: turn the actor to face whichever way it is moving.
|
||||
*
|
||||
* Clears every facing bit and sets the one matching the first movement bit it
|
||||
* finds, in the order left, right, up, down -- so an actor moving diagonally
|
||||
* faces the horizontal component. An actor with `movement_controls_face` clear
|
||||
* is left alone entirely, which is the hook for something that aims
|
||||
* independently of the way it walks.
|
||||
*
|
||||
* @param obj The actor to turn. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj is `NULL`.
|
||||
*
|
||||
* @note An actor that has stopped moving is left with no facing bit at all,
|
||||
* rather than keeping the way it was last facing -- so its state no longer
|
||||
* matches any "standing still facing left" sprite. The implementation
|
||||
* carries a TODO saying as much.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_actor_automatic_face(akgl_Actor *obj);
|
||||
/**
|
||||
* @brief Attach one actor to another so it moves with it.
|
||||
*
|
||||
* A child is positioned relative to its parent rather than simulated: the
|
||||
* physics step snaps it to the parent's position plus its own velocity fields,
|
||||
* used here as a fixed offset. That is what a carried lantern or a turret on a
|
||||
* tank wants.
|
||||
*
|
||||
* The parent takes a reference on the child, and releasing the parent releases
|
||||
* every child with it.
|
||||
*
|
||||
* @param obj The parent. Required.
|
||||
* @param child The actor to attach. Required. Must not already have a parent --
|
||||
* this builds a tree, not a graph. Its `x`/`y` become an offset
|
||||
* from the parent's position rather than a world position.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or @p child is `NULL`.
|
||||
* @throws AKERR_RELATIONSHIP If @p child already has a parent. Detach it first --
|
||||
* though note there is no detach function, so in practice this means
|
||||
* releasing and rebuilding it.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p obj already has #AKGL_ACTOR_MAX_CHILDREN
|
||||
* children.
|
||||
*
|
||||
* @warning Nothing checks for a cycle. Making an actor its own ancestor makes
|
||||
* akgl_heap_release_actor recurse until the stack runs out.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_actor_add_child(akgl_Actor *obj, akgl_Actor *child);
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_Actor_cmhf_left_on(akgl_Actor *obj, SDL_Event *event);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_Actor_cmhf_left_off(akgl_Actor *obj, SDL_Event *event);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_Actor_cmhf_right_on(akgl_Actor *obj, SDL_Event *event);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_Actor_cmhf_right_off(akgl_Actor *obj, SDL_Event *event);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_Actor_cmhf_up_on(akgl_Actor *obj, SDL_Event *event);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_Actor_cmhf_up_off(akgl_Actor *obj, SDL_Event *event);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_Actor_cmhf_down_on(akgl_Actor *obj, SDL_Event *event);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_Actor_cmhf_down_off(akgl_Actor *obj, SDL_Event *event);
|
||||
/*
|
||||
* The control-map handler functions ("cmhf"). These are what
|
||||
* akgl_controller_default binds the arrow keys and the D-pad to, and what a
|
||||
* game's own akgl_Control bindings can point at. They are called with the
|
||||
* control map's target actor, not with a global "player", so they work for any
|
||||
* number of locally controlled actors.
|
||||
*
|
||||
* Each pair is symmetric: the `_on` handler starts movement in a direction and
|
||||
* turns the actor to face it, and the `_off` handler stops it dead -- zeroing
|
||||
* acceleration, thrust, environmental velocity and velocity on that axis, so
|
||||
* there is no coasting. That is an arcade feel rather than a physical one, and
|
||||
* it is deliberate; a game wanting momentum binds its own handlers instead.
|
||||
*
|
||||
* An `_on` handler clears *every* facing and movement bit before setting its
|
||||
* own, so holding two directions at once moves in whichever was pressed last
|
||||
* rather than diagonally.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @brief Start the actor moving left, and turn it to face left.
|
||||
* @param obj The actor to move, supplied by the control map. Required, along
|
||||
* with its `basechar`, whose acceleration is negated onto `ax`.
|
||||
* @param event The event that triggered this. Required, but not read -- the
|
||||
* binding has already decided what the event means.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj, @p event, or `obj->basechar` is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_actor_cmhf_left_on(akgl_Actor *obj, SDL_Event *event);
|
||||
/**
|
||||
* @brief Stop the actor moving left, zeroing everything on the x axis.
|
||||
* @param obj The actor to stop. Required. `basechar` is not needed here.
|
||||
* @param event The event that triggered this. Required, but not read.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or @p event is `NULL`.
|
||||
* @note It clears the x axis whichever way the actor was going, so releasing
|
||||
* left while holding right also stops the rightward movement.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_actor_cmhf_left_off(akgl_Actor *obj, SDL_Event *event);
|
||||
/**
|
||||
* @brief Start the actor moving right, and turn it to face right.
|
||||
* @param obj The actor to move. Required, along with its `basechar`.
|
||||
* @param event The event that triggered this. Required, but not read.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj, @p event, or `obj->basechar` is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_actor_cmhf_right_on(akgl_Actor *obj, SDL_Event *event);
|
||||
/**
|
||||
* @brief Stop the actor moving right, zeroing everything on the x axis.
|
||||
* @param obj The actor to stop. Required.
|
||||
* @param event The event that triggered this. Required, but not read.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or @p event is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_actor_cmhf_right_off(akgl_Actor *obj, SDL_Event *event);
|
||||
/**
|
||||
* @brief Start the actor moving up the screen, and turn it to face up.
|
||||
* @param obj The actor to move. Required, along with its `basechar`, whose y
|
||||
* acceleration is negated -- y grows downward.
|
||||
* @param event The event that triggered this. Required, but not read.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj, @p event, or `obj->basechar` is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_actor_cmhf_up_on(akgl_Actor *obj, SDL_Event *event);
|
||||
/**
|
||||
* @brief Stop the actor moving up, zeroing everything on the y axis.
|
||||
* @param obj The actor to stop. Required.
|
||||
* @param event The event that triggered this. Required, but not read.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or @p event is `NULL`.
|
||||
* @note This also zeroes `ey`, so an actor under gravity has its accumulated
|
||||
* fall cancelled by releasing a movement key.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_actor_cmhf_up_off(akgl_Actor *obj, SDL_Event *event);
|
||||
/**
|
||||
* @brief Start the actor moving down the screen, and turn it to face down.
|
||||
* @param obj The actor to move. Required, along with its `basechar`.
|
||||
* @param event The event that triggered this. Required, but not read.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj, @p event, or `obj->basechar` is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_actor_cmhf_down_on(akgl_Actor *obj, SDL_Event *event);
|
||||
/**
|
||||
* @brief Stop the actor moving down, zeroing everything on the y axis.
|
||||
* @param obj The actor to stop. Required.
|
||||
* @param event The event that triggered this. Required, but not read.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or @p event is `NULL`.
|
||||
* @note Zeroes `ey` as well; see akgl_actor_cmhf_up_off.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_actor_cmhf_down_off(akgl_Actor *obj, SDL_Event *event);
|
||||
|
||||
/**
|
||||
* @brief `SDL_EnumerateProperties` callback that applies an akgl_Iterator to one registered actor.
|
||||
*
|
||||
* Runs the operations the iterator asks for, in a fixed order regardless of the
|
||||
* order the bits were set: layer filter, update, tilemap scaling, render. With
|
||||
* #AKGL_ITERATOR_OP_LAYERMASK set, an actor on any other layer is skipped
|
||||
* entirely; with #AKGL_ITERATOR_OP_TILEMAPSCALE clear, `scale` is forced to 1.0
|
||||
* rather than left alone.
|
||||
*
|
||||
* @param userdata The akgl_Iterator carrying the operation flags, passed through
|
||||
* by `SDL_EnumerateProperties`. Required despite the `void *`.
|
||||
* @param registry #AKGL_REGISTRY_ACTOR, supplied by SDL.
|
||||
* @param name The actor's registry key. Required.
|
||||
*
|
||||
* @warning This is an SDL callback, so it returns `void` and has nowhere to
|
||||
* propagate an error to. It ends in `FINISH_NORETURN`, which logs the
|
||||
* stack trace and then calls libakerror's unhandled-error handler --
|
||||
* whose default implementation **exits the process** with the error
|
||||
* status. An actor whose `updatefunc` fails therefore terminates the
|
||||
* game rather than skipping a frame. Install your own
|
||||
* `akerr_handler_unhandled_error` if that is not what you want.
|
||||
*/
|
||||
void akgl_registry_iterate_actor(void *userdata, SDL_PropertiesID registry, const char *name);
|
||||
|
||||
#endif // _AKGL_ACTOR_H_
|
||||
|
||||
@@ -1,8 +1,44 @@
|
||||
#ifndef _ASSETS_H_
|
||||
#define _ASSETS_H_
|
||||
/**
|
||||
* @file assets.h
|
||||
* @brief Loads the game's startup assets into the global mixer and track table.
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_ASSETS_H_
|
||||
#define _AKGL_ASSETS_H_
|
||||
|
||||
#include <akerror.h>
|
||||
|
||||
/**
|
||||
* @brief Load a music file, bind it to the background-music track, and start it playing.
|
||||
*
|
||||
* Loads @p fname through `akgl_mixer`, creates a track for it, stores that track
|
||||
* in `akgl_tracks[AKGL_GAME_AUDIO_TRACK_BGM]`, and starts playback. The loaded
|
||||
* audio is also published as the global `bgm`. This is a *startup* helper: it
|
||||
* assumes the mixer already exists, so `akgl_game_init` (or a bare
|
||||
* `akgl_audio_init`) has to have run first, and it overwrites whatever the BGM
|
||||
* track slot held without releasing it.
|
||||
*
|
||||
* Loading is deferred inside SDL_mixer (`MIX_LoadAudio(..., true)` predecodes),
|
||||
* so a large file costs its decode time here rather than at first play.
|
||||
*
|
||||
* On any failure after the audio loads, the audio is destroyed again before the
|
||||
* error is returned; the track, if one was created, is not.
|
||||
*
|
||||
* @param fname Path to a music file in any format SDL_mixer can open. Required.
|
||||
* Used verbatim -- it is not resolved against `SDL_GetBasePath()`,
|
||||
* so a relative path is relative to the process working directory.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p fname is `NULL`, if SDL_mixer cannot load the
|
||||
* file (missing, unreadable, or an unsupported format), or if it cannot
|
||||
* allocate a track for it. The message carries `SDL_GetError()`.
|
||||
* @throws AKGL_ERR_SDL If the loaded audio cannot be bound to the new track, or
|
||||
* if playback fails to start.
|
||||
*
|
||||
* @note The infinite-loop request does not currently take effect:
|
||||
* `MIX_PROP_PLAY_LOOPS_NUMBER` is set on property set 0, which is the
|
||||
* "no properties" sentinel rather than a set this function owns, so the
|
||||
* call is rejected and the music plays once.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_load_start_bgm(char *fname);
|
||||
|
||||
#endif //_ASSETS_H_
|
||||
#endif //_AKGL_ASSETS_H_
|
||||
|
||||
321
include/akgl/audio.h
Normal file
321
include/akgl/audio.h
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* @file audio.h
|
||||
* @brief Declares the public audio API.
|
||||
*
|
||||
* A small tone generator: a fixed set of voices, each with a waveform, a
|
||||
* frequency -- held, or walking toward another one -- a gate length and an ADSR
|
||||
* envelope, mixed to one stream of float samples. This is what a synthesised-
|
||||
* voice vocabulary needs -- SOUND, PLAY, ENVELOPE and VOL all describe a note
|
||||
* rather than a recording -- and it is deliberately separate from the SDL3_mixer
|
||||
* side of the library, which loads and plays audio *assets*. Nothing here reads
|
||||
* a file.
|
||||
*
|
||||
* The voice table exists whether or not an audio device is open.
|
||||
* akgl_audio_init() connects it to one; without that a caller can still set up
|
||||
* voices and pull samples itself with akgl_audio_mix(), which is how the test
|
||||
* suite exercises the synthesis without depending on a sound card's timing.
|
||||
* What a caller cannot do is set up voices and expect to hear them with no
|
||||
* device open.
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_AUDIO_H_
|
||||
#define _AKGL_AUDIO_H_
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/types.h>
|
||||
|
||||
/**
|
||||
* @brief Voices that can sound at once.
|
||||
*
|
||||
* Three, because the machine this vocabulary comes from had three and its
|
||||
* music is written for three. A voice is addressed here by a zero-based index;
|
||||
* a language whose own voices are numbered from one maps them itself.
|
||||
*/
|
||||
#define AKGL_AUDIO_MAX_VOICES 3
|
||||
|
||||
/** @brief Sample rate of the generated stream, in frames per second. */
|
||||
#define AKGL_AUDIO_SAMPLE_RATE 44100
|
||||
|
||||
/** @brief Frames the device callback generates per pass through the mixer. */
|
||||
#define AKGL_AUDIO_MIX_FRAMES 512
|
||||
|
||||
/**
|
||||
* @brief How often a frequency sweep takes one step, in steps per second.
|
||||
*
|
||||
* Sixty, because the machine whose SOUND statement this serves advanced its
|
||||
* sweep on a 60 Hz interrupt and its tunes are written for that rate. It
|
||||
* divides #AKGL_AUDIO_SAMPLE_RATE exactly, so a step boundary always lands on a
|
||||
* whole frame.
|
||||
*/
|
||||
#define AKGL_AUDIO_SWEEP_TICK_HZ 60
|
||||
|
||||
/** @brief Frames between two steps of a sweep. #AKGL_AUDIO_SAMPLE_RATE / #AKGL_AUDIO_SWEEP_TICK_HZ. */
|
||||
#define AKGL_AUDIO_SWEEP_TICK_FRAMES (AKGL_AUDIO_SAMPLE_RATE / AKGL_AUDIO_SWEEP_TICK_HZ)
|
||||
|
||||
/**
|
||||
* @brief Shape of one voice's oscillator.
|
||||
*
|
||||
* The trailing comment on each is the waveform number the C128 SOUND statement
|
||||
* uses for the same shape, for a caller translating one to the other.
|
||||
*/
|
||||
typedef enum {
|
||||
AKGL_AUDIO_WAVE_TRIANGLE = 0, /**< SOUND waveform 0. Soft, flute-like. */
|
||||
AKGL_AUDIO_WAVE_SAWTOOTH = 1, /**< SOUND waveform 1. Bright, brassy. */
|
||||
AKGL_AUDIO_WAVE_SQUARE = 2, /**< SOUND waveform 2. Hollow, reedy. The default. */
|
||||
AKGL_AUDIO_WAVE_NOISE = 3, /**< SOUND waveform 3. Unpitched; percussion. */
|
||||
AKGL_AUDIO_WAVE_SINE = 4 /**< No SOUND equivalent. A pure tone. */
|
||||
} akgl_AudioWaveform;
|
||||
|
||||
/** @brief Holds one voice's oscillator, envelope, and how far through it is. */
|
||||
typedef struct {
|
||||
bool active; /**< Whether this voice is sounding. Cleared by the mixer once gate and release are both spent, so it goes quiet without being told to. */
|
||||
akgl_AudioWaveform waveform; /**< Oscillator shape. Persists across notes; set once with akgl_audio_waveform(). */
|
||||
float32_t hz; /**< Frequency of the current note. 0.0 when the voice has never sounded. */
|
||||
/**
|
||||
* @brief Position through one cycle, 0.0 to 1.0.
|
||||
*
|
||||
* Derived from `elapsed_frames` and `hz` each sample rather than
|
||||
* accumulated, so a long note does not drift off pitch. Reading it is
|
||||
* meaningful; writing it is not.
|
||||
*/
|
||||
float32_t phase;
|
||||
/** @brief Frames the gate stays open, before the release begins. */
|
||||
uint32_t duration_frames;
|
||||
/** @brief Frames generated since the tone started, gate and release. */
|
||||
uint32_t elapsed_frames;
|
||||
/** @brief Frames to rise from silence to full level. 0 starts at full level. */
|
||||
uint32_t attack_frames;
|
||||
/** @brief Frames to fall from full level to `sustain`. 0 drops to it at once. */
|
||||
uint32_t decay_frames;
|
||||
/** @brief Frames to fall to silence after the gate closes. 0 cuts off at once. */
|
||||
uint32_t release_frames;
|
||||
/** @brief Level the envelope decays to and holds, 0.0 to 1.0. */
|
||||
float32_t sustain;
|
||||
/** @brief Pitch the current sweep started from. Equal to `sweep_to_hz` when the voice is not sweeping. */
|
||||
float32_t sweep_from_hz;
|
||||
/** @brief Pitch the sweep is heading for. The pitch holds there once it arrives. */
|
||||
float32_t sweep_to_hz;
|
||||
/** @brief Hertz added or subtracted per sweep step. 0.0 means this voice holds one pitch. */
|
||||
float32_t sweep_step_hz;
|
||||
} akgl_AudioVoice;
|
||||
|
||||
/** @brief The process-wide voice table. */
|
||||
extern akgl_AudioVoice akgl_audio_voices[AKGL_AUDIO_MAX_VOICES];
|
||||
|
||||
/**
|
||||
* @brief Open an audio device and start pulling samples from the voice table.
|
||||
*
|
||||
* Requires SDL's audio subsystem to be initialized. Repeating the call is a
|
||||
* no-op, so a program that cannot order its initialization precisely may call
|
||||
* it more than once. The voice table is reset only on the first call, so this
|
||||
* does not silence a voice that is already sounding.
|
||||
*
|
||||
* The device is resumed immediately rather than left paused, which is SDL's
|
||||
* default -- a caller who set up a voice and heard nothing would have no error
|
||||
* to explain it.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKGL_ERR_SDL If no playback device can be opened -- none present, none
|
||||
* permitted, or SDL's audio subsystem never initialized -- or if the
|
||||
* device cannot be resumed. The message carries `SDL_GetError()`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_init(void);
|
||||
|
||||
/**
|
||||
* @brief Close the audio device and silence every voice.
|
||||
*
|
||||
* Safe to call when no device is open, and safe to call twice. It also puts the
|
||||
* voice table back to its defaults, so a subsequent akgl_audio_init() starts
|
||||
* from a known state rather than from whatever was left sounding.
|
||||
*
|
||||
* @return `NULL`. There is no failure path -- SDL's stream teardown reports
|
||||
* nothing.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_shutdown(void);
|
||||
|
||||
/**
|
||||
* @brief Sound a note on one voice for a fixed time.
|
||||
*
|
||||
* The voice's envelope and waveform are whatever akgl_audio_envelope() and
|
||||
* akgl_audio_waveform() last set them to. @p ms is the length of the gate: the
|
||||
* voice's release runs *after* it, so a voice with a release stays audible
|
||||
* slightly longer than @p ms. Sounding a voice that is already sounding
|
||||
* restarts it from the beginning of its envelope, and clears any sweep
|
||||
* akgl_audio_sweep() left on it -- this is one pitch, held.
|
||||
*
|
||||
* @param voice Zero-based voice index, 0 to #AKGL_AUDIO_MAX_VOICES - 1.
|
||||
* @param hz Frequency in hertz. Must be greater than 0. There is no upper
|
||||
* bound check, so a frequency above half the sample rate aliases
|
||||
* rather than being refused.
|
||||
* @param ms Gate length in milliseconds. Must be non-zero -- a zero-length
|
||||
* tone is refused rather than treated as "stop", because
|
||||
* akgl_audio_stop() already means that. Rounded down to a whole
|
||||
* number of frames, so a duration under ~0.023 ms rounds to
|
||||
* nothing.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p voice is outside the table, if @p hz is not
|
||||
* positive, or if @p ms is 0. Each message says which.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_tone(int voice, float32_t hz, uint32_t ms);
|
||||
|
||||
/**
|
||||
* @brief Sound a note whose pitch walks from one frequency toward another.
|
||||
*
|
||||
* A siren, a laser, a falling bomb: akgl_audio_tone() with the pitch moving.
|
||||
* The note starts at @p from_hz and steps by @p step_hz every
|
||||
* 1/#AKGL_AUDIO_SWEEP_TICK_HZ of a second toward @p to_hz, stopping when it
|
||||
* arrives and holding there for whatever is left of @p ms. Direction comes from
|
||||
* the two frequencies, not from the sign of @p step_hz -- a sweep to a lower
|
||||
* pitch is @p to_hz below @p from_hz, and @p step_hz stays positive.
|
||||
*
|
||||
* The step is taken on the mixer's own frame counter, which is the whole reason
|
||||
* this is here rather than in a caller's step loop: a caller re-issuing tones
|
||||
* ties audible pitch to how often it happens to run, so the same siren changes
|
||||
* shape with the frame rate. Nothing outside this library can see that counter.
|
||||
*
|
||||
* Everything else matches akgl_audio_tone(): the envelope, the waveform, the
|
||||
* gate, and the release that follows it. Equal frequencies are legal and are
|
||||
* simply a held tone, so a caller translating a statement that computes its own
|
||||
* limits does not have to special-case them.
|
||||
*
|
||||
* @param voice Zero-based voice index, 0 to #AKGL_AUDIO_MAX_VOICES - 1.
|
||||
* @param from_hz Pitch the note starts at. Must be greater than 0.
|
||||
* @param to_hz Pitch the sweep walks toward and then holds. Must be greater
|
||||
* than 0. Below @p from_hz sweeps down, above it sweeps up,
|
||||
* equal to it holds.
|
||||
* @param step_hz Hertz per step. Must be greater than 0; the last step is
|
||||
* clamped to @p to_hz rather than overshooting it. A step larger
|
||||
* than the whole interval arrives in one tick.
|
||||
* @param ms Gate length in milliseconds. Must be non-zero. A gate shorter
|
||||
* than the sweep needs is not an error -- the note ends partway
|
||||
* through, which is what a short siren sounds like.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p voice is outside the table, if @p from_hz,
|
||||
* @p to_hz or @p step_hz is not positive, or if @p ms is 0. Each
|
||||
* message says which.
|
||||
*
|
||||
* @note A swept voice accumulates its phase one frame at a time instead of
|
||||
* deriving it from the frame counter the way a held note does. Deriving
|
||||
* it assumes a constant frequency, and using it here would jump the
|
||||
* waveform -- an audible click -- at every step. The cost is the small
|
||||
* drift the derived form exists to avoid, which a note that is changing
|
||||
* pitch anyway cannot be said to suffer from.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_sweep(int voice, float32_t from_hz, float32_t to_hz, float32_t step_hz, uint32_t ms);
|
||||
|
||||
/**
|
||||
* @brief Silence one voice immediately, skipping its release.
|
||||
*
|
||||
* A hard cut, not a note-off: the release stage is not run, so the sound stops
|
||||
* on the next sample. Stopping a voice that is already silent is a no-op.
|
||||
*
|
||||
* @param voice Zero-based voice index, 0 to #AKGL_AUDIO_MAX_VOICES - 1.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p voice is outside the table.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_stop(int voice);
|
||||
|
||||
/**
|
||||
* @brief Choose the oscillator shape one voice will use.
|
||||
*
|
||||
* Takes effect on the next akgl_audio_tone(); it does not reshape a note that
|
||||
* is already sounding.
|
||||
*
|
||||
* @param voice Zero-based voice index, 0 to #AKGL_AUDIO_MAX_VOICES - 1.
|
||||
* @param waveform One of the ::akgl_AudioWaveform shapes. The setting persists
|
||||
* across notes until changed.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p voice is outside the table, or @p waveform is
|
||||
* not one of the five defined shapes.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_waveform(int voice, akgl_AudioWaveform waveform);
|
||||
|
||||
/**
|
||||
* @brief Set one voice's ADSR envelope.
|
||||
*
|
||||
* @p attack, @p decay and @p release are milliseconds; @p sustain is the level
|
||||
* the envelope decays to and holds while the gate is open, from 0.0 to 1.0. A
|
||||
* zero-length stage is skipped rather than divided by.
|
||||
*
|
||||
* Takes effect on the next akgl_audio_tone().
|
||||
*
|
||||
* @param voice Zero-based voice index, 0 to #AKGL_AUDIO_MAX_VOICES - 1.
|
||||
* @param attack Milliseconds to rise from silence to full level. 0 starts at
|
||||
* full level.
|
||||
* @param decay Milliseconds to fall from full level to @p sustain. 0 drops to
|
||||
* it at once.
|
||||
* @param sustain Held level while the gate is open, 0.0 to 1.0 inclusive. 0.0 is
|
||||
* legal and means the note is audible only through its attack and
|
||||
* decay -- a plucked sound.
|
||||
* @param release Milliseconds to fall to silence once the gate closes. 0 cuts
|
||||
* off at once. An attack plus decay longer than the note's gate
|
||||
* is not an error: the release simply starts partway up the ramp.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p voice is outside the table, or @p sustain is
|
||||
* outside 0.0 to 1.0. The three durations are unbounded.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_envelope(int voice, uint32_t attack, uint32_t decay, float32_t sustain, uint32_t release);
|
||||
|
||||
/**
|
||||
* @brief Set the level every voice is scaled by.
|
||||
*
|
||||
* Applied after the voices are summed and before the mix is clamped, so it
|
||||
* takes effect on notes already sounding. akgl_audio_shutdown() puts it back
|
||||
* to 1.0.
|
||||
*
|
||||
* @param level Master level, 0.0 to 1.0 inclusive. 0.0 is silence.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p level is outside 0.0 to 1.0. The message
|
||||
* reports it.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_volume(float32_t level);
|
||||
|
||||
/**
|
||||
* @brief Report whether a voice is still sounding.
|
||||
*
|
||||
* A voice goes quiet on its own when its gate and release have both elapsed, so
|
||||
* this is how a caller waits out a note without keeping its own clock.
|
||||
*
|
||||
* @param voice Zero-based voice index, 0 to #AKGL_AUDIO_MAX_VOICES - 1.
|
||||
* @param active Receives `true` while the voice is sounding, including during
|
||||
* its release. Required -- the return value is the error context.
|
||||
* The flag is only cleared by the mixer, so a voice whose time is
|
||||
* up still reads active until samples are next generated; with no
|
||||
* device open and nobody calling akgl_audio_mix(), it never
|
||||
* changes.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p voice is outside the table.
|
||||
* @throws AKERR_NULLPOINTER If @p active is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_voice_active(int voice, bool *active);
|
||||
|
||||
/**
|
||||
* @brief Generate the next @p frames mono samples from the voice table.
|
||||
*
|
||||
* The device callback installed by akgl_audio_init() is a loop around this. A
|
||||
* host that owns its own audio pipeline can call it directly instead and never
|
||||
* open a device here at all. Samples are single-precision, one channel, in the
|
||||
* range -1.0 to 1.0, and every active voice is advanced by @p frames.
|
||||
*
|
||||
* @param dest Receives @p frames samples. Required, and must have room for all
|
||||
* of them -- the count is trusted, not checked against anything.
|
||||
* Overwritten, not accumulated into.
|
||||
* @param frames How many samples to generate. 0 is a no-op, not an error.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p dest is `NULL`.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p frames is negative.
|
||||
*
|
||||
* @warning This mutates the voice table -- it advances each voice's frame
|
||||
* counter and clears `active` on voices that have finished. Calling it
|
||||
* while a device opened by akgl_audio_init() is running means two
|
||||
* threads advancing the same voices, and this path does not take the
|
||||
* stream lock. Use one or the other, not both.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_mix(float32_t *dest, int frames);
|
||||
|
||||
#endif // _AKGL_AUDIO_H_
|
||||
@@ -1,36 +1,163 @@
|
||||
/**
|
||||
* @file character.h
|
||||
* @brief The reusable half of an actor: movement constants and a state-to-sprite map.
|
||||
*
|
||||
* A character is the template; an akgl_Actor is the instance. Everything that is
|
||||
* the same for every goblin on the map -- top speed, acceleration, frame dwell
|
||||
* time, and which sprite to draw for which combination of actor-state bits --
|
||||
* lives here and is shared by pointer, so a hundred actors cost one character.
|
||||
*
|
||||
* Characters are pool objects (akgl_heap_next_character) and are published in
|
||||
* the #AKGL_REGISTRY_CHARACTER property registry under their name, which is how
|
||||
* akgl_actor_set_character finds them. akgl_registry_init must have run first.
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_CHARACTER_H_
|
||||
#define _AKGL_CHARACTER_H_
|
||||
|
||||
#include <SDL3/SDL_properties.h>
|
||||
#include "types.h"
|
||||
#include "sprite.h"
|
||||
#include <akgl/types.h>
|
||||
#include <akgl/sprite.h>
|
||||
|
||||
#define AKGL_SPRITE_MAX_CHARACTER_NAME_LENGTH 128
|
||||
#define AKGL_MAX_HEAP_CHARACTER 256
|
||||
#define AKGL_CHARACTER_MAX_NAME_LENGTH 128
|
||||
|
||||
/** @brief Defines reusable movement parameters and actor-state sprite bindings. */
|
||||
typedef struct akgl_Character {
|
||||
uint8_t refcount;
|
||||
char name[AKGL_SPRITE_MAX_CHARACTER_NAME_LENGTH];
|
||||
SDL_PropertiesID state_sprites;
|
||||
uint64_t speedtime;
|
||||
float32_t ax;
|
||||
float32_t ay;
|
||||
float32_t az;
|
||||
float32_t sx;
|
||||
float32_t sy;
|
||||
float32_t sz;
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*sprite_add)(struct akgl_Character *, akgl_Sprite *, int);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*sprite_get)(struct akgl_Character *, int, akgl_Sprite **);
|
||||
uint8_t refcount; /**< Pool bookkeeping; 0 means the slot is free. */
|
||||
char name[AKGL_CHARACTER_MAX_NAME_LENGTH]; /**< Registry key. Truncated, not rejected, if the source name is longer. */
|
||||
SDL_PropertiesID state_sprites; /**< State bitmask (decimal, as a string) -> akgl_Sprite *. */
|
||||
uint64_t speedtime; /**< Nanoseconds one sprite frame is held before advancing. Read from JSON in milliseconds and scaled by #AKGL_TIME_ONEMS_NS. */
|
||||
float32_t ax; /**< Acceleration along x, world units per second squared. Copied into an actor by akgl_actor_set_character. */
|
||||
float32_t ay; /**< Acceleration along y. */
|
||||
float32_t az; /**< Acceleration along z. Not read from JSON; stays 0 unless set by hand. */
|
||||
float32_t sx; /**< Maximum speed along x, world units per second. */
|
||||
float32_t sy; /**< Maximum speed along y. */
|
||||
float32_t sz; /**< Maximum speed along z. Not read from JSON; stays 0 unless set by hand. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*sprite_add)(struct akgl_Character *, akgl_Sprite *, int); /**< Bound to akgl_character_sprite_add by akgl_character_initialize. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*sprite_get)(struct akgl_Character *, int, akgl_Sprite **); /**< Bound to akgl_character_sprite_get by akgl_character_initialize. */
|
||||
} akgl_Character;
|
||||
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_character_initialize(akgl_Character *basechar, char *name);
|
||||
/**
|
||||
* @brief Zero a pooled character, name it, and publish it in the character registry.
|
||||
*
|
||||
* Wipes the struct, copies @p name into it, creates the empty state-to-sprite
|
||||
* property set, binds the `sprite_add`/`sprite_get` function pointers, inserts
|
||||
* the character into #AKGL_REGISTRY_CHARACTER under @p name, and takes the first
|
||||
* reference. Everything numeric (speeds, accelerations, `speedtime`) is left at
|
||||
* zero for the caller -- or akgl_character_load_json -- to fill in.
|
||||
*
|
||||
* @param basechar Pooled character to initialize, normally straight from
|
||||
* akgl_heap_next_character. Required. Any previous contents are
|
||||
* discarded without releasing the sprites they referenced.
|
||||
* @param name Registry key, NUL-terminated. Required. Truncated at
|
||||
* #AKGL_CHARACTER_MAX_NAME_LENGTH. A name already in the
|
||||
* registry is silently replaced, and the character it displaced
|
||||
* becomes unreachable rather than being released.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p basechar or @p name is `NULL`, or if SDL
|
||||
* cannot create the state-to-sprite property set (out of memory).
|
||||
* @throws AKERR_KEY If the character cannot be written into
|
||||
* #AKGL_REGISTRY_CHARACTER -- in practice, because akgl_registry_init
|
||||
* has not run and the registry id is still 0.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_character_initialize(akgl_Character *obj, char *name);
|
||||
/**
|
||||
* @brief Bind a sprite to one exact combination of actor-state bits.
|
||||
*
|
||||
* The map key is the decimal spelling of @p state, so lookups match the *whole*
|
||||
* value: a sprite added for `FACE_LEFT|MOVING_LEFT` is not found by a lookup for
|
||||
* `FACE_LEFT` alone. Callers are expected to register every combination they
|
||||
* intend to draw. Adding a sprite takes a reference on it.
|
||||
*
|
||||
* @param basechar Character to add the mapping to. Required. Must already have
|
||||
* been through akgl_character_initialize.
|
||||
* @param ref Sprite to draw for @p state. Required. Its `refcount` is
|
||||
* incremented, so the character keeps it alive.
|
||||
* @param state The exact actor-state bitmask (`AKGL_ACTOR_STATE_*`) this
|
||||
* sprite is for. 0 is accepted and is a usable key.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p basechar or @p ref is `NULL`.
|
||||
*
|
||||
* @note Re-adding a different sprite for a @p state that is already mapped
|
||||
* replaces the entry without releasing the sprite it displaced, so the
|
||||
* displaced sprite's reference is never given back.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_character_sprite_add(akgl_Character *basechar, akgl_Sprite *ref, int state);
|
||||
/**
|
||||
* @brief Look up the sprite bound to one exact combination of actor-state bits.
|
||||
*
|
||||
* The counterpart to akgl_character_sprite_add, and matched the same way: an
|
||||
* exact match on the whole @p state value, with no fallback to a subset of the
|
||||
* bits and no default sprite. Unlike most lookups in this codebase, *not finding
|
||||
* one is an error* rather than a successful "nothing here" -- an actor with no
|
||||
* sprite for its current state cannot be drawn.
|
||||
*
|
||||
* @param basechar Character to search. Required.
|
||||
* @param state The exact actor-state bitmask to look up.
|
||||
* @param dest Receives the mapped sprite. Required -- the return value is
|
||||
* spoken for by the error context. Set to `NULL` when there is
|
||||
* no mapping, alongside the AKERR_KEY error.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p basechar or @p dest is `NULL`.
|
||||
* @throws AKERR_KEY If no sprite is bound to exactly that @p state. The message
|
||||
* carries the state both as a decimal and as a bit pattern.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_character_sprite_get(akgl_Character *basechar, int state, akgl_Sprite **dest);
|
||||
|
||||
// This is an SDL iterator so we can't return our error state from it.
|
||||
/**
|
||||
* @brief `SDL_EnumerateProperties` callback that applies an akgl_Iterator to one state-to-sprite entry.
|
||||
*
|
||||
* Currently implements exactly one operation: with #AKGL_ITERATOR_OP_RELEASE
|
||||
* set, it hands the mapped sprite back to its heap layer. Every other flag is
|
||||
* ignored here. Used to tear a character's sprite map down entry by entry.
|
||||
*
|
||||
* @param userdata The akgl_Iterator carrying the operation flags, passed through
|
||||
* by `SDL_EnumerateProperties`. Required despite the `void *` --
|
||||
* a `NULL` here is an error, not "no operations".
|
||||
* @param props The character's `state_sprites` set, supplied by SDL.
|
||||
* @param name The property key: one state bitmask in decimal. Required.
|
||||
*
|
||||
* @warning This is an SDL callback, so it returns `void` and has nowhere to
|
||||
* propagate an error to. It ends in `FINISH_NORETURN`, which logs the
|
||||
* stack trace and then calls libakerror's unhandled-error handler --
|
||||
* whose default implementation **exits the process** with the error
|
||||
* status. A missing sprite or a `NULL` @p userdata therefore terminates
|
||||
* the game rather than skipping an entry. Install your own
|
||||
* `akerr_handler_unhandled_error` if that is not what you want.
|
||||
*/
|
||||
void akgl_character_state_sprites_iterate(void *userdata, SDL_PropertiesID props, const char *name);
|
||||
|
||||
/**
|
||||
* @brief Build a character from a JSON definition file and register it.
|
||||
*
|
||||
* Claims a character from the pool, then reads: `name` (the registry key),
|
||||
* `sprite_mappings` (an array of `{ "sprite": <name>, "state": [<state names>] }`
|
||||
* objects), `speedtime` in seconds, `speed_x`, `speed_y`, `acceleration_x`, and
|
||||
* `acceleration_y`. Each mapping's `state` array is OR-ed together into one
|
||||
* bitmask by looking every name up in #AKGL_REGISTRY_ACTOR_STATE_STRINGS, so
|
||||
* `["FACE_LEFT", "MOVING_LEFT"]` becomes a single key.
|
||||
*
|
||||
* Every referenced sprite must already be in #AKGL_REGISTRY_SPRITE: this loads
|
||||
* characters, not sprites, so akgl_sprite_load_json runs first. On any failure
|
||||
* the pooled character is released again.
|
||||
*
|
||||
* @param filename Path to the JSON document. Required. Used verbatim -- it is
|
||||
* not resolved against `SDL_GetBasePath()`.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p filename is `NULL`; if the file cannot be
|
||||
* opened or does not parse (the message carries jansson's line number
|
||||
* and text); or if a mapping names a sprite that is not in the sprite
|
||||
* registry.
|
||||
* @throws AKERR_KEY If a `state` array contains a name that is not a known
|
||||
* actor state, if a required top-level key is absent, or if the
|
||||
* character cannot be added to the registry.
|
||||
* @throws AKERR_TYPE If a key is present but holds the wrong JSON type -- for
|
||||
* example `speedtime` as a string. The message names the key.
|
||||
* @throws AKERR_OUTOFBOUNDS If a `state` array is indexed past its end.
|
||||
* @throws AKGL_ERR_HEAP If the character pool or the string pool is exhausted.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_character_load_json(char *filename);
|
||||
|
||||
#endif // _AKGL_CHARACTER_H_
|
||||
|
||||
@@ -1,49 +1,365 @@
|
||||
#ifndef _CONTROLLER_H_
|
||||
#define _CONTROLLER_H_
|
||||
/**
|
||||
* @file controller.h
|
||||
* @brief Input binding: SDL events in, actor state changes out.
|
||||
*
|
||||
* A control map ties one actor to one keyboard and one gamepad, and holds up to
|
||||
* #AKGL_MAX_CONTROLS bindings. A binding says "when event X arrives from device
|
||||
* Y carrying button or key Z, call this handler". Eight maps means up to eight
|
||||
* locally controlled players, each on its own device.
|
||||
*
|
||||
* The host pumps SDL events into akgl_controller_handle_event(), which scans the
|
||||
* maps in order and stops at the first binding that claims the event -- so a key
|
||||
* bound in two maps only fires once, in the lower-numbered one.
|
||||
*
|
||||
* Alongside that, and independent of it, every key press is pushed into a small
|
||||
* ring buffer that akgl_controller_poll_key() and akgl_controller_poll_keystroke()
|
||||
* drain. That serves a caller that wants "is there a key waiting" without owning
|
||||
* an event loop, and it sees keys whether or not a control map also claimed them.
|
||||
*
|
||||
* The two pollers read the same buffer and differ in what they hand back. A game
|
||||
* asking "was the up arrow pressed" wants a keycode and nothing else, which is
|
||||
* akgl_controller_poll_key(). A line editor asking "what did the user type" needs
|
||||
* the modifier state and the character SDL composed -- there is no `"` keycode,
|
||||
* and no keycode at all for a dead key or an IME commit -- which is
|
||||
* akgl_controller_poll_keystroke().
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_CONTROLLER_H_
|
||||
#define _AKGL_CONTROLLER_H_
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akerror.h>
|
||||
#include "types.h"
|
||||
#include <akgl/types.h>
|
||||
// The binding handlers below take an akgl_Actor *, so this header cannot be
|
||||
// included before akgl/actor.h without one. akgl_Actor is a typedef of a named
|
||||
// struct, and repeating a typedef is not C99, so it is included rather than
|
||||
// forward-declared. actor.h reaches only types.h and character.h, so this does
|
||||
// not close a cycle.
|
||||
#include <akgl/actor.h>
|
||||
|
||||
/** @brief How many control maps exist -- effectively the local player limit. */
|
||||
#define AKGL_MAX_CONTROL_MAPS 8
|
||||
/** @brief Bindings per control map. The default map installed by akgl_controller_default uses 8 of them. */
|
||||
#define AKGL_MAX_CONTROLS 32
|
||||
|
||||
/**
|
||||
* @brief Keystrokes akgl_controller_handle_event() will hold for a poller.
|
||||
*
|
||||
* The Commodore keyboard buffer this serves held ten. Thirty-two is enough that
|
||||
* a program which polls once per frame never loses a key to a fast typist, and
|
||||
* small enough that the buffer stays a fixed-size object in the library's data
|
||||
* segment.
|
||||
*/
|
||||
#define AKGL_CONTROLLER_KEY_BUFFER 32
|
||||
|
||||
/**
|
||||
* @brief Bytes of composed text one buffered keystroke can carry, NUL included.
|
||||
*
|
||||
* A UTF-8 code point is at most four bytes, so this holds one character and its
|
||||
* terminator with room to spare. An input event carrying more than fits -- an
|
||||
* IME committing a whole word at once -- is truncated on a code point boundary
|
||||
* rather than split through the middle of one.
|
||||
*/
|
||||
#define AKGL_CONTROLLER_KEYSTROKE_TEXT 8
|
||||
|
||||
/** @brief One buffered keystroke: which key, which modifiers, and what it typed. */
|
||||
typedef struct {
|
||||
uint32_t event_on;
|
||||
uint32_t event_off;
|
||||
uint8_t button;
|
||||
SDL_Keycode key;
|
||||
uint8_t axis;
|
||||
uint8_t axis_range_min;
|
||||
uint8_t axis_range_max;
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*handler_on)(akgl_Actor *obj, SDL_Event *event);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*handler_off)(akgl_Actor *obj, SDL_Event *event);
|
||||
SDL_Keycode key; /**< The keycode, or 0 for an entry that carries only composed text -- an IME commit, or a character finished by a dead key. */
|
||||
SDL_Keymod mod; /**< Modifier state when the key went down. 0 on a text-only entry, whose text already reflects them. */
|
||||
char text[AKGL_CONTROLLER_KEYSTROKE_TEXT]; /**< What the keystroke composed to, UTF-8. The empty string for a key that produces no character, such as an arrow or a bare modifier. */
|
||||
} akgl_Keystroke;
|
||||
|
||||
/** @brief Maps one SDL input to pressed and released callbacks. */
|
||||
typedef struct {
|
||||
uint32_t event_on; /**< SDL event type that fires `handler_on`, e.g. `SDL_EVENT_KEY_DOWN`. */
|
||||
uint32_t event_off; /**< SDL event type that fires `handler_off`, e.g. `SDL_EVENT_KEY_UP`. */
|
||||
uint8_t button; /**< Gamepad button (`SDL_GamepadButton`) this binding is for. Only consulted for gamepad events. */
|
||||
SDL_Keycode key; /**< Keycode this binding is for. Only consulted for keyboard events. */
|
||||
uint8_t axis; /**< Analogue axis. Declared but not yet consulted by akgl_controller_handle_event. */
|
||||
uint8_t axis_range_min; /**< Low end of the axis range that counts as "on". Not yet consulted. */
|
||||
uint8_t axis_range_max; /**< High end of that range. Not yet consulted. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*handler_on)(akgl_Actor *obj, SDL_Event *event); /**< Called with the map's target on `event_on`. Required if `event_on` can fire. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*handler_off)(akgl_Actor *obj, SDL_Event *event); /**< Called with the map's target on `event_off`. */
|
||||
} akgl_Control;
|
||||
|
||||
/** @brief Groups input bindings for one actor and its input devices. */
|
||||
typedef struct {
|
||||
akgl_Actor *target;
|
||||
uint16_t nextMap;
|
||||
akgl_Control controls[AKGL_MAX_CONTROLS];
|
||||
SDL_KeyboardID kbid;
|
||||
SDL_JoystickID jsid;
|
||||
SDL_MouseID mouseid;
|
||||
SDL_PenID penid;
|
||||
akgl_Actor *target; /**< The actor these bindings drive. A `NULL` target makes the whole map inert, which is how an unused slot is spelled. */
|
||||
uint16_t nextMap; /**< Number of bindings in use; the index akgl_controller_pushmap writes to next. */
|
||||
akgl_Control controls[AKGL_MAX_CONTROLS]; /**< The bindings, scanned in order. */
|
||||
SDL_KeyboardID kbid; /**< Keyboard this map listens to. A keyboard event from any other id is ignored, which is what keeps two players on two keyboards apart. */
|
||||
SDL_JoystickID jsid; /**< Gamepad this map listens to, matched the same way. */
|
||||
SDL_MouseID mouseid; /**< Mouse this map listens to. Declared but not yet consulted. */
|
||||
SDL_PenID penid; /**< Pen this map listens to. Declared but not yet consulted. */
|
||||
} akgl_ControlMap;
|
||||
|
||||
extern akgl_ControlMap GAME_ControlMaps[AKGL_MAX_CONTROL_MAPS];
|
||||
/** @brief Every control map. Zeroed by akgl_game_init; index it by the same id the functions below take. */
|
||||
extern akgl_ControlMap akgl_controlmaps[AKGL_MAX_CONTROL_MAPS];
|
||||
|
||||
/**
|
||||
* @brief Log every attached keyboard and its SDL id.
|
||||
*
|
||||
* A diagnostic, not a query: it writes to the SDL log rather than returning
|
||||
* anything. Its use is finding the `kbid` to hand akgl_controller_default when
|
||||
* more than one keyboard is attached.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If SDL cannot enumerate keyboards. The message
|
||||
* carries `SDL_GetError()`; note that "no keyboards attached" is
|
||||
* reported by SDL as an empty list, not as a failure.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_list_keyboards(void);
|
||||
|
||||
/**
|
||||
* @brief Dispatch one SDL event to whichever control map binds it.
|
||||
*
|
||||
* The entry point for the whole subsystem -- call it for every event the host
|
||||
* pumps. A key press is recorded in the poll buffer first, whether or not any
|
||||
* map wants it, and an `SDL_EVENT_TEXT_INPUT` is attached to the press it
|
||||
* belongs to; then the maps are scanned in index order and, within a map,
|
||||
* bindings in the order they were pushed. The **first** binding whose event type
|
||||
* and device id and button/key all match wins, and the scan stops there.
|
||||
*
|
||||
* An event nothing binds is not an error: it returns success having done
|
||||
* nothing, which is what lets a host pass every event through unconditionally.
|
||||
*
|
||||
* @param appstate Passed through from SDL's callback. Required -- but only as a
|
||||
* non-`NULL` token: nothing here reads it. Pass any non-`NULL`
|
||||
* pointer if your program has no app state.
|
||||
* @param event The event to dispatch. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p appstate or @p event is `NULL`.
|
||||
* @throws AKERR_* Whatever the matched binding's handler raises.
|
||||
*
|
||||
* @warning A matched binding's handler pointer is not checked, so a control
|
||||
* pushed with a `NULL` `handler_on` or `handler_off` crashes when its
|
||||
* event arrives.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_handle_event(void *appstate, SDL_Event *event);
|
||||
|
||||
/**
|
||||
* @brief Set the movement and facing bits on the "player" actor for a D-pad or arrow press.
|
||||
*
|
||||
* A whole-application shortcut rather than a control-map binding: it looks the
|
||||
* actor up by the literal name `"player"` in #AKGL_REGISTRY_ACTOR instead of
|
||||
* being told which actor to act on, so it only ever drives one. The
|
||||
* akgl_actor_cmhf_* handlers are the general form.
|
||||
*
|
||||
* Facing follows movement unless the actor sets `movement_controls_face`, which
|
||||
* is how an actor that aims independently of the direction it walks opts out.
|
||||
*
|
||||
* @param appstate Passed through from SDL. Required as a non-`NULL` token only;
|
||||
* never read.
|
||||
* @param event The button or key event. Required. Both the gamepad and
|
||||
* keyboard unions are read on every call, so the arm that does
|
||||
* not correspond to the event type is read as garbage -- which
|
||||
* works only because no real button and keycode pair collides.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p appstate or @p event is `NULL`, or if there is
|
||||
* no actor registered under the name "player".
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_handle_button_down(void *appstate, SDL_Event *event);
|
||||
/**
|
||||
* @brief Clear the movement bit on the "player" actor for a D-pad or arrow release, and reset its animation.
|
||||
*
|
||||
* The counterpart to akgl_controller_handle_button_down. It clears only the movement
|
||||
* bit, leaving the facing bits alone so the actor keeps looking the way it was
|
||||
* walking, and rewinds `curSpriteFrameId` to 0 so the next step starts from the
|
||||
* first frame of the walk cycle rather than wherever it stopped.
|
||||
*
|
||||
* @param appstate Passed through from SDL. Required as a non-`NULL` token only.
|
||||
* @param event The button or key release event. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p appstate or @p event is `NULL`, or if there is
|
||||
* no actor registered under the name "player".
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_handle_button_up(void *appstate, SDL_Event *event);
|
||||
/**
|
||||
* @brief Open a gamepad that has just been plugged in, and log its mapping.
|
||||
*
|
||||
* SDL delivers no button events from an unopened gamepad, so a device that
|
||||
* appears mid-session has to be opened here the way akgl_controller_open_gamepads
|
||||
* opens the ones present at startup. A gamepad SDL has already opened is logged
|
||||
* and left alone.
|
||||
*
|
||||
* The mapping is logged because a controller with no entry in the database
|
||||
* produces no button events at all, and that is otherwise indistinguishable
|
||||
* from a broken binding.
|
||||
*
|
||||
* @param appstate Passed through from SDL. Required as a non-`NULL` token only.
|
||||
* @param event The SDL_EVENT_GAMEPAD_ADDED event. Required. The joystick id
|
||||
* is read out of the `gbutton` arm rather than `gdevice`, which
|
||||
* works because the two share their `which` field.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p appstate or @p event is `NULL`.
|
||||
*
|
||||
* @note A failed `SDL_OpenGamepad` is logged rather than reported: this returns
|
||||
* success and the device stays silent.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_handle_added(void *appstate, SDL_Event *event);
|
||||
/**
|
||||
* @brief Close a gamepad that has been unplugged.
|
||||
*
|
||||
* An unplugged device SDL still has open is a leaked handle, so this closes it.
|
||||
* A removal for a device that was never opened is logged and otherwise ignored.
|
||||
* Any control map still holding that `jsid` simply stops matching -- the map is
|
||||
* not torn down.
|
||||
*
|
||||
* @param appstate Passed through from SDL. Required as a non-`NULL` token only.
|
||||
* @param event The SDL_EVENT_GAMEPAD_REMOVED event. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p appstate or @p event is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_handle_removed(void *appstate, SDL_Event *event);
|
||||
|
||||
/**
|
||||
* @brief Append a binding to a control map.
|
||||
*
|
||||
* The binding is copied, so the caller's `akgl_Control` can be a stack local
|
||||
* reused across pushes -- which is exactly what akgl_controller_default does.
|
||||
* Bindings can only be appended; there is no remove, and no way to reset a map
|
||||
* short of zeroing it in ::akgl_controlmaps directly.
|
||||
*
|
||||
* @param controlmapid Which map to append to, 0 through
|
||||
* #AKGL_MAX_CONTROL_MAPS - 1.
|
||||
* @param control The binding to copy in. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p control is `NULL`.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p controlmapid is at or above
|
||||
* #AKGL_MAX_CONTROL_MAPS, or if the map already holds
|
||||
* #AKGL_MAX_CONTROLS bindings.
|
||||
*
|
||||
* @warning A **negative** @p controlmapid is not rejected -- only the upper
|
||||
* bound is checked -- and indexes before the start of
|
||||
* ::akgl_controlmaps. TODO.md, "Known and still open" item 11.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_pushmap(int controlmapid, akgl_Control *control);
|
||||
|
||||
/**
|
||||
* @brief Bind an actor to the arrow keys and the D-pad, in one call.
|
||||
*
|
||||
* Points the map at the named actor and pushes eight bindings: the four arrow
|
||||
* keys and the four D-pad directions, each wired to the matching
|
||||
* `akgl_actor_cmhf_*_on`/`_off` pair. It is the "just give me something that
|
||||
* works" path -- a game wanting different keys builds its own bindings with
|
||||
* akgl_controller_pushmap.
|
||||
*
|
||||
* @param controlmapid Which map to configure, 0 through
|
||||
* #AKGL_MAX_CONTROL_MAPS - 1.
|
||||
* @param actorname Registry name of the actor to drive. Required in practice,
|
||||
* though a `NULL` is reported as "not found" rather than as
|
||||
* a null pointer.
|
||||
* @param kbid SDL keyboard id to listen to. Only events from this
|
||||
* keyboard match; see akgl_controller_list_keyboards for
|
||||
* how to find it.
|
||||
* @param jsid SDL gamepad id to listen to, matched the same way.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p controlmapid is at or above
|
||||
* #AKGL_MAX_CONTROL_MAPS, or if the map cannot hold eight more bindings.
|
||||
* @throws AKGL_ERR_REGISTRY If @p actorname is not in #AKGL_REGISTRY_ACTOR --
|
||||
* usually because the actor has not been created yet.
|
||||
*
|
||||
* @warning A negative @p controlmapid is not rejected. See
|
||||
* akgl_controller_pushmap.
|
||||
* @note It appends rather than replaces, so calling it twice on the same map
|
||||
* leaves sixteen bindings and the first eight are the ones that fire.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_default(int controlmapid, char *actorname, int kbid, int jsid);
|
||||
|
||||
/**
|
||||
* @brief Open every gamepad currently attached.
|
||||
*
|
||||
* SDL will not deliver button events from a gamepad nobody has opened, so this
|
||||
* runs once at startup -- akgl_game_init calls it. Devices plugged in later are
|
||||
* SDL_EVENT_GAMEPAD_ADDED events, handled elsewhere.
|
||||
*
|
||||
* No gamepads attached is success, not an error.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If SDL reports gamepads present but cannot
|
||||
* enumerate them, or if one of them cannot be opened. The message
|
||||
* carries `SDL_GetError()`.
|
||||
*
|
||||
* @note The enumeration array is only freed on the success path, so a failure
|
||||
* part-way through leaks it.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_open_gamepads(void);
|
||||
#endif // _CONTROLLER_H_
|
||||
|
||||
/**
|
||||
* @brief Take the oldest waiting keystroke, if there is one.
|
||||
*
|
||||
* The rest of this header is built around the host pumping SDL events into
|
||||
* akgl_controller_handle_event(), which suits a game loop and does not suit an
|
||||
* embedded interpreter asking "is there a key waiting, yes or no" without
|
||||
* owning the event loop itself. Every key press that reaches
|
||||
* akgl_controller_handle_event() is recorded in a fixed ring buffer first,
|
||||
* whether or not a control map claims it, and this drains that buffer one
|
||||
* keystroke per call.
|
||||
*
|
||||
* When no key is waiting the call still succeeds: @p available is set to
|
||||
* `false` and @p keycode to 0. The caller polls, it does not block.
|
||||
*
|
||||
* A full buffer drops the *newest* keystroke rather than the oldest, so what
|
||||
* was typed first is what is read first. This runs on whichever thread pumps
|
||||
* events; it is not synchronized.
|
||||
*
|
||||
* Entries that carry only composed text and no keycode are discarded on the way
|
||||
* past rather than reported as keycode 0: this form is for a caller that acts on
|
||||
* keys, and there is no key to report. A caller that wants those characters uses
|
||||
* akgl_controller_poll_keystroke() instead.
|
||||
*
|
||||
* @param keycode Receives the SDL keycode, or 0 when nothing was waiting.
|
||||
* Required -- the return value is the error context.
|
||||
* @param available Receives `true` when a keystroke was taken, `false` when the
|
||||
* buffer was empty. Required. Check this rather than testing
|
||||
* @p keycode against 0.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p keycode or @p available is `NULL`. Both are
|
||||
* required; there is no "I only want to know whether one is waiting"
|
||||
* form.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_poll_key(int *keycode, bool *available);
|
||||
|
||||
/**
|
||||
* @brief Take the oldest waiting keystroke whole: key, modifiers and text.
|
||||
*
|
||||
* akgl_controller_poll_key() with nothing thrown away. A line editor needs all
|
||||
* three: the keycode to recognise Backspace and Return, the modifier state to
|
||||
* tell Ctrl-C from a `c`, and the composed text because a keycode cannot express
|
||||
* what a shifted key produces on the user's own layout. `"`, `!`, `(`, `)`, `:`
|
||||
* and `;` are all unreachable from a keycode alone, and so is every lower-case
|
||||
* letter.
|
||||
*
|
||||
* The text is what SDL composed, taken from the `SDL_EVENT_TEXT_INPUT` that
|
||||
* follows the key press -- the only correct way to get a character out of SDL,
|
||||
* and what makes a keyboard layout, a compose key and a dead key work. **SDL
|
||||
* only sends those events while text input is started**, so a host that wants
|
||||
* the `text` field populated calls `SDL_StartTextInput()` on its window first.
|
||||
* Without it, `key` and `mod` still arrive and `text` is always empty.
|
||||
*
|
||||
* The two pollers drain the same buffer, so a keystroke taken by one is not
|
||||
* waiting for the other.
|
||||
*
|
||||
* @param dest Receives the keystroke. Required. Written only when a
|
||||
* keystroke was waiting; zeroed otherwise, so `key` is 0 and
|
||||
* `text` is the empty string.
|
||||
* @param available Receives `true` when a keystroke was taken, `false` when the
|
||||
* buffer was empty. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p dest or @p available is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_poll_keystroke(akgl_Keystroke *dest, bool *available);
|
||||
|
||||
/**
|
||||
* @brief Discard every keystroke waiting in the buffer.
|
||||
*
|
||||
* For a caller that has been ignoring input and does not want a backlog acted
|
||||
* on the moment it starts polling again.
|
||||
*
|
||||
* This empties only the polling buffer. Keys already dispatched to control maps
|
||||
* have had their effect and cannot be taken back.
|
||||
*
|
||||
* @return `NULL`. There is no failure path -- it resets two counters.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_flush_keys(void);
|
||||
#endif // _AKGL_CONTROLLER_H_
|
||||
|
||||
@@ -1,6 +1,245 @@
|
||||
#ifndef _DRAW_H_
|
||||
#define _DRAW_H_
|
||||
/**
|
||||
* @file draw.h
|
||||
* @brief Declares the public draw API.
|
||||
*
|
||||
* Immediate-mode plotting against whichever renderer the caller hands in. This
|
||||
* is the shape a BASIC-style graphics vocabulary needs -- DRAW, BOX, CIRCLE,
|
||||
* PAINT, SSHAPE and GSHAPE all say "put this on the screen now" rather than
|
||||
* "add this to the scene" -- and it sits alongside the actor and tilemap
|
||||
* rendering rather than replacing it.
|
||||
*
|
||||
* Every entry point takes its color as an argument instead of reading a
|
||||
* current-color global. A caller that has a notion of a current color (a BASIC
|
||||
* COLOR statement, say) already owns that state and does not need the library
|
||||
* to keep a second copy that can disagree with it. The renderer's own draw
|
||||
* color is saved and restored around each call, so drawing a line never changes
|
||||
* what the next SDL_RenderClear() paints.
|
||||
*/
|
||||
|
||||
void akgl_draw_background(int w, int h);
|
||||
#ifndef _AKGL_DRAW_H_
|
||||
#define _AKGL_DRAW_H_
|
||||
|
||||
#endif //_DRAW_H_
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/renderer.h>
|
||||
#include <akgl/types.h>
|
||||
|
||||
/**
|
||||
* @brief Spans akgl_draw_flood_fill() will hold while walking a region.
|
||||
*
|
||||
* The fill keeps a fixed stack of horizontal runs still to be examined rather
|
||||
* than recursing per pixel. A region complicated enough to need more than this
|
||||
* many pending runs at once reports AKERR_OUTOFBOUNDS instead of overflowing;
|
||||
* an ordinary convex or moderately concave shape needs a few dozen.
|
||||
*/
|
||||
#define AKGL_DRAW_MAX_FLOOD_SPANS 4096
|
||||
|
||||
/**
|
||||
* @brief Paint an 8x8 grey checkerboard over a region, the way an image editor shows transparency.
|
||||
*
|
||||
* A diagnostic backdrop rather than a general primitive -- `charviewer` uses it
|
||||
* so a sprite's transparent pixels are visible instead of blending into black.
|
||||
*
|
||||
* Until 0.5.0 this was the one function in the library outside the error
|
||||
* protocol: it returned `void`, drew through the *global* `akgl_renderer`
|
||||
* without checking it, and left the renderer's draw colour changed. It now
|
||||
* takes a backend like every other entry point here and restores the colour it
|
||||
* found, which is also what makes it testable without a world.
|
||||
*
|
||||
* @param self The backend to draw through. Required, along with its
|
||||
* `sdl_renderer`.
|
||||
* @param w Width of the region to cover, in pixels, starting at x = 0. Zero
|
||||
* or negative paints nothing and is not reported.
|
||||
* @param h Height of the region, starting at y = 0. Both round up to whole
|
||||
* 8px cells, so a 12-pixel height paints 16.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self or `self->sdl_renderer` is `NULL`.
|
||||
* @throws AKGL_ERR_SDL If the draw colour cannot be read or set, or a cell
|
||||
* cannot be filled.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_background(akgl_RenderBackend *self, int w, int h);
|
||||
|
||||
/**
|
||||
* @brief Plot a single pixel.
|
||||
* @param self The backend to draw through. Required, along with its
|
||||
* `sdl_renderer`.
|
||||
* @param x Horizontal position in render-target pixels. Outside the target
|
||||
* is clipped by SDL, not reported.
|
||||
* @param y Vertical position.
|
||||
* @param color Colour to plot in, including alpha. Blending follows the
|
||||
* renderer's current blend mode, which this does not change.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self or `self->sdl_renderer` is `NULL`.
|
||||
* @throws AKGL_ERR_SDL If the draw colour cannot be read or set, or if the plot
|
||||
* itself fails. The message carries `SDL_GetError()`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_point(akgl_RenderBackend *self, float32_t x, float32_t y, SDL_Color color);
|
||||
|
||||
/**
|
||||
* @brief Draw a line between two points, endpoints included.
|
||||
* @param self The backend to draw through. Required, along with its
|
||||
* `sdl_renderer`.
|
||||
* @param x1 Horizontal position of the first endpoint.
|
||||
* @param y1 Vertical position of the first endpoint.
|
||||
* @param x2 Horizontal position of the second endpoint.
|
||||
* @param y2 Vertical position of the second endpoint. Coincident endpoints
|
||||
* draw a single pixel rather than nothing.
|
||||
* @param color Colour to draw in, including alpha.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self or `self->sdl_renderer` is `NULL`.
|
||||
* @throws AKGL_ERR_SDL If the draw colour cannot be read or set, or if the line
|
||||
* cannot be drawn.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_line(akgl_RenderBackend *self, float32_t x1, float32_t y1, float32_t x2, float32_t y2, SDL_Color color);
|
||||
|
||||
/**
|
||||
* @brief Draw the outline of a rectangle.
|
||||
* @param self The backend to draw through. Required, along with its
|
||||
* `sdl_renderer`.
|
||||
* @param rect The rectangle, in render-target pixels. Required. A zero or
|
||||
* negative width or height draws nothing and is not reported.
|
||||
* @param color Colour to draw in, including alpha.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self, `self->sdl_renderer`, or @p rect is
|
||||
* `NULL`.
|
||||
* @throws AKGL_ERR_SDL If the draw colour cannot be read or set, or if the
|
||||
* outline cannot be drawn.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_rect(akgl_RenderBackend *self, SDL_FRect *rect, SDL_Color color);
|
||||
|
||||
/**
|
||||
* @brief Fill a rectangle, outline included.
|
||||
* @param self The backend to draw through. Required, along with its
|
||||
* `sdl_renderer`.
|
||||
* @param rect The rectangle, in render-target pixels. Required. A zero or
|
||||
* negative width or height fills nothing and is not reported.
|
||||
* @param color Colour to fill with, including alpha.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self, `self->sdl_renderer`, or @p rect is
|
||||
* `NULL`.
|
||||
* @throws AKGL_ERR_SDL If the draw colour cannot be read or set, or if the fill
|
||||
* cannot be drawn.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_filled_rect(akgl_RenderBackend *self, SDL_FRect *rect, SDL_Color color);
|
||||
|
||||
/**
|
||||
* @brief Draw the outline of a circle.
|
||||
*
|
||||
* SDL3 has no circle primitive, so this plots one with the midpoint circle
|
||||
* algorithm -- integer arithmetic, eight-way symmetry, one pass per octant. A
|
||||
* radius of zero draws the center pixel and nothing else.
|
||||
*
|
||||
* @param self The backend to draw through. Required, along with its
|
||||
* `sdl_renderer`.
|
||||
* @param x Horizontal position of the centre. Rounded to the nearest whole
|
||||
* pixel -- the algorithm is integer-only.
|
||||
* @param y Vertical position of the centre, rounded the same way.
|
||||
* @param radius Radius in pixels, rounded to the nearest whole pixel. Must not
|
||||
* be negative; 0 draws the centre pixel alone.
|
||||
* @param color Colour to draw in, including alpha. The outline is one pixel
|
||||
* wide and is not anti-aliased.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self or `self->sdl_renderer` is `NULL`.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p radius is negative. The message reports it.
|
||||
* @throws AKGL_ERR_SDL If the draw colour cannot be read or set, or if any of
|
||||
* the plotting passes fails. Failures inside the loop are recorded and
|
||||
* reported once at the end rather than aborting mid-circle, so a partial
|
||||
* arc may already be on the target.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_circle(akgl_RenderBackend *self, float32_t x, float32_t y, float32_t radius, SDL_Color color);
|
||||
|
||||
/**
|
||||
* @brief Flood the connected region containing one pixel with a color.
|
||||
*
|
||||
* SDL3 has no flood fill either, and unlike the shape primitives it cannot be
|
||||
* done on the GPU side: the region is defined by what is already on the screen.
|
||||
* This reads the render target back, walks the region on the CPU with a
|
||||
* bounded span stack, and blits the result over the area it touched.
|
||||
*
|
||||
* Filling a region that already holds @p color is a no-op rather than an error.
|
||||
* A seed outside the render target reports AKERR_OUTOFBOUNDS.
|
||||
*
|
||||
* The region is four-connected -- it spreads up, down, left and right, not
|
||||
* diagonally -- and its boundary is any pixel whose colour differs from the
|
||||
* seed's, exactly. There is no tolerance, so an anti-aliased edge stops the fill
|
||||
* at its first blended pixel and leaves a fringe.
|
||||
*
|
||||
* @param self The backend to draw through. Required, along with its
|
||||
* `sdl_renderer`.
|
||||
* @param x Horizontal position of the seed pixel, in render-target pixels.
|
||||
* Must be inside the target.
|
||||
* @param y Vertical position of the seed pixel.
|
||||
* @param color Colour to fill with. Written over the region rather than blended,
|
||||
* since the pixels going back are the ones just read out.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self or `self->sdl_renderer` is `NULL`.
|
||||
* @throws AKERR_OUTOFBOUNDS If the seed is outside the render target -- the
|
||||
* message reports both the seed and the target size -- or if the region
|
||||
* needs more than #AKGL_DRAW_MAX_FLOOD_SPANS pending spans, in which
|
||||
* case **it is left partially filled**.
|
||||
* @throws AKGL_ERR_SDL If the render target cannot be read back, converted,
|
||||
* uploaded, or blitted.
|
||||
*
|
||||
* @warning Not reentrant, and not safe from two threads: the span stack is a
|
||||
* single file-scope array. Nothing that touches an `SDL_Renderer` is
|
||||
* thread-safe either way.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_flood_fill(akgl_RenderBackend *self, int x, int y, SDL_Color color);
|
||||
|
||||
/**
|
||||
* @brief Read a rectangle of the render target into a surface.
|
||||
*
|
||||
* The save half of SSHAPE/GSHAPE. When `*dest` is `NULL` the function allocates
|
||||
* the surface and the caller owns it from then on -- release it with
|
||||
* SDL_DestroySurface(). When `*dest` already points at a surface of exactly
|
||||
* @p src's dimensions the pixels are copied into it instead, so a caller
|
||||
* saving the same region repeatedly does not churn allocations.
|
||||
*
|
||||
* @param self The backend to read from. Required, along with its `sdl_renderer`.
|
||||
* @param src Rectangle of the render target to read, in pixels. Required. It
|
||||
* must fit *entirely* inside the target: SDL would otherwise clip
|
||||
* the read and hand back a smaller surface than was asked for, which
|
||||
* a caller pasting it back would not notice.
|
||||
* @param dest Address of the destination surface. Required, and `*dest` must be
|
||||
* initialized -- `NULL` to have one allocated (the caller then owns
|
||||
* it and frees it with `SDL_DestroySurface`), or an existing surface
|
||||
* of exactly @p src's dimensions to reuse.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self, `self->sdl_renderer`, @p src, or @p dest
|
||||
* is `NULL`.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p src has no area, if it does not fit inside the
|
||||
* render target, or if a supplied `*dest` is a different size from
|
||||
* @p src. Each message reports both sets of dimensions.
|
||||
* @throws AKGL_ERR_SDL If the target size cannot be queried, the pixels cannot
|
||||
* be read, or the copy into a supplied `*dest` fails.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_copy_region(akgl_RenderBackend *self, SDL_Rect *src, SDL_Surface **dest);
|
||||
|
||||
/**
|
||||
* @brief Draw a saved surface back onto the render target.
|
||||
*
|
||||
* The restore half of SSHAPE/GSHAPE, taking what akgl_draw_copy_region()
|
||||
* produced. The surface is not consumed and may be pasted as many times as the
|
||||
* caller likes.
|
||||
*
|
||||
* @param self The backend to draw through. Required, along with its
|
||||
* `sdl_renderer`.
|
||||
* @param src The surface to paste. Required. Drawn at its own size -- there is
|
||||
* no scaling -- and *replacing* what is on the target rather than
|
||||
* blending with it, which is GSHAPE's default behaviour and means a
|
||||
* saved region's transparent pixels come back as transparent rather
|
||||
* than letting the background show through.
|
||||
* @param x Horizontal position of the paste's left edge.
|
||||
* @param y Vertical position of its top edge. Parts falling outside the
|
||||
* target are clipped by SDL, not reported.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self, `self->sdl_renderer`, or @p src is
|
||||
* `NULL`.
|
||||
* @throws AKGL_ERR_SDL If the surface cannot be uploaded as a texture, its blend
|
||||
* mode cannot be set, or the draw fails.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_paste_region(akgl_RenderBackend *self, SDL_Surface *src, float32_t x, float32_t y);
|
||||
|
||||
#endif //_AKGL_DRAW_H_
|
||||
|
||||
@@ -1,18 +1,99 @@
|
||||
#ifndef _ERROR_H_
|
||||
#define _ERROR_H_
|
||||
/**
|
||||
* @file error.h
|
||||
* @brief Declares the public error API.
|
||||
*/
|
||||
|
||||
// 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.
|
||||
#define DISABLE_GCC_WARNING_FORMAT_TRUNCATION \
|
||||
_Pragma("GCC diagnostic push") \
|
||||
#ifndef _AKGL_ERROR_H_
|
||||
#define _AKGL_ERROR_H_
|
||||
|
||||
#include <akerror.h>
|
||||
|
||||
/*
|
||||
* libakerror 1.0.0 is the floor. That release moved the status-name table into a
|
||||
* private registry -- AKERR_MAX_ERR_VALUE and __AKERR_ERROR_NAMES are gone, the
|
||||
* registry entry points raise akerr_ErrorContext * instead of returning int, and
|
||||
* the library gained an soname -- so a translation unit that pairs this header
|
||||
* with a pre-1.0.0 akerror.h is an ABI mismatch, not just a compile problem.
|
||||
*
|
||||
* libakerror publishes no version macro, so this feature-tests on
|
||||
* AKERR_FIRST_CONSUMER_STATUS, which that release introduced, rather than on a
|
||||
* version number that does not exist. Without the guard an embedded build is
|
||||
* fine but a stale installed header fails much further in, on the AKGL_ERR_*
|
||||
* codes below and again inside src/heap.c.
|
||||
*
|
||||
* See deps/libakerror/UPGRADING.md.
|
||||
*/
|
||||
#ifndef AKERR_FIRST_CONSUMER_STATUS
|
||||
#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
|
||||
|
||||
// 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\"")
|
||||
|
||||
#define RESTORE_GCC_WARNINGS \
|
||||
#define RESTORE_GCC_WARNINGS \
|
||||
_Pragma("GCC diagnostic pop")
|
||||
|
||||
#define AKGL_ERR_SDL AKERR_LAST_ERRNO_VALUE + 1
|
||||
#define AKGL_ERR_LOGICINTERRUPT AKERR_LAST_ERRNO_VALUE + 2
|
||||
// libakerror reserves statuses 0-255 for the host's errno values and its own
|
||||
// AKERR_* codes; consumers allocate from AKERR_FIRST_CONSUMER_STATUS upward.
|
||||
// These are fixed offsets from that base rather than from AKERR_LAST_ERRNO_VALUE
|
||||
// so that a libc which grows an errno cannot move them out from under us.
|
||||
//
|
||||
// akgl_error_init() reserves this whole band in one call and registers a name
|
||||
// for every code below. Add a code here and you must name it there, or it
|
||||
// prints as "Unknown Error" in every stack trace that carries it.
|
||||
#define AKGL_ERR_OWNER "libakgl"
|
||||
#define AKGL_ERR_BASE AKERR_FIRST_CONSUMER_STATUS
|
||||
|
||||
#endif // _ERROR_H_
|
||||
#define AKGL_ERR_SDL (AKGL_ERR_BASE + 0) /**< An SDL call failed; the message carries SDL_GetError() */
|
||||
#define AKGL_ERR_REGISTRY (AKGL_ERR_BASE + 1) /**< A registry property or lookup operation failed */
|
||||
#define AKGL_ERR_HEAP (AKGL_ERR_BASE + 2) /**< A heap pool has no free object left to hand out */
|
||||
#define AKGL_ERR_BEHAVIOR (AKGL_ERR_BASE + 3) /**< A component did not behave the way its contract requires */
|
||||
#define AKGL_ERR_LOGICINTERRUPT (AKGL_ERR_BASE + 4) /**< Actor logic is telling the physics simulator to skip it */
|
||||
|
||||
// One past the last libakgl status. The reservation is all-or-nothing -- a
|
||||
// subset or superset of an existing one is refused -- so this must stay one
|
||||
// past the highest code above.
|
||||
#define AKGL_ERR_LIMIT (AKGL_ERR_BASE + 5)
|
||||
#define AKGL_ERR_COUNT (AKGL_ERR_LIMIT - AKGL_ERR_BASE)
|
||||
|
||||
/**
|
||||
* @brief Claim the libakgl status band and register a name for every code in it.
|
||||
*
|
||||
* Call this before anything else in libakgl. Every other subsystem raises
|
||||
* AKGL_ERR_* codes, and a code raised before this runs carries no name into its
|
||||
* stack trace. Repeating the call is a no-op, so a program that cannot order its
|
||||
* initialization precisely may call it more than once.
|
||||
*
|
||||
* @throws AKERR_STATUS_RANGE_OVERLAP When another component already owns part of the band.
|
||||
* @throws AKERR_STATUS_RANGE_FULL When libakerror has no reservation slots left.
|
||||
* @throws AKERR_STATUS_NAME_FULL When libakerror's name registry is full.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_error_init(void);
|
||||
|
||||
#endif // _AKGL_ERROR_H_
|
||||
|
||||
@@ -1,72 +1,372 @@
|
||||
/**
|
||||
* @file game.h
|
||||
* @brief Process-wide game state, the startup sequence, and the per-frame tick.
|
||||
*
|
||||
* This is the top of the library. `akgl_game_init` brings up SDL, the pools, the
|
||||
* registries and the audio and font engines; `akgl_game_update` is one frame --
|
||||
* update every actor, step the physics, draw the world.
|
||||
*
|
||||
* There is exactly one of everything. `akgl_renderer`, `akgl_physics`, `akgl_camera`, and
|
||||
* `akgl_gamemap` are globals pointing at the `akgl_default_*` storage below them, so a
|
||||
* program can swap in its own instance by reassigning the pointer without the
|
||||
* rest of the library knowing. That is the whole extent of the indirection:
|
||||
* there is no notion of two worlds at once.
|
||||
*
|
||||
* The startup order that actually works:
|
||||
*
|
||||
* 1. fill in `akgl_game.name`, `akgl_game.version`, and `akgl_game.uri` -- akgl_game_init
|
||||
* refuses to run without them;
|
||||
* 2. akgl_game_init();
|
||||
* 3. akgl_registry_load_properties() or akgl_set_property(), to configure
|
||||
* screen size, physics constants and so on;
|
||||
* 4. akgl_render_2d_init(renderer) and akgl_physics_factory(physics, ...), both
|
||||
* of which read that configuration;
|
||||
* 5. load assets, then loop on akgl_game_update().
|
||||
*
|
||||
* @warning None of this is thread-safe beyond the `akgl_game.state` mutex, and that
|
||||
* mutex protects the state flags, not the pools or the registries.
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_GAME_H_
|
||||
#define _AKGL_GAME_H_
|
||||
|
||||
#include <stdint.h>
|
||||
#include <SDL3_mixer/SDL_mixer.h>
|
||||
#include "types.h"
|
||||
#include "tilemap.h"
|
||||
#include "renderer.h"
|
||||
#include "physics.h"
|
||||
|
||||
#define AKGL_VERSION "0.1.0"
|
||||
#include <akgl/types.h>
|
||||
#include <akgl/tilemap.h>
|
||||
#include <akgl/renderer.h>
|
||||
#include <akgl/physics.h>
|
||||
// AKGL_VERSION used to be defined here by hand, which is how akgl.pc came to
|
||||
// ship an empty Version field: nothing tied the two together.
|
||||
#include <akgl/version.h>
|
||||
|
||||
/** @brief Slot in ::akgl_tracks reserved for background music. Note that slot 0 is unused. */
|
||||
#define AKGL_GAME_AUDIO_TRACK_BGM 1
|
||||
/** @brief Size of the ::akgl_tracks table. Every simultaneous sound needs its own slot. */
|
||||
#define AKGL_GAME_AUDIO_MAX_TRACKS 64
|
||||
|
||||
/** @brief Nanoseconds in one second. The unit `SDL_GetTicksNS` reports in. */
|
||||
#define AKGL_TIME_ONESEC_NS 1000000000
|
||||
#define AKGL_TIME_ONESEC_MS 1000000
|
||||
/**
|
||||
* @brief Nanoseconds in one millisecond. The scale factor from JSON durations to internal ones.
|
||||
*
|
||||
* Sprite and character definitions state frame durations in milliseconds
|
||||
* because that is what a human writing one wants to type; everything that
|
||||
* compares them against `SDL_GetTicksNS` needs nanoseconds.
|
||||
*
|
||||
* This was called `AKGL_TIME_ONESEC_MS` until 0.5.0, which said "one second in
|
||||
* milliseconds" and held 1000000 -- a name and a value that described two
|
||||
* different quantities. Both callers that meant the scale factor were right;
|
||||
* the one that read it as a one-second budget was wrong by a factor of a
|
||||
* thousand. See akgl_game_state_lock.
|
||||
*/
|
||||
#define AKGL_TIME_ONEMS_NS 1000000
|
||||
|
||||
/**
|
||||
* @brief How long akgl_game_state_lock keeps trying for the state mutex, in milliseconds.
|
||||
*
|
||||
* A deliberate ceiling rather than a blocking wait: a deadlock here reports an
|
||||
* error the caller can act on instead of hanging the process.
|
||||
*/
|
||||
#define AKGL_GAME_STATE_LOCK_BUDGET_MS 1000
|
||||
/** @brief How long akgl_game_state_lock sleeps between attempts, in milliseconds. */
|
||||
#define AKGL_GAME_STATE_LOCK_RETRY_MS 100
|
||||
|
||||
/* ==================== GAME STATE VARIABLES =================== */
|
||||
|
||||
/** @brief Stores application-defined game-state flags. */
|
||||
typedef struct {
|
||||
float32_t w;
|
||||
float32_t h;
|
||||
SDL_Texture *texture;
|
||||
} akgl_Frame;
|
||||
|
||||
typedef struct {
|
||||
int32_t flags;
|
||||
int32_t flags; /**< Meaning is entirely the application's; the library never reads it. Guard changes with akgl_game_state_lock. */
|
||||
} akgl_GameState;
|
||||
|
||||
/** @brief Stores game metadata, timing, synchronization, and FPS accounting. */
|
||||
typedef struct {
|
||||
char libversion[32];
|
||||
char version[32];
|
||||
char name[256];
|
||||
char uri[256];
|
||||
akgl_GameState state;
|
||||
SDL_Mutex *statelock;
|
||||
int16_t fps;
|
||||
SDL_Time gameStartTime;
|
||||
SDL_Time lastIterTime;
|
||||
SDL_Time lastFPSTime;
|
||||
int16_t framesSinceUpdate;
|
||||
void (*lowfpsfunc)(void);
|
||||
char libversion[32]; /**< libakgl's version, stamped by akgl_game_init. Compared on load to refuse a save from another build. */
|
||||
char version[32]; /**< The *application's* version. Caller-supplied, required, and must be a semver string. */
|
||||
char name[256]; /**< Application name. Caller-supplied and required; also becomes SDL's app metadata. */
|
||||
char uri[256]; /**< Application URI, e.g. a reverse-DNS identifier. Caller-supplied, required, and used as the window title. */
|
||||
akgl_GameState state; /**< The application's own state flags. */
|
||||
SDL_Mutex *statelock; /**< Guards `state`. Created by akgl_game_init. */
|
||||
int16_t fps; /**< Frames drawn during the last completed second. Recomputed once per second, not per frame. */
|
||||
SDL_Time gameStartTime; /**< `SDL_GetTicksNS()` at akgl_game_init. */
|
||||
SDL_Time lastIterTime; /**< Timestamp of the most recent akgl_game_update_fps call. */
|
||||
SDL_Time lastFPSTime; /**< When `fps` was last recomputed. */
|
||||
int16_t framesSinceUpdate; /**< Frames counted so far in the current second. */
|
||||
void (*lowfpsfunc)(void); /**< Called every frame while `fps` is under 30. akgl_game_init installs akgl_game_lowfps, and akgl_game_update_fps installs it too if it finds this NULL -- an embedder that binds its own renderer rather than calling akgl_game_init used to crash here on frame one. Replace it to do something more useful than log. */
|
||||
} akgl_Game;
|
||||
|
||||
extern SDL_Window *window;
|
||||
extern akgl_Tilemap gamemap;
|
||||
extern MIX_Audio *bgm;
|
||||
/** @brief The SDL window, created by akgl_render_2d_init. `NULL` until then. */
|
||||
extern SDL_Window *akgl_window;
|
||||
/** @brief The background music, loaded by akgl_load_start_bgm. `NULL` until then. */
|
||||
extern MIX_Audio *akgl_bgm;
|
||||
/** @brief The mixer device, created by akgl_game_init. Everything audio goes through it. */
|
||||
extern MIX_Mixer *akgl_mixer;
|
||||
/** @brief Playback tracks by slot. #AKGL_GAME_AUDIO_TRACK_BGM is the music track; the rest are the application's to assign. */
|
||||
extern MIX_Track *akgl_tracks[AKGL_GAME_AUDIO_MAX_TRACKS];
|
||||
extern SDL_FRect camera;
|
||||
extern akgl_Game game;
|
||||
extern akgl_RenderBackend renderer;
|
||||
extern akgl_PhysicsBackend physics;
|
||||
/** @brief Storage behind the default `akgl_camera`. Point `akgl_camera` elsewhere rather than reaching for this. */
|
||||
extern SDL_FRect akgl_default_camera;
|
||||
/** @brief The one game object: metadata, timing, and FPS accounting. */
|
||||
extern akgl_Game akgl_game;
|
||||
/** @brief Storage behind the default `akgl_renderer`. */
|
||||
extern akgl_RenderBackend akgl_default_renderer;
|
||||
/** @brief Storage behind the default `akgl_physics`. */
|
||||
extern akgl_PhysicsBackend akgl_default_physics;
|
||||
/** @brief Storage behind the default `akgl_gamemap`. */
|
||||
extern akgl_Tilemap akgl_default_gamemap;
|
||||
|
||||
#define AKGL_BITMASK_HAS(x, y) (x & y) == y
|
||||
#define AKGL_BITMASK_HASNOT(x, y) (x & y) != y
|
||||
#define AKGL_BITMASK_ADD(x, y) x |= y
|
||||
#define AKGL_BITMASK_DEL(x, y) x &= ~(y)
|
||||
#define AKGL_BITMASK_CLEAR(x) x = 0;
|
||||
/** @brief Currently active tilemap. */
|
||||
extern akgl_Tilemap *akgl_gamemap;
|
||||
/** @brief Currently active renderer. */
|
||||
extern akgl_RenderBackend *akgl_renderer;
|
||||
/** @brief Currently active physics backend. */
|
||||
extern akgl_PhysicsBackend *akgl_physics;
|
||||
/** @brief Currently active camera. */
|
||||
extern SDL_FRect *akgl_camera;
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_game_init();
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_game_init_screen();
|
||||
void akgl_game_updateFPS();
|
||||
/**
|
||||
* @brief True when every bit of `y` is set in `x`. Not "any of them" -- all of them.
|
||||
*
|
||||
* Fully parenthesized, so it composes: `!AKGL_BITMASK_HAS(a, b)` and
|
||||
* `AKGL_BITMASK_HAS(a, b) && cond` both mean what they read as. Until 0.5.0 it
|
||||
* expanded to a bare `(x & y) == y`, so a negation bound to the `&` and parsed
|
||||
* as `!(a & b) == b`. Nothing in the tree negated it -- #AKGL_BITMASK_HASNOT
|
||||
* exists for that -- which is the only reason it was latent rather than live.
|
||||
*/
|
||||
#define AKGL_BITMASK_HAS(x, y) ((((x) & (y)) == (y)))
|
||||
/** @brief True when at least one bit of `y` is missing from `x`. */
|
||||
#define AKGL_BITMASK_HASNOT(x, y) ((((x) & (y)) != (y)))
|
||||
/** @brief Set every bit of `y` in `x`. Modifies `x`. */
|
||||
#define AKGL_BITMASK_ADD(x, y) ((x) |= (y))
|
||||
/** @brief Clear every bit of `y` in `x`. Modifies `x`. */
|
||||
#define AKGL_BITMASK_DEL(x, y) ((x) &= ~(y))
|
||||
/**
|
||||
* @brief Clear every bit of `x`. Modifies `x`.
|
||||
*
|
||||
* Carries no trailing semicolon: write `AKGL_BITMASK_CLEAR(flags);` like any
|
||||
* other statement. It used to include one, so every ordinary use produced an
|
||||
* empty statement after it and a use as the whole body of an unbraced `if`
|
||||
* would have taken the following statement with it.
|
||||
*/
|
||||
#define AKGL_BITMASK_CLEAR(x) ((x) = 0)
|
||||
|
||||
/**
|
||||
* @brief Bring the whole library up: error codes, pools, registries, SDL, audio, fonts, gamepads.
|
||||
*
|
||||
* In order: claim the libakgl status band (so every later error has a name),
|
||||
* stamp the library version, start the frame clock, create the state mutex,
|
||||
* check that the caller filled in the three required `game` fields, zero the
|
||||
* pools, create the registries, hand SDL the app metadata, clear the control
|
||||
* maps, `SDL_Init` video/gamepad/audio, load the bundled controller database,
|
||||
* open any attached gamepads, start SDL_mixer and SDL_ttf, and finally point
|
||||
* `akgl_renderer`, `akgl_physics`, `akgl_camera`, and `akgl_gamemap` at their default storage.
|
||||
*
|
||||
* What it does *not* do: create the window, choose a physics backend, or load
|
||||
* any configuration. Those read properties, so they come after the caller has
|
||||
* set them. See the sequence at the top of this file.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If `akgl_game.name`, `akgl_game.version`, or `akgl_game.uri` is
|
||||
* empty. All three are required and there are no defaults -- the
|
||||
* window title, SDL's app metadata, and the savegame compatibility
|
||||
* check are all built from them.
|
||||
* @throws AKGL_ERR_SDL If the state mutex cannot be created, if `SDL_Init`
|
||||
* fails, if a controller-database entry is rejected, if SDL_mixer
|
||||
* cannot start or open the default playback device, or if SDL_ttf
|
||||
* cannot start. Each message carries `SDL_GetError()`.
|
||||
* @throws AKERR_STATUS_RANGE_OVERLAP If another component already owns part of
|
||||
* the libakgl status band. See akgl_error_init.
|
||||
* @throws AKERR_* Whatever the heap, registry, and controller initializers raise.
|
||||
*
|
||||
* @note It takes the state lock on the way in and releases it on the way out, so
|
||||
* a failure part-way through leaves the mutex held.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_game_init(void);
|
||||
/**
|
||||
* @brief Count this frame, and recompute the frame rate once a second has passed.
|
||||
*
|
||||
* Called at the top of akgl_game_update, so a caller running its own loop needs
|
||||
* to call it itself. `akgl_game.fps` is only refreshed when a full second has
|
||||
* elapsed, so it is a completed-second average rather than an instantaneous
|
||||
* figure -- and it reads 0 for the first second of the process, which is under
|
||||
* the low-FPS threshold and so fires `lowfpsfunc` on every frame until the first
|
||||
* second is up.
|
||||
*/
|
||||
void akgl_game_update_fps(void);
|
||||
/**
|
||||
* @brief Write the game state and the name-to-pointer tables to a save file.
|
||||
*
|
||||
* Writes the `akgl_Game` struct verbatim, then four name tables -- actors,
|
||||
* sprites, spritesheets, characters -- each mapping a registered name to the
|
||||
* address the object had at save time, and each terminated by a zeroed name and
|
||||
* a zeroed pointer. The tables are what let akgl_game_load reconnect pointers
|
||||
* between objects that will be at different addresses next run.
|
||||
*
|
||||
* @param fpath Path to write. Required. Opened with `"wb"`; an existing file is
|
||||
* truncated.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p fpath is `NULL`.
|
||||
* @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
|
||||
* session. See also TODO.md, "Known and still open" item 7 -- the writer
|
||||
* and the reader disagree on the name-field widths, so a save with any
|
||||
* registered spritesheet cannot be read back.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_game_save(char *fpath);
|
||||
/**
|
||||
* @brief Read a save file back, refusing one that does not match this build.
|
||||
*
|
||||
* Reads the saved `akgl_Game`, then rejects the file unless the library version,
|
||||
* the game version, the game name, and the game URI all match the running
|
||||
* program -- versions by exact semver equality, name and URI by string compare.
|
||||
* Only then does it copy the saved state over `game` and rebuild the four
|
||||
* old-address-to-current-object maps.
|
||||
*
|
||||
* @param fpath Path to read. Required. Opened with `"rb"`.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p fpath is `NULL`.
|
||||
* @throws ENOENT, EACCES Or whatever else `fopen(3)` reports.
|
||||
* @throws AKERR_EOF If a read runs off the end of the file -- which is what a
|
||||
* truncated or corrupt name table looks like.
|
||||
* @throws AKERR_IO On a stream error.
|
||||
* @throws AKERR_VALUE If either version string in the save file, or either in
|
||||
* 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`
|
||||
* sets it builds are never destroyed, so each call leaks them.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_game_load(char *fpath);
|
||||
/**
|
||||
* @brief The default `akgl_game.lowfpsfunc`: log the current frame rate.
|
||||
*
|
||||
* Called from akgl_game_update_fps on every frame where `akgl_game.fps` is under 30.
|
||||
* It is a placeholder -- the point of the hook is that a game can replace it
|
||||
* with something that actually sheds work.
|
||||
*/
|
||||
void akgl_game_lowfps(void);
|
||||
/**
|
||||
* @brief Take the game-state mutex, retrying rather than blocking.
|
||||
*
|
||||
* Polls with `SDL_TryLockMutex` on a 100 ms cadence instead of blocking
|
||||
* outright, so a deadlock reports an error rather than hanging the process.
|
||||
*
|
||||
* @return `NULL` once the lock is held, otherwise an error context owned by the
|
||||
* caller.
|
||||
* @throws AKGL_ERR_SDL If #AKGL_GAME_STATE_LOCK_BUDGET_MS elapses with the lock
|
||||
* still held elsewhere. `SDL_GetError()` is appended for whatever it is
|
||||
* worth, but after a failed `SDL_TryLockMutex` it is usually stale or
|
||||
* empty -- contention is reported by the return value, not by an error
|
||||
* string. The status is the signal, not the text.
|
||||
*
|
||||
* @note Before 0.5.0 the loop counted against a constant named
|
||||
* `AKGL_TIME_ONESEC_MS` that held 1000000, so it retried 10,000 times at
|
||||
* 100 ms and blocked for roughly sixteen minutes rather than the one
|
||||
* second documented here.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_game_state_lock(void);
|
||||
/**
|
||||
* @brief Release the game-state mutex.
|
||||
*
|
||||
* @return `NULL`. `SDL_UnlockMutex` reports nothing, so there is no failure path
|
||||
* -- including for the case that matters, unlocking a mutex this thread
|
||||
* does not hold, which is undefined behaviour in SDL rather than an
|
||||
* error here.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_game_state_unlock(void);
|
||||
/**
|
||||
* @brief One frame: update every actor layer by layer, step the physics, draw the world.
|
||||
*
|
||||
* Takes the state lock, counts the frame, then calls each live actor's
|
||||
* `updatefunc` **exactly once**, optionally rescaling it to the tilemap first.
|
||||
* Then it steps `akgl_physics` and draws through `akgl_renderer`, and releases
|
||||
* the lock.
|
||||
*
|
||||
* Until 0.5.0 the sweep sat inside a walk over #AKGL_TILEMAP_MAX_LAYERS that
|
||||
* never compared an actor's `layer` to the layer it was on, so every actor
|
||||
* updated sixteen times a frame. Updating an actor is not a per-layer
|
||||
* operation; drawing is, and akgl_render_2d_draw_world walks the layers itself.
|
||||
*
|
||||
* @param opflags Iterator flags. Optional -- `NULL` selects
|
||||
* #AKGL_ITERATOR_OP_UPDATE alone, which is every live actor
|
||||
* once. Two bits are read here:
|
||||
* #AKGL_ITERATOR_OP_TILEMAPSCALE, without which every actor's
|
||||
* `scale` is forced to 1.0; and #AKGL_ITERATOR_OP_LAYERMASK,
|
||||
* which restricts the sweep to the actors whose `layer` matches
|
||||
* `layerid`. Note that the flags are *not* forwarded to the
|
||||
* physics or render calls, both of which are passed `NULL`.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKGL_ERR_SDL If the state lock cannot be taken.
|
||||
* @throws AKERR_* Whatever an actor's `updatefunc`, akgl_tilemap_scale_actor,
|
||||
* the physics backend, or the renderer raises.
|
||||
*
|
||||
* @warning Every failure path returns with the state lock still held, and a live
|
||||
* actor's `updatefunc` is called without a `NULL` check -- a hand-built
|
||||
* actor that never went through akgl_actor_initialize crashes here.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_game_update(akgl_Iterator *opflags);
|
||||
|
||||
|
||||
/*
|
||||
* These functions are part of the internal API and should not be called by the
|
||||
* user. They are only exposed here for unit testing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @brief Write all four name-to-address tables, each with its terminator.
|
||||
*
|
||||
* Actors, sprites, spritesheets, characters, in that order -- which is the order
|
||||
* akgl_game_load reads them back in. Each table is the registry enumerated one
|
||||
* entry at a time, followed by a zeroed name field and a zeroed pointer that the
|
||||
* reader stops on.
|
||||
*
|
||||
* @param fp The open save-game stream. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p fp is `NULL`.
|
||||
* @throws AKERR_IO On a stream error or a short write while emitting a
|
||||
* terminator.
|
||||
*
|
||||
* @note Only the terminators are written through the error-reporting path. The
|
||||
* entries themselves go through `SDL_EnumerateProperties`, whose callbacks
|
||||
* cannot report failure upward and instead terminate the process -- so a
|
||||
* write error mid-table never reaches this function's return value.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_game_save_actors(FILE *fp);
|
||||
/**
|
||||
* @brief Refuse a save file unless its version matches the running one exactly.
|
||||
*
|
||||
* Both strings are parsed as semver and compared with `"="` -- exact equality,
|
||||
* not compatibility. That is deliberate and strict: a save file is a raw memory
|
||||
* image of `akgl_Game` and the object tables, so any change to a struct layout
|
||||
* invalidates it, and semver has no way to say "the layout did not move".
|
||||
*
|
||||
* @param versiontype What is being compared -- `"library"` or `"game"`. Used
|
||||
* only to build the message, but required, since a bare
|
||||
* "incompatible version" error would not say which.
|
||||
* @param newversion The version read out of the save file. Required.
|
||||
* @param curversion The version of the running program or library. Required.
|
||||
* @return `NULL` when the two match, otherwise an error context owned by the
|
||||
* caller.
|
||||
* @throws AKERR_NULLPOINTER If any of the three arguments is `NULL`.
|
||||
* @throws AKERR_VALUE If either string is not valid semver. The message says
|
||||
* which side it came from.
|
||||
* @throws AKERR_API If both parse but are not equal. The message quotes both.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_game_load_versioncmp(char *versiontype, char *newversion, char *curversion);
|
||||
|
||||
#endif //_AKGL_GAME_H_
|
||||
|
||||
@@ -1,10 +1,38 @@
|
||||
/**
|
||||
* @file heap.h
|
||||
* @brief The object pools. This library does not call `malloc`, it claims slots.
|
||||
*
|
||||
* Every runtime object -- actors, sprites, spritesheets, characters, strings --
|
||||
* comes out of a fixed, statically allocated array declared here. A "heap layer"
|
||||
* is one such array plus its `next`/`release` pair. Allocation is a linear scan
|
||||
* for a slot whose `refcount` is 0; release decrements, and the slot is zeroed
|
||||
* and unregistered when the count reaches 0.
|
||||
*
|
||||
* The consequence to design around is that exhaustion is a *normal* error, not
|
||||
* an out-of-memory catastrophe: AKGL_ERR_HEAP means the pool is full, and the
|
||||
* fix is usually a missing release rather than a bigger pool. Every ceiling
|
||||
* below is overridable at compile time, so a game that needs 256 actors defines
|
||||
* `AKGL_MAX_HEAP_ACTOR` before including this -- but the arrays are sized at
|
||||
* compile time, so the library and everything linking it must agree.
|
||||
*
|
||||
* If you need a new kind of runtime object, add a layer here. Do not reach for
|
||||
* the allocator.
|
||||
*
|
||||
* @warning The acquire functions are asymmetric: akgl_heap_next_string takes the
|
||||
* reference for you, and the other four do not -- their caller is
|
||||
* expected to take it, which in practice the `*_initialize` function
|
||||
* does. Until one is taken the slot is still free and the next
|
||||
* allocation hands out the same pointer. TODO.md, "Known and still
|
||||
* open" item 8.
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_HEAP_H_
|
||||
#define _AKGL_HEAP_H_
|
||||
|
||||
#include "sprite.h"
|
||||
#include "actor.h"
|
||||
#include "character.h"
|
||||
#include "staticstring.h"
|
||||
#include <akgl/sprite.h>
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/character.h>
|
||||
#include <akgl/staticstring.h>
|
||||
#include <akerror.h>
|
||||
|
||||
#ifndef AKGL_MAX_HEAP_ACTOR
|
||||
@@ -23,24 +51,171 @@
|
||||
#define AKGL_MAX_HEAP_STRING 256
|
||||
#endif
|
||||
|
||||
extern akgl_Actor HEAP_ACTOR[AKGL_MAX_HEAP_ACTOR];
|
||||
extern akgl_Sprite HEAP_SPRITE[AKGL_MAX_HEAP_SPRITE];
|
||||
extern akgl_SpriteSheet HEAP_SPRITESHEET[AKGL_MAX_HEAP_SPRITESHEET];
|
||||
extern akgl_Character HEAP_CHARACTER[AKGL_MAX_HEAP_CHARACTER];
|
||||
extern akgl_String HEAP_STRING[AKGL_MAX_HEAP_STRING];
|
||||
/** @brief The actor pool. Public so the render and physics sweeps can walk it directly instead of going through the registry. */
|
||||
extern akgl_Actor akgl_heap_actors[AKGL_MAX_HEAP_ACTOR];
|
||||
/** @brief The sprite pool. 16 per actor, on the assumption of one sprite per state combination. */
|
||||
extern akgl_Sprite akgl_heap_sprites[AKGL_MAX_HEAP_SPRITE];
|
||||
/** @brief The spritesheet pool. Sized like the sprite pool, though sharing means far fewer are used in practice. */
|
||||
extern akgl_SpriteSheet akgl_heap_spritesheets[AKGL_MAX_HEAP_SPRITESHEET];
|
||||
/** @brief The character pool. */
|
||||
extern akgl_Character akgl_heap_characters[AKGL_MAX_HEAP_CHARACTER];
|
||||
/** @brief The string pool. Every entry is PATH_MAX bytes, so this is the largest of the five by a wide margin. */
|
||||
extern akgl_String akgl_heap_strings[AKGL_MAX_HEAP_STRING];
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_init();
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_init_actor();
|
||||
/**
|
||||
* @brief Zero every pool, marking every slot free.
|
||||
*
|
||||
* Call once at startup, before anything allocates. Calling it again is a reset,
|
||||
* not a refresh: it does not release textures, clear registries, or consult
|
||||
* reference counts, so every live object becomes a dangling pointer and every
|
||||
* registry entry points at a zeroed slot. akgl_game_init calls it for you.
|
||||
*
|
||||
* @return `NULL`. There is no failure path today -- it is a series of `memset`s
|
||||
* -- but check it anyway; the signature exists so a layer that needs
|
||||
* real setup has somewhere to report from.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_init(void);
|
||||
/**
|
||||
* @brief Zero the actor pool only.
|
||||
*
|
||||
* Split out from akgl_heap_init so a caller can reset the actors between levels
|
||||
* while keeping the loaded sprites, sheets, and characters -- those are the
|
||||
* expensive ones, since they own textures. Pair it with
|
||||
* akgl_registry_init_actor, which clears the matching registry.
|
||||
*
|
||||
* @return `NULL`. No failure path today; see akgl_heap_init.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_init_actor(void);
|
||||
/**
|
||||
* @brief Claim a free actor slot.
|
||||
* @param dest Receives a pointer to the free slot. Required, and **not**
|
||||
* checked -- a `NULL` here is a crash, not an error context. The
|
||||
* slot is *not* zeroed and its `refcount` is not incremented; it
|
||||
* stays free until akgl_actor_initialize takes the reference.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKGL_ERR_HEAP If every one of the #AKGL_MAX_HEAP_ACTOR slots is in use.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_next_actor(akgl_Actor **dest);
|
||||
/**
|
||||
* @brief Claim a free sprite slot.
|
||||
* @param dest Receives a pointer to the free slot. Required and unchecked; not
|
||||
* zeroed, and no reference taken. See akgl_heap_next_actor.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKGL_ERR_HEAP If every one of the #AKGL_MAX_HEAP_SPRITE slots is in use.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_next_sprite(akgl_Sprite **dest);
|
||||
/**
|
||||
* @brief Claim a free spritesheet slot.
|
||||
* @param dest Receives a pointer to the free slot. Required and unchecked; not
|
||||
* zeroed, and no reference taken. See akgl_heap_next_actor.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKGL_ERR_HEAP If every one of the #AKGL_MAX_HEAP_SPRITESHEET slots is
|
||||
* in use.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_next_spritesheet(akgl_SpriteSheet **dest);
|
||||
/**
|
||||
* @brief Claim a free character slot.
|
||||
* @param dest Receives a pointer to the free slot. Required and unchecked; not
|
||||
* zeroed, and no reference taken. See akgl_heap_next_actor.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKGL_ERR_HEAP If every one of the #AKGL_MAX_HEAP_CHARACTER slots is in
|
||||
* use.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_next_character(akgl_Character **dest);
|
||||
/**
|
||||
* @brief Claim a free string slot, and take the reference on it.
|
||||
*
|
||||
* The odd one out: this *does* increment `refcount`, so the slot is yours the
|
||||
* moment it returns and stays yours until you call akgl_heap_release_string.
|
||||
* That is what makes the scratch-buffer idiom -- claim, use, release in
|
||||
* `CLEANUP` -- safe. The contents are whatever the last holder left; call
|
||||
* akgl_string_initialize if you need it clean.
|
||||
*
|
||||
* @param dest Receives a pointer to the claimed slot. Required and **not**
|
||||
* checked -- a `NULL` here is a crash, not an error context.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKGL_ERR_HEAP If every one of the #AKGL_MAX_HEAP_STRING slots is in
|
||||
* use. In practice this means a missing release somewhere, not a pool
|
||||
* that is genuinely too small.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_next_string(akgl_String **dest);
|
||||
|
||||
/**
|
||||
* @brief Drop a reference to an actor, tearing it down when the last one goes.
|
||||
*
|
||||
* At zero it releases every child recursively, clears the actor's entry from
|
||||
* #AKGL_REGISTRY_ACTOR, and zeroes the slot.
|
||||
*
|
||||
* @param ptr The actor to release. Required. A slot whose `refcount` is already
|
||||
* 0 is re-torn-down rather than rejected, which is harmless on a
|
||||
* zeroed slot and destructive on a live one that was never
|
||||
* registered.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p ptr is `NULL`.
|
||||
*
|
||||
* @warning Children are released unconditionally, and the recursion has no cycle
|
||||
* check: an actor reachable from its own child list recurses until the
|
||||
* stack runs out.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_release_actor(akgl_Actor *ptr);
|
||||
/**
|
||||
* @brief Drop a reference to a sprite, tearing it down when the last one goes.
|
||||
*
|
||||
* At zero it clears the sprite's entry from #AKGL_REGISTRY_SPRITE and zeroes the
|
||||
* slot. The spritesheet it pointed at is *not* released -- the sprite only
|
||||
* borrowed it.
|
||||
*
|
||||
* @param ptr The sprite to release. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p ptr is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_release_sprite(akgl_Sprite *ptr);
|
||||
/**
|
||||
* @brief Drop a reference to a spritesheet, destroying its texture when the last one goes.
|
||||
*
|
||||
* The only release that frees a resource outside the pool: at zero it clears the
|
||||
* entry from #AKGL_REGISTRY_SPRITESHEET, destroys the `SDL_Texture`, and zeroes
|
||||
* the slot. Any sprite still pointing at the sheet is left with a dangling
|
||||
* pointer, since nothing takes a reference on a sheet on a sprite's behalf.
|
||||
*
|
||||
* @param ptr The spritesheet to release. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p ptr is `NULL`.
|
||||
*
|
||||
* @note Destroying a texture is a main-thread operation in SDL, so this must be
|
||||
* called from the thread that owns the renderer.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_release_spritesheet(akgl_SpriteSheet *ptr);
|
||||
/**
|
||||
* @brief Drop a reference to a character, tearing it down when the last one goes.
|
||||
*
|
||||
* At zero it clears the entry from #AKGL_REGISTRY_CHARACTER and zeroes the slot.
|
||||
*
|
||||
* @param ptr The character to release. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p ptr is `NULL`.
|
||||
*
|
||||
* @note At zero it also walks the state-to-sprite map with
|
||||
* akgl_character_state_sprites_iterate and #AKGL_ITERATOR_OP_RELEASE,
|
||||
* giving back every reference akgl_character_sprite_add took, and then
|
||||
* destroys the `SDL_PropertiesID` holding the map. Before 0.5.0 it did
|
||||
* neither, so loading and releasing characters level by level exhausted
|
||||
* the sprite pool and leaked a property set per character.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_release_character(akgl_Character *ptr);
|
||||
/**
|
||||
* @brief Drop a reference to a pooled string, zeroing it when the last one goes.
|
||||
*
|
||||
* Strings are not registered anywhere, so this is just the reference count and a
|
||||
* wipe. It is safe to call on a string a function may or may not have claimed,
|
||||
* which is why the `CLEANUP` blocks in this library call it unconditionally.
|
||||
*
|
||||
* @param ptr The string to release. Required -- unlike the pattern elsewhere, a
|
||||
* `NULL` here is an error rather than a no-op, so `CLEANUP` blocks
|
||||
* wrap it in `IGNORE()`.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p ptr is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_heap_release_string(akgl_String *ptr);
|
||||
|
||||
#endif //_AKGL_HEAP_H_
|
||||
|
||||
@@ -1,43 +1,65 @@
|
||||
/**
|
||||
* @file iterator.h
|
||||
* @brief The work order handed to a registry sweep.
|
||||
*
|
||||
* There is no iterator object with a `next()` on it. Traversal is SDL's --
|
||||
* `SDL_EnumerateProperties` over a registry -- and this struct is the `userdata`
|
||||
* carried into each callback, telling it *which* entries to touch and *what* to
|
||||
* do to each one. akgl_registry_iterate_actor and
|
||||
* akgl_registry_iterate_character are the callbacks that read it;
|
||||
* akgl_game_update, akgl_physics_simulate, and akgl_render_2d_draw_world are the
|
||||
* entry points that take one.
|
||||
*
|
||||
* The operations are independent bits, not an enum: a single sweep can update,
|
||||
* scale, and render, and they run in that fixed order regardless of how the bits
|
||||
* were set. Passing `NULL` where an `akgl_Iterator *` is expected is not an
|
||||
* error at the top-level entry points -- each substitutes its own default set --
|
||||
* but it *is* an error once inside a callback.
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_ITERATOR_H_
|
||||
#define _AKGL_ITERATOR_H_
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
/** @brief Selects operations and an optional layer for actor traversal. */
|
||||
typedef struct {
|
||||
uint32_t flags;
|
||||
uint8_t layerid;
|
||||
uint32_t flags; /**< Bitwise OR of the `AKGL_ITERATOR_OP_*` values below. */
|
||||
uint8_t layerid; /**< Layer to restrict the sweep to. Read only when #AKGL_ITERATOR_OP_LAYERMASK is set. */
|
||||
} akgl_Iterator;
|
||||
|
||||
#define AKGL_ITERATOR_OP_UPDATE 1 // 1
|
||||
#define AKGL_ITERATOR_OP_RENDER 1 << 1 // 2
|
||||
#define AKGL_ITERATOR_OP_RELEASE 1 << 2 // 4
|
||||
#define AKGL_ITERATOR_OP_LAYERMASK 1 << 3 // 8
|
||||
#define AKGL_ITERATOR_OP_TILEMAPSCALE 1 << 4 // 16
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_5 1 << 5 // 32
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_6 1 << 6 // 64
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_7 1 << 7 // 128
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_8 1 << 8 // 256
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_9 1 << 9 // 512
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_10 1 << 10 // 1024
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_11 1 << 11 // 2048
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_12 1 << 12 // 4096
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_13 1 << 13 // 8192
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_14 1 << 14 // 16384
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_15 1 << 15 // 32768
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_16 1 << 16 // 65536
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_17 1 << 17 // 131072
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_18 1 << 18 // 262144
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_19 1 << 19 // 524288
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_20 1 << 20 // 1048576
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_21 1 << 21 // 2097152
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_22 1 << 22 // 4194304
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_23 1 << 23 // 8388608
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_24 1 << 24 // 16777216
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_25 1 << 25 // 33554432
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_26 1 << 26 // 67108864
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_27 1 << 27 // 134217728
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_28 1 << 28 // 268435456
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_29 1 << 29 // 536870912
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_30 1 << 30 // 1073741824
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_31 1 << 31 // 2147483648
|
||||
#define AKGL_ITERATOR_OP_UPDATE (1 << 0) // 1 Call the actor's updatefunc
|
||||
#define AKGL_ITERATOR_OP_RENDER (1 << 1) // 2 Call the actor's renderfunc
|
||||
#define AKGL_ITERATOR_OP_RELEASE (1 << 2) // 4 Release the object back to its heap layer
|
||||
#define AKGL_ITERATOR_OP_LAYERMASK (1 << 3) // 8 Skip anything whose layer != layerid
|
||||
#define AKGL_ITERATOR_OP_TILEMAPSCALE (1 << 4) // 16 Scale actors to the tilemap; otherwise force scale 1.0
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_5 (1 << 5) // 32
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_6 (1 << 6) // 64
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_7 (1 << 7) // 128
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_8 (1 << 8) // 256
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_9 (1 << 9) // 512
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_10 (1 << 10) // 1024
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_11 (1 << 11) // 2048
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_12 (1 << 12) // 4096
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_13 (1 << 13) // 8192
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_14 (1 << 14) // 16384
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_15 (1 << 15) // 32768
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_16 (1 << 16) // 65536
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_17 (1 << 17) // 131072
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_18 (1 << 18) // 262144
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_19 (1 << 19) // 524288
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_20 (1 << 20) // 1048576
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_21 (1 << 21) // 2097152
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_22 (1 << 22) // 4194304
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_23 (1 << 23) // 8388608
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_24 (1 << 24) // 16777216
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_25 (1 << 25) // 33554432
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_26 (1 << 26) // 67108864
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_27 (1 << 27) // 134217728
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_28 (1 << 28) // 268435456
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_29 (1 << 29) // 536870912
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_30 (1 << 30) // 1073741824
|
||||
#define AKGL_ITERATOR_OP_UNDEFINED_31 (1 << 31) // 2147483648
|
||||
|
||||
|
||||
#endif // _AKGL_ITERATOR_H_
|
||||
|
||||
@@ -1,17 +1,238 @@
|
||||
#ifndef _JSON_HELPERS_H_
|
||||
#define _JSON_HELPERS_H_
|
||||
/**
|
||||
* @file json_helpers.h
|
||||
* @brief Typed jansson accessors that report "missing" and "wrong type" as errors.
|
||||
*
|
||||
* Every asset in this library is described by a JSON document, and jansson's own
|
||||
* accessors answer "missing key", "wrong type", and "index past the end" all
|
||||
* with the same `NULL`. These wrappers split those apart -- AKERR_KEY,
|
||||
* AKERR_TYPE, AKERR_OUTOFBOUNDS -- so a malformed asset file produces a message
|
||||
* naming the key and what was wrong with it, instead of a `NULL` dereference
|
||||
* three frames later.
|
||||
*
|
||||
* Conventions that run through the whole set, so they need not be repeated per
|
||||
* function:
|
||||
*
|
||||
* - **Absence is an error here.** Unlike the search functions in libakstdlib,
|
||||
* these treat a missing key as AKERR_KEY. An *optional* key is expressed by
|
||||
* passing the resulting error to akgl_get_json_with_default() rather than by
|
||||
* the accessor staying quiet.
|
||||
* - **The result comes back through @p dest**, because the return value is the
|
||||
* error context.
|
||||
* - **`json_t *` results are borrowed, not owned.** Objects and arrays are
|
||||
* returned as pointers into the document; they are valid until the document is
|
||||
* freed and must not be `json_decref`'d.
|
||||
* - **@p dest is not `NULL`-checked** except where noted, so a `NULL` there is a
|
||||
* crash rather than an error context.
|
||||
* - **The document itself is never modified.**
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_JSON_HELPERS_H_
|
||||
#define _AKGL_JSON_HELPERS_H_
|
||||
|
||||
#include <akerror.h>
|
||||
#include "staticstring.h"
|
||||
#include <akgl/types.h>
|
||||
#include <jansson.h>
|
||||
#include <akgl/staticstring.h>
|
||||
|
||||
/**
|
||||
* @brief Read a nested object out of a JSON object.
|
||||
* @param obj The object to read from. Required.
|
||||
* @param key The member name to look up. A `NULL` key is reported as a missing
|
||||
* key rather than as a `NULL` pointer.
|
||||
* @param dest Receives a borrowed pointer to the nested object; not written on
|
||||
* any failure path. Not checked for `NULL`.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj is `NULL`.
|
||||
* @throws AKERR_KEY If @p key is absent. The message names it.
|
||||
* @throws AKERR_TYPE If @p key is present but is not an object.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_get_json_object_value(json_t *obj, char *key, json_t **dest);
|
||||
/**
|
||||
* @brief Read a boolean out of a JSON object.
|
||||
* @param obj The object to read from. Required.
|
||||
* @param key The member name to look up.
|
||||
* @param dest Receives the value; not written on any failure path. Not checked
|
||||
* for `NULL`.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj is `NULL`.
|
||||
* @throws AKERR_KEY If @p key is absent.
|
||||
* @throws AKERR_TYPE If @p key is present but is not `true` or `false`. A `0`
|
||||
* or `1` is a number in JSON, and is refused here.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_get_json_boolean_value(json_t *obj, char *key, bool *dest);
|
||||
/**
|
||||
* @brief Read an integer out of a JSON object.
|
||||
* @param obj The object to read from. Required.
|
||||
* @param key The member name to look up.
|
||||
* @param dest Receives the value, narrowed to `int`. Not written on any failure
|
||||
* path, and not checked for `NULL`.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj is `NULL`.
|
||||
* @throws AKERR_KEY If @p key is absent.
|
||||
* @throws AKERR_TYPE If @p key is present but is not an integer. This is strict:
|
||||
* `3.0` is a real in JSON and is refused, not truncated. Use
|
||||
* akgl_get_json_number_value() where either spelling should be accepted.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_get_json_integer_value(json_t *obj, char *key, int *dest);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_get_json_number_value(json_t *obj, char *key, float *dest);
|
||||
/**
|
||||
* @brief Read a number out of a JSON object as a `float`.
|
||||
* @param obj The object to read from. Required.
|
||||
* @param key The member name to look up.
|
||||
* @param dest Receives the value, narrowed to `float`. Not written on any
|
||||
* failure path, and not checked for `NULL`.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj is `NULL`.
|
||||
* @throws AKERR_KEY If @p key is absent.
|
||||
* @throws AKERR_TYPE If @p key is present but is not a number. Integers and
|
||||
* reals are both accepted, so `1` and `1.0` behave alike.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_get_json_number_value(json_t *obj, char *key, float32_t *dest);
|
||||
/**
|
||||
* @brief Read a number out of a JSON object as a `double`.
|
||||
*
|
||||
* The full-precision form of akgl_get_json_number_value(), for the physics
|
||||
* constants, which are `double`.
|
||||
*
|
||||
* @param obj The object to read from. Required.
|
||||
* @param key The member name to look up.
|
||||
* @param dest Receives the value. Not written on any failure path, and not
|
||||
* checked for `NULL`.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj is `NULL`.
|
||||
* @throws AKERR_KEY If @p key is absent.
|
||||
* @throws AKERR_TYPE If @p key is present but is not a number.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_get_json_double_value(json_t *obj, char *key, float64_t *dest);
|
||||
/**
|
||||
* @brief Read a string out of a JSON object into a pooled akgl_String.
|
||||
*
|
||||
* Unlike the other accessors this copies, because the caller wants a buffer it
|
||||
* can keep rather than a pointer into the document. `*dest` doubles as an input:
|
||||
* `NULL` means "claim one for me", non-`NULL` means "write into this one".
|
||||
*
|
||||
* @param obj The object to read from. Required.
|
||||
* @param key The member name to look up. Required -- checked here, unlike the
|
||||
* other accessors in this file.
|
||||
* @param dest Address of the destination string. Required, and it must be
|
||||
* *initialized*: set `*dest` to `NULL` to have a pool string claimed
|
||||
* for you, or to a claimed string to write in place. An
|
||||
* indeterminate `*dest` is dereferenced. Either way the caller
|
||||
* releases it with akgl_heap_release_string().
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj, @p key, or @p dest is `NULL`.
|
||||
* @throws AKERR_KEY If @p key is absent.
|
||||
* @throws AKERR_TYPE If @p key is present but is not a string.
|
||||
* @throws AKGL_ERR_HEAP If `*dest` was `NULL` and the string pool is exhausted.
|
||||
*
|
||||
* @note A value longer than #AKGL_MAX_STRING_LENGTH is truncated silently, and
|
||||
* is left without a terminator -- this is `strncpy`, not `strlcpy`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_get_json_string_value(json_t *obj, char *key, akgl_String **dest);
|
||||
/**
|
||||
* @brief Read an array out of a JSON object.
|
||||
* @param obj The object to read from. Required.
|
||||
* @param key The member name to look up.
|
||||
* @param dest Receives a borrowed pointer to the array; not written on any
|
||||
* failure path. Not checked for `NULL`. Use `json_array_size()` on
|
||||
* it and the `akgl_get_json_array_index_*` family to walk it.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj is `NULL`.
|
||||
* @throws AKERR_KEY If @p key is absent.
|
||||
* @throws AKERR_TYPE If @p key is present but is not an array.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_get_json_array_value(json_t *obj, char *key, json_t **dest);
|
||||
/**
|
||||
* @brief Read one element of a JSON array as an object.
|
||||
* @param array The array to read from. Required.
|
||||
* @param index Zero-based element index. A negative index is reported the same
|
||||
* way as one past the end.
|
||||
* @param dest Receives a borrowed pointer to the element; not written on any
|
||||
* failure path. Not checked for `NULL`.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p array is `NULL`.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p index is outside the array. The message
|
||||
* reports the index.
|
||||
* @throws AKERR_TYPE If the element is not an object.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_get_json_array_index_object(json_t *array, int index, json_t **dest);
|
||||
/**
|
||||
* @brief Read one element of a JSON array as an integer.
|
||||
* @param array The array to read from. Required.
|
||||
* @param index Zero-based element index.
|
||||
* @param dest Receives the value, narrowed to `int`; not written on any failure
|
||||
* path. Not checked for `NULL`.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p array is `NULL`.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p index is outside the array.
|
||||
* @throws AKERR_TYPE If the element is not an integer. Strict, as in
|
||||
* akgl_get_json_integer_value().
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_get_json_array_index_integer(json_t *array, int index, int *dest);
|
||||
/**
|
||||
* @brief Read one element of a JSON array into a pooled akgl_String.
|
||||
* @param array The array to read from. Required.
|
||||
* @param index Zero-based element index.
|
||||
* @param dest Address of the destination string, with the same claim-or-reuse
|
||||
* contract as akgl_get_json_string_value(): required, must be
|
||||
* initialized, and released by the caller.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p array or @p dest is `NULL`.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p index is outside the array.
|
||||
* @throws AKERR_TYPE If the element is not a string.
|
||||
* @throws AKGL_ERR_HEAP If `*dest` was `NULL` and the string pool is exhausted.
|
||||
*
|
||||
* @note Truncates silently at #AKGL_MAX_STRING_LENGTH, as
|
||||
* akgl_get_json_string_value() does.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_get_json_array_index_string(json_t *array, int index, akgl_String **dest);
|
||||
|
||||
#endif // _JSON_HELPERS_H_
|
||||
/**
|
||||
* @brief Turn a "key not found" error from one of the accessors above into a default value.
|
||||
*
|
||||
* This is how an optional key is spelled. Run the accessor, hand its error
|
||||
* context here along with the fallback, and a missing key becomes @p dest
|
||||
* holding @p defval and a `NULL` return; anything else propagates untouched:
|
||||
*
|
||||
* ```c
|
||||
* int width = 0;
|
||||
* int width_default = 32;
|
||||
* PASS(errctx,
|
||||
* akgl_get_json_with_default(
|
||||
* akgl_get_json_integer_value(json, "width", &width),
|
||||
* &width_default,
|
||||
* &width,
|
||||
* sizeof(int)
|
||||
* ));
|
||||
* ```
|
||||
*
|
||||
* A `NULL` @p e -- the accessor succeeded -- is the ordinary case and returns at
|
||||
* once without touching @p dest. When the error *is* consumed it is also
|
||||
* released, so the caller must not release it again.
|
||||
*
|
||||
* @param e The error context to inspect, straight from an accessor.
|
||||
* `NULL` means "no error", which is not itself an error.
|
||||
* @param defval The fallback value to copy. Required when @p e is non-`NULL`.
|
||||
* @param dest Where to copy it. Required when @p e is non-`NULL`. Must be the
|
||||
* same destination the accessor was given, and at least
|
||||
* @p defsize bytes.
|
||||
* @param defsize Bytes to copy out of @p defval. Trusted, not derived -- it must
|
||||
* match the type both sides actually are, since this is a
|
||||
* `memcpy` through `void *` with no type information.
|
||||
* @return `NULL` when the error was consumed or there was none, otherwise an
|
||||
* error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p e is non-`NULL` and @p defval or @p dest is
|
||||
* `NULL`.
|
||||
* @throws AKERR_* Whatever @p e carried, if it is not one of the statuses this
|
||||
* defaults on -- an AKERR_TYPE from a key that exists but is the wrong
|
||||
* type propagates, which is right: that is a malformed document, not an
|
||||
* omitted setting.
|
||||
*
|
||||
* @note It defaults on AKERR_KEY and AKERR_INDEX, but *not* on
|
||||
* AKERR_OUTOFBOUNDS -- which is the status the
|
||||
* `akgl_get_json_array_index_*` family actually raises for a short array.
|
||||
* So this pairs with the object accessors and does not currently give an
|
||||
* array index a default.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_get_json_with_default(akerr_ErrorContext *e, void *defval, void *dest, uint32_t defsize);
|
||||
|
||||
#endif // _AKGL_JSON_HELPERS_H_
|
||||
|
||||
@@ -1,40 +1,262 @@
|
||||
#ifndef _PHYSICS_H_
|
||||
#define _PHYSICS_H_
|
||||
/**
|
||||
* @file physics.h
|
||||
* @brief The pluggable physics backend, and the two implementations that ship.
|
||||
*
|
||||
* Same shape as the renderer: a record of function pointers plus an initializer
|
||||
* that fills it in. `null` accepts every call and changes nothing, which is what
|
||||
* a menu screen or a test harness wants; `arcade` applies gravity, drag, thrust
|
||||
* and a speed cap. Selecting between them is akgl_physics_factory, driven by the
|
||||
* `physics.engine` configuration property -- not a branch in the simulation.
|
||||
*
|
||||
* The model an actor is simulated under, and the fields on akgl_Actor that carry
|
||||
* it:
|
||||
*
|
||||
* | Term | Fields | Comes from |
|
||||
* |-----------------------|--------------|-----------------------------------------------|
|
||||
* | 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 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_
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akerror.h>
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/iterator.h>
|
||||
#include <akgl/staticstring.h>
|
||||
|
||||
/** @brief Defines a pluggable physics backend and its environmental parameters. */
|
||||
typedef struct akgl_PhysicsBackend {
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*simulate)(struct akgl_PhysicsBackend *self, akgl_Iterator *opflags);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*gravity)(struct akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*collide)(struct akgl_PhysicsBackend *self, akgl_Actor *a1, akgl_Actor *a2);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*move)(struct akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*simulate)(struct akgl_PhysicsBackend *self, akgl_Iterator *opflags); /**< Step the whole world. Both backends point this at akgl_physics_simulate. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*gravity)(struct akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt); /**< Apply environmental acceleration to one actor. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*collide)(struct akgl_PhysicsBackend *self, akgl_Actor *a1, akgl_Actor *a2); /**< Resolve a collision between two actors. Not called by the simulation loop yet. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*move)(struct akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt); /**< Commit one actor's velocity to its position. */
|
||||
|
||||
double drag_x;
|
||||
double drag_y;
|
||||
double drag_z;
|
||||
double gravity_x;
|
||||
double gravity_y;
|
||||
double gravity_z;
|
||||
SDL_Time gravity_time;
|
||||
SDL_Time timer_gravity;
|
||||
|
||||
float64_t drag_x; /**< Fraction of environmental velocity shed per second along x. 0 disables. From `physics.drag.x`. */
|
||||
float64_t drag_y; /**< Drag along y. From `physics.drag.y`. */
|
||||
float64_t drag_z; /**< Drag along z. From `physics.drag.z`. */
|
||||
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. 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;
|
||||
|
||||
/**
|
||||
* @brief Gravity for the null backend: accept the call and change nothing.
|
||||
* @param self The backend. Required -- the one thing this does check.
|
||||
* @param actor The actor that would have been accelerated. Ignored; `NULL` is
|
||||
* accepted.
|
||||
* @param dt Seconds since the previous step. Ignored.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_null_gravity(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt);
|
||||
/**
|
||||
* @brief Collision for the null backend: accept the call and change nothing.
|
||||
*
|
||||
* Note that this *succeeds* where the arcade backend's collide refuses -- the
|
||||
* null backend's contract is "nothing collides", which is an answer, not a gap.
|
||||
*
|
||||
* @param self The backend. Required.
|
||||
* @param a1 First actor. Ignored; `NULL` is accepted.
|
||||
* @param a2 Second actor. Ignored; `NULL` is accepted.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_null_collide(akgl_PhysicsBackend *self, akgl_Actor *a1, akgl_Actor *a2);
|
||||
/**
|
||||
* @brief Movement for the null backend: accept the call and change nothing.
|
||||
*
|
||||
* Actors under the null backend do not move on their own. Anything that sets an
|
||||
* actor's `x`/`y` directly still works -- this only declines to integrate
|
||||
* velocity.
|
||||
*
|
||||
* @param self The backend. Required.
|
||||
* @param actor The actor that would have moved. Ignored; `NULL` is accepted.
|
||||
* @param dt Seconds since the previous step. Ignored.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_null_move(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt);
|
||||
/**
|
||||
* @brief Install the null backend's methods.
|
||||
*
|
||||
* Reads no configuration and touches no gravity or drag fields, so whatever was
|
||||
* in them stays -- harmlessly, since none of the null methods look.
|
||||
*
|
||||
* @param self The backend to initialize. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_init_null(akgl_PhysicsBackend *self);
|
||||
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_ss_gravity(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_ss_collide(akgl_PhysicsBackend *self, akgl_Actor *a1, akgl_Actor *a2);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_ss_move(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_init_sidescroller(akgl_PhysicsBackend *self);
|
||||
/**
|
||||
* @brief Accelerate one actor's environmental velocity by gravity.
|
||||
*
|
||||
* Adds `gravity * dt` to the actor's `ex`/`ey`/`ez` on each axis whose gravity
|
||||
* is non-zero, with the sign chosen for screen space: x pulls left, y pulls
|
||||
* down, z pulls away from the camera. It touches only the environmental term, so
|
||||
* an actor's own thrust is unaffected and the speed cap does not apply -- which
|
||||
* is why a falling actor keeps accelerating past its character's top speed.
|
||||
*
|
||||
* @param self The backend supplying the gravity constants. Required.
|
||||
* @param actor The actor to accelerate. Required.
|
||||
* @param dt Seconds since the previous step. A `dt` of 0 is a no-op; a
|
||||
* negative one accelerates backwards.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self or @p actor is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_arcade_gravity(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt);
|
||||
/**
|
||||
* @brief Collision for the arcade backend. Not implemented.
|
||||
*
|
||||
* Deliberately loud rather than silently permissive: unlike
|
||||
* akgl_physics_null_collide, which means "nothing collides", this means "nobody
|
||||
* has written this yet", and a caller that reaches it should find out.
|
||||
*
|
||||
* @param self The backend. Required, and checked before the refusal.
|
||||
* @param a1 First actor. Never examined.
|
||||
* @param a2 Second actor. Never examined.
|
||||
* @return Never `NULL`.
|
||||
* @throws AKERR_NULLPOINTER If @p self is `NULL`.
|
||||
* @throws AKERR_API Otherwise, always, with the message "Not implemented".
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_arcade_collide(akgl_PhysicsBackend *self, akgl_Actor *a1, akgl_Actor *a2);
|
||||
/**
|
||||
* @brief Commit an actor's velocity to its position.
|
||||
*
|
||||
* The last step of a simulation tick: `position += velocity * dt` on all three
|
||||
* axes. It does not clamp to the map, consult the tilemap, or test for
|
||||
* collisions -- an actor will walk straight through a wall and off the edge of
|
||||
* the world.
|
||||
*
|
||||
* @param self The backend. Required, though nothing on it is read.
|
||||
* @param actor The actor to move. Required. Its `vx`/`vy`/`vz` must already have
|
||||
* been computed; akgl_physics_simulate does that immediately
|
||||
* before calling this.
|
||||
* @param dt Seconds since the previous step.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self or @p actor is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_arcade_move(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt);
|
||||
/**
|
||||
* @brief Install the arcade backend's methods and read its constants from configuration.
|
||||
*
|
||||
* Reads `physics.gravity.x`, `.y`, `.z` and `physics.drag.x`, `.y`, `.z` from
|
||||
* the property registry, all defaulting to `"0.0"` -- so an arcade backend with
|
||||
* no configuration behaves like the null one until something is set.
|
||||
*
|
||||
* @param self The backend to initialize. Required. Its method pointers are
|
||||
* installed before the properties are read, so a failure part-way
|
||||
* leaves a usable backend with some constants still at their
|
||||
* previous values.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self is `NULL`.
|
||||
* @throws AKERR_VALUE If one of the six properties is set to something that is
|
||||
* not a number.
|
||||
* @throws ERANGE If one of them does not fit a `double`.
|
||||
* @throws AKGL_ERR_HEAP If the string pool is exhausted.
|
||||
*
|
||||
* @note With #AKGL_REGISTRY_PROPERTIES uninitialized every property reads back
|
||||
* as its default, so this silently configures zero gravity and zero drag
|
||||
* rather than reporting anything. See the warning in registry.h.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_init_arcade(akgl_PhysicsBackend *self);
|
||||
|
||||
/**
|
||||
* @brief Select and initialize a physics backend by name.
|
||||
*
|
||||
* The dispatch point for the whole subsystem: `"null"` and `"arcade"` are the
|
||||
* two names, and akgl_game_init passes whatever the `physics.engine` property
|
||||
* holds. Adding a backend means adding a name here, not a branch in the
|
||||
* simulation.
|
||||
*
|
||||
* @param self The backend to initialize. Required.
|
||||
* @param type The backend name. Required. Matched on its leading characters, so
|
||||
* `"nullify"` selects `null` and `"arcadia"` selects `arcade`.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self or @p type is `NULL`.
|
||||
* @throws AKERR_KEY If @p type matches neither name. The message quotes what was
|
||||
* asked for.
|
||||
* @throws AKERR_* Whatever the selected initializer raises.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_factory(akgl_PhysicsBackend *self, akgl_String *type);
|
||||
|
||||
/**
|
||||
* @brief Step every live actor forward by the time elapsed since the previous call.
|
||||
*
|
||||
* Shared by both backends -- what differs is the `gravity` and `move` they point
|
||||
* at. For each live actor, in pool order:
|
||||
*
|
||||
* - a child actor is snapped to its parent's position plus its own `v` as an
|
||||
* 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 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 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".
|
||||
* Only #AKGL_ITERATOR_OP_LAYERMASK is honoured, restricting the
|
||||
* step to actors on `layerid`; the update, render, and release
|
||||
* bits are ignored here.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self or `self->move` is `NULL`.
|
||||
* @throws AKERR_* Whatever an actor's `movementlogicfunc`, or the backend's
|
||||
* `gravity` or `move`, raises. The first failure aborts the whole step,
|
||||
* leaving the actors already processed advanced and the rest not.
|
||||
*
|
||||
* @note An actor's `movementlogicfunc` can raise AKGL_ERR_LOGICINTERRUPT to say
|
||||
* "skip the rest of the simulation for me this tick". It is handled here
|
||||
* and does not propagate -- it is a control signal wearing an error's
|
||||
* clothes, not a failure. Only `movementlogicfunc` gets that treatment:
|
||||
* the `gravity` and `move` calls return straight out of the loop on any
|
||||
* status, LOGICINTERRUPT included, so a backend must not use it to opt an
|
||||
* actor out from there.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_simulate(akgl_PhysicsBackend *self, akgl_Iterator *opflags);
|
||||
|
||||
#endif // _PHYSICS_H_
|
||||
#endif // _AKGL_PHYSICS_H_
|
||||
|
||||
@@ -1,29 +1,219 @@
|
||||
#ifndef _REGISTRY_H_
|
||||
#define _REGISTRY_H_
|
||||
/**
|
||||
* @file registry.h
|
||||
* @brief The name-to-object lookup tables, and the string configuration store.
|
||||
*
|
||||
* Nothing in this library is passed around by pointer where a name will do. An
|
||||
* actor names the character it instantiates, a character names the sprites it
|
||||
* draws, a sprite names the sheet it cuts frames from -- all resolved at load
|
||||
* time through these eight registries. That is what lets the whole asset graph
|
||||
* be described in JSON files that reference each other by name.
|
||||
*
|
||||
* They are SDL property sets, not akgl types, so a caller can enumerate one with
|
||||
* `SDL_EnumerateProperties` -- which is exactly what akgl_registry_iterate_actor
|
||||
* and akgl_character_state_sprites_iterate are for.
|
||||
*
|
||||
* Every id starts at 0, which SDL treats as "no such property set": reads return
|
||||
* the default and writes are silently dropped. So an uninitialized registry does
|
||||
* not fail loudly, it fails quietly, and akgl_registry_init has to run first.
|
||||
*
|
||||
* @warning akgl_registry_init does *not* initialize
|
||||
* #AKGL_REGISTRY_PROPERTIES -- akgl_registry_init_properties is a
|
||||
* separate call, made by akgl_game_init but by nothing else. A program
|
||||
* that builds its own startup path and skips it gets a silently
|
||||
* no-op akgl_set_property and an akgl_get_property that always hands
|
||||
* back the caller's default, which in turn means akgl_render_2d_init
|
||||
* and akgl_physics_init_arcade quietly ignore their configuration.
|
||||
* TODO.md, "Known and still open" item 3.
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_REGISTRY_H_
|
||||
#define _AKGL_REGISTRY_H_
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/staticstring.h>
|
||||
|
||||
/** @brief Actor name -> `akgl_Actor *`. Written by akgl_actor_initialize, cleared when an actor's last reference goes. */
|
||||
extern SDL_PropertiesID AKGL_REGISTRY_ACTOR;
|
||||
/** @brief Sprite name -> `akgl_Sprite *`. The `name` field from a sprite JSON. */
|
||||
extern SDL_PropertiesID AKGL_REGISTRY_SPRITE;
|
||||
/** @brief Resolved image path -> `akgl_SpriteSheet *`. Keyed by path so two sprites sharing an image share the texture. */
|
||||
extern SDL_PropertiesID AKGL_REGISTRY_SPRITESHEET;
|
||||
/** @brief Character name -> `akgl_Character *`. What akgl_actor_set_character resolves against. */
|
||||
extern SDL_PropertiesID AKGL_REGISTRY_CHARACTER;
|
||||
/** @brief Actor-state name -> its bit value, as a number. Lets character JSON say "AKGL_ACTOR_STATE_FACE_LEFT" instead of 2. */
|
||||
extern SDL_PropertiesID AKGL_REGISTRY_ACTOR_STATE_STRINGS;
|
||||
/** @brief Font name -> `TTF_Font *`. Names are chosen by the caller of akgl_text_loadfont, not derived from the file. */
|
||||
extern SDL_PropertiesID AKGL_REGISTRY_FONT;
|
||||
/** @brief Music name -> audio handle. Created but not yet populated by anything in the library. */
|
||||
extern SDL_PropertiesID AKGL_REGISTRY_MUSIC;
|
||||
/** @brief Configuration key -> string value. Read through akgl_get_property; everything is a string, including numbers. */
|
||||
extern SDL_PropertiesID AKGL_REGISTRY_PROPERTIES;
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_init();
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_init_music();
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_init_font();
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_init_actor();
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_init_sprite();
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_init_spritesheet();
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_init_character();
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_init_properties();
|
||||
/**
|
||||
* @brief Create the seven asset registries, in dependency order.
|
||||
*
|
||||
* Spritesheet, sprite, character, actor, actor-state-strings, font, music. Call
|
||||
* it once at startup, before loading anything.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If SDL cannot allocate any one of the property sets;
|
||||
* the message names which registry failed. The registries created before
|
||||
* the failure are left in place rather than torn down.
|
||||
*
|
||||
* @warning This does not create #AKGL_REGISTRY_PROPERTIES -- see the warning on
|
||||
* this file.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_init(void);
|
||||
/**
|
||||
* @brief Create the music registry.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If SDL cannot allocate the property set.
|
||||
* @note Overwrites the existing id without destroying the old set, so calling it
|
||||
* twice leaks the first one along with everything registered in it.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_init_music(void);
|
||||
/**
|
||||
* @brief Create the font registry.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If SDL cannot allocate the property set.
|
||||
* @note Overwrites the existing id without destroying the old set. See
|
||||
* akgl_registry_init_music.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_init_font(void);
|
||||
/**
|
||||
* @brief Create the actor registry, destroying any previous one first.
|
||||
*
|
||||
* The one initializer here that cleans up after itself, which is what makes it
|
||||
* safe to call between levels: it destroys the old property set before creating
|
||||
* the replacement, so the actors from the previous map are unregistered in one
|
||||
* step. Note that it destroys the *registry*, not the actors -- releasing those
|
||||
* is akgl_heap_release_actor's job.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If SDL cannot allocate the property set. The old one
|
||||
* has already been destroyed at that point, so the registry is left at 0.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_init_actor(void);
|
||||
/**
|
||||
* @brief Create the sprite registry.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If SDL cannot allocate the property set.
|
||||
* @note Overwrites the existing id without destroying the old set. See
|
||||
* akgl_registry_init_music.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_init_sprite(void);
|
||||
/**
|
||||
* @brief Create the spritesheet registry.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If SDL cannot allocate the property set.
|
||||
* @note Overwrites the existing id without destroying the old set. See
|
||||
* akgl_registry_init_music.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_init_spritesheet(void);
|
||||
/**
|
||||
* @brief Create the character registry.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If SDL cannot allocate the property set.
|
||||
* @note Overwrites the existing id without destroying the old set. See
|
||||
* akgl_registry_init_music.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_init_character(void);
|
||||
/**
|
||||
* @brief Create the configuration-property registry.
|
||||
*
|
||||
* Deliberately separate from akgl_registry_init, because configuration has to be
|
||||
* in place before the subsystems that read it start up. akgl_game_init creates
|
||||
* it; filling it in -- with akgl_registry_load_properties or akgl_set_property --
|
||||
* is the caller's job, and has to happen before akgl_render_2d_init or
|
||||
* akgl_physics_init_arcade run.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If SDL cannot allocate the property set.
|
||||
* @note Overwrites the existing id without destroying the old set, so every
|
||||
* property set before a second call is lost.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_init_properties(void);
|
||||
/**
|
||||
* @brief Load a JSON configuration file into the property registry.
|
||||
*
|
||||
* Expects a document with a top-level `properties` object whose members are all
|
||||
* strings; each becomes one entry. Numbers are configured as strings here and
|
||||
* parsed by whoever reads them -- `akgl_game.screenwidth` is `"800"`, not `800`.
|
||||
*
|
||||
* @param fname Path to the JSON document. Required. Used verbatim.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p fname is `NULL`, or if the file cannot be
|
||||
* opened or does not parse. The message carries jansson's line number
|
||||
* and text.
|
||||
* @throws AKERR_KEY If the document has no top-level `properties` member.
|
||||
* @throws AKERR_TYPE If `properties` is not an object, or if one of its members
|
||||
* is not a string.
|
||||
* @throws AKGL_ERR_HEAP If the string pool is exhausted.
|
||||
*
|
||||
* @note Writes into #AKGL_REGISTRY_PROPERTIES, so akgl_registry_init_properties
|
||||
* has to have run -- otherwise every entry is silently dropped and this
|
||||
* still returns success.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_load_properties(char *fname);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_init_actor_state_strings();
|
||||
/**
|
||||
* @brief Create the actor-state-name registry and fill it from the name table.
|
||||
*
|
||||
* Walks `AKGL_ACTOR_STATE_STRING_NAMES` and maps entry `i` to the value `1 << i`,
|
||||
* which is what lets a character JSON write `"AKGL_ACTOR_STATE_FACE_LEFT"` and
|
||||
* have akgl_character_load_json turn it into a bit.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If SDL cannot allocate the property set. The
|
||||
* individual name registrations are not checked.
|
||||
*
|
||||
* @note The name table it reads disagrees with actor.h in two places: bits 11
|
||||
* and 12 are named `UNDEFINED_11`/`UNDEFINED_12` rather than `MOVING_IN`
|
||||
* and `MOVING_OUT`, so those two states cannot be named from JSON at all.
|
||||
* TODO.md items 24-26.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_init_actor_state_strings(void);
|
||||
/**
|
||||
* @brief Set one configuration property.
|
||||
*
|
||||
* @param name Property key. Required.
|
||||
* @param value Property value. Required. SDL copies it, so the caller's buffer
|
||||
* can go away afterwards.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p name or @p value is `NULL`.
|
||||
*
|
||||
* @note The write itself is unchecked. If #AKGL_REGISTRY_PROPERTIES is still 0 --
|
||||
* akgl_registry_init_properties never having run -- this discards the
|
||||
* value and returns success.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_set_property(char *name, char *value);
|
||||
/**
|
||||
* @brief Read one configuration property into a pooled string, with a fallback.
|
||||
*
|
||||
* Absence is not an error: an unset property yields @p def, which is how every
|
||||
* caller in the library gets a working default without checking first.
|
||||
*
|
||||
* @param name Property key. Required.
|
||||
* @param dest Receives the value. Required. If `*dest` is `NULL` a string is
|
||||
* claimed from the pool for you, so initialize it to `NULL` on the
|
||||
* first call; either way the caller releases it with
|
||||
* akgl_heap_release_string. If `*dest` is non-`NULL` it is written
|
||||
* in place.
|
||||
* @param def Value to use when @p name is not set. Effectively required --
|
||||
* a `NULL` here on an unset property is an AKERR_NULLPOINTER rather
|
||||
* than an empty result.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p name or @p dest is `NULL`, or if the property
|
||||
* is unset and @p def is `NULL`.
|
||||
* @throws AKERR_OUTOFBOUNDS If the stored value is #AKGL_MAX_STRING_LENGTH bytes
|
||||
* or longer, which will not fit an akgl_String with its terminator.
|
||||
* @throws AKERR_VALUE If the value and the destination overlap in memory.
|
||||
* @throws AKGL_ERR_HEAP If `*dest` was `NULL` and the string pool is exhausted.
|
||||
*
|
||||
* @note Only the value and its terminator are copied. The rest of @p dest keeps
|
||||
* whatever the previous holder left there, so read the result as a C
|
||||
* string rather than as #AKGL_MAX_STRING_LENGTH bytes.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_get_property(char *name, akgl_String **dest, char *def);
|
||||
|
||||
#endif //_REGISTRY_H_
|
||||
#endif //_AKGL_REGISTRY_H_
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
#ifndef _RENDERER_H_
|
||||
#define _RENDERER_H_
|
||||
/**
|
||||
* @file renderer.h
|
||||
* @brief The pluggable rendering backend: a record of function pointers plus an initializer.
|
||||
*
|
||||
* There is one backend shipped, the 2D SDL one, and `akgl_render_2d_init` is its
|
||||
* initializer -- it creates the window and the `SDL_Renderer` and then fills in
|
||||
* the six `akgl_render_2d_*` entry points. A different renderer is a different
|
||||
* initializer populating the same struct, not a branch inside these functions.
|
||||
*
|
||||
* Those are two separable jobs, and a host that already owns an `SDL_Renderer`
|
||||
* -- an embedded interpreter, which must not create the window -- wants only the
|
||||
* second. akgl_render_2d_bind() is that half on its own; akgl_render_2d_init()
|
||||
* makes the window and then calls it.
|
||||
*
|
||||
* Callers do not normally name the `akgl_render_2d_*` functions directly; they
|
||||
* go through the pointers on the backend (`renderer->frame_start(renderer)`),
|
||||
* which is what makes the swap possible. The global `akgl_renderer` in game.h is the
|
||||
* instance the rest of the library draws through.
|
||||
*
|
||||
* A frame is `frame_start` (clear), any number of `draw_*` calls, then
|
||||
* `frame_end` (present).
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_RENDERER_H_
|
||||
#define _AKGL_RENDERER_H_
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
@@ -7,23 +30,171 @@
|
||||
|
||||
#include <akgl/iterator.h>
|
||||
|
||||
/** @brief Defines a pluggable renderer backend and drawing callbacks. */
|
||||
typedef struct akgl_RenderBackend {
|
||||
SDL_Renderer *sdl_renderer;
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*shutdown)(struct akgl_RenderBackend *self);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*frame_start)(struct akgl_RenderBackend *self);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*frame_end)(struct akgl_RenderBackend *self);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*draw_texture)(struct akgl_RenderBackend *self, SDL_Texture *texture, SDL_FRect *src, SDL_FRect *dest, double angle, SDL_FPoint *center, SDL_FlipMode flip);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*draw_mesh)(struct akgl_RenderBackend *self);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*draw_world)(struct akgl_RenderBackend *self, akgl_Iterator *opflags);
|
||||
SDL_Renderer *sdl_renderer; /**< The SDL renderer, created by the initializer. Owned by SDL, not freed here. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*shutdown)(struct akgl_RenderBackend *self); /**< Tear the backend down. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*frame_start)(struct akgl_RenderBackend *self); /**< Begin a frame: clear the target. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*frame_end)(struct akgl_RenderBackend *self); /**< End a frame: present it. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*draw_texture)(struct akgl_RenderBackend *self, SDL_Texture *texture, SDL_FRect *src, SDL_FRect *dest, double angle, SDL_FPoint *center, SDL_FlipMode flip); /**< Blit one texture. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*draw_mesh)(struct akgl_RenderBackend *self); /**< Reserved for a 3D backend; the 2D one refuses. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*draw_world)(struct akgl_RenderBackend *self, akgl_Iterator *opflags); /**< Draw the whole scene, layer by layer. */
|
||||
} akgl_RenderBackend;
|
||||
|
||||
/**
|
||||
* @brief Tear the 2D backend down.
|
||||
*
|
||||
* A no-op placeholder that always succeeds. The window and `SDL_Renderer` this
|
||||
* backend created are still owned by SDL and are reclaimed by `SDL_Quit`, so
|
||||
* there is nothing here to release yet; the hook exists so a backend that *does*
|
||||
* own resources has somewhere to free them.
|
||||
*
|
||||
* @param self The backend to shut down. Ignored -- not even dereferenced, so
|
||||
* `NULL` is currently harmless.
|
||||
* @return `NULL` always. It has no failure path today; check it anyway, because
|
||||
* a backend that acquires anything will grow one.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_render_2d_shutdown(akgl_RenderBackend *self);
|
||||
/**
|
||||
* @brief Begin a frame: clear the render target to opaque black.
|
||||
* @param self The backend to draw through. Required, and dereferenced *before*
|
||||
* it is checked -- a `NULL` @p self is a crash, not an error.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If `self->sdl_renderer` is `NULL`, which means the
|
||||
* backend was never run through akgl_render_2d_init.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_render_2d_frame_start(akgl_RenderBackend *self);
|
||||
/**
|
||||
* @brief End a frame: present everything drawn since akgl_render_2d_frame_start.
|
||||
* @param self The backend to draw through. Required, and dereferenced *before*
|
||||
* it is checked -- a `NULL` @p self is a crash, not an error.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If `self->sdl_renderer` is `NULL`, which means the
|
||||
* backend was never run through akgl_render_2d_init.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_render_2d_frame_end(akgl_RenderBackend *self);
|
||||
/**
|
||||
* @brief Blit a texture, rotating it only when asked to.
|
||||
*
|
||||
* A rotation of exactly 0 takes the plain `SDL_RenderTexture` path, which is why
|
||||
* @p center and @p flip are only consulted when @p angle is non-zero.
|
||||
*
|
||||
* @param self The backend to draw through. Required.
|
||||
* @param texture The texture to blit. Required.
|
||||
* @param src The rectangle of @p texture to take, in pixels. Optional --
|
||||
* `NULL` means the whole texture.
|
||||
* @param dest Where to put it on the render target, in pixels. Optional --
|
||||
* `NULL` means stretch to fill the whole target. Not an output
|
||||
* parameter despite the name.
|
||||
* @param angle Clockwise rotation in degrees. Exactly 0 skips rotation
|
||||
* entirely, along with @p center and @p flip.
|
||||
* @param center Pivot for the rotation, relative to @p dest. Required *when*
|
||||
* @p angle is non-zero, ignored otherwise. SDL's own "NULL means
|
||||
* the centre of dest" convention is not available here.
|
||||
* @param flip Horizontal/vertical mirroring. Applied only on the rotated
|
||||
* path; ignored when @p angle is 0.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self or @p texture is `NULL`; if @p angle is
|
||||
* non-zero and @p center is `NULL`; or if the SDL draw call itself
|
||||
* fails, in which case the message carries `SDL_GetError()`. That last
|
||||
* case is a reused status rather than a pointer problem.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_render_2d_draw_texture(akgl_RenderBackend *self, SDL_Texture *texture, SDL_FRect *src, SDL_FRect *dest, double angle, SDL_FPoint *center, SDL_FlipMode flip);
|
||||
/**
|
||||
* @brief Draw geometry. Not implemented by the 2D backend.
|
||||
*
|
||||
* The hook exists so a 3D backend has a slot to fill; this one refuses every
|
||||
* call. It is not a stub that quietly does nothing -- it fails loudly, so a
|
||||
* caller that reaches it finds out at once.
|
||||
*
|
||||
* @param self The backend. Ignored.
|
||||
* @return Never `NULL`.
|
||||
* @throws AKERR_API Always, with the message "Not implemented".
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_render_2d_draw_mesh(akgl_RenderBackend *self);
|
||||
/**
|
||||
* @brief Draw the whole scene: every tilemap layer, and the actors standing on it.
|
||||
*
|
||||
* Walks layers from 0 to #AKGL_TILEMAP_MAX_LAYERS. For each, it draws that layer
|
||||
* of the global `akgl_gamemap` through `akgl_camera` (if the map has that many layers),
|
||||
* then sweeps the actor pool and calls `renderfunc` on every live actor whose
|
||||
* `layer` matches. Sweeping in layer order is what puts actors in front of the
|
||||
* scenery they stand on and behind the scenery they walk under.
|
||||
*
|
||||
* It reads the globals `akgl_gamemap`, `akgl_camera`, and `akgl_heap_actors` directly rather
|
||||
* than taking them as arguments, so there is exactly one world to draw.
|
||||
*
|
||||
* @param self The backend to draw through. Required.
|
||||
* @param opflags Iterator flags. Optional -- `NULL` substitutes a zeroed set.
|
||||
* Currently ignored either way: this function sweeps the actor
|
||||
* pool itself instead of going through
|
||||
* akgl_registry_iterate_actor, so no `AKGL_ITERATOR_OP_*` bit
|
||||
* reaches anything. The parameter is here for the backend
|
||||
* signature and for the layer-mask support that has yet to be
|
||||
* wired up.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self is `NULL`.
|
||||
* @throws AKERR_* Whatever akgl_tilemap_draw or an actor's own `renderfunc`
|
||||
* raises; the first failure aborts the frame and propagates unchanged.
|
||||
*
|
||||
* @warning Neither the global `akgl_gamemap` nor a live actor's `renderfunc` is
|
||||
* checked before use, so drawing before akgl_tilemap_load, or with a
|
||||
* hand-built actor that was never run through akgl_actor_initialize,
|
||||
* is a crash rather than an error context.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_render_2d_draw_world(akgl_RenderBackend *self, akgl_Iterator *opflags);
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_render_init2d(akgl_RenderBackend *self);
|
||||
/**
|
||||
* @brief Install the 2D backend's methods on a backend the caller owns.
|
||||
*
|
||||
* The vtable half of akgl_render_2d_init(), with no window, no renderer, and no
|
||||
* property registry involved: it points @p self's six function pointers at the
|
||||
* `akgl_render_2d_*` entry points and returns. `sdl_renderer` is not touched,
|
||||
* so a caller who has already put its own `SDL_Renderer` there keeps it, and a
|
||||
* caller who has not gets a backend whose entry points all report
|
||||
* `AKERR_NULLPOINTER` rather than crash.
|
||||
*
|
||||
* This is the entry point for a host that owns its own window -- it can drive
|
||||
* akgl_actor_render(), akgl_tilemap_draw() and the rest through a backend
|
||||
* libakgl never created. akgl_render_2d_init() is the same thing with a window
|
||||
* in front of it.
|
||||
*
|
||||
* @param self The backend to bind. Required. Only the method pointers are
|
||||
* written.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_render_2d_bind(akgl_RenderBackend *self);
|
||||
|
||||
#endif // _RENDERER_H_
|
||||
/**
|
||||
* @brief Create the window and SDL renderer, and bind the 2D backend's methods.
|
||||
*
|
||||
* Reads `akgl_game.screenwidth` and `akgl_game.screenheight` from the property registry
|
||||
* (both defaulting to the string "0", which asks SDL for a zero-sized window),
|
||||
* creates the window and renderer with `akgl_game.uri` as the title, points `akgl_camera`
|
||||
* at the full screen rectangle, and then calls akgl_render_2d_bind() to install
|
||||
* the six `akgl_render_2d_*` function pointers on @p self.
|
||||
*
|
||||
* Because the dimensions come from the registry, akgl_registry_init_properties
|
||||
* and the property writes have to happen first -- see the note on
|
||||
* akgl_registry_init about the properties registry not being initialized for
|
||||
* callers that skip akgl_game_init.
|
||||
*
|
||||
* @param self The backend to initialize. Required. Its `sdl_renderer` and method
|
||||
* pointers are overwritten; anything it held before is not released.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self is `NULL`.
|
||||
* @throws AKERR_VALUE If `akgl_game.screenwidth` or `akgl_game.screenheight` is set to
|
||||
* something that is not a base-10 integer.
|
||||
* @throws ERANGE If either dimension does not fit in an `int`.
|
||||
* @throws AKGL_ERR_SDL If the window and renderer cannot be created. The message
|
||||
* carries `SDL_GetError()`.
|
||||
* @throws AKGL_ERR_HEAP If the string pool is exhausted while reading the two
|
||||
* properties.
|
||||
*
|
||||
* @note The two pooled strings holding the dimensions are only released on the
|
||||
* success path, so each failed initialization leaks two string slots.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_render_2d_init(akgl_RenderBackend *self);
|
||||
|
||||
#endif // _AKGL_RENDERER_H_
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
/**
|
||||
* @file sprite.h
|
||||
* @brief Spritesheets (one texture, many frames) and the animations cut out of them.
|
||||
*
|
||||
* The split is deliberate: an akgl_SpriteSheet owns the `SDL_Texture` and the
|
||||
* frame grid, and any number of akgl_Sprite animations point at the same sheet
|
||||
* and name the frame indices they use. Loading two sprites from the same image
|
||||
* loads the image once -- akgl_sprite_load_json looks the sheet up in
|
||||
* #AKGL_REGISTRY_SPRITESHEET by resolved path before creating one.
|
||||
*
|
||||
* Both are pool objects (akgl_heap_next_sprite, akgl_heap_next_spritesheet) and
|
||||
* both publish themselves in a registry under their name, which is how
|
||||
* akgl_character_sprite_add and the tilemap loader find them. Because loading a
|
||||
* sheet uploads a texture, the renderer has to exist first -- so
|
||||
* akgl_render_2d_init, or akgl_game_init, before any of this.
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_SPRITE_H_
|
||||
#define _AKGL_SPRITE_H_
|
||||
|
||||
@@ -11,36 +28,142 @@
|
||||
#define AKGL_SPRITE_MAX_REGISTRY_SIZE 1024
|
||||
#define AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH 512
|
||||
|
||||
#define AKGL_MAX_HEAP_SPRITE (AKGL_MAX_HEAP_ACTOR * 16)
|
||||
#define AKGL_MAX_HEAP_SPRITESHEET AKGL_MAX_HEAP_SPRITE
|
||||
|
||||
/** @brief Stores a loaded spritesheet texture and frame geometry. */
|
||||
typedef struct {
|
||||
uint8_t refcount;
|
||||
SDL_Texture *texture;
|
||||
char name[AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH];
|
||||
uint16_t sprite_w;
|
||||
uint16_t sprite_h;
|
||||
uint8_t refcount; /**< Pool bookkeeping; 0 means the slot is free. Destroys the texture when it reaches 0. */
|
||||
SDL_Texture *texture; /**< The whole sheet as one GPU texture. Owned by this struct. */
|
||||
char name[AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH]; /**< Registry key: the resolved path the image was loaded from. */
|
||||
uint16_t sprite_w; /**< Frame width. Vestigial: nothing in the library writes or reads it; the grid comes from akgl_Sprite::width. */
|
||||
uint16_t sprite_h; /**< Frame height. Vestigial, same as sprite_w. */
|
||||
} akgl_SpriteSheet;
|
||||
|
||||
/** @brief Describes an animated sprite and its spritesheet reference. */
|
||||
typedef struct {
|
||||
uint8_t refcount;
|
||||
uint8_t frameids[AKGL_SPRITE_MAX_FRAMES]; // which IDs on the spritesheet belong to our frames
|
||||
uint32_t frames; // how many frames are in this animation
|
||||
uint32_t width;
|
||||
uint32_t height;
|
||||
uint32_t speed; // how many milliseconds a given sprite frame should be visible before cycling
|
||||
bool loop; // when this sprite is done playing, it should immediately start again
|
||||
bool loopReverse; // when this sprite is done playing, it should go in reverse order through its frames
|
||||
char name[AKGL_SPRITE_MAX_NAME_LENGTH];
|
||||
akgl_SpriteSheet *sheet;
|
||||
uint8_t refcount; /**< Pool bookkeeping; 0 means the slot is free. One reference per character that maps it. */
|
||||
uint8_t frameids[AKGL_SPRITE_MAX_FRAMES]; /**< Frame numbers on the sheet, in playback order. Counted left to right, then wrapping to the next row. */
|
||||
uint32_t frames; /**< How many entries of frameids are in use. */
|
||||
uint32_t width; /**< Frame width in pixels; also the horizontal stride used to find a frame on the sheet. */
|
||||
uint32_t height; /**< Frame height in pixels; the vertical stride once frames wrap to the next row. */
|
||||
uint32_t speed; /**< Nanoseconds one frame is held. Read from JSON in milliseconds and scaled by #AKGL_TIME_ONEMS_NS. */
|
||||
bool loop; /**< Restart from frame 0 when the last frame is reached, instead of holding it. */
|
||||
bool loopReverse; /**< Play back down to frame 0 instead of jumping to it -- a ping-pong loop. */
|
||||
char name[AKGL_SPRITE_MAX_NAME_LENGTH]; /**< Registry key, from the JSON `name` field. */
|
||||
akgl_SpriteSheet *sheet; /**< The sheet these frames are cut from. Borrowed, not owned. */
|
||||
} akgl_Sprite;
|
||||
|
||||
// initializes a new sprite to use the given sheet and otherwise sets to zero
|
||||
/**
|
||||
* @brief Zero a pooled sprite, bind it to a sheet, and publish it in the sprite registry.
|
||||
*
|
||||
* Sets the name and the sheet and takes the first reference; everything else --
|
||||
* frame list, geometry, speed, loop flags -- is left at zero for the caller, or
|
||||
* akgl_sprite_load_json, to fill in. The sheet is borrowed, so this does *not*
|
||||
* take a reference on it: releasing the sheet out from under a live sprite
|
||||
* leaves a dangling pointer.
|
||||
*
|
||||
* @param spr Pooled sprite to initialize, normally from akgl_heap_next_sprite.
|
||||
* Required. Any previous contents are discarded.
|
||||
* @param name Registry key, NUL-terminated. Required. Copied at a fixed
|
||||
* #AKGL_SPRITE_MAX_NAME_LENGTH bytes, so a shorter string reads
|
||||
* past its end -- pass a name from an akgl_String or another
|
||||
* buffer of at least that size. An existing entry with the same
|
||||
* name is silently replaced.
|
||||
* @param sheet The spritesheet this sprite's frames come from. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p spr, @p name, or @p sheet is `NULL`.
|
||||
* @throws AKERR_KEY If the sprite cannot be written into #AKGL_REGISTRY_SPRITE
|
||||
* -- in practice, because akgl_registry_init has not run.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_sprite_initialize(akgl_Sprite *spr, char *name, akgl_SpriteSheet *sheet);
|
||||
// loads a given image file into a new spritesheet
|
||||
/**
|
||||
* @brief Load an image file as a texture and publish it as a spritesheet.
|
||||
*
|
||||
* Uploads @p filename through SDL_image using the global `akgl_renderer`, so the
|
||||
* renderer must already be initialized. The sheet is registered in
|
||||
* #AKGL_REGISTRY_SPRITESHEET under @p filename, which is the key
|
||||
* akgl_sprite_load_json looks it up by to avoid loading the same image twice.
|
||||
*
|
||||
* @param sheet Pooled spritesheet to initialize, normally from
|
||||
* akgl_heap_next_spritesheet. Required. Zeroed first, so a
|
||||
* texture it already held is leaked rather than destroyed.
|
||||
* @param sprite_w Frame width in pixels. Accepted and currently discarded --
|
||||
* the frame grid actually used at draw time comes from the
|
||||
* akgl_Sprite's `width`/`height`, not from here.
|
||||
* @param sprite_h Frame height in pixels. Same caveat as @p sprite_w.
|
||||
* @param filename Path to the image, in any format SDL_image can decode.
|
||||
* Required. Doubles as the registry key, so callers should pass
|
||||
* an already-resolved path; two spellings of the same file are
|
||||
* two sheets. Truncated at
|
||||
* #AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p sheet or @p filename is `NULL`.
|
||||
* @throws AKGL_ERR_SDL If the image cannot be loaded -- missing, unreadable, or
|
||||
* a format SDL_image was not built with. The message carries
|
||||
* `SDL_GetError()`.
|
||||
* @throws AKERR_KEY If the sheet cannot be written into
|
||||
* #AKGL_REGISTRY_SPRITESHEET.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_spritesheet_initialize(akgl_SpriteSheet *sheet, int sprite_w, int sprite_h, char *filename);
|
||||
/**
|
||||
* @brief Build a sprite -- and, if needed, its spritesheet -- from a JSON definition file.
|
||||
*
|
||||
* Reads `name`, `width`, `height`, `speed` (seconds, scaled to milliseconds),
|
||||
* `loop`, `loopReverse`, a `frames` array of frame indices, and a `spritesheet`
|
||||
* object holding `filename`, `frame_width`, and `frame_height`. The sheet's
|
||||
* `filename` is resolved *relative to the directory of @p filename*, so a sprite
|
||||
* definition can sit next to its image and move with it.
|
||||
*
|
||||
* If a spritesheet with that resolved path is already registered it is reused;
|
||||
* otherwise one is claimed from the pool and loaded. On failure both the sprite
|
||||
* and any sheet loaded for it are released again.
|
||||
*
|
||||
* @param filename Path to the JSON document. Required. Must be shorter than
|
||||
* #AKGL_MAX_STRING_LENGTH -- it is copied into a pooled string
|
||||
* so `dirname` can be taken without modifying the caller's
|
||||
* buffer.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p filename is `NULL`, or if the file cannot be
|
||||
* opened or does not parse. The message carries jansson's line number
|
||||
* and text.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p filename is at least #AKGL_MAX_STRING_LENGTH
|
||||
* bytes long, or if the `frames` array is indexed past its end.
|
||||
* @throws AKERR_KEY If a required key is absent, or if the sprite or sheet
|
||||
* cannot be added to its registry. The message names the key.
|
||||
* @throws AKERR_TYPE If a key is present with the wrong JSON type -- `speed` as
|
||||
* a string, `frames` as an object.
|
||||
* @throws AKGL_ERR_SDL If the spritesheet image fails to load.
|
||||
* @throws AKGL_ERR_HEAP If the sprite, spritesheet, or string pool is exhausted.
|
||||
*
|
||||
* @note The `frames` array is not bounded against #AKGL_SPRITE_MAX_FRAMES. A
|
||||
* definition with more than 16 frames writes past `frameids` into the
|
||||
* rest of the struct.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_sprite_load_json(char *filename);
|
||||
|
||||
akerr_ErrorContext *akgl_sprite_sheet_coords_for_frame(akgl_Sprite *self, SDL_FRect *srccoords, uint8_t frameid);
|
||||
/**
|
||||
* @brief Work out where one animation frame sits on its spritesheet.
|
||||
*
|
||||
* Frames are numbered in reading order across the sheet: multiply the frame
|
||||
* number by the sprite width, then wrap whole rows off the right-hand edge of
|
||||
* the texture and step down by the sprite height for each. Callers use the
|
||||
* result as the source rectangle for `SDL_RenderTexture`.
|
||||
*
|
||||
* @param self The sprite whose sheet and frame geometry to use. Required,
|
||||
* along with its `sheet`.
|
||||
* @param srccoords Receives the frame's rectangle on the sheet, in pixels.
|
||||
* Required -- the return value is the error context.
|
||||
* @param frameid Index into the sprite's `frameids` array, not a frame number
|
||||
* on the sheet. Not bounds-checked against `frames` or
|
||||
* #AKGL_SPRITE_MAX_FRAMES; an out-of-range index reads a
|
||||
* neighbouring struct member and yields a nonsense rectangle
|
||||
* rather than an error.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self, @p srccoords, or `self->sheet` is
|
||||
* `NULL`. Note that `sheet->texture` is dereferenced without a check,
|
||||
* so a registered-but-unloaded sheet is a crash rather than an error.
|
||||
*/
|
||||
akerr_ErrorContext *akgl_spritesheet_coords_for_frame(akgl_Sprite *self, SDL_FRect *srccoords, uint8_t frameid);
|
||||
|
||||
#endif //_AKGL_SPRITE_H_
|
||||
|
||||
@@ -1,18 +1,77 @@
|
||||
#ifndef _STRING_H_
|
||||
#define _STRING_H_
|
||||
/**
|
||||
* @file staticstring.h
|
||||
* @brief A fixed-capacity string object handed out by the akgl string heap layer.
|
||||
*
|
||||
* The library allocates nothing at runtime, so a "string" here is a
|
||||
* PATH_MAX-sized buffer claimed from the pool with akgl_heap_next_string and
|
||||
* given back with akgl_heap_release_string. Capacity is fixed at compile time:
|
||||
* these functions truncate rather than grow, and truncation is silent.
|
||||
*/
|
||||
|
||||
#include "string.h"
|
||||
#ifndef _AKGL_STATICSTRING_H_
|
||||
#define _AKGL_STATICSTRING_H_
|
||||
|
||||
#include <string.h>
|
||||
#include <akerror.h>
|
||||
#include <limits.h>
|
||||
|
||||
#define AKGL_MAX_STRING_LENGTH PATH_MAX
|
||||
|
||||
/** @brief Provides a fixed-capacity, heap-managed string buffer. */
|
||||
typedef struct
|
||||
{
|
||||
int refcount;
|
||||
char data[AKGL_MAX_STRING_LENGTH];
|
||||
int refcount; /**< Pool bookkeeping; 0 means the slot is free. Owned by the heap layer. */
|
||||
char data[AKGL_MAX_STRING_LENGTH]; /**< The characters. Not guaranteed NUL-terminated when filled to capacity. */
|
||||
} akgl_String;
|
||||
|
||||
/**
|
||||
* @brief Set a pooled string's contents and mark the slot in use.
|
||||
*
|
||||
* 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 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`.
|
||||
*
|
||||
* @note Until 0.5.0 the `NULL` @p init path zeroed `sizeof(akgl_String)` bytes
|
||||
* starting at `data`, which is four bytes past the end of the buffer --
|
||||
* `refcount` sits in front of it, so the overrun landed on the next pool
|
||||
* slot's reference count.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_string_initialize(akgl_String *obj, char *init);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_string_copy(akgl_String *src, akgl_String *dst, int count);
|
||||
#endif //_STRING_H_
|
||||
/**
|
||||
* @brief Copy the contents of one pooled string into another.
|
||||
*
|
||||
* 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
|
||||
* 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, 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 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_
|
||||
|
||||
@@ -1,9 +1,176 @@
|
||||
#ifndef _TEXT_H_
|
||||
#define _TEXT_H_
|
||||
/**
|
||||
* @file text.h
|
||||
* @brief Loading fonts and drawing or measuring strings with them.
|
||||
*
|
||||
* Fonts are `TTF_Font *` handles kept in the #AKGL_REGISTRY_FONT property
|
||||
* registry under a caller-chosen name; there is no akgl font type wrapping them.
|
||||
* SDL_ttf must be initialized (akgl_game_init does it) before any of this.
|
||||
*
|
||||
* The two measure functions do not touch the renderer, so they are usable
|
||||
* before -- or entirely without -- a window. Drawing is immediate mode: each
|
||||
* akgl_text_rendertextat() call rasterizes, uploads, blits, and throws the
|
||||
* texture away, which is fine for a HUD line and wrong for a large body of
|
||||
* static text redrawn every frame.
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_TEXT_H_
|
||||
#define _AKGL_TEXT_H_
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_ttf/SDL_ttf.h>
|
||||
#include <akerror.h>
|
||||
|
||||
/**
|
||||
* @brief Open a TrueType font at one size and publish it in the font registry.
|
||||
*
|
||||
* A size is baked into the handle, so the same file at two sizes is two calls
|
||||
* under two names. Nothing releases these: the handles live until the process
|
||||
* ends.
|
||||
*
|
||||
* @param name Registry key to publish the font under. Required. An existing
|
||||
* entry with the same name is replaced, and the font it
|
||||
* displaced is closed through akgl_text_unloadfont -- but only
|
||||
* after the new one has opened, so a failed load leaves the
|
||||
* caller with the font they already had.
|
||||
* @param filepath Path to a `.ttf`/`.otf` file. Required. Used verbatim -- not
|
||||
* resolved against `SDL_GetBasePath()`.
|
||||
* @param size Point size to rasterize at. Passed straight to SDL_ttf, which
|
||||
* rejects anything that is not positive.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p name or @p filepath is `NULL`.
|
||||
* @throws AKGL_ERR_SDL If the font cannot be opened -- missing, unreadable, not
|
||||
* a font, or a @p size SDL_ttf refuses. The message carries
|
||||
* `SDL_GetError()`.
|
||||
* @throws AKERR_KEY If the font cannot be written into #AKGL_REGISTRY_FONT --
|
||||
* in practice, because akgl_registry_init has not run.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_text_loadfont(char *name, char *filepath, int size);
|
||||
/**
|
||||
* @brief Close a loaded font and take it out of the font registry.
|
||||
*
|
||||
* The other half of akgl_text_loadfont, and for a while the half that did not
|
||||
* exist: a font could be opened and published but never handed back, so a game
|
||||
* that changed fonts between scenes had no way to reclaim the one it had
|
||||
* finished with. A `TTF_Font` is about ten kilobytes once FreeType's own
|
||||
* structures are counted.
|
||||
*
|
||||
* The registry entry is cleared before the font is closed, so a font is never
|
||||
* reachable through #AKGL_REGISTRY_FONT after it has gone back to SDL_ttf.
|
||||
*
|
||||
* @param name Registry key the font was published under. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p name is `NULL`.
|
||||
* @throws AKERR_KEY If no font is registered under @p name -- including the
|
||||
* case where it was already unloaded, which makes a double unload an
|
||||
* error rather than a double close.
|
||||
*
|
||||
* @warning Anything still holding the `TTF_Font *` -- a caller that fetched it
|
||||
* from the registry earlier, a pending akgl_text_rendertextat -- is
|
||||
* left with a dangling pointer. Fonts are not reference counted.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_text_unloadfont(char *name);
|
||||
/**
|
||||
* @brief Close every font in #AKGL_REGISTRY_FONT and destroy the registry.
|
||||
*
|
||||
* A font, once loaded, was reachable only by name -- so a game that exited
|
||||
* without unloading each one by hand left them open, and `SDL_Quit` destroying
|
||||
* the property registry took the last reference to them with it. That is
|
||||
* bounded by how many fonts a game loads rather than unbounded, but there was
|
||||
* no way to do anything about it at all.
|
||||
*
|
||||
* Call this during shutdown, **before** `TTF_Quit` or `SDL_Quit`. Afterwards
|
||||
* #AKGL_REGISTRY_FONT is 0 and akgl_text_loadfont needs
|
||||
* akgl_registry_init_font() again before it can register anything.
|
||||
*
|
||||
* @return `NULL`. Closing a font reports nothing, and an uninitialized registry
|
||||
* is success rather than an error -- shutdown paths run after partial
|
||||
* startups.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_text_unloadallfonts(void);
|
||||
/**
|
||||
* @brief Rasterize a string and blit it at a screen position, in one call.
|
||||
*
|
||||
* Renders blended (anti-aliased, alpha-blended) through SDL_ttf, uploads the
|
||||
* result to a texture, draws it through the global `akgl_renderer`, and destroys both
|
||||
* the texture and the surface before returning. The text is drawn at its natural
|
||||
* size -- @p x and @p y are the top-left corner, not a centre.
|
||||
*
|
||||
* Coordinates are screen coordinates, not world ones: this does not go through
|
||||
* the camera, so a HUD stays put while the world scrolls under it.
|
||||
*
|
||||
* @param font Font to render with, from akgl_text_loadfont. Required.
|
||||
* @param text UTF-8 text. Required. May contain newlines, which break
|
||||
* lines on either path.
|
||||
* @param color Text colour, including alpha.
|
||||
* @param wraplength Wrap width in pixels. Greater than 0 wraps on word
|
||||
* boundaries at that width; 0 or less draws a single line and
|
||||
* breaks only on newlines in @p text.
|
||||
* @param x Left edge of the text, in screen pixels.
|
||||
* @param y Top edge of the text, in screen pixels.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p font or @p text is `NULL`; if the global
|
||||
* `akgl_renderer`, its `sdl_renderer`, or its `draw_texture` is `NULL` --
|
||||
* that last one is the state a backend is in between being allocated
|
||||
* and being run through akgl_render_2d_bind(); if SDL_ttf cannot
|
||||
* rasterize the string; or if the surface cannot be uploaded as a
|
||||
* texture. The last two carry `SDL_GetError()` and are a reused status
|
||||
* rather than a pointer problem.
|
||||
* @throws AKERR_* Whatever the backend's `draw_texture` raises.
|
||||
*
|
||||
* @note On a failure after rasterizing -- the texture upload, or the draw -- the
|
||||
* surface and texture are not destroyed, because the error returns before
|
||||
* the cleanup. Repeated failures leak.
|
||||
* @note The empty string is **refused**, not drawn as nothing: SDL_ttf reports
|
||||
* "Text has zero width" and this passes that on as `AKERR_NULLPOINTER`.
|
||||
* akgl_text_measure() accepts it, so the two disagree. A caller drawing a
|
||||
* line of text that may be empty has to check for it. TODO.md, "Known and
|
||||
* still open".
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_text_rendertextat(TTF_Font *font, char *text, SDL_Color color, int wraplength, int x, int y);
|
||||
/**
|
||||
* @brief Report the size, in pixels, that @p text would occupy on one line.
|
||||
*
|
||||
* Nothing is drawn and no renderer is required. A caller building a character
|
||||
* grid measures one cell with this -- the advance width of a single glyph in a
|
||||
* monospaced font -- and derives the rest of the grid from it.
|
||||
*
|
||||
* @param font Font to measure with, from akgl_text_loadfont. Required.
|
||||
* @param text UTF-8 text to measure. Required. The empty string is legal and
|
||||
* measures 0 wide by one line high.
|
||||
* @param w Receives the width in pixels. Required.
|
||||
* @param h Receives the height in pixels -- one line, whatever @p text
|
||||
* contains, since this form does not wrap. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p font, @p text, @p w, or @p h is `NULL`.
|
||||
* @throws AKGL_ERR_SDL If SDL_ttf cannot measure the string -- a corrupt font,
|
||||
* or text that is not valid UTF-8. The message carries `SDL_GetError()`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_text_measure(TTF_Font *font, char *text, int *w, int *h);
|
||||
/**
|
||||
* @brief Report the size, in pixels, that @p text would occupy when wrapped.
|
||||
*
|
||||
* The companion to akgl_text_measure() for the wrapping case, matching the
|
||||
* @p wraplength argument akgl_text_rendertextat() already takes: a string
|
||||
* longer than @p wraplength reports the height of every line it breaks onto.
|
||||
* A @p wraplength of zero wraps only on newlines in @p text.
|
||||
*
|
||||
* @param font Font to measure with, from akgl_text_loadfont. Required.
|
||||
* @param text UTF-8 text to measure. Required.
|
||||
* @param wraplength Wrap width in pixels. 0 wraps on newlines only. Negative is
|
||||
* refused rather than passed through: SDL_ttf reads a negative
|
||||
* width as a very large unsigned one and silently stops
|
||||
* wrapping, which would return a measurement that is wrong
|
||||
* rather than an error.
|
||||
* @param w Receives the width in pixels: the longest line, not
|
||||
* @p wraplength. Required.
|
||||
* @param h Receives the height in pixels, covering every line the text
|
||||
* wraps onto. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p font, @p text, @p w, or @p h is `NULL`.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p wraplength is negative.
|
||||
* @throws AKGL_ERR_SDL If SDL_ttf cannot measure the string -- a corrupt font,
|
||||
* or text that is not valid UTF-8. The message carries `SDL_GetError()`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_text_measure_wrapped(TTF_Font *font, char *text, int wraplength, int *w, int *h);
|
||||
|
||||
#endif // _TEXT_H_
|
||||
#endif // _AKGL_TEXT_H_
|
||||
|
||||
@@ -1,63 +1,113 @@
|
||||
#ifndef _TILEMAP_H_
|
||||
#define _TILEMAP_H_
|
||||
/**
|
||||
* @file tilemap.h
|
||||
* @brief Loading and drawing Tiled maps: tilesets, layers, embedded actors, per-map physics.
|
||||
*
|
||||
* The map format is the JSON export from the Tiled editor, so the level design
|
||||
* tool is somebody else's and the loader is the only thing that has to know
|
||||
* about it. A map is a stack of layers -- tile grids, images, and object groups
|
||||
* -- drawn over a set of tilesets, each tileset being one image cut into a grid.
|
||||
*
|
||||
* Three things beyond ordinary Tiled semantics are worth knowing:
|
||||
*
|
||||
* - **Object layers can spawn actors.** An object of type `actor` names a
|
||||
* character in its custom properties, and loading the map creates and
|
||||
* registers the actor rather than just recording a rectangle.
|
||||
* - **A map can carry its own physics.** A `physics.model` custom property on
|
||||
* the map selects a backend, and `physics.gravity.*` / `physics.drag.*` set
|
||||
* its constants, so a swimming level and a walking level differ by data
|
||||
* rather than by code.
|
||||
* - **A map can carry a pseudo-3D perspective.** Two objects named
|
||||
* `p_foreground` and `p_vanishing` in an object layer, each with a `scale`
|
||||
* property, define a band down the screen over which actors are scaled --
|
||||
* which is what makes a character look further away as they walk up the
|
||||
* screen. akgl_tilemap_scale_actor applies it.
|
||||
*
|
||||
* All paths inside a map -- tileset images, layer images -- are resolved
|
||||
* relative to the map file, so a map and its art move together.
|
||||
*
|
||||
* Note the size of akgl_Tilemap: a layer's tile grid alone is 512x512 ints, and
|
||||
* a tileset's offset table is 65536 pairs. It is a megabytes-large object and
|
||||
* belongs in static storage, which is where `akgl_default_gamemap` puts it. Do not put
|
||||
* one on the stack.
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_TILEMAP_H_
|
||||
#define _AKGL_TILEMAP_H_
|
||||
|
||||
#include <limits.h>
|
||||
#include "actor.h"
|
||||
#include "staticstring.h"
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/staticstring.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <jansson.h>
|
||||
|
||||
/** @brief Widest map, in tiles. Width times height is what is actually bounded. */
|
||||
#define AKGL_TILEMAP_MAX_WIDTH 512
|
||||
/** @brief Tallest map, in tiles. */
|
||||
#define AKGL_TILEMAP_MAX_HEIGHT 512
|
||||
/** @brief Layers per map. Also the number of draw passes akgl_render_2d_draw_world makes. */
|
||||
#define AKGL_TILEMAP_MAX_LAYERS 16
|
||||
/** @brief Tilesets per map. */
|
||||
#define AKGL_TILEMAP_MAX_TILESETS 16
|
||||
/** @brief Entries in a tileset's offset table. Indexed by *local* tile id, so a tileset with a high `firstgid` still starts at 0. */
|
||||
#define AKGL_TILEMAP_MAX_TILES_PER_IMAGE 65536
|
||||
/** @brief Longest tileset name. */
|
||||
#define AKGL_TILEMAP_MAX_TILESET_NAME_SIZE 512
|
||||
/** @brief Longest resolved tileset image path. */
|
||||
#define AKGL_TILEMAP_MAX_TILESET_FILENAME_SIZE PATH_MAX
|
||||
/** @brief Longest object name. Note that an object naming an actor is truncated at #AKGL_ACTOR_MAX_NAME_LENGTH (128) instead. */
|
||||
#define AKGL_TILEMAP_MAX_OBJECT_NAME_SIZE 512
|
||||
/** @brief Objects in one object layer. Not enforced by the loader -- a longer group writes past the array. */
|
||||
#define AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER 128
|
||||
|
||||
/** @brief Object `type` for a Tiled object whose `type` field is the string "actor". */
|
||||
#define AKGL_TILEMAP_OBJECT_TYPE_ACTOR 1
|
||||
|
||||
/** @brief Layer `type` for a Tiled `tilelayer`: a grid of tile ids. */
|
||||
#define AKGL_TILEMAP_LAYER_TYPE_TILES 1
|
||||
/** @brief Layer `type` for a Tiled `objectgroup`: actors and perspective markers. */
|
||||
#define AKGL_TILEMAP_LAYER_TYPE_OBJECTS 2
|
||||
/** @brief Layer `type` for a Tiled `imagelayer`: one image drawn at the origin. */
|
||||
#define AKGL_TILEMAP_LAYER_TYPE_IMAGE 3
|
||||
|
||||
/** @brief One object placed in a Tiled object layer. */
|
||||
typedef struct {
|
||||
float x;
|
||||
float y;
|
||||
int gid;
|
||||
int id;
|
||||
int height;
|
||||
int width;
|
||||
int rotation;
|
||||
int type;
|
||||
bool visible;
|
||||
akgl_Actor *actorptr;
|
||||
char name[AKGL_TILEMAP_MAX_OBJECT_NAME_SIZE];
|
||||
float32_t x; /**< Position in map pixels, from the Tiled object. Copied onto the actor it spawns. */
|
||||
float32_t y; /**< Position in map pixels. */
|
||||
int gid; /**< Global tile id, for a tile object. Not read by the loader. */
|
||||
int id; /**< Tiled's object id. Not read by the loader. */
|
||||
int height; /**< Height in map pixels. Read into the map's perspective bands, not into this field. */
|
||||
int width; /**< Width in map pixels. Not read by the loader. */
|
||||
int rotation; /**< Rotation in degrees. Not read by the loader. */
|
||||
int type; /**< #AKGL_TILEMAP_OBJECT_TYPE_ACTOR, or 0 for anything else. */
|
||||
bool visible; /**< Copied onto the actor. Forced to `false` for perspective markers, which are geometry rather than scenery. */
|
||||
akgl_Actor *actorptr; /**< The actor this object spawned or attached to, for an actor object. `NULL` otherwise. Borrowed. */
|
||||
char name[AKGL_TILEMAP_MAX_OBJECT_NAME_SIZE]; /**< Object name. For an actor object this is the registry key; for a perspective marker it is `p_foreground` or `p_vanishing`. */
|
||||
} akgl_TilemapObject;
|
||||
|
||||
/** @brief One layer of a tilemap: a tile grid, an image, or a group of objects. */
|
||||
typedef struct {
|
||||
short type;
|
||||
float opacity;
|
||||
bool visible;
|
||||
int height;
|
||||
int width;
|
||||
int x;
|
||||
int y;
|
||||
int id;
|
||||
SDL_Texture *texture;
|
||||
int data[AKGL_TILEMAP_MAX_WIDTH * AKGL_TILEMAP_MAX_HEIGHT];
|
||||
akgl_TilemapObject objects[AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER];
|
||||
short type; /**< Which of the `AKGL_TILEMAP_LAYER_TYPE_*` kinds this is; decides which of the members below mean anything. */
|
||||
float32_t opacity; /**< 0.0 to 1.0, from Tiled. Recorded but not yet applied at draw time. */
|
||||
bool visible; /**< From Tiled. Recorded but not yet consulted at draw time. */
|
||||
int height; /**< Tile layer: height in tiles. Image layer: the texture's height in pixels. */
|
||||
int width; /**< Tile layer: width in tiles. Image layer: the texture's width in pixels. */
|
||||
int x; /**< Layer offset from Tiled. Recorded but not applied at draw time. */
|
||||
int y; /**< Layer offset from Tiled. */
|
||||
int id; /**< Tiled's layer id. Not the same as the index into akgl_Tilemap::layers. */
|
||||
SDL_Texture *texture; /**< Image layers only: the whole layer as one texture. `NULL` otherwise. Owned by this struct. */
|
||||
int data[AKGL_TILEMAP_MAX_WIDTH * AKGL_TILEMAP_MAX_HEIGHT]; /**< Tile layers only: global tile ids in row-major order. 0 means an empty cell. */
|
||||
akgl_TilemapObject objects[AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER]; /**< Object layers only. */
|
||||
} akgl_TilemapLayer;
|
||||
|
||||
/** @brief One tileset: an image cut into a grid, plus the lookup table that finds a tile in it. */
|
||||
typedef struct {
|
||||
int columns;
|
||||
int firstgid;
|
||||
char imagefilename[AKGL_TILEMAP_MAX_TILESET_FILENAME_SIZE];
|
||||
int imageheight;
|
||||
int imagewidth;
|
||||
char name[AKGL_TILEMAP_MAX_TILESET_NAME_SIZE];
|
||||
SDL_Texture *texture;
|
||||
int columns; /**< Tiles per row in the image. What turns a linear tile id into a row and column. */
|
||||
int firstgid; /**< Global id of this tileset's first tile. Subtracting it from a map cell gives the local tile id. */
|
||||
char imagefilename[AKGL_TILEMAP_MAX_TILESET_FILENAME_SIZE]; /**< Resolved absolute path to the image, from the map file's directory. */
|
||||
int imageheight; /**< Image height in pixels, as declared by Tiled. */
|
||||
int imagewidth; /**< Image width in pixels, as declared. */
|
||||
char name[AKGL_TILEMAP_MAX_TILESET_NAME_SIZE]; /**< Tileset name from Tiled. Diagnostic only; lookups go by `firstgid`. */
|
||||
SDL_Texture *texture; /**< The image as one texture. Owned by this struct. */
|
||||
// Use this as a lookup table instead of storing tiles
|
||||
// in individual textures to blit them from a single
|
||||
// texture at runtime
|
||||
@@ -72,53 +122,518 @@ typedef struct {
|
||||
// lead to premature exhaustion of AKGL_TILEMAP_MAX_TILES_PER_IMAGE
|
||||
// because set D or E may only have 64 tiles but they may be
|
||||
// at the upper end of the array bound already because of this.
|
||||
int tile_offsets[AKGL_TILEMAP_MAX_TILES_PER_IMAGE][2];
|
||||
int tilecount;
|
||||
int tileheight;
|
||||
int tilewidth;
|
||||
int spacing;
|
||||
int margin;
|
||||
int tile_offsets[AKGL_TILEMAP_MAX_TILES_PER_IMAGE][2]; /**< Local tile id -> {x, y} pixel offset into the image. Computed once at load by akgl_tilemap_compute_tileset_offsets. */
|
||||
int tilecount; /**< How many tiles the image holds. */
|
||||
int tileheight; /**< Height of one tile in pixels. */
|
||||
int tilewidth; /**< Width of one tile in pixels. */
|
||||
int spacing; /**< Pixels between adjacent tiles in the image. */
|
||||
int margin; /**< Pixels of border around the whole grid. Recorded but **not** accounted for by the offset computation. */
|
||||
} akgl_Tileset;
|
||||
|
||||
/** @brief Represents a complete tilemap and its optional physics backend. */
|
||||
typedef struct {
|
||||
int tilewidth;
|
||||
int tileheight;
|
||||
int width;
|
||||
int height;
|
||||
int numlayers;
|
||||
int orientation; // 0 = orthogonal, 1 = isometric
|
||||
int numtilesets;
|
||||
int p_foreground_y;
|
||||
int p_vanishing_y;
|
||||
int p_foreground_h;
|
||||
int p_vanishing_h;
|
||||
float p_foreground_scale;
|
||||
float p_vanishing_scale;
|
||||
float p_scale;
|
||||
float p_rate;
|
||||
akgl_Tileset tilesets[AKGL_TILEMAP_MAX_TILESETS];
|
||||
akgl_TilemapLayer layers[AKGL_TILEMAP_MAX_LAYERS];
|
||||
int tilewidth; /**< Width of one map cell in pixels. Tiles from a tileset with a different tile size are not rescaled. */
|
||||
int tileheight; /**< Height of one map cell in pixels. */
|
||||
int width; /**< Map width in tiles. */
|
||||
int height; /**< Map height in tiles. */
|
||||
int numlayers; /**< Layers actually loaded, at most #AKGL_TILEMAP_MAX_LAYERS. */
|
||||
int orientation; /**< 0 = orthogonal, 1 = isometric. Always set to 0 by the loader; isometric is not implemented. */
|
||||
int numtilesets; /**< Tilesets actually loaded. */
|
||||
int p_foreground_y; /**< Y of the `p_foreground` marker: the row at which actors are at full size. 0 disables perspective. */
|
||||
int p_vanishing_y; /**< Y of the `p_vanishing` marker: the row at which actors are smallest. 0 disables perspective. */
|
||||
int p_foreground_h; /**< Height of the `p_foreground` marker object. Recorded; not used in the current rate calculation. */
|
||||
int p_vanishing_h; /**< Height of the `p_vanishing` marker object. Recorded, likewise. */
|
||||
float32_t p_foreground_scale; /**< Actor scale at `p_foreground_y`. Defaults to 1.0. */
|
||||
float32_t p_vanishing_scale; /**< Actor scale at `p_vanishing_y`. Defaults to 1.0. */
|
||||
float32_t p_scale; /**< Unused. Left from an earlier formulation of the perspective maths. */
|
||||
float32_t p_rate; /**< Scale change per pixel of y between the two markers. Derived at load; what akgl_tilemap_scale_actor interpolates with. */
|
||||
akgl_Tileset tilesets[AKGL_TILEMAP_MAX_TILESETS]; /**< The tilesets, in the order Tiled listed them. */
|
||||
akgl_TilemapLayer layers[AKGL_TILEMAP_MAX_LAYERS]; /**< The layers, in draw order: index 0 is furthest back. */
|
||||
|
||||
// Different levels may have different physics.
|
||||
bool use_own_physics; /**< Set when the map declared a `physics.model` property. A caller that honours it simulates through `akgl_physics` instead of the global backend. */
|
||||
akgl_PhysicsBackend physics; /**< This map's own backend, valid only when `use_own_physics` is set. */
|
||||
} akgl_Tilemap;
|
||||
|
||||
/**
|
||||
* @brief Load a Tiled JSON map: physics, geometry, layers, tilesets, and any actors it spawns.
|
||||
*
|
||||
* Zeroes @p dest, then reads the map's own physics properties, its tile
|
||||
* dimensions, its size in tiles, its layers, and its tilesets -- in that order,
|
||||
* because layers reference tilesets by global id and objects reference
|
||||
* characters by name. Finally, if the map carried both perspective markers, it
|
||||
* works out the per-pixel scaling rate between them.
|
||||
*
|
||||
* Loading a map has side effects beyond @p dest: tileset and layer images are
|
||||
* uploaded as textures, and every `actor` object in an object layer is created
|
||||
* in the actor pool and published in #AKGL_REGISTRY_ACTOR. So the renderer, the
|
||||
* pools, the registries, and the characters the map names all have to be in
|
||||
* place first.
|
||||
*
|
||||
* @param fname Path to the map JSON. Required. Its directory becomes the root
|
||||
* that every tileset and layer image inside is resolved against.
|
||||
* @param dest The tilemap to fill in. Required. Zeroed first, so a map already
|
||||
* loaded into it has its textures leaked rather than destroyed --
|
||||
* call akgl_tilemap_release first if you are reusing one. Do not
|
||||
* put one of these on the stack; see the note on this file.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p fname or @p dest is `NULL`; if the map file
|
||||
* cannot be opened or does not parse, with jansson's line and text; or
|
||||
* if a tileset image cannot be loaded.
|
||||
* @throws AKERR_OUTOFBOUNDS If the map is larger than
|
||||
* #AKGL_TILEMAP_MAX_WIDTH x #AKGL_TILEMAP_MAX_HEIGHT tiles, if it has
|
||||
* more than #AKGL_TILEMAP_MAX_LAYERS layers, or if a layer's own
|
||||
* declared size exceeds the same bound.
|
||||
* @throws AKERR_KEY If a required key is absent, if an `actor` object has an
|
||||
* empty name, or if the map names a physics model that does not exist.
|
||||
* @throws AKERR_TYPE If a key is present with the wrong JSON type.
|
||||
* @throws ENOENT If @p fname or a path referenced inside it does not exist.
|
||||
* @throws AKGL_ERR_SDL If a layer image cannot be loaded.
|
||||
* @throws AKGL_ERR_HEAP If the string or actor pool is exhausted.
|
||||
*
|
||||
* @note Nothing is released on a failure part-way through: textures already
|
||||
* uploaded and actors already created stay. Treat a failed load as
|
||||
* needing akgl_tilemap_release and a fresh actor registry.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_tilemap_load(char *fname, akgl_Tilemap *dest);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_tilemap_draw(akgl_Tilemap *dest, SDL_FRect *viewport, int layeridx);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_tilemap_draw_tileset(akgl_Tilemap *dest, int tilesetidx);
|
||||
/**
|
||||
* @brief Draw one layer of a map, clipped to a viewport.
|
||||
*
|
||||
* Only the tiles that intersect @p viewport are drawn, and the tiles at its
|
||||
* edges are drawn partially, so scrolling is smooth rather than snapping to the
|
||||
* tile grid. An image layer ignores the viewport entirely and is drawn once at
|
||||
* the origin.
|
||||
*
|
||||
* Drawing goes through the global `akgl_renderer`, not through a backend passed in.
|
||||
*
|
||||
* @param map The map to draw from. Required.
|
||||
* @param viewport The rectangle of the map, in map pixels, that is on screen.
|
||||
* Required. Usually the global `akgl_camera`.
|
||||
* @param layeridx Which layer to draw, 0 to `numlayers - 1`. **Not**
|
||||
* bounds-checked: an index past the end reads a neighbouring
|
||||
* layer, or past the array.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p map or @p viewport is `NULL`.
|
||||
* @throws AKERR_* Whatever the renderer's `draw_texture` raises. The first
|
||||
* failure abandons the rest of the layer.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_tilemap_draw(akgl_Tilemap *map, SDL_FRect *viewport, int layeridx);
|
||||
/**
|
||||
* @brief Draw a whole tileset to the screen, tile by tile, from its offset table.
|
||||
*
|
||||
* A debugging tool, not a rendering path. It reconstructs the original tileset
|
||||
* image out of the offsets computed at load time, so if the picture that appears
|
||||
* matches the source image the offset table is right -- and if tiles are shifted
|
||||
* or duplicated, it is not. The `charviewer` utility uses it.
|
||||
*
|
||||
* @param map The map holding the tileset. Required.
|
||||
* @param tilesetidx Which tileset to draw, 0 to `numtilesets - 1`.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p map is `NULL`.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p tilesetidx is at or above `numtilesets`. A
|
||||
* negative index is not rejected.
|
||||
* @throws AKERR_* Whatever the renderer's `draw_texture` raises.
|
||||
*
|
||||
* @note Tiles are laid out using the *map's* tile size rather than the
|
||||
* tileset's, so a tileset whose tiles are a different size from the map's
|
||||
* is reconstructed at the wrong pitch.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_tilemap_draw_tileset(akgl_Tilemap *map, int tilesetidx);
|
||||
|
||||
/*
|
||||
* These functions are part of the internal API and should not be called by the user.
|
||||
* They are only exposed here for unit testing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @brief Find one entry in a Tiled `properties` array by name, checking its declared type.
|
||||
*
|
||||
* Tiled does not store custom properties as JSON object members. It stores them
|
||||
* as an array of `{"name": ..., "type": ..., "value": ...}` objects, so getting
|
||||
* one means a linear scan and a type-string comparison rather than a lookup.
|
||||
* That is what this does; the `akgl_get_json_properties_*` wrappers put a typed
|
||||
* face on it.
|
||||
*
|
||||
* @param obj The Tiled object -- map, layer, or object -- whose `properties`
|
||||
* array to search. Required.
|
||||
* @param key The property name to find. Required.
|
||||
* @param type The type string Tiled wrote, e.g. `"string"`, `"int"`, `"float"`.
|
||||
* A property found under the right name but the wrong type is an
|
||||
* error, not a miss. Not `NULL`-checked.
|
||||
* @param dest Receives a borrowed pointer to the whole property object -- the
|
||||
* one with `name`, `type`, and `value` -- not to its value. Not
|
||||
* `NULL`-checked.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or @p key is `NULL`.
|
||||
* @throws AKERR_KEY If @p obj has no `properties` array, or if no entry in it
|
||||
* has that name.
|
||||
* @throws AKERR_TYPE If `properties` is not an array, an entry is malformed, or
|
||||
* the named property's declared type is not @p type. That last message
|
||||
* reports both the expected and the actual type.
|
||||
* @throws AKGL_ERR_HEAP If the string pool is exhausted.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_get_json_tilemap_property(json_t *obj, char *key, char *type, json_t **dest);
|
||||
/**
|
||||
* @brief Read a Tiled custom property declared as `string`.
|
||||
* @param obj The Tiled object whose `properties` array to search. Required.
|
||||
* @param key The property name. Required.
|
||||
* @param dest Receives the value in a *newly claimed* pool string. Required. Note
|
||||
* that this always claims -- unlike akgl_get_json_string_value it
|
||||
* does not reuse a string already in `*dest`, so passing one leaks
|
||||
* it. The caller releases the result with akgl_heap_release_string.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or @p key is `NULL`.
|
||||
* @throws AKERR_KEY If the property is absent, or @p obj has no properties.
|
||||
* @throws AKERR_TYPE If the property is not declared `string`.
|
||||
* @throws AKGL_ERR_HEAP If the string pool is exhausted.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_get_json_properties_string(json_t *obj, char *key, akgl_String **dest);
|
||||
/**
|
||||
* @brief Read a Tiled custom property declared as `int`.
|
||||
* @param obj The Tiled object whose `properties` array to search. Required.
|
||||
* @param key The property name. Required.
|
||||
* @param dest Receives the value. Not written on any failure path.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or @p key is `NULL`.
|
||||
* @throws AKERR_KEY If the property is absent, or @p obj has no properties.
|
||||
* @throws AKERR_TYPE If the property is not declared `int`, or its `value` is
|
||||
* not an integer.
|
||||
* @throws AKGL_ERR_HEAP If the string pool is exhausted.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_get_json_properties_integer(json_t *obj, char *key, int *dest);
|
||||
/**
|
||||
* @brief Build a tileset's local-id-to-pixel-offset table.
|
||||
*
|
||||
* Run once per tileset at load time so drawing a tile is an array lookup rather
|
||||
* than a division. Walks the grid left to right and top to bottom, stepping by
|
||||
* tile size plus `spacing`.
|
||||
*
|
||||
* @param dest The map holding the tileset. Required in practice, though
|
||||
* not checked -- it is dereferenced immediately.
|
||||
* @param tilesetidx Which tileset, 0 to `numtilesets - 1`. Not bounds-checked.
|
||||
* @return `NULL`. There is no failure path: it is arithmetic into an already
|
||||
* allocated table.
|
||||
*
|
||||
* @note Two known limits. `margin` is not accounted for, so a tileset image with
|
||||
* a border produces offsets shifted by it. And the table is indexed by
|
||||
* local id from 0, while akgl_tilemap_draw indexes it by
|
||||
* `tilenum - firstgid`, so the entries a second tileset needs sit at the
|
||||
* front of its own table -- see the FIXME on akgl_Tileset::tile_offsets
|
||||
* for why that wastes space.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_tilemap_compute_tileset_offsets(akgl_Tilemap *dest, int tilesetidx);
|
||||
/**
|
||||
* @brief Load one object layer: spawn its actors and record its perspective markers.
|
||||
*
|
||||
* Two kinds of object are understood. `actor` objects name a registered actor,
|
||||
* or create one -- claiming it from the pool, initializing it, and binding the
|
||||
* character named in its `character` property -- and place it at the object's
|
||||
* coordinates on this layer. `perspective` objects named `p_foreground` or
|
||||
* `p_vanishing` set the map's scaling band and are themselves invisible.
|
||||
* Anything else is recorded and otherwise ignored.
|
||||
*
|
||||
* An actor named by two objects is not duplicated: the second takes another
|
||||
* reference on the first.
|
||||
*
|
||||
* @param dest The map to load into. Required.
|
||||
* @param root The layer's JSON object -- not the map root. Required.
|
||||
* @param layerid Index of the layer being loaded, which becomes each spawned
|
||||
* actor's `layer`. Not bounds-checked.
|
||||
* @param dirname Directory to resolve paths against. Accepted for signature
|
||||
* consistency with the other layer loaders; nothing here uses it.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p dest or @p root is `NULL`.
|
||||
* @throws AKERR_KEY If the layer has no `objects` array, if a required key is
|
||||
* absent, or if an `actor` object has an empty name.
|
||||
* @throws AKERR_TYPE If a key is present with the wrong JSON type.
|
||||
* @throws AKERR_OUTOFBOUNDS If the array is indexed past its end.
|
||||
* @throws AKGL_ERR_HEAP If the actor or string pool is exhausted.
|
||||
* @throws AKERR_* Whatever akgl_actor_initialize or akgl_actor_set_character
|
||||
* raises -- notably AKERR_KEY if the named character is not registered.
|
||||
*
|
||||
* @warning The object count is not checked against
|
||||
* #AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER, so a layer with more than 128
|
||||
* objects writes past the array.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_tilemap_load_layer_objects(akgl_Tilemap *dest, json_t *root, int layerid, akgl_String *dirname);
|
||||
/**
|
||||
* @brief Load one tile layer's grid of global tile ids.
|
||||
*
|
||||
* @param dest The map to load into. Required.
|
||||
* @param root The layer's JSON object -- not the map root. Required.
|
||||
* @param layerid Index of the layer being loaded. Not bounds-checked.
|
||||
* @param dirname Directory to resolve paths against. Required, though a tile
|
||||
* layer references no files and nothing here uses it.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p dest, @p root, or @p dirname is `NULL`.
|
||||
* @throws AKERR_OUTOFBOUNDS If the layer's declared `width` times `height`
|
||||
* reaches #AKGL_TILEMAP_MAX_WIDTH x #AKGL_TILEMAP_MAX_HEIGHT, or if the
|
||||
* `data` array is shorter than that product.
|
||||
* @throws AKERR_KEY If `height`, `width`, or `data` is absent.
|
||||
* @throws AKERR_TYPE If one of them has the wrong JSON type, or a cell is not an
|
||||
* integer.
|
||||
*
|
||||
* @note The declared `width` and `height` are trusted over the actual length of
|
||||
* `data`: a layer declaring more cells than it lists fails on the read
|
||||
* past the end rather than on a length check.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_tilemap_load_layer_tile(akgl_Tilemap *dest, json_t *root, int layerid, akgl_String *dirname);
|
||||
/**
|
||||
* @brief Load every layer of a map, dispatching on each layer's declared type.
|
||||
*
|
||||
* Reads the common fields -- id, opacity, visibility, offset -- then hands the
|
||||
* layer to the loader for its kind: `objectgroup`, `tilelayer`, or `imagelayer`.
|
||||
* A layer of any other type keeps its common fields and is otherwise skipped,
|
||||
* with `type` left at 0.
|
||||
*
|
||||
* @param dest The map to load into. Required. `numlayers` is set from the
|
||||
* array's length before any bound is checked, so it may briefly
|
||||
* exceed #AKGL_TILEMAP_MAX_LAYERS on the failure path.
|
||||
* @param root The map's root JSON object. Required.
|
||||
* @param dirname Directory to resolve layer image paths against. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p dest, @p root, or @p dirname is `NULL`.
|
||||
* @throws AKERR_OUTOFBOUNDS If the map has more than #AKGL_TILEMAP_MAX_LAYERS
|
||||
* layers.
|
||||
* @throws AKERR_KEY If the map has no `layers` array, or a layer is missing a
|
||||
* common field.
|
||||
* @throws AKERR_TYPE If a field has the wrong JSON type.
|
||||
* @throws AKERR_* Whatever the per-kind loader raises.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_tilemap_load_layers(akgl_Tilemap *dest, json_t *root, akgl_String *dirname);
|
||||
/**
|
||||
* @brief Load one tileset's metadata and upload its image.
|
||||
*
|
||||
* @param tileset The tileset's JSON object. Required in practice, though not
|
||||
* checked -- it is passed straight to the accessors, which
|
||||
* report it.
|
||||
* @param dest The map to load into. Required, unchecked, dereferenced at once.
|
||||
* @param tsidx Which slot to fill, 0 to #AKGL_TILEMAP_MAX_TILESETS - 1. Not
|
||||
* bounds-checked.
|
||||
* @param dirname Directory to resolve the tileset's `image` path against --
|
||||
* the map file's own directory. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If a required key is missing its object, or if the
|
||||
* tileset image fails to load. The image message carries
|
||||
* `SDL_GetError()`.
|
||||
* @throws AKERR_KEY If one of `columns`, `firstgid`, `imageheight`,
|
||||
* `imagewidth`, `margin`, `spacing`, `tilecount`, `tileheight`,
|
||||
* `tilewidth`, `name`, or `image` is absent.
|
||||
* @throws AKERR_TYPE If one of them has the wrong JSON type.
|
||||
* @throws AKERR_OUTOFBOUNDS If the resolved image path is too long for a pooled
|
||||
* string.
|
||||
* @throws ENOENT If the image path does not exist.
|
||||
* @throws AKGL_ERR_HEAP If the string pool is exhausted.
|
||||
*
|
||||
* @note This does not go through the spritesheet registry, so a tileset image
|
||||
* shared between two maps is loaded twice.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_tilemap_load_tilesets_each(json_t *tileset, akgl_Tilemap *dest, int tsidx, akgl_String *dirname);
|
||||
/**
|
||||
* @brief Load every tileset of a map and compute each one's offset table.
|
||||
*
|
||||
* @param dest The map to load into. Required. `numtilesets` is reset to 0 and
|
||||
* incremented as each tileset succeeds, so it always reflects
|
||||
* what actually loaded.
|
||||
* @param root The map's root JSON object. Required.
|
||||
* @param dirname Directory to resolve tileset image paths against. Required in
|
||||
* practice; not checked here.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p dest or @p root is `NULL`.
|
||||
* @throws AKERR_KEY If the map has no `tilesets` array.
|
||||
* @throws AKERR_TYPE If `tilesets` is not an array of objects.
|
||||
* @throws AKERR_* Whatever akgl_tilemap_load_tilesets_each raises.
|
||||
*
|
||||
* @warning The tileset count is not checked against
|
||||
* #AKGL_TILEMAP_MAX_TILESETS, so a map with more than 16 tilesets
|
||||
* writes past the array.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_tilemap_load_tilesets(akgl_Tilemap *dest, json_t *root, akgl_String *dirname);
|
||||
/**
|
||||
* @brief Destroy the textures a map owns.
|
||||
*
|
||||
* Call before reusing a tilemap struct for a different map; akgl_tilemap_load
|
||||
* zeroes rather than releases, so without this the previous map's textures are
|
||||
* leaked.
|
||||
*
|
||||
* @param dest The map to release. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p dest is `NULL`.
|
||||
*
|
||||
* @warning Known defect: the layer loop destroys `tilesets[i].texture` rather
|
||||
* than `layers[i].texture`, so tileset textures are destroyed twice and
|
||||
* image-layer textures never. Nothing is set to `NULL` either, so a
|
||||
* second call is a use-after-free. It also does not release the actors
|
||||
* the map's object layers created. TODO.md, "Known and still open"
|
||||
* item 2.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_tilemap_release(akgl_Tilemap *dest);
|
||||
/**
|
||||
* @brief Scale an actor for its distance up the screen, using the map's perspective band.
|
||||
*
|
||||
* The pseudo-3D trick: an actor at or below `p_foreground_y` is drawn at
|
||||
* `p_foreground_scale`, one at or above `p_vanishing_y` at `p_vanishing_scale`,
|
||||
* and one in between is interpolated linearly. A map with no perspective markers
|
||||
* has both scales at 1.0 and a rate of 0, so this is a no-op rather than a
|
||||
* special case.
|
||||
*
|
||||
* Only the actor's `scale` is written; nothing is drawn. akgl_game_update calls
|
||||
* it before each actor's update when #AKGL_ITERATOR_OP_TILEMAPSCALE is set.
|
||||
*
|
||||
* @param map The map supplying the perspective band. Required.
|
||||
* @param actor The actor to scale. Required. Its `y` is read and its `scale`
|
||||
* written.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p map or @p actor is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_tilemap_scale_actor(akgl_Tilemap *map, akgl_Actor *actor);
|
||||
|
||||
#endif //_TILEMAP_H_
|
||||
/**
|
||||
* @brief Read a Tiled custom property declared as `number`.
|
||||
*
|
||||
* Tiled writes `float` for a floating-point property, so this matches nothing
|
||||
* Tiled produces today -- akgl_get_json_properties_float is the one the loader
|
||||
* uses. Kept because a hand-written or older map may still say `number`.
|
||||
*
|
||||
* @param obj The Tiled object whose `properties` array to search. Required.
|
||||
* @param key The property name. Required.
|
||||
* @param dest Receives the value as a `float`. Not written on any failure path.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or @p key is `NULL`.
|
||||
* @throws AKERR_KEY If the property is absent, or @p obj has no properties.
|
||||
* @throws AKERR_TYPE If the property is not declared `number`, or its `value` is
|
||||
* not numeric.
|
||||
* @throws AKGL_ERR_HEAP If the string pool is exhausted.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_get_json_properties_number(json_t *obj, char *key, float32_t *dest);
|
||||
/**
|
||||
* @brief Read a Tiled custom property declared as `float`.
|
||||
*
|
||||
* The spelling Tiled actually writes for a floating-point property. This is how
|
||||
* the `scale` on a perspective marker is read.
|
||||
*
|
||||
* @param obj The Tiled object whose `properties` array to search. Required.
|
||||
* @param key The property name. Required.
|
||||
* @param dest Receives the value. Not written on any failure path.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or @p key is `NULL`.
|
||||
* @throws AKERR_KEY If the property is absent, or @p obj has no properties.
|
||||
* @throws AKERR_TYPE If the property is not declared `float`, or its `value` is
|
||||
* not numeric.
|
||||
* @throws AKGL_ERR_HEAP If the string pool is exhausted.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_get_json_properties_float(json_t *obj, char *key, float32_t *dest);
|
||||
/**
|
||||
* @brief Read a Tiled custom property declared as `float`, at full precision.
|
||||
*
|
||||
* Same declared type as akgl_get_json_properties_float, but writing a `double`.
|
||||
* The map's physics constants are `double`, which is the reason both exist.
|
||||
*
|
||||
* @param obj The Tiled object whose `properties` array to search. Required.
|
||||
* @param key The property name. Required.
|
||||
* @param dest Receives the value. Not written on any failure path.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or @p key is `NULL`.
|
||||
* @throws AKERR_KEY If the property is absent, or @p obj has no properties.
|
||||
* akgl_tilemap_load_physics passes this status to
|
||||
* akgl_get_json_with_default, which turns it into a default of 0.0.
|
||||
* @throws AKERR_TYPE If the property is not declared `float`, or its `value` is
|
||||
* not numeric.
|
||||
* @throws AKGL_ERR_HEAP If the string pool is exhausted.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_get_json_properties_double(json_t *obj, char *key, float64_t *dest);
|
||||
/**
|
||||
* @brief Turn one Tiled object into a live actor, creating it if it is not already registered.
|
||||
*
|
||||
* The object's name is the actor's registry key, which is what lets the same
|
||||
* actor be placed by more than one object: the first placement creates it and
|
||||
* binds the character named in the object's `character` property, and every
|
||||
* later one just takes another reference. Either way the object's position,
|
||||
* visibility, and layer are copied onto the actor and the object keeps a
|
||||
* pointer to it.
|
||||
*
|
||||
* Not declared in tilemap.h -- reachable only through
|
||||
* akgl_tilemap_load_layer_objects.
|
||||
*
|
||||
* @param curobj The tilemap object, with its `name`, `x`, `y`, and
|
||||
* `visible` already read from JSON. Required, unchecked,
|
||||
* dereferenced at once.
|
||||
* @param layerdatavalue The object's JSON, for the custom properties --
|
||||
* `character` and `state` -- that are not part of Tiled's
|
||||
* own object fields. Required.
|
||||
* @param layerid The layer this object sits on, copied onto the actor.
|
||||
* @param dirname Directory to resolve paths against. Accepted for
|
||||
* signature consistency; nothing here uses it.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_KEY If the object's name is empty -- an actor has to be
|
||||
* addressable -- if the `character` or `state` property is absent, or if
|
||||
* the named character is not in #AKGL_REGISTRY_CHARACTER.
|
||||
* @throws AKERR_TYPE If `character` is not declared `string` or `state` not
|
||||
* declared `int`.
|
||||
* @throws AKERR_NULLPOINTER If akgl_actor_initialize refuses the name.
|
||||
* @throws AKGL_ERR_HEAP If the actor or string pool is exhausted.
|
||||
*
|
||||
* @note An existing actor's `character` and position are overwritten by each
|
||||
* later placement, so two objects naming one actor leave it wherever the
|
||||
* last one put it rather than producing two.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_tilemap_load_layer_object_actor(akgl_TilemapObject *curobj, json_t *layerdatavalue, int layerid, akgl_String *dirname);
|
||||
/**
|
||||
* @brief Load one image layer: upload its image as a single texture.
|
||||
*
|
||||
* An image layer is one picture rather than a grid, which is what a parallax
|
||||
* backdrop wants. The layer's `width` and `height` are taken from the loaded
|
||||
* texture rather than from the JSON, so they are always the true pixel size.
|
||||
*
|
||||
* Not declared in tilemap.h -- reachable only through akgl_tilemap_load_layers.
|
||||
*
|
||||
* @param dest The map to load into. Required.
|
||||
* @param root The layer's JSON object. Required.
|
||||
* @param layerid Which layer slot to fill. Not bounds-checked.
|
||||
* @param dirname Directory to resolve the layer's `image` path against.
|
||||
* Required. Joined with a `/` rather than run through
|
||||
* akgl_path_relative, so unlike a tileset image this path is not
|
||||
* canonicalized and an absolute one will not work.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p dest, @p root, or @p dirname is `NULL`.
|
||||
* @throws AKERR_KEY If the layer has no `image`.
|
||||
* @throws AKERR_TYPE If `image` is not a string.
|
||||
* @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.
|
||||
* @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);
|
||||
/**
|
||||
* @brief Give the map its own physics backend, if it asked for one.
|
||||
*
|
||||
* A map with a `physics.model` custom property gets its own backend, configured
|
||||
* from `physics.gravity.x`/`.y`/`.z` and `physics.drag.x`/`.y`/`.z`, and
|
||||
* `use_own_physics` is set so a caller knows to simulate through it. Each
|
||||
* constant defaults to 0.0 when absent, so a map can name a model and override
|
||||
* only what it cares about.
|
||||
*
|
||||
* Absence is the normal case at every level: a map with no properties at all, or
|
||||
* with properties but no `physics.model`, uses the game's global backend and
|
||||
* this returns success. That is why the AKERR_KEY handlers here are not error
|
||||
* paths -- they are the "map did not ask" path.
|
||||
*
|
||||
* Not declared in tilemap.h -- reachable only through akgl_tilemap_load.
|
||||
*
|
||||
* @param dest The map to configure. Required in practice; not checked here,
|
||||
* since akgl_tilemap_load has already done so.
|
||||
* @param root The map's root JSON object. Required, likewise.
|
||||
* @return `NULL` on success -- including when the map declared no physics at
|
||||
* all -- otherwise an error context owned by the caller.
|
||||
* @throws AKERR_KEY If `physics.model` names a backend that does not exist.
|
||||
* @throws AKERR_TYPE If `physics.model` is not declared `string`, or one of the
|
||||
* six constants is not declared `float`.
|
||||
* @throws AKERR_NULLPOINTER If akgl_physics_factory refuses its arguments.
|
||||
* @throws AKGL_ERR_HEAP If the string pool is exhausted.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_tilemap_load_physics(akgl_Tilemap *dest, json_t *root);
|
||||
|
||||
#endif //_AKGL_TILEMAP_H_
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
/**
|
||||
* @file types.h
|
||||
* @brief Width-named floating point aliases, to match the `<stdint.h>` spelling.
|
||||
*
|
||||
* C names its integers by width and its floats by rank, so a struct full of
|
||||
* `uint32_t` and `float` reads inconsistently. These are the float spellings the
|
||||
* rest of the library uses. They are plain aliases -- no guarantee beyond what
|
||||
* the platform's `float` and `double` already provide -- and are deliberately
|
||||
* *not* C23's `_Float32`/`_Float64`, which carry IEEE-754 requirements this
|
||||
* library does not need.
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_TYPES_H_
|
||||
#define _AKGL_TYPES_H_
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
/** @brief Single-precision float. Positions, velocities, and scale factors. */
|
||||
typedef float float32_t;
|
||||
/** @brief Double-precision float. The physics backend keeps its drag and gravity constants at this width. */
|
||||
typedef double float64_t;
|
||||
|
||||
#endif // _AKGL_TYPES_H_
|
||||
|
||||
@@ -1,32 +1,189 @@
|
||||
#ifndef _UTIL_H_
|
||||
#define _UTIL_H_
|
||||
/**
|
||||
* @file util.h
|
||||
* @brief Axis-aligned collision tests, path resolution, and two test-only image helpers.
|
||||
*
|
||||
* The grab bag. Three unrelated groups live here: rectangle/point overlap for
|
||||
* the physics backend, path resolution for the asset loaders, and a pair of
|
||||
* pixel-comparison routines that exist only so tests can assert on what was
|
||||
* actually drawn.
|
||||
*
|
||||
* All the geometry here is axis-aligned and treats edges as touching: a point
|
||||
* exactly on a boundary is inside. There is no rotation support and no
|
||||
* separating-axis test.
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_UTIL_H_
|
||||
#define _AKGL_UTIL_H_
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akerror.h>
|
||||
#include <stdbool.h>
|
||||
#include <akgl/staticstring.h>
|
||||
|
||||
typedef struct point {
|
||||
int x;
|
||||
int y;
|
||||
int z;
|
||||
} point;
|
||||
/** @brief An integer point. Carries a `z` the collision routines do not use. */
|
||||
typedef struct akgl_Point {
|
||||
int x; /**< Horizontal position, in whatever space the caller is working in. */
|
||||
int y; /**< Vertical position. */
|
||||
int z; /**< Depth. Never written by akgl_rectangle_points and never read by the collision tests. */
|
||||
} akgl_Point;
|
||||
|
||||
typedef struct RectanglePoints {
|
||||
point topleft;
|
||||
point topright;
|
||||
point bottomleft;
|
||||
point bottomright;
|
||||
} RectanglePoints;
|
||||
/**
|
||||
* @brief The four corners of an axis-aligned rectangle, precomputed.
|
||||
*
|
||||
* akgl_collide_rectangles works corner by corner rather than by comparing edge
|
||||
* spans, so it wants the corners as points. akgl_rectangle_points derives one of
|
||||
* these from an `SDL_FRect`.
|
||||
*/
|
||||
typedef struct akgl_RectanglePoints {
|
||||
akgl_Point topleft; /**< (x, y). */
|
||||
akgl_Point topright; /**< (x + w, y). */
|
||||
akgl_Point bottomleft; /**< (x, y + h). */
|
||||
akgl_Point bottomright; /**< (x + w, y + h). */
|
||||
} akgl_RectanglePoints;
|
||||
|
||||
#define AKGL_COLLIDE_RECTANGLES(r1x, r1y, r1w, r1h, r2x, r2y, r2w, r2h) ((r1x < (r2x + r2w)) || ((r1x + r1w) > r2x)
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_rectangle_points(RectanglePoints *dest, SDL_FRect *rect);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_collide_point_rectangle(point *p, RectanglePoints *r, bool *collide);
|
||||
/**
|
||||
* @brief Expand a rectangle into its four corner points.
|
||||
*
|
||||
* Coordinates are truncated from `float` to `int` on the way in, so a rectangle
|
||||
* at x = 10.9 has its corners at 10. That is deliberate for tile-grid work and
|
||||
* wrong for sub-pixel work; callers needing the latter should not round-trip
|
||||
* through this.
|
||||
*
|
||||
* @param dest Receives the corners. Required.
|
||||
* @param rect The rectangle, in any coordinate space. Required. `w` and `h` are
|
||||
* taken as extents from `x`/`y`, so a negative one produces a
|
||||
* rectangle whose "bottom right" is above and left of its "top
|
||||
* left" -- which every test here then reports as empty.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p dest or @p rect is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_rectangle_points(akgl_RectanglePoints *dest, SDL_FRect *rect);
|
||||
/**
|
||||
* @brief Test whether a point falls inside a rectangle, edges included.
|
||||
*
|
||||
* Compares against `topleft` and `bottomright` only, so it assumes @p r is
|
||||
* well-formed -- the two corners actually being the minimum and maximum. `z` is
|
||||
* ignored on both sides: this is a 2D test.
|
||||
*
|
||||
* @param p The point to test. Required.
|
||||
* @param r The rectangle, as corners from akgl_rectangle_points. Required.
|
||||
* @param collide Receives `true` when the point is inside or exactly on an edge,
|
||||
* `false` otherwise. Required -- the return value is the error
|
||||
* context. Not written on any failure path.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p p, @p r, or @p collide is `NULL`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_collide_point_rectangle(akgl_Point *p, akgl_RectanglePoints *r, bool *collide);
|
||||
/**
|
||||
* @brief Test whether two rectangles overlap, edges included.
|
||||
*
|
||||
* Tests all eight corners -- each rectangle's four against the other -- and
|
||||
* stops at the first hit. Checking both directions is what catches the case
|
||||
* where one rectangle is entirely inside the other and so has no corner within
|
||||
* its neighbour.
|
||||
*
|
||||
* @param r1 First rectangle. Required.
|
||||
* @param r2 Second rectangle. Required. Order does not matter.
|
||||
* @param collide Receives `true` on any overlap or shared edge, `false`
|
||||
* otherwise. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p r1, @p r2, or @p collide is `NULL`.
|
||||
*
|
||||
* @note A corner-containment test misses the one arrangement where two
|
||||
* rectangles overlap in a cross without either enclosing a corner of the
|
||||
* other -- a tall thin rectangle crossing a short wide one. Both are
|
||||
* reported as not colliding.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_collide_rectangles(SDL_FRect *r1, SDL_FRect *r2, bool *collide);
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_path_relative(char *root, char *path, akgl_String *dst);
|
||||
/**
|
||||
* @brief Resolve an asset path, trying the working directory before the given root.
|
||||
*
|
||||
* Asset files name their neighbours relatively -- a sprite definition names its
|
||||
* spritesheet, a tilemap names its tilesets -- and "relative" has to mean
|
||||
* relative to the file doing the naming, not to wherever the game was launched
|
||||
* from. So this tries @p path against the process working directory first, and
|
||||
* only if that does not exist joins it onto @p root and resolves that. Either
|
||||
* way the result is absolute, with symlinks and `..` folded out.
|
||||
*
|
||||
* @param root Directory to fall back to, normally `dirname` of the file that
|
||||
* contained @p path. Required, even when unused.
|
||||
* @param path The path to resolve, relative or absolute. Required.
|
||||
* @param dest Receives the resolved absolute path. Required, and must already be
|
||||
* a claimed pool string -- this writes into it, it does not claim
|
||||
* one for you.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p root, @p path, or @p dest is `NULL`.
|
||||
* @throws AKERR_OUTOFBOUNDS If `root + "/" + path` would not fit in
|
||||
* #AKGL_MAX_STRING_LENGTH.
|
||||
* @throws ENOENT If neither spelling names an existing file. Any other `errno`
|
||||
* `realpath(3)` can raise -- EACCES on an unsearchable directory,
|
||||
* ELOOP, ENOTDIR -- propagates the same way.
|
||||
* @throws AKGL_ERR_HEAP If the string pool is exhausted.
|
||||
*
|
||||
* @note The fallback path -- the common one, since most asset references are
|
||||
* relative to their own file rather than to the working directory --
|
||||
* returns straight out of the ENOENT handler and so never releases the
|
||||
* error context it was handling. Each such call consumes one slot of
|
||||
* libakerror's fixed 128-entry context array for the life of the process.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_path_relative(char *root, char *path, akgl_String *dest);
|
||||
|
||||
// These are REALLY slow routines that are only useful in testing harnesses
|
||||
/**
|
||||
* @brief Assert that two surfaces hold byte-identical pixels.
|
||||
*
|
||||
* A `memcmp` over the raw pixel buffer, so it is exact: one differing byte in
|
||||
* one pixel is a failure. Meant for test harnesses asserting on rendered output,
|
||||
* not for anything on a frame path.
|
||||
*
|
||||
* @param s1 First surface. Required. Its `pitch * h` is what determines how many
|
||||
* bytes are compared.
|
||||
* @param s2 Second surface. Required.
|
||||
* @return `NULL` when the pixels match, otherwise an error context owned by the
|
||||
* caller. "Not equal" is reported as an error, not as an out-param.
|
||||
* @throws AKERR_NULLPOINTER If @p s1 or @p s2 is `NULL`, or either has no
|
||||
* pixel buffer.
|
||||
* @throws AKERR_VALUE If the surfaces differ in size, pitch, or pixel format,
|
||||
* or if their pixels differ.
|
||||
*
|
||||
* Dimensions, pitch and pixel format are compared first, and a difference in
|
||||
* any of them is reported as a mismatch. Until 0.5.0 they were not, so a
|
||||
* smaller @p s2 was read past its end instead -- benign in practice and
|
||||
* immediately fatal under a memory checker.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_compare_sdl_surfaces(SDL_Surface *s1, SDL_Surface *s2);
|
||||
/**
|
||||
* @brief Draw two textures in turn, read the framebuffer back after each, and compare.
|
||||
*
|
||||
* The test-harness counterpart to akgl_compare_sdl_surfaces: it answers "do
|
||||
* these two textures *render* the same", which is not the same question as "are
|
||||
* these two textures identical", because the renderer's scaling and blending sit
|
||||
* in between. Both are drawn into the same rectangle against a cleared target.
|
||||
*
|
||||
* @param t1 First texture. Required.
|
||||
* @param t2 Second texture. Required.
|
||||
* @param x Left edge of the region, in pixels. Used for the source
|
||||
* rectangle, the destination, and the readback alike.
|
||||
* @param y Top edge of the region.
|
||||
* @param w Width of the region.
|
||||
* @param h Height of the region.
|
||||
* @param writeout Optional filename for a PNG of the *first* render, written
|
||||
* under `SDL_GetBasePath()`. `NULL` skips it. This is a
|
||||
* debugging aid -- when an image assertion fails, this is how
|
||||
* you see what was actually drawn.
|
||||
* @return `NULL` when the two renders match, otherwise an error context owned by
|
||||
* the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p t1 or @p t2 is `NULL`.
|
||||
* @throws AKGL_ERR_SDL If the framebuffer cannot be read back.
|
||||
* @throws AKERR_IO If @p writeout is given and the PNG cannot be written.
|
||||
* @throws AKERR_VALUE If the two renders differ.
|
||||
* @throws AKGL_ERR_HEAP If the string pool is exhausted.
|
||||
*
|
||||
* @note Until 0.5.0 both passes drew @p t1, so this always reported a match and
|
||||
* every image assertion built on it asserted nothing. It draws @p t2 on
|
||||
* the second pass now.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_render_and_compare(SDL_Texture *t1, SDL_Texture *t2, int x, int y, int w, int h, char *writeout);
|
||||
|
||||
#endif // _UTIL_H_
|
||||
#endif // _AKGL_UTIL_H_
|
||||
|
||||
56
include/akgl/version.h.in
Normal file
56
include/akgl/version.h.in
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @file version.h
|
||||
* @brief Declares the libakgl version, both as compiled against and as linked.
|
||||
*
|
||||
* GENERATED FILE -- edit include/akgl/version.h.in, never the copy in the build
|
||||
* tree. Every value here comes from the project() call in CMakeLists.txt, which
|
||||
* is also what sets the shared library's VERSION and SOVERSION and the Version
|
||||
* field in akgl.pc. One number, one place, so the header, the soname and
|
||||
* pkg-config cannot drift apart.
|
||||
*
|
||||
* AKGL_VERSION is the version you *compiled against*. akgl_version() reports the
|
||||
* version of the libakgl you actually *linked*. They disagree when a stale
|
||||
* shared library is ahead of the new one on the loader path -- the failure the
|
||||
* soname exists to prevent and this pair exists to diagnose.
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_VERSION_H_
|
||||
#define _AKGL_VERSION_H_
|
||||
|
||||
#define AKGL_VERSION "@PROJECT_VERSION@"
|
||||
#define AKGL_VERSION_MAJOR @PROJECT_VERSION_MAJOR@
|
||||
#define AKGL_VERSION_MINOR @PROJECT_VERSION_MINOR@
|
||||
#define AKGL_VERSION_PATCH @PROJECT_VERSION_PATCH@
|
||||
|
||||
/**
|
||||
* @brief True when the headers on the include path are at least the given version.
|
||||
*
|
||||
* For consumers that must build against more than one libakgl release. This is
|
||||
* the test libakstdlib could not write against libakerror, which published no
|
||||
* version macro and had to feature-test on AKERR_FIRST_CONSUMER_STATUS instead.
|
||||
*
|
||||
* @code
|
||||
* #if AKGL_VERSION_AT_LEAST(0, 2, 0)
|
||||
* akgl_something_new();
|
||||
* #endif
|
||||
* @endcode
|
||||
*/
|
||||
#define AKGL_VERSION_AT_LEAST(major, minor, patch) \
|
||||
((AKGL_VERSION_MAJOR > (major)) || \
|
||||
(AKGL_VERSION_MAJOR == (major) && AKGL_VERSION_MINOR > (minor)) || \
|
||||
(AKGL_VERSION_MAJOR == (major) && AKGL_VERSION_MINOR == (minor) && \
|
||||
AKGL_VERSION_PATCH >= (patch)))
|
||||
|
||||
/**
|
||||
* @brief Report the version of the libakgl that is actually linked.
|
||||
*
|
||||
* Returns "major.minor.patch". Compare it against AKGL_VERSION to detect a
|
||||
* stale shared library. Never returns NULL, and the storage is static -- the
|
||||
* caller must not free or modify it.
|
||||
*
|
||||
* This returns a string rather than an akerr_ErrorContext * because it cannot
|
||||
* fail, the same reason akerr_name_for_status() returns a name directly.
|
||||
*/
|
||||
const char *akgl_version(void);
|
||||
|
||||
#endif // _AKGL_VERSION_H_
|
||||
@@ -1,32 +1,108 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Regenerate include/akgl/SDL_GameControllerDB.h from the community controller
|
||||
# database.
|
||||
#
|
||||
# That header is tracked on purpose: it is the offline fallback that keeps the
|
||||
# library buildable when upstream is renamed, rate-limited, taken down, or
|
||||
# simply unreachable. This script therefore has one rule above all others --
|
||||
# **it must never replace a good header with a worse one**. Until 0.5.0 it did
|
||||
# exactly that: no `set -e`, no check on curl's exit status, so a failed fetch
|
||||
# wrote an empty mappings file, overwrote the good tracked header with
|
||||
# `AKGL_SDL_GAMECONTROLLER_DB_LEN 0` and an empty initializer, and exited 0.
|
||||
# Verified by pointing it at an unresolvable host.
|
||||
#
|
||||
# Everything is assembled in a temporary directory and moved into place only
|
||||
# once it has been checked. A failure leaves the existing header untouched and
|
||||
# exits non-zero.
|
||||
#
|
||||
# This is no longer run by an ordinary build. `cmake --build build --target
|
||||
# controllerdb` is the deliberate way to update the mappings, so a build needs
|
||||
# no network access and does not leave the working tree dirty.
|
||||
#
|
||||
# Usage: mkcontrollermappings.sh <repository-root>
|
||||
|
||||
rootdir=$1
|
||||
set -euo pipefail
|
||||
|
||||
curl https://raw.githubusercontent.com/mdqinc/SDL_GameControllerDB/refs/heads/master/gamecontrollerdb.txt | grep -v '^#' | grep -v '^$' | sed s/',$'//g > mappings.txt
|
||||
AKGL_CONTROLLERDB_URL="${AKGL_CONTROLLERDB_URL:-https://raw.githubusercontent.com/mdqinc/SDL_GameControllerDB/refs/heads/master/gamecontrollerdb.txt}"
|
||||
# A floor, not a target. The database has carried well over two thousand entries
|
||||
# for years; a few hundred means something truncated the response, which is a
|
||||
# fetch that "succeeded" and still must not be committed.
|
||||
AKGL_CONTROLLERDB_MIN_LINES="${AKGL_CONTROLLERDB_MIN_LINES:-1000}"
|
||||
|
||||
filelen=$(wc -l mappings.txt | cut -d ' ' -f 1)
|
||||
function die()
|
||||
{
|
||||
echo "mkcontrollermappings: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cat > ${rootdir}/include/akgl/SDL_GameControllerDB.h <<EOF
|
||||
#ifndef _SDL_GAMECONTROLLERDB_H_
|
||||
#define _SDL_GAMECONTROLLERDB_H_
|
||||
if [[ $# -lt 1 ]]; then
|
||||
die "usage: $0 <repository-root>"
|
||||
fi
|
||||
|
||||
// Taken from https://raw.githubusercontent.com/mdqinc/SDL_GameControllerDB/refs/heads/master/gamecontrollerdb.txt on $(date)
|
||||
rootdir="$1"
|
||||
target="${rootdir}/include/akgl/SDL_GameControllerDB.h"
|
||||
|
||||
#define AKGL_SDL_GAMECONTROLLER_DB_LEN ${filelen}
|
||||
if [[ ! -d "${rootdir}/include/akgl" ]]; then
|
||||
die "no such directory: ${rootdir}/include/akgl"
|
||||
fi
|
||||
command -v curl >/dev/null 2>&1 || die "curl is not available"
|
||||
|
||||
const char *SDL_GAMECONTROLLER_DB[] = {
|
||||
EOF
|
||||
workdir="$(mktemp -d)"
|
||||
|
||||
counter=0
|
||||
function cleanup()
|
||||
{
|
||||
rm -rf "${workdir}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
cat mappings.txt | while read LINE;
|
||||
do
|
||||
if [[ $counter -gt 0 ]]; then
|
||||
printf ",\n" >> ${rootdir}/include/akgl/SDL_GameControllerDB.h;
|
||||
fi
|
||||
printf " \"${LINE}\"" >> ${rootdir}/include/akgl/SDL_GameControllerDB.h;
|
||||
counter=$((counter + 1))
|
||||
done
|
||||
raw="${workdir}/raw.txt"
|
||||
mappings="${workdir}/mappings.txt"
|
||||
staged="${workdir}/SDL_GameControllerDB.h"
|
||||
|
||||
printf "\n};\n" >> ${rootdir}/include/akgl/SDL_GameControllerDB.h
|
||||
printf "#endif // _SDL_GAMECONTROLLERDB_H_\n" >> ${rootdir}/include/akgl/SDL_GameControllerDB.h
|
||||
# --fail so an HTTP error status is an exit status rather than an error page in
|
||||
# the body. Not a pipeline: the exit status being checked is curl's, and a
|
||||
# pipeline reports the last command's.
|
||||
if ! curl --fail --silent --show-error --location "${AKGL_CONTROLLERDB_URL}" --output "${raw}"; then
|
||||
die "fetch failed from ${AKGL_CONTROLLERDB_URL}; ${target} left as it was"
|
||||
fi
|
||||
|
||||
grep -v '^#' "${raw}" | grep -v '^$' | sed s/',$'//g > "${mappings}" || true
|
||||
|
||||
filelen=$(wc -l < "${mappings}" | tr -d ' ')
|
||||
|
||||
if [[ "${filelen}" -lt "${AKGL_CONTROLLERDB_MIN_LINES}" ]]; then
|
||||
die "fetched only ${filelen} mappings, expected at least ${AKGL_CONTROLLERDB_MIN_LINES}; ${target} left as it was"
|
||||
fi
|
||||
|
||||
# Every mapping becomes a C string literal, so a quote or a backslash would
|
||||
# produce a header that does not compile -- or, worse, one that does.
|
||||
if grep -q '["\\]' "${mappings}"; then
|
||||
die "fetched mappings contain a quote or a backslash; ${target} left as it was"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "#ifndef _AKGL_SDL_GAMECONTROLLERDB_H_"
|
||||
echo "#define _AKGL_SDL_GAMECONTROLLERDB_H_"
|
||||
echo ""
|
||||
echo "// Taken from ${AKGL_CONTROLLERDB_URL} on $(date)"
|
||||
echo ""
|
||||
echo "#define AKGL_SDL_GAMECONTROLLER_DB_LEN ${filelen}"
|
||||
echo ""
|
||||
echo "const char *SDL_GAMECONTROLLER_DB[] = {"
|
||||
# One pass: wrap each mapping as a quoted, indented, comma-terminated entry,
|
||||
# then drop the trailing comma from the last. An empty initializer is a
|
||||
# constraint violation in ISO C that compiles only as a GCC extension, and
|
||||
# the length check above is what guarantees there is at least one entry.
|
||||
sed -e 's/.*/ "&",/' -e '$ s/,$//' "${mappings}"
|
||||
echo "};"
|
||||
echo "#endif // _AKGL_SDL_GAMECONTROLLERDB_H_"
|
||||
} > "${staged}"
|
||||
|
||||
# Checked before it is allowed anywhere near the tracked copy.
|
||||
if ! grep -q "^#define AKGL_SDL_GAMECONTROLLER_DB_LEN *${filelen}\$" "${staged}"; then
|
||||
die "staged header did not come out as expected; ${target} left as it was"
|
||||
fi
|
||||
|
||||
mv "${staged}" "${target}"
|
||||
echo "mkcontrollermappings: wrote ${filelen} mappings to ${target}"
|
||||
|
||||
86
scripts/check_api_surface.sh
Executable file
86
scripts/check_api_surface.sh
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Every function with external linkage must be declared in a header.
|
||||
#
|
||||
# A non-static function that appears in no header is still in the ABI but is
|
||||
# unreachable by callers, and it collides with any consumer that happens to pick
|
||||
# the same name. libakgl shipped nineteen of those. The rule is in AGENTS.md
|
||||
# under "API Surface"; this is what enforces it.
|
||||
#
|
||||
# Usage: check_api_surface.sh <libakgl.so> <header-dir> [<header-dir> ...]
|
||||
#
|
||||
# Exits 0 when every exported akgl_* symbol is declared, 1 otherwise, and 2 when
|
||||
# it cannot do its job -- a missing library or no nm.
|
||||
|
||||
set -u
|
||||
|
||||
if [ "$#" -lt 2 ]; then
|
||||
echo "usage: $0 <libakgl.so> <header-dir> [<header-dir> ...]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
LIBRARY="$1"
|
||||
shift
|
||||
|
||||
if ! command -v nm >/dev/null 2>&1; then
|
||||
echo "check_api_surface: nm is not available; skipping" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ ! -f "${LIBRARY}" ]; then
|
||||
echo "check_api_surface: no such library: ${LIBRARY}" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
DECLARATIONS="$(mktemp)"
|
||||
trap 'rm -f "${DECLARATIONS}"' EXIT
|
||||
|
||||
# Comments are stripped first. Several of these names appear in prose -- the
|
||||
# whole point of the check is that being *mentioned* in a header is not the same
|
||||
# as being declared in one, and that distinction is exactly what went wrong with
|
||||
# the gamepad_handle_* handlers.
|
||||
for dir in "$@"; do
|
||||
[ -d "${dir}" ] || continue
|
||||
find "${dir}" -name '*.h' -print0 |
|
||||
xargs -0 --no-run-if-empty cat |
|
||||
sed -e 's://.*::' |
|
||||
awk 'BEGIN { incomment = 0 }
|
||||
{
|
||||
line = $0
|
||||
while ( 1 ) {
|
||||
if ( incomment ) {
|
||||
idx = index(line, "*/")
|
||||
if ( idx == 0 ) { line = ""; break }
|
||||
line = substr(line, idx + 2); incomment = 0
|
||||
} else {
|
||||
idx = index(line, "/*")
|
||||
if ( idx == 0 ) { break }
|
||||
printf "%s", substr(line, 1, idx - 1)
|
||||
line = substr(line, idx + 2); incomment = 1
|
||||
}
|
||||
}
|
||||
print line
|
||||
}' >> "${DECLARATIONS}"
|
||||
done
|
||||
|
||||
UNDECLARED=""
|
||||
for symbol in $(nm -D --defined-only "${LIBRARY}" | awk '$2 == "T" { print $3 }' | grep '^akgl_' | sort); do
|
||||
if ! grep -qE "(^|[^A-Za-z0-9_])${symbol}[^A-Za-z0-9_]" "${DECLARATIONS}"; then
|
||||
UNDECLARED="${UNDECLARED} ${symbol}"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "${UNDECLARED}" ]; then
|
||||
echo "check_api_surface: exported but declared in no header:" >&2
|
||||
for symbol in ${UNDECLARED}; do
|
||||
echo " ${symbol}" >&2
|
||||
done
|
||||
echo "" >&2
|
||||
echo "Declare each one in the header for its subsystem, or make it static." >&2
|
||||
echo "A function exposed only so tests can reach it goes under the existing" >&2
|
||||
echo "\"part of the internal API\" comment block." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "check_api_surface: every exported akgl_* symbol is declared"
|
||||
exit 0
|
||||
145
scripts/check_error_protocol.py
Executable file
145
scripts/check_error_protocol.py
Executable file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enforce the two akerror control-flow rules whose failure mode is silent.
|
||||
|
||||
AGENTS.md states both, and both have already cost this library a leak:
|
||||
|
||||
1. Never use a ``*_RETURN`` macro inside an ``ATTEMPT`` block. It returns past
|
||||
``CLEANUP``, so every release, ``fclose`` and free in ``CLEANUP`` is
|
||||
skipped. ``akgl_get_json_tilemap_property`` leaked two pooled strings on
|
||||
every *successful* lookup this way.
|
||||
|
||||
2. Never ``return`` from inside a ``HANDLE`` block. ``FINISH`` is what carries
|
||||
``RELEASE_ERROR``, so leaving early never gives the handled context back to
|
||||
``AKERR_ARRAY_ERROR``. That cost ``akgl_path_relative`` one slot per call
|
||||
and killed the process on the 129th.
|
||||
|
||||
Neither produces a compiler diagnostic, and neither shows up in a passing test
|
||||
run until the pool it drains is empty. Hence a linter.
|
||||
|
||||
Usage: check_error_protocol.py <file-or-directory> [...]
|
||||
|
||||
Exits 0 when clean, 1 on a violation.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
# A ``*_RETURN`` macro invocation: SUCCEED_RETURN, FAIL_RETURN, FAIL_ZERO_RETURN,
|
||||
# FAIL_NONZERO_RETURN, and anything later shaped like them.
|
||||
RETURN_MACRO = re.compile(r"\b[A-Z][A-Z0-9_]*_RETURN\s*\(")
|
||||
BARE_RETURN = re.compile(r"\breturn\b")
|
||||
ATTEMPT_OPEN = re.compile(r"\bATTEMPT\s*\{")
|
||||
CLEANUP_OPEN = re.compile(r"\}\s*CLEANUP\s*\{")
|
||||
HANDLE_OPEN = re.compile(r"\}\s*HANDLE(?:_GROUP)?\s*\(")
|
||||
FINISH_ANY = re.compile(r"\}\s*FINISH(?:_NORETURN)?\s*\(")
|
||||
|
||||
|
||||
def strip_comments(text):
|
||||
"""Blank out comments, keeping line numbering intact.
|
||||
|
||||
Comments are removed rather than skipped because several of these keywords
|
||||
appear in prose -- "which should never return AKERR_KEY" is a sentence, not
|
||||
a violation.
|
||||
"""
|
||||
out = []
|
||||
i = 0
|
||||
n = len(text)
|
||||
while i < n:
|
||||
ch = text[i]
|
||||
if ch == '"' or ch == "'":
|
||||
quote = ch
|
||||
out.append(ch)
|
||||
i += 1
|
||||
while i < n:
|
||||
if text[i] == "\\" and (i + 1) < n:
|
||||
out.append(" ")
|
||||
i += 2
|
||||
continue
|
||||
out.append(text[i])
|
||||
if text[i] == quote:
|
||||
i += 1
|
||||
break
|
||||
i += 1
|
||||
continue
|
||||
if text.startswith("/*", i):
|
||||
j = text.find("*/", i + 2)
|
||||
j = n if j == -1 else (j + 2)
|
||||
out.append("".join(c if c == "\n" else " " for c in text[i:j]))
|
||||
i = j
|
||||
continue
|
||||
if text.startswith("//", i):
|
||||
j = text.find("\n", i)
|
||||
j = n if j == -1 else j
|
||||
out.append(" " * (j - i))
|
||||
i = j
|
||||
continue
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def check(path):
|
||||
findings = []
|
||||
lines = strip_comments(open(path, encoding="utf-8", errors="replace").read()).split("\n")
|
||||
in_attempt = False
|
||||
in_handle = False
|
||||
|
||||
for number, line in enumerate(lines, 1):
|
||||
if ATTEMPT_OPEN.search(line):
|
||||
in_attempt = True
|
||||
continue
|
||||
if CLEANUP_OPEN.search(line):
|
||||
in_attempt = False
|
||||
if HANDLE_OPEN.search(line):
|
||||
in_handle = True
|
||||
continue
|
||||
if FINISH_ANY.search(line):
|
||||
in_handle = False
|
||||
continue
|
||||
|
||||
if in_attempt and RETURN_MACRO.search(line):
|
||||
findings.append(
|
||||
(number, "a *_RETURN macro inside an ATTEMPT block returns past CLEANUP; "
|
||||
"use the matching *_BREAK variant"))
|
||||
if in_handle and BARE_RETURN.search(line):
|
||||
findings.append(
|
||||
(number, "returning from inside a HANDLE block skips the RELEASE_ERROR that "
|
||||
"FINISH carries; set a flag and act on it after FINISH"))
|
||||
return findings
|
||||
|
||||
|
||||
def sources(targets):
|
||||
for target in targets:
|
||||
if os.path.isdir(target):
|
||||
for root, _dirs, files in os.walk(target):
|
||||
for name in sorted(files):
|
||||
if name.endswith(".c") and not name.endswith("~"):
|
||||
yield os.path.join(root, name)
|
||||
else:
|
||||
yield target
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) < 2:
|
||||
print(__doc__, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
total = 0
|
||||
for path in sources(argv[1:]):
|
||||
for number, message in check(path):
|
||||
print("%s:%d: %s" % (path, number, message), file=sys.stderr)
|
||||
total += 1
|
||||
|
||||
if total:
|
||||
print("", file=sys.stderr)
|
||||
print("check_error_protocol: %d violation(s). See the Error-Handling Protocol "
|
||||
"section of AGENTS.md." % total, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("check_error_protocol: clean")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
98
scripts/hooks/pre-commit
Executable file
98
scripts/hooks/pre-commit
Executable file
@@ -0,0 +1,98 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Reindent staged C sources to the canonical style before the commit is made.
|
||||
# See AGENTS.md -> "Coding Style".
|
||||
#
|
||||
# Enable with:
|
||||
# git config core.hooksPath scripts/hooks
|
||||
# Bypass a single commit with `git commit --no-verify`.
|
||||
#
|
||||
# The hook checks the *staged* content, not the working tree, so what gets
|
||||
# committed is what was verified. When a file needs reindenting it is fixed in
|
||||
# the working tree and re-staged -- but only when the working tree and the index
|
||||
# agree for that file. If they disagree (a partial `git add -p`), re-staging
|
||||
# would sweep unstaged work into the commit, so the hook stops and asks you to
|
||||
# do it yourself.
|
||||
|
||||
set -eu
|
||||
|
||||
root=$(git rev-parse --show-toplevel)
|
||||
reindent="$root/scripts/reindent.sh"
|
||||
|
||||
# Leave conflict resolution alone.
|
||||
if [ -e "$root/.git/MERGE_HEAD" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -x "$reindent" ]; then
|
||||
echo "pre-commit: $reindent missing or not executable; skipping style check" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Without Emacs the canonical style cannot be applied or even checked. Warn
|
||||
# rather than block: a hook that fails closed on a missing optional tool just
|
||||
# teaches everyone to pass --no-verify.
|
||||
if ! command -v emacs >/dev/null 2>&1; then
|
||||
echo "pre-commit: emacs not found; skipping reindent (see AGENTS.md)" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
staged=$(git diff --cached --name-only --diff-filter=ACMR \
|
||||
| grep -E '^(src|include|tests|util)/.*\.[ch]$' \
|
||||
| grep -v -x -F 'include/akgl/SDL_GameControllerDB.h' || true)
|
||||
|
||||
if [ -z "$staged" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT INT TERM
|
||||
|
||||
# Reindent a copy of each file's staged content and see whether it moves.
|
||||
needs=''
|
||||
for f in $staged; do
|
||||
copy="$tmp/$(printf '%s' "$f" | tr '/' '_')"
|
||||
git show ":$f" >"$copy"
|
||||
cp "$copy" "$copy.orig"
|
||||
if ! "$reindent" "$copy" >/dev/null 2>&1; then
|
||||
echo "pre-commit: reindent failed on $f; commit aborted" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! cmp -s "$copy" "$copy.orig"; then
|
||||
needs="$needs $f"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$needs" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Refuse to touch anything if even one file is partially staged.
|
||||
blocked=''
|
||||
for f in $needs; do
|
||||
if ! git diff --quiet -- "$f"; then
|
||||
blocked="$blocked $f"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$blocked" ]; then
|
||||
echo "pre-commit: these files need reindenting but have unstaged changes," >&2
|
||||
echo "so re-staging them would pull unstaged work into the commit:" >&2
|
||||
for f in $blocked; do echo " $f" >&2; done
|
||||
echo >&2
|
||||
echo "Reindent and stage them yourself, then commit again:" >&2
|
||||
echo " scripts/reindent.sh$(for f in $blocked; do printf ' %s' "$f"; done)" >&2
|
||||
echo " git add$(for f in $blocked; do printf ' %s' "$f"; done)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Safe: for every file needing work, the index and working tree agree.
|
||||
# shellcheck disable=SC2086
|
||||
"$reindent" $needs
|
||||
# shellcheck disable=SC2086
|
||||
git add $needs
|
||||
|
||||
echo "pre-commit: reindented and re-staged:" >&2
|
||||
for f in $needs; do echo " $f" >&2; done
|
||||
|
||||
exit 0
|
||||
108
scripts/memcheck.sh
Executable file
108
scripts/memcheck.sh
Executable file
@@ -0,0 +1,108 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Run the existing CTest suites under valgrind and report what leaked.
|
||||
#
|
||||
# scripts/memcheck.sh check every suite in ./build
|
||||
# scripts/memcheck.sh -R tilemap check only the suites matching a regex
|
||||
# scripts/memcheck.sh -LE perf skip the perf suites
|
||||
#
|
||||
# Anything after the build directory is handed straight to ctest, so every
|
||||
# selection flag ctest understands works here.
|
||||
#
|
||||
# Environment:
|
||||
# AKGL_BUILD_DIR build tree to check (default: build)
|
||||
# AKGL_MEMCHECK_LOG directory for the per-suite valgrind logs
|
||||
# (default: $AKGL_BUILD_DIR/Testing/Temporary)
|
||||
#
|
||||
# Exit status: 0 when valgrind found nothing, 1 when it found something, 2 on a
|
||||
# usage or environment error. This is the part `ctest -T memcheck` will not do
|
||||
# on its own -- it records defects and still exits 0, which is no use as a gate.
|
||||
|
||||
set -eu
|
||||
|
||||
AKGL_BUILD_DIR="${AKGL_BUILD_DIR:-build}"
|
||||
|
||||
function memcheck_die()
|
||||
{
|
||||
echo "memcheck: $*" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
# The suite runs headless on purpose. Loading the real GPU stack costs thousands
|
||||
# of unfixable findings inside the vendor driver, and none of them are libakgl's.
|
||||
# A suite that sets these hints itself is unaffected; the ones that do not --
|
||||
# tests/tilemap.c and tests/sprite.c -- pick them up from here.
|
||||
export SDL_VIDEO_DRIVER="${SDL_VIDEO_DRIVER:-dummy}"
|
||||
export SDL_RENDER_DRIVER="${SDL_RENDER_DRIVER:-software}"
|
||||
export SDL_AUDIO_DRIVER="${SDL_AUDIO_DRIVER:-dummy}"
|
||||
|
||||
command -v valgrind >/dev/null 2>&1 || memcheck_die "valgrind not found"
|
||||
command -v ctest >/dev/null 2>&1 || memcheck_die "ctest not found"
|
||||
[ -f "$AKGL_BUILD_DIR/CMakeCache.txt" ] || memcheck_die "$AKGL_BUILD_DIR is not a configured build tree"
|
||||
|
||||
AKGL_MEMCHECK_LOG="${AKGL_MEMCHECK_LOG:-$AKGL_BUILD_DIR/Testing/Temporary}"
|
||||
|
||||
# Old logs would be counted as this run's findings. CTest numbers them by test
|
||||
# id, so a run narrowed with -R leaves the others behind.
|
||||
rm -f "$AKGL_MEMCHECK_LOG"/MemoryChecker.*.log
|
||||
|
||||
# The run itself is allowed to fail: a suite that valgrind makes slow enough to
|
||||
# trip an assertion still produced a log worth reading, and the log is what
|
||||
# decides the exit status below.
|
||||
set +e
|
||||
ctest --test-dir "$AKGL_BUILD_DIR" -T memcheck --output-on-failure "$@"
|
||||
AKGL_CTEST_STATUS=$?
|
||||
set -e
|
||||
|
||||
# What counts as a finding. "Definitely lost" is a leak nobody can argue with;
|
||||
# the invalid-access lines are memcheck telling us the library read or wrote
|
||||
# outside an allocation, which is worse than a leak and easier to fix.
|
||||
#
|
||||
# "are definitely lost", not "definitely lost": the LEAK SUMMARY block ends every
|
||||
# clean log with "definitely lost: 0 bytes in 0 blocks", and matching that
|
||||
# reported a finding in all twenty-three suites, including the ones that never
|
||||
# call malloc.
|
||||
AKGL_MEMCHECK_PATTERN='are definitely lost|Invalid read|Invalid write|Invalid free|Mismatched free|Source and destination overlap|depends on uninitialised|Use of uninitialised'
|
||||
|
||||
total=0
|
||||
echo
|
||||
echo "== valgrind findings by suite =="
|
||||
for log in "$AKGL_MEMCHECK_LOG"/MemoryChecker.*.log; do
|
||||
[ -f "$log" ] || continue
|
||||
# CTest writes the test name into the log's first line as the command it ran.
|
||||
suite=$(grep -m1 -oE 'akgl_test_[a-z_]+' "$log" || basename "$log")
|
||||
count=$(grep -cE "$AKGL_MEMCHECK_PATTERN" "$log" || true)
|
||||
total=$((total + count))
|
||||
if [ "$count" -gt 0 ]; then
|
||||
printf '%-24s %3d finding(s) %s\n' "$suite" "$count" "$log"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$total" -eq 0 ]; then
|
||||
echo "none"
|
||||
echo
|
||||
# A clean valgrind run and a failing test are different things: a suite can
|
||||
# assert its way to a non-zero exit without leaking a byte, and saying
|
||||
# "clean" and returning 0 for that would hide it.
|
||||
if [ "$AKGL_CTEST_STATUS" -ne 0 ]; then
|
||||
echo "memcheck: valgrind found nothing, but ctest exited $AKGL_CTEST_STATUS -- a suite failed on its own terms" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "memcheck: clean ($AKGL_MEMCHECK_LOG)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== library frames in those stacks, most frequent first =="
|
||||
# Keyed on the function name rather than the file, because src/physics.c and
|
||||
# tests/physics.c are both "physics.c" in a valgrind frame and only one of them
|
||||
# is the library. Anything a caller can reach is an akgl_ symbol by convention,
|
||||
# which makes the prefix a usable filter; a static helper appears under whatever
|
||||
# akgl_ function called it, one frame further down.
|
||||
grep -hoE 'by 0x[0-9A-Fa-f]+: akgl_[A-Za-z0-9_]+ \([a-z_]+\.c:[0-9]+\)' \
|
||||
"$AKGL_MEMCHECK_LOG"/MemoryChecker.*.log 2>/dev/null |
|
||||
sed -E 's/^by 0x[0-9A-Fa-f]+: //' | sort | uniq -c | sort -rn | head -20
|
||||
|
||||
echo
|
||||
echo "memcheck: $total finding(s); full stacks in $AKGL_MEMCHECK_LOG" >&2
|
||||
exit 1
|
||||
468
scripts/mutation_test.py
Executable file
468
scripts/mutation_test.py
Executable file
@@ -0,0 +1,468 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Mutation testing harness for libakgl.
|
||||
|
||||
Mutation testing measures how good the test suite is at catching bugs. It works
|
||||
by making many small, deliberate breakages ("mutants") to the library source --
|
||||
flipping a comparison, deleting a statement, swapping true/false -- and then
|
||||
running the whole CTest suite against each one. If the tests fail, the mutant is
|
||||
"killed" (good: the tests noticed the bug). If the tests still pass, the mutant
|
||||
"survived" (bad: a real bug of that shape would slip through unnoticed).
|
||||
|
||||
The mutation score is killed / (killed + survived). Surviving mutants are printed
|
||||
with file:line and the exact change so they can be turned into new test cases.
|
||||
|
||||
This harness has no third-party dependencies (Python stdlib + the project's
|
||||
normal cmake/ctest toolchain). It never mutates the real working tree: it copies
|
||||
the repo to a scratch directory and mutates there.
|
||||
|
||||
Usage:
|
||||
scripts/mutation_test.py [options]
|
||||
|
||||
--source-root DIR repo root to copy (default: parent of this script's dir)
|
||||
--target FILE source file to mutate, relative to root; repeatable.
|
||||
Default: all libakgl-owned C files under src/
|
||||
--work DIR scratch dir for the mutated copy (default: a temp dir)
|
||||
--timeout SECONDS per-suite ctest timeout (default: 120)
|
||||
--exclude-test REGEX CTest regex to exclude (default: none)
|
||||
--threshold PCT exit non-zero if mutation score < PCT (default: 0 = off)
|
||||
--list only list the mutants that would be run, then exit
|
||||
--keep keep the scratch working copy on exit (for debugging)
|
||||
-j N (reserved) currently runs sequentially
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Mutation operators
|
||||
#
|
||||
# Each operator yields zero or more (start, end, replacement) edits for a single
|
||||
# line of source. The driver applies exactly one edit per mutant so every mutant
|
||||
# differs from the original by one localized change.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# Relational operator replacement: map each operator to the alternatives that
|
||||
# meaningfully change behaviour (not merely the strict negation).
|
||||
_REL = {
|
||||
"==": ["!="],
|
||||
"!=": ["=="],
|
||||
"<=": ["<", "=="],
|
||||
">=": [">", "=="],
|
||||
"<": ["<=", ">"],
|
||||
">": [">=", "<"],
|
||||
}
|
||||
# Match a relational operator that is NOT part of ->, <<, >>, =>, <=, >=, ==, !=
|
||||
# unless we intend it. We tokenize the two-char operators first, then single.
|
||||
_REL_TWO = re.compile(r"(==|!=|<=|>=)")
|
||||
_REL_ONE = re.compile(r"(?<![-<>=!+])([<>])(?![=<>])")
|
||||
|
||||
_LOGICAL = {"&&": "||", "||": "&&"}
|
||||
_LOG_RE = re.compile(r"(&&|\|\|)")
|
||||
|
||||
_BOOL = {"true": "false", "false": "true"}
|
||||
_BOOL_RE = re.compile(r"\b(true|false)\b")
|
||||
|
||||
# Arithmetic / compound-assignment on whitespace-delimited operands only, to
|
||||
# avoid touching ++, --, ->, unary signs, or pointer/format punctuation.
|
||||
_ARITH_RE = re.compile(r"(?<=\s)([+\-])(?=\s)")
|
||||
_ARITH = {"+": "-", "-": "+"}
|
||||
_COMPOUND_RE = re.compile(r"(\+=|-=)")
|
||||
_COMPOUND = {"+=": "-=", "-=": "+="}
|
||||
|
||||
# Integer literal replacement: 0 <-> 1 (word-bounded, not inside identifiers or
|
||||
# larger numbers, not a float).
|
||||
_INT_RE = re.compile(r"(?<![\w.])([01])(?![\w.])")
|
||||
_INT = {"0": "1", "1": "0"}
|
||||
|
||||
|
||||
def _op_edits(line):
|
||||
"""Yield (tag, start, end, replacement) for every candidate mutation."""
|
||||
# Relational (two-char first so we don't split them with the one-char pass)
|
||||
for m in _REL_TWO.finditer(line):
|
||||
for alt in _REL[m.group(1)]:
|
||||
yield ("ROR", m.start(1), m.end(1), alt)
|
||||
for m in _REL_ONE.finditer(line):
|
||||
for alt in _REL[m.group(1)]:
|
||||
yield ("ROR", m.start(1), m.end(1), alt)
|
||||
for m in _LOG_RE.finditer(line):
|
||||
yield ("LCR", m.start(1), m.end(1), _LOGICAL[m.group(1)])
|
||||
for m in _BOOL_RE.finditer(line):
|
||||
yield ("BCR", m.start(1), m.end(1), _BOOL[m.group(1)])
|
||||
for m in _COMPOUND_RE.finditer(line):
|
||||
yield ("AOR", m.start(1), m.end(1), _COMPOUND[m.group(1)])
|
||||
for m in _ARITH_RE.finditer(line):
|
||||
yield ("AOR", m.start(1), m.end(1), _ARITH[m.group(1)])
|
||||
for m in _INT_RE.finditer(line):
|
||||
yield ("ICR", m.start(1), m.end(1), _INT[m.group(1)])
|
||||
|
||||
|
||||
# Statement-deletion: neutralize a whole statement. We only delete statements
|
||||
# that are safe to drop without guaranteeing a compile error, so a surviving
|
||||
# deletion is a genuine test gap rather than compiler noise.
|
||||
_STMT_DELETABLE = re.compile(
|
||||
r"""^\s*(
|
||||
break |
|
||||
return\b[^;]* |
|
||||
[A-Za-z_][-\w>().\[\]* ]*\s*=\s*[^;]* | # assignments
|
||||
[A-Za-z_][\w]*\s*\([^;]*\) # bare function calls
|
||||
)\s*;\s*(\\?)\s*$""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Deciding which lines are eligible to mutate
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# Skip preprocessor control and the block of constant/error-code #defines in the
|
||||
# template header: mutating buffer sizes or renumbering error codes produces
|
||||
# equivalent or uninteresting mutants that swamp the signal.
|
||||
_SKIP_LINE = re.compile(
|
||||
r"""^\s*(
|
||||
\#\s*(include|ifn?def|ifdef|if|elif|else|endif|error|pragma|undef) |
|
||||
\#\s*define\s+AKERR_(MAX|LAST|NULLPOINTER|OUTOFBOUNDS|API|ATTRIBUTE|
|
||||
TYPE|KEY|INDEX|FORMAT|IO|VALUE|RELATIONSHIP|EOF|CIRCULAR_REFERENCE|
|
||||
ITERATOR_BREAK|NOT_IMPLEMENTED|BADEXC|NOIGNORE|USE_STDLIB)\b |
|
||||
\* | // # comment bodies / line comments
|
||||
)""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
def _is_comment_or_blank(line):
|
||||
s = line.strip()
|
||||
return (not s) or s.startswith("//") or s.startswith("/*") or s.startswith("*")
|
||||
|
||||
|
||||
def eligible(line):
|
||||
if _is_comment_or_blank(line):
|
||||
return False
|
||||
if _SKIP_LINE.match(line):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class Mutant:
|
||||
__slots__ = ("path", "lineno", "op", "before", "after", "col")
|
||||
|
||||
def __init__(self, path, lineno, op, before, after, col):
|
||||
self.path = path
|
||||
self.lineno = lineno
|
||||
self.op = op
|
||||
self.before = before
|
||||
self.after = after
|
||||
self.col = col
|
||||
|
||||
def describe(self):
|
||||
return (f"{self.path}:{self.lineno} [{self.op}] "
|
||||
f"col{self.col}: {self.before.strip()} -> {self.after.strip()}")
|
||||
|
||||
|
||||
def generate_mutants(root, rel_target):
|
||||
"""Enumerate all mutants for one target file."""
|
||||
abspath = os.path.join(root, rel_target)
|
||||
with open(abspath, "r") as fh:
|
||||
lines = fh.readlines()
|
||||
|
||||
mutants = []
|
||||
for i, line in enumerate(lines, start=1):
|
||||
if not eligible(line):
|
||||
continue
|
||||
# substitution operators
|
||||
seen = set()
|
||||
for tag, s, e, repl in _op_edits(line):
|
||||
key = (s, e, repl)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
mutated = line[:s] + repl + line[e:]
|
||||
if mutated == line:
|
||||
continue
|
||||
mutants.append(Mutant(rel_target, i, tag, line, mutated, s))
|
||||
# statement deletion
|
||||
m = _STMT_DELETABLE.match(line)
|
||||
if m:
|
||||
indent = line[: len(line) - len(line.lstrip())]
|
||||
cont = "\\" if line.rstrip().endswith("\\") else ""
|
||||
deleted = f"{indent}/* mutant: deleted */ {cont}\n" if cont else f"{indent};\n"
|
||||
mutants.append(Mutant(rel_target, i, "SDL", line, deleted, 0))
|
||||
return mutants
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Build / test orchestration against a scratch copy
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class Runner:
|
||||
def __init__(self, work, timeout, exclude_test):
|
||||
self.work = work
|
||||
self.build = os.path.join(work, "build")
|
||||
self.timeout = timeout
|
||||
self.exclude_test = exclude_test
|
||||
self.env = os.environ.copy()
|
||||
library_dirs = [
|
||||
self.build,
|
||||
os.path.join(self.build, "deps", "SDL"),
|
||||
os.path.join(self.build, "deps", "SDL_image"),
|
||||
os.path.join(self.build, "deps", "SDL_mixer"),
|
||||
os.path.join(self.build, "deps", "SDL_ttf"),
|
||||
]
|
||||
current_library_path = self.env.get("LD_LIBRARY_PATH", "")
|
||||
if current_library_path:
|
||||
library_dirs.append(current_library_path)
|
||||
self.env["LD_LIBRARY_PATH"] = os.pathsep.join(library_dirs)
|
||||
|
||||
def _run(self, cmd, timeout=None):
|
||||
return subprocess.run(
|
||||
cmd, cwd=self.work, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
timeout=timeout, env=self.env,
|
||||
)
|
||||
|
||||
def configure(self):
|
||||
r = self._run(["cmake", "-S", ".", "-B", "build"], timeout=self.timeout)
|
||||
return r.returncode == 0, r.stdout
|
||||
|
||||
def ctest_command(self):
|
||||
command = ["ctest", "--test-dir", "build", "--output-on-failure",
|
||||
"--stop-on-failure"]
|
||||
if self.exclude_test:
|
||||
command.extend(["-E", self.exclude_test])
|
||||
return command
|
||||
|
||||
def build_and_test(self):
|
||||
"""Return ('killed-compile' | 'killed-test' | 'killed-timeout' | 'survived')."""
|
||||
try:
|
||||
b = self._run(["cmake", "--build", "build"], timeout=self.timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
return "killed-timeout"
|
||||
if b.returncode != 0:
|
||||
return "killed-compile"
|
||||
try:
|
||||
t = subprocess.run(
|
||||
self.ctest_command(),
|
||||
env=self.env,
|
||||
cwd=self.work, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return "killed-timeout"
|
||||
return "survived" if t.returncode == 0 else "killed-test"
|
||||
|
||||
|
||||
def _xml_escape(s):
|
||||
return (s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
.replace('"', """))
|
||||
|
||||
|
||||
def write_junit(path, records, targets):
|
||||
"""Write a JUnit XML report. One <testcase> per mutant; a surviving mutant
|
||||
is a <failure> (test-suite gap), a killed mutant is a passing case."""
|
||||
by_file = {t: [] for t in targets}
|
||||
for m, result in records:
|
||||
by_file.setdefault(m.path, []).append((m, result))
|
||||
|
||||
total = len(records)
|
||||
total_fail = sum(1 for _, r in records if r == "survived")
|
||||
out = ['<?xml version="1.0" encoding="UTF-8"?>',
|
||||
f'<testsuites name="mutation" tests="{total}" failures="{total_fail}">']
|
||||
for f, items in by_file.items():
|
||||
if not items:
|
||||
continue
|
||||
fails = sum(1 for _, r in items if r == "survived")
|
||||
out.append(f' <testsuite name="mutation:{_xml_escape(f)}" '
|
||||
f'tests="{len(items)}" failures="{fails}">')
|
||||
for m, result in items:
|
||||
name = _xml_escape(m.describe())
|
||||
cls = "mutation." + _xml_escape(m.path)
|
||||
if result == "survived":
|
||||
detail = _xml_escape(f"{m.before.strip()} -> {m.after.strip()}")
|
||||
out.append(f' <testcase name="{name}" classname="{cls}" time="0">')
|
||||
out.append(f' <failure message="survived mutant '
|
||||
f'({_xml_escape(m.op)} at {_xml_escape(m.path)}:'
|
||||
f'{m.lineno})">{detail}</failure>')
|
||||
out.append(' </testcase>')
|
||||
else:
|
||||
out.append(f' <testcase name="{name}" classname="{cls}" '
|
||||
f'time="0"><system-out>{_xml_escape(result)}'
|
||||
'</system-out></testcase>')
|
||||
out.append(' </testsuite>')
|
||||
out.append('</testsuites>')
|
||||
with open(path, "w") as fh:
|
||||
fh.write("\n".join(out) + "\n")
|
||||
|
||||
|
||||
def copy_tree(src, dst):
|
||||
ignore = shutil.ignore_patterns("build", ".git", "*.o", "*.so", "*~",
|
||||
"#*#", "*.iso")
|
||||
shutil.copytree(src, dst, ignore=ignore, symlinks=True)
|
||||
|
||||
|
||||
def read_lines(path):
|
||||
with open(path) as fh:
|
||||
return fh.readlines()
|
||||
|
||||
|
||||
def write_lines(path, lines):
|
||||
with open(path, "w") as fh:
|
||||
fh.writelines(lines)
|
||||
|
||||
|
||||
def main():
|
||||
# Line-buffer stdout so progress is visible live under CI / the cmake target.
|
||||
try:
|
||||
sys.stdout.reconfigure(line_buffering=True)
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
default_root = os.path.dirname(here)
|
||||
|
||||
ap = argparse.ArgumentParser(description="Mutation testing for libakgl")
|
||||
ap.add_argument("--source-root", default=default_root)
|
||||
ap.add_argument("--target", action="append", default=None)
|
||||
ap.add_argument("--work", default=None)
|
||||
ap.add_argument("--timeout", type=int, default=120)
|
||||
# No default exclusion. This was "^character$" while that suite was
|
||||
# believed to fail deliberately; it does not, and did not.
|
||||
ap.add_argument("--exclude-test", default="",
|
||||
help="CTest regex to exclude (default: none)")
|
||||
ap.add_argument("--threshold", type=float, default=0.0)
|
||||
ap.add_argument("--junit", default=None,
|
||||
help="write a JUnit XML report to this path")
|
||||
ap.add_argument("--max-mutants", type=int, default=0,
|
||||
help="cap the run at N evenly-sampled mutants (0 = all)")
|
||||
ap.add_argument("--list", action="store_true")
|
||||
ap.add_argument("--keep", action="store_true")
|
||||
ap.add_argument("-j", type=int, default=1)
|
||||
args = ap.parse_args()
|
||||
|
||||
root = os.path.abspath(args.source_root)
|
||||
targets = args.target or [
|
||||
"src/actor.c", "src/actor_state_string_names.c", "src/assets.c",
|
||||
"src/character.c", "src/controller.c", "src/draw.c",
|
||||
"src/game.c", "src/heap.c", "src/json_helpers.c",
|
||||
"src/physics.c", "src/registry.c", "src/renderer.c",
|
||||
"src/sprite.c", "src/staticstring.c", "src/text.c",
|
||||
"src/tilemap.c", "src/util.c",
|
||||
]
|
||||
|
||||
# Enumerate mutants from the pristine sources.
|
||||
all_mutants = []
|
||||
for t in targets:
|
||||
all_mutants.extend(generate_mutants(root, t))
|
||||
|
||||
print(f"Generated {len(all_mutants)} mutants across {len(targets)} file(s):")
|
||||
for t in targets:
|
||||
n = sum(1 for m in all_mutants if m.path == t)
|
||||
print(f" {t}: {n}")
|
||||
|
||||
# Optional even-strided sampling to bound run time (CI / smoke tests).
|
||||
if args.max_mutants and len(all_mutants) > args.max_mutants:
|
||||
step = len(all_mutants) / args.max_mutants
|
||||
sampled = [all_mutants[int(i * step)] for i in range(args.max_mutants)]
|
||||
print(f"Sampling {len(sampled)} of {len(all_mutants)} mutants "
|
||||
f"(--max-mutants {args.max_mutants}).")
|
||||
all_mutants = sampled
|
||||
|
||||
if args.list:
|
||||
for m in all_mutants:
|
||||
print(" " + m.describe())
|
||||
return 0
|
||||
|
||||
if not all_mutants:
|
||||
print("No mutants generated; nothing to do.")
|
||||
return 0
|
||||
|
||||
# Scratch working copy.
|
||||
work_parent = args.work or tempfile.mkdtemp(prefix="akgl_mut_")
|
||||
work = os.path.join(work_parent, "src") if args.work else work_parent
|
||||
if os.path.exists(work):
|
||||
shutil.rmtree(work)
|
||||
print(f"\nCopying sources to scratch dir: {work}")
|
||||
copy_tree(root, work)
|
||||
|
||||
runner = Runner(work, args.timeout, args.exclude_test)
|
||||
|
||||
print("Configuring baseline ...")
|
||||
ok, out = runner.configure()
|
||||
if not ok:
|
||||
sys.stderr.write(out.decode(errors="replace"))
|
||||
sys.stderr.write("\nBaseline configure FAILED; aborting.\n")
|
||||
return 2
|
||||
|
||||
print("Verifying baseline is green (no mutation) ...")
|
||||
baseline = runner.build_and_test()
|
||||
if baseline != "survived":
|
||||
sys.stderr.write(f"Baseline is not green ({baseline}); aborting. "
|
||||
"Fix the suite before mutation testing.\n")
|
||||
return 2
|
||||
print("Baseline OK.\n")
|
||||
|
||||
# Group mutants by file so we mutate one file at a time and restore it.
|
||||
killed = {"killed-compile": 0, "killed-test": 0, "killed-timeout": 0}
|
||||
survivors = []
|
||||
records = []
|
||||
total = len(all_mutants)
|
||||
|
||||
# Cache pristine contents per target.
|
||||
pristine = {t: read_lines(os.path.join(work, t)) for t in targets}
|
||||
|
||||
for idx, m in enumerate(all_mutants, start=1):
|
||||
tgt_abs = os.path.join(work, m.path)
|
||||
lines = list(pristine[m.path])
|
||||
lines[m.lineno - 1] = m.after
|
||||
write_lines(tgt_abs, lines)
|
||||
try:
|
||||
result = runner.build_and_test()
|
||||
finally:
|
||||
write_lines(tgt_abs, pristine[m.path]) # always restore
|
||||
|
||||
records.append((m, result))
|
||||
if result == "survived":
|
||||
survivors.append(m)
|
||||
mark = "SURVIVED"
|
||||
else:
|
||||
killed[result] += 1
|
||||
mark = result.upper()
|
||||
print(f"[{idx}/{total}] {mark:16} {m.describe()}")
|
||||
|
||||
total_killed = sum(killed.values())
|
||||
score = 100.0 * total_killed / total if total else 100.0
|
||||
|
||||
print("\n" + "=" * 72)
|
||||
print("MUTATION TESTING SUMMARY")
|
||||
print("=" * 72)
|
||||
print(f" total mutants : {total}")
|
||||
print(f" killed (test) : {killed['killed-test']}")
|
||||
print(f" killed (compile): {killed['killed-compile']}")
|
||||
print(f" killed (timeout): {killed['killed-timeout']}")
|
||||
print(f" survived : {len(survivors)}")
|
||||
print(f" mutation score : {score:.1f}%")
|
||||
if survivors:
|
||||
print("\nSurviving mutants (test-suite gaps -- turn these into tests):")
|
||||
for m in survivors:
|
||||
print(" " + m.describe())
|
||||
|
||||
if args.junit:
|
||||
junit_path = os.path.abspath(args.junit)
|
||||
write_junit(junit_path, records, targets)
|
||||
print(f"\nJUnit report written to: {junit_path}")
|
||||
|
||||
if not args.keep and not args.work:
|
||||
shutil.rmtree(work_parent, ignore_errors=True)
|
||||
else:
|
||||
print(f"\nScratch working copy kept at: {work}")
|
||||
|
||||
if args.threshold > 0 and score < args.threshold:
|
||||
print(f"\nFAIL: mutation score {score:.1f}% < threshold {args.threshold:.1f}%")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
87
scripts/reindent.el
Normal file
87
scripts/reindent.el
Normal file
@@ -0,0 +1,87 @@
|
||||
;;; reindent.el --- batch reindent to cc-mode "stroustrup" -*- lexical-binding: t -*-
|
||||
|
||||
;; Reindents each file named on the command line, in place, to the project's
|
||||
;; canonical style: cc-mode "stroustrup", 4 columns per level, tabs 8 columns
|
||||
;; wide. See AGENTS.md -> "Coding Style".
|
||||
;;
|
||||
;; The style is set explicitly here rather than read from .dir-locals.el so that
|
||||
;; the result does not depend on where the file lives -- the pre-commit hook
|
||||
;; runs this over temporary copies outside the project tree.
|
||||
;;
|
||||
;; Usage: emacs --batch -Q -l scripts/reindent.el -- FILE...
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'cc-mode)
|
||||
|
||||
(setq make-backup-files nil
|
||||
create-lockfiles nil
|
||||
auto-save-default nil
|
||||
vc-handled-backends nil
|
||||
inhibit-message t)
|
||||
|
||||
(defun akgl-retab-leading-whitespace ()
|
||||
"Rewrite every line's leading whitespace as tabs-then-spaces at `tab-width'.
|
||||
|
||||
`indent-region' fixes the column a line starts at, but `indent-line-to' leaves
|
||||
a line alone when it is already at the right column even if the bytes are
|
||||
wrong -- eight spaces where the canonical form is one tab. This pass closes
|
||||
that gap.
|
||||
|
||||
Deliberately not `tabify': that function's `tabify-regexp' is \" [ \\t]+\",
|
||||
which is NOT anchored to the line start, so it rewrites runs of spaces
|
||||
anywhere on the line and destroys the hand-aligned columns in the bit-flag
|
||||
tables in actor.h and iterator.h. Only leading whitespace is touched here, and
|
||||
lines beginning inside a string literal are skipped outright."
|
||||
(goto-char (point-min))
|
||||
(while (not (eobp))
|
||||
(let ((bol (point)))
|
||||
(unless (nth 3 (syntax-ppss bol))
|
||||
(skip-chars-forward " \t" (line-end-position))
|
||||
(let ((col (current-column)))
|
||||
(unless (or (zerop col) (eolp))
|
||||
(let ((want (concat (make-string (/ col tab-width) ?\t)
|
||||
(make-string (% col tab-width) ?\s))))
|
||||
(unless (string= want (buffer-substring bol (point)))
|
||||
(delete-region bol (point))
|
||||
(insert want)))))))
|
||||
(forward-line 1)))
|
||||
|
||||
(defun akgl-reindent-file (path)
|
||||
"Reindent PATH in place. Returns t if the file changed on disk."
|
||||
(let ((before (with-temp-buffer
|
||||
(insert-file-contents path)
|
||||
(buffer-string))))
|
||||
(with-current-buffer (find-file-noselect path t)
|
||||
(c-mode)
|
||||
(c-set-style "stroustrup")
|
||||
(setq indent-tabs-mode t
|
||||
tab-width 8
|
||||
c-basic-offset 4
|
||||
require-final-newline t)
|
||||
;; 1. Put every line at its correct column.
|
||||
(indent-region (point-min) (point-max))
|
||||
;; 2. Convert leading whitespace to the canonical tab/space mix.
|
||||
(akgl-retab-leading-whitespace)
|
||||
;; 3. Trailing whitespace and a single final newline.
|
||||
(delete-trailing-whitespace)
|
||||
(goto-char (point-max))
|
||||
(unless (bolp) (insert "\n"))
|
||||
(let ((changed (not (string= before (buffer-string)))))
|
||||
(when changed (save-buffer))
|
||||
(kill-buffer)
|
||||
changed))))
|
||||
|
||||
;; Emacs leaves the "--" separator in `command-line-args-left'; drop it, along
|
||||
;; with any empty argument, so the remainder is exactly the file list.
|
||||
(dolist (path (seq-remove (lambda (a) (or (string= a "--") (string= a "")))
|
||||
command-line-args-left))
|
||||
(when (akgl-reindent-file path)
|
||||
(princ (format "reindented %s\n" path))))
|
||||
|
||||
;; Exit 0 on success whether or not anything changed, so that any non-zero
|
||||
;; status from this script means a real failure. Callers detect "something
|
||||
;; changed" from stdout, or by comparing files themselves.
|
||||
(kill-emacs 0)
|
||||
|
||||
;;; reindent.el ends here
|
||||
101
scripts/reindent.sh
Executable file
101
scripts/reindent.sh
Executable file
@@ -0,0 +1,101 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Reindent C sources to the project's canonical style (cc-mode "stroustrup",
|
||||
# 4-column offset, 8-column tabs). See AGENTS.md -> "Coding Style".
|
||||
#
|
||||
# scripts/reindent.sh reindent every tracked C source in place
|
||||
# scripts/reindent.sh FILE... reindent only the named files
|
||||
# scripts/reindent.sh --check ... report non-conforming files, change nothing
|
||||
#
|
||||
# Exit status: 0 if everything already conforms (or was reindented), 1 if
|
||||
# --check found a file that needs reindenting, 2 on a usage or environment
|
||||
# error.
|
||||
|
||||
set -eu
|
||||
|
||||
root=$(git rev-parse --show-toplevel)
|
||||
elisp="$root/scripts/reindent.el"
|
||||
|
||||
# Directories whose C sources are hand-maintained. Anything outside these is
|
||||
# vendored (deps/) or generated (include/akgl/SDL_GameControllerDB.h) and is
|
||||
# left alone.
|
||||
SCOPE='src include tests util'
|
||||
GENERATED='include/akgl/SDL_GameControllerDB.h'
|
||||
|
||||
check=0
|
||||
if [ "${1:-}" = "--check" ]; then
|
||||
check=1
|
||||
shift
|
||||
fi
|
||||
|
||||
if ! command -v emacs >/dev/null 2>&1; then
|
||||
echo "reindent: emacs not found; cannot verify or apply the canonical style" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ ! -f "$elisp" ]; then
|
||||
echo "reindent: missing $elisp" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Build the file list: explicit arguments, or every tracked C source in scope.
|
||||
if [ "$#" -gt 0 ]; then
|
||||
files=$(for f in "$@"; do printf '%s\n' "$f"; done)
|
||||
else
|
||||
files=$(cd "$root" && git ls-files $SCOPE | grep -E '\.[ch]$' || true)
|
||||
fi
|
||||
|
||||
# Drop generated files and anything that no longer exists on disk.
|
||||
files=$(printf '%s\n' "$files" | grep -v -x -F "$GENERATED" || true)
|
||||
files=$(cd "$root" && for f in $files; do [ -f "$f" ] && printf '%s\n' "$f"; done)
|
||||
|
||||
if [ -z "$files" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$check" -eq 0 ]; then
|
||||
# Reindent in place. A non-zero status here is a real failure -- never
|
||||
# swallow it, or a broken Emacs would look like "everything already conforms".
|
||||
# shellcheck disable=SC2086
|
||||
(cd "$root" && emacs --batch -Q -l "$elisp" -- $files) || {
|
||||
echo "reindent: emacs failed; no files were reindented" >&2
|
||||
exit 2
|
||||
}
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --check: reindent throwaway copies and report which originals differ. The
|
||||
# copies keep their extension so cc-mode still selects the right major mode.
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT INT TERM
|
||||
|
||||
# Accept both repo-relative and absolute paths: the default file list is
|
||||
# relative, but callers (including the pre-commit hook) pass absolute ones.
|
||||
abspath() {
|
||||
case "$1" in
|
||||
/*) printf '%s' "$1" ;;
|
||||
*) printf '%s/%s' "$root" "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
copies=''
|
||||
for f in $files; do
|
||||
dest="$tmp/$(printf '%s' "$f" | tr '/' '_')"
|
||||
cp "$(abspath "$f")" "$dest"
|
||||
copies="$copies $dest"
|
||||
done
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
emacs --batch -Q -l "$elisp" -- $copies >/dev/null || {
|
||||
echo "reindent: emacs failed; cannot determine whether sources conform" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
status=0
|
||||
for f in $files; do
|
||||
dest="$tmp/$(printf '%s' "$f" | tr '/' '_')"
|
||||
if ! cmp -s "$(abspath "$f")" "$dest"; then
|
||||
echo "$f"
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
exit $status
|
||||
85
scripts/valgrind.supp
Normal file
85
scripts/valgrind.supp
Normal file
@@ -0,0 +1,85 @@
|
||||
# Valgrind suppressions for the libakgl memory-check run.
|
||||
#
|
||||
# Used by `cmake --build build --target memcheck` and by any hand-run
|
||||
# `ctest -T memcheck`; see AGENTS.md -> "Memory checking".
|
||||
#
|
||||
# Every entry here is a deliberate decision that a finding belongs to somebody
|
||||
# else's code and cannot be fixed from this repository. Nothing that libakgl
|
||||
# allocates is suppressed, and nothing is suppressed merely because it is noisy.
|
||||
# If a suppression starts hiding a real finding, delete it -- a false negative in
|
||||
# a leak check is worth more than a quiet log.
|
||||
#
|
||||
# The memcheck target runs the suite headless (SDL_VIDEO_DRIVER=dummy,
|
||||
# SDL_RENDER_DRIVER=software, SDL_AUDIO_DRIVER=dummy) precisely so that the GPU
|
||||
# stack is never loaded. That removes thousands of unfixable findings inside
|
||||
# amdgpu_dri.so / Mesa / libGLX without suppressing anything, which is why there
|
||||
# are no driver entries below. Run the suite against a real driver and you will
|
||||
# need them; do not add them here on that account.
|
||||
|
||||
# SDL allocates its global state -- the hint table, the property registry, the
|
||||
# log category array, the clipboard -- on first use and reclaims it in SDL_Quit.
|
||||
# A test that fails before SDL_Quit, and every test that deliberately leaves SDL
|
||||
# up so a later assertion can look at it, leaves those blocks behind. They are
|
||||
# one-per-process, not per-call, so they cannot accumulate into a leak.
|
||||
{
|
||||
sdl3-global-state-still-reachable
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: reachable
|
||||
...
|
||||
fun:SDL_InitSubSystem_REAL
|
||||
}
|
||||
|
||||
# dlopen keeps the link map and the loaded objects' TLS blocks for the process
|
||||
# lifetime; a dlclose does not return them. SDL loads its video, audio, and
|
||||
# render backends this way, and SDL_image and SDL_mixer load their codecs the
|
||||
# same way.
|
||||
{
|
||||
dl-open-keeps-its-link-map
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: reachable,possible
|
||||
...
|
||||
fun:_dl_open
|
||||
}
|
||||
|
||||
# FreeType, reached through SDL_ttf, caches per-face and per-size structures
|
||||
# inside the library instance and frees them in FT_Done_FreeType, which SDL_ttf
|
||||
# calls from TTF_Quit. A suite that opens a font and exits without TTF_Quit
|
||||
# reports them; a font libakgl itself failed to close is a different finding and
|
||||
# is NOT covered here -- it appears under akgl_text_loadfont, not under
|
||||
# FT_Init_FreeType.
|
||||
{
|
||||
freetype-library-instance
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: reachable
|
||||
...
|
||||
fun:FT_Init_FreeType
|
||||
}
|
||||
|
||||
# deps/semver's own unit test leaks the buffers it cuts strings into --
|
||||
# semver_unit.c:8 and :21, one calloc each per case. Vendored code, and its test
|
||||
# program at that: not ours to edit, and not worth carrying a local patch for.
|
||||
# Named by function rather than by file so that a rewrite of those cases stops
|
||||
# being suppressed and comes back as a finding.
|
||||
#
|
||||
# This is the only entry here that hides a real leak in a program this build
|
||||
# runs. It is bounded, it is in a test, and the alternative is either a fork of
|
||||
# a vendored dependency or a memcheck run that is always red -- see TODO.md,
|
||||
# "Memory checking" item 39.
|
||||
{
|
||||
vendored-semver-unit-test-strcut-first
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: definite
|
||||
fun:calloc
|
||||
fun:test_strcut_first
|
||||
fun:test_strcut
|
||||
fun:main
|
||||
}
|
||||
{
|
||||
vendored-semver-unit-test-strcut-second
|
||||
Memcheck:Leak
|
||||
match-leak-kinds: definite
|
||||
fun:calloc
|
||||
fun:test_strcut_second
|
||||
fun:test_strcut
|
||||
fun:main
|
||||
}
|
||||
194
src/actor.c
194
src/actor.c
@@ -1,7 +1,13 @@
|
||||
/**
|
||||
* @file actor.c
|
||||
* @brief Implements the actor subsystem.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
#include <string.h>
|
||||
#include <akerror.h>
|
||||
#include <akstdlib.h>
|
||||
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/game.h>
|
||||
@@ -19,18 +25,22 @@ 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;
|
||||
|
||||
|
||||
obj->updatefunc = &akgl_actor_update;
|
||||
obj->renderfunc = &akgl_actor_render;
|
||||
obj->facefunc = &akgl_actor_automatic_face;
|
||||
obj->movementlogicfunc = &akgl_actor_logic_movement;
|
||||
obj->changeframefunc = &akgl_actor_logic_changeframe;
|
||||
obj->addchild = &akgl_actor_add_child;
|
||||
|
||||
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
SDL_SetPointerProperty(AKGL_REGISTRY_ACTOR, name, (void *)obj),
|
||||
@@ -51,6 +61,10 @@ akerr_ErrorContext *akgl_actor_set_character(akgl_Actor *obj, char *basecharname
|
||||
obj->basechar = SDL_GetPointerProperty(AKGL_REGISTRY_CHARACTER, basecharname, NULL);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj->basechar, AKERR_NULLPOINTER, "Character not found in the registry");
|
||||
obj->ax = 0;
|
||||
obj->ay = 0;
|
||||
obj->sx = obj->basechar->sx;
|
||||
obj->sy = obj->basechar->sy;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
@@ -102,7 +116,7 @@ akerr_ErrorContext *akgl_actor_logic_changeframe(akgl_Actor *obj, akgl_Sprite *c
|
||||
} else {
|
||||
// we are at the end of the animation and we either loop forward or do not loop
|
||||
obj->curSpriteFrameId = 0;
|
||||
}
|
||||
}
|
||||
// we are not looping in reverse and we are not at the end of the animation
|
||||
} else {
|
||||
obj->curSpriteFrameId += 1;
|
||||
@@ -113,14 +127,25 @@ akerr_ErrorContext *akgl_actor_logic_changeframe(akgl_Actor *obj, akgl_Sprite *c
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
// raises AKGL_ERR_LOGICINTERRUPT if we don't want the physics object to process us
|
||||
akerr_ErrorContext *akgl_actor_logic_movement(akgl_Actor *obj, float32_t dt)
|
||||
// raises AKGL_ERR_LOGICINTERRUPT if we don't want the physics simulator to process us
|
||||
akerr_ErrorContext *akgl_actor_logic_movement(akgl_Actor *actor, float32_t dt)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj->basechar");
|
||||
// Effectively a NOOP, this is handled by the physics engine now
|
||||
// These functions are still present in case the library user wants per-actor behavior
|
||||
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor");
|
||||
FAIL_ZERO_RETURN(errctx, actor->basechar, AKERR_NULLPOINTER, "actor->basechar");
|
||||
actor->sx = actor->basechar->sx;
|
||||
actor->sy = actor->basechar->sy;
|
||||
actor->sz = actor->basechar->sz;
|
||||
if ( AKGL_BITMASK_HAS(actor->state, AKGL_ACTOR_STATE_MOVING_LEFT) ) {
|
||||
actor->ax = -actor->basechar->ax;
|
||||
} else if ( AKGL_BITMASK_HAS(actor->state, AKGL_ACTOR_STATE_MOVING_RIGHT) ) {
|
||||
actor->ax = actor->basechar->ax;
|
||||
}
|
||||
if ( AKGL_BITMASK_HAS(actor->state, AKGL_ACTOR_STATE_MOVING_UP) ) {
|
||||
actor->ay = -actor->basechar->ay;
|
||||
} else if ( AKGL_BITMASK_HAS(actor->state, AKGL_ACTOR_STATE_MOVING_DOWN) ) {
|
||||
actor->ay = actor->basechar->ay;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
@@ -129,7 +154,7 @@ akerr_ErrorContext *akgl_actor_update(akgl_Actor *obj)
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_Time curtime = 0;
|
||||
akgl_Sprite *curSprite = NULL;
|
||||
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL actor reference");
|
||||
FAIL_ZERO_RETURN(errctx, obj->basechar, AKERR_NULLPOINTER, "Actor has NULL base character reference");
|
||||
|
||||
@@ -149,18 +174,37 @@ akerr_ErrorContext *akgl_actor_update(akgl_Actor *obj)
|
||||
// or changeframefunc, both of which should never return AKERR_KEY...
|
||||
SUCCEED_RETURN(errctx);
|
||||
} FINISH(errctx, true);
|
||||
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Decide whether an actor is worth drawing this frame.
|
||||
*
|
||||
* Two questions at once: is the actor on camera, and does it want to be drawn.
|
||||
* The camera test allows a sprite's own width and height on the near edges, so
|
||||
* an actor partly on screen still counts as visible rather than popping in once
|
||||
* its origin crosses the boundary.
|
||||
*
|
||||
* An actor with no sprite for its current state is reported as not visible
|
||||
* rather than as an error -- there is nothing to draw, which is an answer.
|
||||
*
|
||||
* @param obj The actor to test. Required, along with its `basechar`.
|
||||
* @param camera The visible rectangle in map coordinates. Required in practice;
|
||||
* not checked, and dereferenced once a sprite has been found.
|
||||
* @param visible Receives the verdict. Required; not checked. Written on the
|
||||
* no-sprite path as well as the ordinary ones.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p obj or `obj->basechar` is `NULL`.
|
||||
*/
|
||||
static akerr_ErrorContext *actor_visible(akgl_Actor *obj, SDL_FRect *camera, bool *visible)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_Sprite *curSprite = NULL;
|
||||
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL actor");
|
||||
FAIL_ZERO_RETURN(errctx, obj->basechar, AKERR_NULLPOINTER, "Actor has NULL base character reference");
|
||||
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_character_sprite_get(obj->basechar, obj->state, &curSprite));
|
||||
} CLEANUP {
|
||||
@@ -170,7 +214,7 @@ static akerr_ErrorContext *actor_visible(akgl_Actor *obj, SDL_FRect *camera, boo
|
||||
*visible = false;
|
||||
SUCCEED_RETURN(errctx);
|
||||
} FINISH(errctx, true);
|
||||
|
||||
|
||||
if ( (obj->x < (camera->x - curSprite->width)) ||
|
||||
(obj->x > (camera->x + camera->w)) ||
|
||||
(obj->y < (camera->y - curSprite->height)) ||
|
||||
@@ -189,13 +233,13 @@ akerr_ErrorContext *akgl_actor_render(akgl_Actor *obj)
|
||||
bool visible = false;
|
||||
SDL_FRect src;
|
||||
SDL_FRect dest;
|
||||
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL actor");
|
||||
FAIL_ZERO_RETURN(errctx, obj->basechar, AKERR_NULLPOINTER, "Actor has NULL base character reference");
|
||||
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_character_sprite_get(obj->basechar, obj->state, &curSprite));
|
||||
CATCH(errctx, actor_visible(obj, &camera, &visible));
|
||||
CATCH(errctx, actor_visible(obj, akgl_camera, &visible));
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE(errctx, AKERR_KEY) {
|
||||
@@ -203,7 +247,7 @@ akerr_ErrorContext *akgl_actor_render(akgl_Actor *obj)
|
||||
// If an actor doesn't have a sprite for a state, just log it and move on
|
||||
LOG_ERROR(errctx);
|
||||
} FINISH(errctx, true);
|
||||
|
||||
|
||||
if ( ! visible ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
@@ -217,7 +261,7 @@ akerr_ErrorContext *akgl_actor_render(akgl_Actor *obj)
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx,
|
||||
akgl_sprite_sheet_coords_for_frame(
|
||||
akgl_spritesheet_coords_for_frame(
|
||||
curSprite,
|
||||
&src,
|
||||
obj->curSpriteFrameId)
|
||||
@@ -225,135 +269,173 @@ akerr_ErrorContext *akgl_actor_render(akgl_Actor *obj)
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
|
||||
if ( obj->parent != NULL ) {
|
||||
dest.x = (obj->parent->x + obj->x - camera.x);
|
||||
dest.y = (obj->parent->y + obj->y - camera.y);
|
||||
dest.x = (obj->parent->x + obj->x - akgl_camera->x);
|
||||
dest.y = (obj->parent->y + obj->y - akgl_camera->y);
|
||||
} else {
|
||||
dest.x = (obj->x - camera.x);
|
||||
dest.y = (obj->y - camera.y);
|
||||
dest.x = (obj->x - akgl_camera->x);
|
||||
dest.y = (obj->y - akgl_camera->y);
|
||||
}
|
||||
dest.w = curSprite->width * obj->scale;
|
||||
dest.h = curSprite->width * obj->scale;
|
||||
dest.h = curSprite->height * obj->scale;
|
||||
|
||||
PASS(errctx, renderer.draw_texture(&renderer, curSprite->sheet->texture, &src, &dest, 0, NULL, SDL_FLIP_NONE));
|
||||
PASS(errctx, akgl_renderer->draw_texture(akgl_renderer, curSprite->sheet->texture, &src, &dest, 0, NULL, SDL_FLIP_NONE));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_actor_add_child(akgl_Actor *obj, akgl_Actor *child)
|
||||
{
|
||||
int i = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL parent pointer");
|
||||
FAIL_ZERO_RETURN(errctx, child, AKERR_NULLPOINTER, "NULL child pointer");
|
||||
|
||||
FAIL_NONZERO_RETURN(errctx, child->parent, AKERR_RELATIONSHIP, "Child object already has a parent");
|
||||
for ( i = 0; i < AKGL_ACTOR_MAX_CHILDREN ; i++ ) {
|
||||
if ( obj->children[i] == NULL ) {
|
||||
obj->children[i] = child;
|
||||
child->parent = obj;
|
||||
child->refcount += 1;
|
||||
SUCCEED_RETURN(errctx);
|
||||
int i = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL parent pointer");
|
||||
FAIL_ZERO_RETURN(errctx, child, AKERR_NULLPOINTER, "NULL child pointer");
|
||||
|
||||
FAIL_NONZERO_RETURN(errctx, child->parent, AKERR_RELATIONSHIP, "Child object already has a parent");
|
||||
for ( i = 0; i < AKGL_ACTOR_MAX_CHILDREN ; i++ ) {
|
||||
if ( obj->children[i] == NULL ) {
|
||||
obj->children[i] = child;
|
||||
child->parent = obj;
|
||||
child->refcount += 1;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
FAIL_RETURN(errctx, AKERR_OUTOFBOUNDS, "Parent object has no remaining child slots left");
|
||||
FAIL_RETURN(errctx, AKERR_OUTOFBOUNDS, "Parent object has no remaining child slots left");
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_Actor_cmhf_left_on(akgl_Actor *obj, SDL_Event *event)
|
||||
void akgl_registry_iterate_actor(void *userdata, SDL_PropertiesID registry, const char *name)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL actor");
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event");
|
||||
akgl_Iterator *opflags = (akgl_Iterator *)userdata;
|
||||
akgl_Actor *obj = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
FAIL_ZERO_BREAK(errctx, name, AKERR_NULLPOINTER, "registry_iterate_actor received NULL property name");
|
||||
FAIL_ZERO_BREAK(errctx, opflags, AKERR_NULLPOINTER, "received NULL iterator flags");
|
||||
obj = SDL_GetPointerProperty(registry, name, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, obj, AKERR_KEY, "registry_iterate_actor received property name that was not in the registry");
|
||||
if (AKGL_BITMASK_HAS(opflags->flags, AKGL_ITERATOR_OP_LAYERMASK) && obj->layer != opflags->layerid) {
|
||||
break;
|
||||
}
|
||||
if (AKGL_BITMASK_HAS(opflags->flags, AKGL_ITERATOR_OP_UPDATE)) {
|
||||
CATCH(errctx, obj->updatefunc(obj));
|
||||
}
|
||||
if (AKGL_BITMASK_HAS(opflags->flags, AKGL_ITERATOR_OP_TILEMAPSCALE)) {
|
||||
CATCH(errctx, akgl_tilemap_scale_actor(akgl_gamemap, obj));
|
||||
} else {
|
||||
obj->scale = 1.0;
|
||||
}
|
||||
if (AKGL_BITMASK_HAS(opflags->flags, AKGL_ITERATOR_OP_RENDER)) {
|
||||
CATCH(errctx, obj->renderfunc(obj));
|
||||
}
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_actor_cmhf_left_on(akgl_Actor *obj, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "actor");
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
|
||||
FAIL_ZERO_RETURN(errctx, obj->basechar, AKERR_NULLPOINTER, "actor->basechar");
|
||||
//SDL_Log("event %d (button %d / key %d) moves actor left", event->type, event->gbutton.which, event->key.key);
|
||||
AKGL_BITMASK_DEL(obj->state, (AKGL_ACTOR_STATE_FACE_ALL | AKGL_ACTOR_STATE_MOVING_ALL));
|
||||
obj->ax = -(obj->basechar->ax);
|
||||
AKGL_BITMASK_ADD(obj->state, (AKGL_ACTOR_STATE_MOVING_LEFT | AKGL_ACTOR_STATE_FACE_LEFT));
|
||||
//SDL_Log("new target actor state: %b", obj->state);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_Actor_cmhf_left_off(akgl_Actor *obj, SDL_Event *event)
|
||||
akerr_ErrorContext *akgl_actor_cmhf_left_off(akgl_Actor *obj, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
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 left", event->type, event->gbutton.which, event->key.key);
|
||||
obj->ax = 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);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_Actor_cmhf_right_on(akgl_Actor *obj, SDL_Event *event)
|
||||
akerr_ErrorContext *akgl_actor_cmhf_right_on(akgl_Actor *obj, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL actor");
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event");
|
||||
FAIL_ZERO_RETURN(errctx, obj->basechar, AKERR_NULLPOINTER, "actor->basechar");
|
||||
//SDL_Log("event %d (button %d / key %d) moves actor right", event->type, event->gbutton.which, event->key.key);
|
||||
obj->ax = obj->basechar->ax;
|
||||
AKGL_BITMASK_DEL(obj->state, (AKGL_ACTOR_STATE_FACE_ALL | AKGL_ACTOR_STATE_MOVING_ALL));
|
||||
AKGL_BITMASK_ADD(obj->state, (AKGL_ACTOR_STATE_MOVING_RIGHT | AKGL_ACTOR_STATE_FACE_RIGHT));
|
||||
//SDL_Log("new target actor state: %b", obj->state);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_Actor_cmhf_right_off(akgl_Actor *obj, SDL_Event *event)
|
||||
akerr_ErrorContext *akgl_actor_cmhf_right_off(akgl_Actor *obj, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
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 right", event->type, event->gbutton.which, event->key.key);
|
||||
obj->ax = 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);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_Actor_cmhf_up_on(akgl_Actor *obj, SDL_Event *event)
|
||||
akerr_ErrorContext *akgl_actor_cmhf_up_on(akgl_Actor *obj, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
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) moves actor up", event->type, event->gbutton.which, event->key.key);
|
||||
FAIL_ZERO_RETURN(errctx, obj->basechar, AKERR_NULLPOINTER, "actor->basechar");
|
||||
//SDL_Log("event %d (button %d / key %d) moves actor up", event->type, event->gbutton.which, event->key.key);
|
||||
obj->ay = -(obj->basechar->ay);
|
||||
AKGL_BITMASK_DEL(obj->state, (AKGL_ACTOR_STATE_FACE_ALL | AKGL_ACTOR_STATE_MOVING_ALL));
|
||||
AKGL_BITMASK_ADD(obj->state, (AKGL_ACTOR_STATE_FACE_UP | AKGL_ACTOR_STATE_MOVING_UP));
|
||||
//SDL_Log("new target actor state: %b", obj->state);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_Actor_cmhf_up_off(akgl_Actor *obj, SDL_Event *event)
|
||||
akerr_ErrorContext *akgl_actor_cmhf_up_off(akgl_Actor *obj, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
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 up", event->type, event->gbutton.which, event->key.key);
|
||||
obj->ay = 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);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_Actor_cmhf_down_on(akgl_Actor *obj, SDL_Event *event)
|
||||
akerr_ErrorContext *akgl_actor_cmhf_down_on(akgl_Actor *obj, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL actor");
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event");
|
||||
FAIL_ZERO_RETURN(errctx, obj->basechar, AKERR_NULLPOINTER, "actor->basechar");
|
||||
//SDL_Log("event %d (button %d / key %d) moves actor down", event->type, event->gbutton.which, event->key.key);
|
||||
obj->ay = obj->basechar->ay;
|
||||
AKGL_BITMASK_DEL(obj->state, (AKGL_ACTOR_STATE_FACE_ALL | AKGL_ACTOR_STATE_MOVING_ALL));
|
||||
AKGL_BITMASK_ADD(obj->state, (AKGL_ACTOR_STATE_MOVING_DOWN | AKGL_ACTOR_STATE_FACE_DOWN));
|
||||
//SDL_Log("new target actor state: %b", obj->state);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_Actor_cmhf_down_off(akgl_Actor *obj, SDL_Event *event)
|
||||
akerr_ErrorContext *akgl_actor_cmhf_down_off(akgl_Actor *obj, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
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->ay = 0;
|
||||
obj->ty = 0;
|
||||
obj->vy = 0;
|
||||
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_DOWN);
|
||||
//SDL_Log("new target actor state: %b", obj->state);
|
||||
SUCCEED_RETURN(errctx);
|
||||
|
||||
@@ -1,34 +1,59 @@
|
||||
char *AKGL_ACTOR_STATE_STRING_NAMES[32] = {
|
||||
"AKGL_ACTOR_STATE_FACE_DOWN",
|
||||
"AKGL_ACTOR_STATE_FACE_LEFT",
|
||||
"AKGL_ACTOR_STATE_FACE_RIGHT",
|
||||
"AKGL_ACTOR_STATE_FACE_UP",
|
||||
"AKGL_ACTOR_STATE_ALIVE",
|
||||
"AKGL_ACTOR_STATE_DYING",
|
||||
"AKGL_ACTOR_STATE_DEAD",
|
||||
"AKGL_ACTOR_STATE_MOVING_LEFT",
|
||||
"AKGL_ACTOR_STATE_MOVING_RIGHT",
|
||||
"AKGL_ACTOR_STATE_MOVING_UP",
|
||||
"AKGL_ACTOR_STATE_MOVING_DOWN",
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_11",
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_12",
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_13",
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_14",
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_15",
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_16",
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_17",
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_18",
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_19",
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_20",
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_21",
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_22",
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_23",
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_24",
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_25",
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_26",
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_27",
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_28",
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_29",
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_30",
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_31",
|
||||
/**
|
||||
* @file actor_state_string_names.c
|
||||
* @brief Bit position -> state name, as text. Maintained by hand.
|
||||
*
|
||||
* An earlier comment in `actor.h` said this file was "built by a utility script
|
||||
* and not kept in git, see the Makefile for lib_src/actor_state_string_names.c".
|
||||
* None of that is true: there is no Makefile (the project is CMake), no
|
||||
* `lib_src/`, no such script under `scripts/` or `util/`, and this file is
|
||||
* tracked. It is maintained by hand, and the two things that keeps costing are
|
||||
* worth stating here rather than rediscovering.
|
||||
*
|
||||
* **Entry `i` must name the bit `1 << i` in `actor.h`.** Bits 11 and 12 read
|
||||
* `UNDEFINED_11` and `UNDEFINED_12` here for as long as `actor.h` has called
|
||||
* them `MOVING_IN` and `MOVING_OUT`, which meant a character JSON could not
|
||||
* bind a sprite to either state -- the name it would have to write did not
|
||||
* exist in the registry akgl_registry_init_actor_state_strings builds out of
|
||||
* this array.
|
||||
*
|
||||
* **The array is sized by #AKGL_ACTOR_MAX_STATES**, not by a literal. It was
|
||||
* `[32]` here against `[AKGL_ACTOR_MAX_STATES+1]` in the header, so a consumer
|
||||
* trusting the declared bound read one entry past the object.
|
||||
*/
|
||||
|
||||
#include <akgl/actor.h>
|
||||
|
||||
char *AKGL_ACTOR_STATE_STRING_NAMES[AKGL_ACTOR_MAX_STATES] = {
|
||||
"AKGL_ACTOR_STATE_FACE_DOWN", // 1 << 0
|
||||
"AKGL_ACTOR_STATE_FACE_LEFT", // 1 << 1
|
||||
"AKGL_ACTOR_STATE_FACE_RIGHT", // 1 << 2
|
||||
"AKGL_ACTOR_STATE_FACE_UP", // 1 << 3
|
||||
"AKGL_ACTOR_STATE_ALIVE", // 1 << 4
|
||||
"AKGL_ACTOR_STATE_DYING", // 1 << 5
|
||||
"AKGL_ACTOR_STATE_DEAD", // 1 << 6
|
||||
"AKGL_ACTOR_STATE_MOVING_LEFT", // 1 << 7
|
||||
"AKGL_ACTOR_STATE_MOVING_RIGHT", // 1 << 8
|
||||
"AKGL_ACTOR_STATE_MOVING_UP", // 1 << 9
|
||||
"AKGL_ACTOR_STATE_MOVING_DOWN", // 1 << 10
|
||||
"AKGL_ACTOR_STATE_MOVING_IN", // 1 << 11
|
||||
"AKGL_ACTOR_STATE_MOVING_OUT", // 1 << 12
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_13", // 1 << 13
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_14", // 1 << 14
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_15", // 1 << 15
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_16", // 1 << 16
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_17", // 1 << 17
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_18", // 1 << 18
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_19", // 1 << 19
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_20", // 1 << 20
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_21", // 1 << 21
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_22", // 1 << 22
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_23", // 1 << 23
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_24", // 1 << 24
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_25", // 1 << 25
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_26", // 1 << 26
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_27", // 1 << 27
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_28", // 1 << 28
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_29", // 1 << 29
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_30", // 1 << 30
|
||||
"AKGL_ACTOR_STATE_UNDEFINED_31", // 1 << 31
|
||||
};
|
||||
|
||||
80
src/assets.c
80
src/assets.c
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @file assets.c
|
||||
* @brief Implements the assets subsystem.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
#include <SDL3_mixer/SDL_mixer.h>
|
||||
@@ -9,46 +14,45 @@
|
||||
|
||||
akerr_ErrorContext *akgl_load_start_bgm(char *fname)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
//akgl_String *tmpstr = NULL;
|
||||
MIX_Track *bgmtrack = NULL;
|
||||
SDL_PropertiesID bgmprops = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
//akgl_String *tmpstr = NULL;
|
||||
MIX_Track *bgmtrack = NULL;
|
||||
SDL_PropertiesID bgmprops = 0;
|
||||
|
||||
ATTEMPT {
|
||||
FAIL_ZERO_BREAK(errctx, fname, AKERR_NULLPOINTER, "akgl_load_start_bgm received NULL filename");
|
||||
//CATCH(errctx, akgl_heap_next_string(&tmpstr));
|
||||
//CATCH(errctx, akgl_string_initialize(tmpstr, NULL));
|
||||
|
||||
//SDL_snprintf((char *)&tmpstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), fname);
|
||||
SDL_Log("Loading music asset from %s", fname);
|
||||
bgm = MIX_LoadAudio(akgl_mixer, fname, true);
|
||||
FAIL_ZERO_BREAK(errctx, bgm, AKERR_NULLPOINTER, "Failed to load music asset %s : %s", fname, SDL_GetError());
|
||||
ATTEMPT {
|
||||
FAIL_ZERO_BREAK(errctx, fname, AKERR_NULLPOINTER, "akgl_load_start_bgm received NULL filename");
|
||||
//CATCH(errctx, akgl_heap_next_string(&tmpstr));
|
||||
//CATCH(errctx, akgl_string_initialize(tmpstr, NULL));
|
||||
|
||||
bgmtrack = MIX_CreateTrack(akgl_mixer);
|
||||
FAIL_ZERO_BREAK(errctx, bgmtrack, AKERR_NULLPOINTER, "Failed to create audio track for background music: %s", SDL_GetError());
|
||||
SDL_Log("Loading music asset from %s", fname);
|
||||
akgl_bgm = MIX_LoadAudio(akgl_mixer, fname, true);
|
||||
FAIL_ZERO_BREAK(errctx, akgl_bgm, AKERR_NULLPOINTER, "Failed to load music asset %s : %s", fname, SDL_GetError());
|
||||
|
||||
akgl_tracks[AKGL_GAME_AUDIO_TRACK_BGM] = bgmtrack;
|
||||
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
MIX_SetTrackAudio(bgmtrack, bgm),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
bgmtrack = MIX_CreateTrack(akgl_mixer);
|
||||
FAIL_ZERO_BREAK(errctx, bgmtrack, AKERR_NULLPOINTER, "Failed to create audio track for background music: %s", SDL_GetError());
|
||||
|
||||
SDL_SetNumberProperty(bgmprops, MIX_PROP_PLAY_LOOPS_NUMBER, -1);
|
||||
|
||||
if (!MIX_PlayTrack(bgmtrack, bgmprops)) {
|
||||
FAIL_BREAK(errctx, AKGL_ERR_SDL, "Failed to play music asset %s", fname);
|
||||
}
|
||||
} CLEANUP {
|
||||
//IGNORE(akgl_heap_release_string(tmpstr));
|
||||
if ( errctx != NULL ) {
|
||||
if ( errctx->status != 0 && bgm != NULL) {
|
||||
MIX_DestroyAudio(bgm);
|
||||
}
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
akgl_tracks[AKGL_GAME_AUDIO_TRACK_BGM] = bgmtrack;
|
||||
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
MIX_SetTrackAudio(bgmtrack, akgl_bgm),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
|
||||
SDL_SetNumberProperty(bgmprops, MIX_PROP_PLAY_LOOPS_NUMBER, -1);
|
||||
|
||||
if (!MIX_PlayTrack(bgmtrack, bgmprops)) {
|
||||
FAIL_BREAK(errctx, AKGL_ERR_SDL, "Failed to play music asset %s", fname);
|
||||
}
|
||||
} CLEANUP {
|
||||
//IGNORE(akgl_heap_release_string(tmpstr));
|
||||
if ( errctx != NULL ) {
|
||||
if ( errctx->status != 0 && akgl_bgm != NULL) {
|
||||
MIX_DestroyAudio(akgl_bgm);
|
||||
}
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
526
src/audio.c
Normal file
526
src/audio.c
Normal file
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* @file audio.c
|
||||
* @brief Implements the audio subsystem.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akerror.h>
|
||||
#include <akgl/audio.h>
|
||||
#include <akgl/error.h>
|
||||
|
||||
akgl_AudioVoice akgl_audio_voices[AKGL_AUDIO_MAX_VOICES];
|
||||
|
||||
/*
|
||||
* The device akgl_audio_init() opened, or NULL when the voice table is not
|
||||
* connected to one. Everything that mutates a voice locks this stream when it
|
||||
* is open, because the device callback reads the same table on SDL's audio
|
||||
* thread. When it is NULL there is no other thread to race with.
|
||||
*/
|
||||
static SDL_AudioStream *audiostream = NULL;
|
||||
|
||||
/** @brief Level every voice is scaled by before the mix is clamped. */
|
||||
static float32_t mastervolume = 1.0f;
|
||||
|
||||
/** @brief Scratch the device callback mixes into before handing it to SDL. */
|
||||
static float32_t mixbuffer[AKGL_AUDIO_MIX_FRAMES];
|
||||
|
||||
/*
|
||||
* State for the noise waveform. A 32-bit xorshift rather than rand(): it needs
|
||||
* no allocation, no locking beyond what already guards the voice table, and it
|
||||
* produces the same sequence every run, so a test can assert on noise output.
|
||||
*/
|
||||
static uint32_t noisestate = 0x13579bdfu;
|
||||
|
||||
/** @brief Whether the voice table has been given its defaults yet. */
|
||||
static bool voicesready = false;
|
||||
|
||||
/**
|
||||
* @brief Put every voice back to a flat, audible default.
|
||||
*
|
||||
* A zeroed voice has a sustain of 0.0, which is silence -- so a caller who
|
||||
* sounded a note without first describing an envelope would get nothing and no
|
||||
* error saying why. The default is instead the simplest thing that makes noise:
|
||||
* a square wave with no attack, no decay and no release, held at full level for
|
||||
* as long as the gate is open.
|
||||
*/
|
||||
static void reset_voices(void)
|
||||
{
|
||||
int i = 0;
|
||||
|
||||
for ( i = 0; i < AKGL_AUDIO_MAX_VOICES; i++ ) {
|
||||
akgl_audio_voices[i].active = false;
|
||||
akgl_audio_voices[i].waveform = AKGL_AUDIO_WAVE_SQUARE;
|
||||
akgl_audio_voices[i].hz = 0.0f;
|
||||
akgl_audio_voices[i].phase = 0.0f;
|
||||
akgl_audio_voices[i].duration_frames = 0;
|
||||
akgl_audio_voices[i].elapsed_frames = 0;
|
||||
akgl_audio_voices[i].attack_frames = 0;
|
||||
akgl_audio_voices[i].decay_frames = 0;
|
||||
akgl_audio_voices[i].release_frames = 0;
|
||||
akgl_audio_voices[i].sustain = 1.0f;
|
||||
akgl_audio_voices[i].sweep_from_hz = 0.0f;
|
||||
akgl_audio_voices[i].sweep_to_hz = 0.0f;
|
||||
akgl_audio_voices[i].sweep_step_hz = 0.0f;
|
||||
}
|
||||
mastervolume = 1.0f;
|
||||
voicesready = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Give the voice table its defaults the first time anything touches it.
|
||||
*
|
||||
* The table is process-wide static storage, so it is reachable before
|
||||
* akgl_audio_init() has run. Rather than make every entry point document an
|
||||
* ordering requirement, the first one to arrive sets the defaults.
|
||||
*/
|
||||
static void ensure_voices(void)
|
||||
{
|
||||
if ( voicesready == false ) {
|
||||
reset_voices();
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief Lock the voice table against the device callback, if one is running. */
|
||||
static void lock_voices(void)
|
||||
{
|
||||
if ( audiostream != NULL ) {
|
||||
SDL_LockAudioStream(audiostream);
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief Release the lock taken by lock_voices(). */
|
||||
static void unlock_voices(void)
|
||||
{
|
||||
if ( audiostream != NULL ) {
|
||||
SDL_UnlockAudioStream(audiostream);
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief Convert a duration in milliseconds to a whole number of frames. */
|
||||
static uint32_t frames_for_ms(uint32_t ms)
|
||||
{
|
||||
return (uint32_t)(((uint64_t)ms * AKGL_AUDIO_SAMPLE_RATE) / 1000);
|
||||
}
|
||||
|
||||
/** @brief Next value of the noise oscillator, in the range -1.0 to 1.0. */
|
||||
static float32_t noise_sample(void)
|
||||
{
|
||||
noisestate ^= noisestate << 13;
|
||||
noisestate ^= noisestate >> 17;
|
||||
noisestate ^= noisestate << 5;
|
||||
// The top 24 bits are the well-mixed ones; scale them to -1..1.
|
||||
return (((float32_t)(noisestate >> 8) / 8388607.5f) - 1.0f);
|
||||
}
|
||||
|
||||
/** @brief One sample of @p voice's oscillator at its current phase. */
|
||||
static float32_t voice_oscillator(akgl_AudioVoice *voice)
|
||||
{
|
||||
float32_t value = 0.0f;
|
||||
|
||||
switch ( voice->waveform ) {
|
||||
case AKGL_AUDIO_WAVE_TRIANGLE:
|
||||
if ( voice->phase < 0.5f ) {
|
||||
value = (4.0f * voice->phase) - 1.0f;
|
||||
} else {
|
||||
value = 3.0f - (4.0f * voice->phase);
|
||||
}
|
||||
break;
|
||||
case AKGL_AUDIO_WAVE_SAWTOOTH:
|
||||
value = (2.0f * voice->phase) - 1.0f;
|
||||
break;
|
||||
case AKGL_AUDIO_WAVE_SQUARE:
|
||||
value = ( voice->phase < 0.5f ) ? 1.0f : -1.0f;
|
||||
break;
|
||||
case AKGL_AUDIO_WAVE_NOISE:
|
||||
value = noise_sample();
|
||||
break;
|
||||
case AKGL_AUDIO_WAVE_SINE:
|
||||
value = SDL_sinf(voice->phase * 2.0f * SDL_PI_F);
|
||||
break;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Envelope level @p frame frames into the gate, before the release.
|
||||
*
|
||||
* Split out because the release has to start from wherever the gate left off,
|
||||
* which for a gate shorter than attack plus decay is partway up or down a
|
||||
* ramp rather than at the sustain level.
|
||||
*/
|
||||
static float32_t voice_gate_level(akgl_AudioVoice *voice, uint32_t frame)
|
||||
{
|
||||
uint32_t elapsed = frame;
|
||||
|
||||
if ( elapsed < voice->attack_frames ) {
|
||||
return (float32_t)elapsed / (float32_t)voice->attack_frames;
|
||||
}
|
||||
elapsed -= voice->attack_frames;
|
||||
if ( elapsed < voice->decay_frames ) {
|
||||
return 1.0f - ((1.0f - voice->sustain) * ((float32_t)elapsed / (float32_t)voice->decay_frames));
|
||||
}
|
||||
return voice->sustain;
|
||||
}
|
||||
|
||||
/** @brief Envelope level for @p voice where it currently stands. */
|
||||
static float32_t voice_envelope(akgl_AudioVoice *voice)
|
||||
{
|
||||
uint32_t released = 0;
|
||||
float32_t gatelevel = 0.0f;
|
||||
|
||||
if ( voice->elapsed_frames < voice->duration_frames ) {
|
||||
return voice_gate_level(voice, voice->elapsed_frames);
|
||||
}
|
||||
released = voice->elapsed_frames - voice->duration_frames;
|
||||
if ( released >= voice->release_frames ) {
|
||||
return 0.0f;
|
||||
}
|
||||
gatelevel = voice_gate_level(voice, voice->duration_frames);
|
||||
return gatelevel * (1.0f - ((float32_t)released / (float32_t)voice->release_frames));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Pitch @p voice sounds at where its sweep currently stands.
|
||||
*
|
||||
* Recomputed from the frame counter each time rather than added to as it goes,
|
||||
* so a sweep lands on exactly the frequencies its step size describes however
|
||||
* the caller happens to have chopped up its calls to akgl_audio_mix().
|
||||
*
|
||||
* A voice that is not sweeping keeps the frequency it was given.
|
||||
*/
|
||||
static float32_t voice_sweep_hz(akgl_AudioVoice *voice)
|
||||
{
|
||||
uint32_t ticks = 0;
|
||||
float32_t moved = 0.0f;
|
||||
float32_t hz = 0.0f;
|
||||
|
||||
if ( voice->sweep_step_hz <= 0.0f ) {
|
||||
return voice->hz;
|
||||
}
|
||||
ticks = voice->elapsed_frames / AKGL_AUDIO_SWEEP_TICK_FRAMES;
|
||||
moved = voice->sweep_step_hz * (float32_t)ticks;
|
||||
if ( voice->sweep_to_hz < voice->sweep_from_hz ) {
|
||||
hz = voice->sweep_from_hz - moved;
|
||||
if ( hz < voice->sweep_to_hz ) {
|
||||
hz = voice->sweep_to_hz;
|
||||
}
|
||||
} else {
|
||||
hz = voice->sweep_from_hz + moved;
|
||||
if ( hz > voice->sweep_to_hz ) {
|
||||
hz = voice->sweep_to_hz;
|
||||
}
|
||||
}
|
||||
return hz;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Start a note on @p voice, sweeping or held.
|
||||
*
|
||||
* The whole of what akgl_audio_tone() and akgl_audio_sweep() do once their
|
||||
* arguments have been checked. A held note is a sweep with a step of 0, which
|
||||
* is also what makes a voice reused for a plain tone stop sweeping.
|
||||
*/
|
||||
static void start_note(int voice, float32_t from_hz, float32_t to_hz, float32_t step_hz, uint32_t ms)
|
||||
{
|
||||
ensure_voices();
|
||||
lock_voices();
|
||||
akgl_audio_voices[voice].hz = from_hz;
|
||||
akgl_audio_voices[voice].phase = 0.0f;
|
||||
akgl_audio_voices[voice].sweep_from_hz = from_hz;
|
||||
akgl_audio_voices[voice].sweep_to_hz = to_hz;
|
||||
akgl_audio_voices[voice].sweep_step_hz = step_hz;
|
||||
akgl_audio_voices[voice].duration_frames = frames_for_ms(ms);
|
||||
akgl_audio_voices[voice].elapsed_frames = 0;
|
||||
akgl_audio_voices[voice].active = true;
|
||||
unlock_voices();
|
||||
}
|
||||
|
||||
/** @brief Refuse a voice index that is not in the table. */
|
||||
static akerr_ErrorContext *check_voice(int voice)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
((voice < 0) || (voice >= AKGL_AUDIO_MAX_VOICES)),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Voice %d is not in the range 0 to %d",
|
||||
voice,
|
||||
AKGL_AUDIO_MAX_VOICES - 1);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Fill SDL's request from the voice table.
|
||||
*
|
||||
* SDL holds the stream lock for the duration of this callback, which is the
|
||||
* same lock lock_voices() takes, so the voice table cannot change underneath a
|
||||
* mix in progress.
|
||||
*/
|
||||
static void SDLCALL audio_stream_callback(void *userdata, SDL_AudioStream *stream, int additional_amount, int total_amount)
|
||||
{
|
||||
akerr_ErrorContext *errctx = NULL;
|
||||
int frames = 0;
|
||||
|
||||
while ( additional_amount > 0 ) {
|
||||
frames = additional_amount / (int)sizeof(float32_t);
|
||||
if ( frames > AKGL_AUDIO_MIX_FRAMES ) {
|
||||
frames = AKGL_AUDIO_MIX_FRAMES;
|
||||
}
|
||||
if ( frames <= 0 ) {
|
||||
return;
|
||||
}
|
||||
errctx = akgl_audio_mix(mixbuffer, frames);
|
||||
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);
|
||||
return;
|
||||
}
|
||||
SDL_PutAudioStreamData(stream, mixbuffer, frames * (int)sizeof(float32_t));
|
||||
additional_amount -= frames * (int)sizeof(float32_t);
|
||||
}
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_audio_init(void)
|
||||
{
|
||||
SDL_AudioSpec spec;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
if ( audiostream != NULL ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
ensure_voices();
|
||||
|
||||
spec.format = SDL_AUDIO_F32;
|
||||
spec.channels = 1;
|
||||
spec.freq = AKGL_AUDIO_SAMPLE_RATE;
|
||||
|
||||
audiostream = SDL_OpenAudioDeviceStream(
|
||||
SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK,
|
||||
&spec,
|
||||
&audio_stream_callback,
|
||||
NULL);
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
audiostream,
|
||||
AKGL_ERR_SDL,
|
||||
"Couldn't open an audio device: %s",
|
||||
SDL_GetError());
|
||||
|
||||
// Devices open paused so a caller can set a stream up before it is heard.
|
||||
// Nothing here needs that, and a caller who expected akgl_audio_tone() to
|
||||
// make a sound would otherwise get silence with no error to explain it.
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
SDL_ResumeAudioStreamDevice(audiostream),
|
||||
AKGL_ERR_SDL,
|
||||
"Couldn't start the audio device: %s",
|
||||
SDL_GetError());
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_audio_shutdown(void)
|
||||
{
|
||||
SDL_AudioStream *closing = audiostream;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
// Clear the pointer before destroying the stream: lock_voices() checks it,
|
||||
// and SDL_DestroyAudioStream can run the callback one last time.
|
||||
audiostream = NULL;
|
||||
if ( closing != NULL ) {
|
||||
SDL_DestroyAudioStream(closing);
|
||||
}
|
||||
reset_voices();
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_audio_tone(int voice, float32_t hz, uint32_t ms)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, check_voice(voice));
|
||||
FAIL_NONZERO_RETURN(errctx, (hz <= 0.0f), AKERR_OUTOFBOUNDS, "Frequency %f is not positive", hz);
|
||||
FAIL_ZERO_RETURN(errctx, ms, AKERR_OUTOFBOUNDS, "A tone needs a duration; use akgl_audio_stop to silence a voice");
|
||||
|
||||
// A step of 0 is what says "one pitch, held", so this also clears a sweep
|
||||
// left on the voice by an earlier akgl_audio_sweep().
|
||||
start_note(voice, hz, hz, 0.0f, ms);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_audio_sweep(int voice, float32_t from_hz, float32_t to_hz, float32_t step_hz, uint32_t ms)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, check_voice(voice));
|
||||
FAIL_NONZERO_RETURN(errctx, (from_hz <= 0.0f), AKERR_OUTOFBOUNDS, "Start frequency %f is not positive", from_hz);
|
||||
FAIL_NONZERO_RETURN(errctx, (to_hz <= 0.0f), AKERR_OUTOFBOUNDS, "Target frequency %f is not positive", to_hz);
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
(step_hz <= 0.0f),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Step %f is not positive; the direction of a sweep comes from the two frequencies",
|
||||
step_hz);
|
||||
FAIL_ZERO_RETURN(errctx, ms, AKERR_OUTOFBOUNDS, "A tone needs a duration; use akgl_audio_stop to silence a voice");
|
||||
|
||||
start_note(voice, from_hz, to_hz, step_hz, ms);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_audio_stop(int voice)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, check_voice(voice));
|
||||
|
||||
ensure_voices();
|
||||
lock_voices();
|
||||
akgl_audio_voices[voice].active = false;
|
||||
akgl_audio_voices[voice].elapsed_frames = 0;
|
||||
akgl_audio_voices[voice].phase = 0.0f;
|
||||
unlock_voices();
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_audio_waveform(int voice, akgl_AudioWaveform waveform)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, check_voice(voice));
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
((waveform < AKGL_AUDIO_WAVE_TRIANGLE) || (waveform > AKGL_AUDIO_WAVE_SINE)),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Waveform %d is not one of the %d shapes",
|
||||
(int)waveform,
|
||||
(int)AKGL_AUDIO_WAVE_SINE + 1);
|
||||
|
||||
ensure_voices();
|
||||
lock_voices();
|
||||
akgl_audio_voices[voice].waveform = waveform;
|
||||
unlock_voices();
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_audio_envelope(int voice, uint32_t attack, uint32_t decay, float32_t sustain, uint32_t release)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, check_voice(voice));
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
((sustain < 0.0f) || (sustain > 1.0f)),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Sustain level %f is not between 0.0 and 1.0",
|
||||
sustain);
|
||||
|
||||
ensure_voices();
|
||||
lock_voices();
|
||||
akgl_audio_voices[voice].attack_frames = frames_for_ms(attack);
|
||||
akgl_audio_voices[voice].decay_frames = frames_for_ms(decay);
|
||||
akgl_audio_voices[voice].release_frames = frames_for_ms(release);
|
||||
akgl_audio_voices[voice].sustain = sustain;
|
||||
unlock_voices();
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_audio_volume(float32_t level)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
((level < 0.0f) || (level > 1.0f)),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Volume level %f is not between 0.0 and 1.0",
|
||||
level);
|
||||
|
||||
ensure_voices();
|
||||
lock_voices();
|
||||
mastervolume = level;
|
||||
unlock_voices();
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_audio_voice_active(int voice, bool *active)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, check_voice(voice));
|
||||
FAIL_ZERO_RETURN(errctx, active, AKERR_NULLPOINTER, "NULL activity destination");
|
||||
|
||||
ensure_voices();
|
||||
lock_voices();
|
||||
*active = akgl_audio_voices[voice].active;
|
||||
unlock_voices();
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_audio_mix(float32_t *dest, int frames)
|
||||
{
|
||||
akgl_AudioVoice *voice = NULL;
|
||||
float32_t sum = 0.0f;
|
||||
bool sweeping = false;
|
||||
int i = 0;
|
||||
int v = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL sample destination");
|
||||
FAIL_NONZERO_RETURN(errctx, (frames < 0), AKERR_OUTOFBOUNDS, "Frame count %d is negative", frames);
|
||||
|
||||
for ( i = 0; i < frames; i++ ) {
|
||||
sum = 0.0f;
|
||||
for ( v = 0; v < AKGL_AUDIO_MAX_VOICES; v++ ) {
|
||||
voice = &akgl_audio_voices[v];
|
||||
if ( voice->active == false ) {
|
||||
continue;
|
||||
}
|
||||
if ( voice->elapsed_frames >= (voice->duration_frames + voice->release_frames) ) {
|
||||
// Gate and release are both spent. The voice goes quiet on its
|
||||
// own so a caller does not have to keep a clock to stop it.
|
||||
voice->active = false;
|
||||
voice->elapsed_frames = 0;
|
||||
voice->phase = 0.0f;
|
||||
continue;
|
||||
}
|
||||
sweeping = (voice->sweep_step_hz > 0.0f);
|
||||
if ( sweeping ) {
|
||||
voice->hz = voice_sweep_hz(voice);
|
||||
} else {
|
||||
// Derived from the frame counter rather than accumulated one
|
||||
// increment at a time. A float increment of hz/rate is not exact,
|
||||
// and adding it 44100 times a second walks the pitch off over the
|
||||
// length of a held note.
|
||||
voice->phase = (float32_t)SDL_fmod(
|
||||
((double)voice->elapsed_frames * (double)voice->hz) / (double)AKGL_AUDIO_SAMPLE_RATE,
|
||||
1.0);
|
||||
}
|
||||
sum += voice_oscillator(voice) * voice_envelope(voice);
|
||||
if ( sweeping ) {
|
||||
// The derived form above assumes one frequency for the whole note.
|
||||
// Under a sweep it would jump the waveform at every step -- an
|
||||
// audible click -- so a swept voice accumulates instead and takes
|
||||
// the drift the derived form exists to avoid.
|
||||
voice->phase = (float32_t)SDL_fmod(
|
||||
(double)voice->phase + ((double)voice->hz / (double)AKGL_AUDIO_SAMPLE_RATE),
|
||||
1.0);
|
||||
}
|
||||
voice->elapsed_frames += 1;
|
||||
}
|
||||
sum = sum * mastervolume;
|
||||
// Three voices at full level can sum past full scale. Clamping rather
|
||||
// than scaling by the voice count keeps a single voice at the level it
|
||||
// was asked for instead of a third of it.
|
||||
if ( sum > 1.0f ) {
|
||||
sum = 1.0f;
|
||||
} else if ( sum < -1.0f ) {
|
||||
sum = -1.0f;
|
||||
}
|
||||
dest[i] = sum;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
130
src/character.c
130
src/character.c
@@ -1,6 +1,12 @@
|
||||
/**
|
||||
* @file character.c
|
||||
* @brief Implements the character subsystem.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
#include <akerror.h>
|
||||
#include <akstdlib.h>
|
||||
#include <string.h>
|
||||
#include <jansson.h>
|
||||
|
||||
@@ -19,13 +25,15 @@ 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_SPRITE_MAX_CHARACTER_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");
|
||||
|
||||
|
||||
obj->sprite_add = &akgl_character_sprite_add;
|
||||
obj->sprite_get = &akgl_character_sprite_get;
|
||||
|
||||
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
SDL_SetPointerProperty(AKGL_REGISTRY_CHARACTER, name, (void *)obj),
|
||||
@@ -39,19 +47,42 @@ akerr_ErrorContext *akgl_character_sprite_add(akgl_Character *basechar, akgl_Spr
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
char stateval[32];
|
||||
akgl_Sprite *displaced = NULL;
|
||||
FAIL_ZERO_RETURN(errctx, basechar, AKERR_NULLPOINTER, "NULL character reference");
|
||||
FAIL_ZERO_RETURN(errctx, ref, AKERR_NULLPOINTER, "NULL sprite reference");
|
||||
memset(&stateval, 0x00, 32);
|
||||
SDL_itoa(state, (char *)&stateval, 10);
|
||||
SDL_SetPointerProperty(basechar->state_sprites, (char *)&stateval, ref);
|
||||
SDL_Log("Added sprite %s to character %s for state %b", (char *)&ref->name, (char *)&basechar->name, state);
|
||||
|
||||
// Whatever this state was bound to is losing its binding, and the reference
|
||||
// taken for that binding with it. Without this a character that rebinds a
|
||||
// state while alive leaks one sprite slot per rebind -- releasing at
|
||||
// teardown only gives back whatever the map happens to hold at the end.
|
||||
displaced = (akgl_Sprite *)SDL_GetPointerProperty(basechar->state_sprites, (char *)&stateval, NULL);
|
||||
|
||||
// Take the new reference before anything can fail, so a failed write cannot
|
||||
// leave the sprite bound with no reference behind it.
|
||||
ref->refcount += 1;
|
||||
if ( SDL_SetPointerProperty(basechar->state_sprites, (char *)&stateval, ref) == false ) {
|
||||
ref->refcount -= 1;
|
||||
FAIL_RETURN(
|
||||
errctx,
|
||||
AKERR_KEY,
|
||||
"Unable to bind sprite %s to character %s for state %d: %s",
|
||||
(char *)&ref->name,
|
||||
(char *)&basechar->name,
|
||||
state,
|
||||
SDL_GetError());
|
||||
}
|
||||
|
||||
if ( (displaced != NULL) && (displaced != ref) ) {
|
||||
PASS(errctx, akgl_heap_release_sprite(displaced));
|
||||
}
|
||||
SDL_Log("Added sprite %s to character %s for state %b", (char *)&ref->name, (char *)&basechar->name, state);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_character_sprite_get(akgl_Character *basechar, int state, akgl_Sprite **dest)
|
||||
{
|
||||
akgl_Sprite *target = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
char stateval[32];
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL pointer to sprite pointer (**dest)");
|
||||
@@ -60,14 +91,12 @@ akerr_ErrorContext *akgl_character_sprite_get(akgl_Character *basechar, int stat
|
||||
SDL_itoa(state, (char *)&stateval, 10);
|
||||
*dest = (akgl_Sprite *)SDL_GetPointerProperty(basechar->state_sprites, (char *)&stateval, NULL);
|
||||
FAIL_ZERO_RETURN(errctx, *dest, AKERR_KEY, "Sprite for state %d (%b) not found in the character's registry", state, state);
|
||||
target = *dest;
|
||||
//SDL_Log("Sprite state %d (%s) has character %s", state, (char *)&stateval, target->name);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
// SDL iterator so we can't return error information here, void only
|
||||
// this means we don't have anywhere to send exceptions up to, so if we hit an error, we log and exit(1) here
|
||||
void akgl_character_state_sprites_iterate(void *userdata, SDL_PropertiesID registry, const char *name)
|
||||
void akgl_character_state_sprites_iterate(void *userdata, SDL_PropertiesID props, const char *name)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_Sprite *spriteptr;
|
||||
@@ -75,7 +104,7 @@ void akgl_character_state_sprites_iterate(void *userdata, SDL_PropertiesID regis
|
||||
ATTEMPT {
|
||||
FAIL_ZERO_BREAK(errctx, opflags, AKERR_NULLPOINTER, "Character state sprite iterator received null iterator op pointer");
|
||||
FAIL_ZERO_BREAK(errctx, name, AKERR_NULLPOINTER, "Character state sprite iterator received null sprite name");
|
||||
spriteptr = (akgl_Sprite *)SDL_GetPointerProperty(registry, name, NULL);
|
||||
spriteptr = (akgl_Sprite *)SDL_GetPointerProperty(props, name, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, spriteptr, AKERR_NULLPOINTER, "Character state sprite for %s not found", name);
|
||||
if ( AKGL_BITMASK_HAS(opflags->flags, AKGL_ITERATOR_OP_RELEASE) ) {
|
||||
CATCH(errctx, akgl_heap_release_sprite(spriteptr));
|
||||
@@ -85,15 +114,39 @@ void akgl_character_state_sprites_iterate(void *userdata, SDL_PropertiesID regis
|
||||
} FINISH_NORETURN(errctx);
|
||||
}
|
||||
|
||||
static akerr_ErrorContext *akgl_character_load_json_state_int_from_strings(json_t *states, int *dest)
|
||||
/**
|
||||
* @brief OR a JSON array of actor-state names together into one bitmask.
|
||||
*
|
||||
* This is what lets a character JSON say `["FACE_LEFT", "MOVING_LEFT"]` rather
|
||||
* than a number: each name is looked up in #AKGL_REGISTRY_ACTOR_STATE_STRINGS
|
||||
* and OR-ed in. A name that is not a known state is an error rather than being
|
||||
* skipped -- a typo in a state name would otherwise bind a sprite to the wrong
|
||||
* combination and show up as art that never appears.
|
||||
*
|
||||
* @param states JSON array of state-name strings. Required. An empty array is
|
||||
* legal and leaves @p dest as it was.
|
||||
* @param dest Receives the OR of every named bit. Required in practice, and
|
||||
* **not** checked -- the guard that should test it tests @p states
|
||||
* a second time instead, so a `NULL` here is a crash. It is OR-ed
|
||||
* into rather than assigned, so the caller must zero it first.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p states is `NULL`.
|
||||
* @throws AKERR_KEY If a name is not in the actor-state registry. The message
|
||||
* quotes it. Note that bits 11 and 12 are unreachable by name; see
|
||||
* akgl_registry_init_actor_state_strings.
|
||||
* @throws AKERR_TYPE If an array element is not a string.
|
||||
* @throws AKERR_OUTOFBOUNDS If the array is indexed past its end.
|
||||
* @throws AKGL_ERR_HEAP If the string pool is exhausted.
|
||||
*/
|
||||
static akerr_ErrorContext *character_load_json_state_int_from_strings(json_t *states, int *dest)
|
||||
{
|
||||
int i = 0;
|
||||
long newstate = 0;
|
||||
akgl_String *tmpstring = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, states, AKERR_NULLPOINTER, "NULL states array");
|
||||
FAIL_ZERO_RETURN(errctx, states, AKERR_NULLPOINTER, "NULL destination integer");
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination integer");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_string(&tmpstring));
|
||||
for ( i = 0; i < json_array_size((json_t *)states) ; i++ ) {
|
||||
@@ -109,7 +162,30 @@ static akerr_ErrorContext *akgl_character_load_json_state_int_from_strings(json_
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
static akerr_ErrorContext *akgl_character_load_json_inner(json_t *json, akgl_Character *obj)
|
||||
/**
|
||||
* @brief Name a character and build its state-to-sprite map from a JSON document.
|
||||
*
|
||||
* The half of akgl_character_load_json that deals with identity and sprites; the
|
||||
* caller handles the numeric fields afterwards. Reads `name`, initializes the
|
||||
* character with it, then walks `sprite_mappings` binding each named sprite to
|
||||
* the bitmask its `state` array adds up to.
|
||||
*
|
||||
* Every sprite named must already be in #AKGL_REGISTRY_SPRITE.
|
||||
*
|
||||
* @param json The parsed character document. Required in practice; not checked,
|
||||
* and passed straight to accessors that report it.
|
||||
* @param obj The pooled character to fill in. Required, unchecked -- it is
|
||||
* handed to akgl_character_initialize, which does check it.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If a mapping names a sprite that is not registered.
|
||||
* The message names the character, the state, and the sprite.
|
||||
* @throws AKERR_KEY If `name`, `sprite_mappings`, or a mapping's `sprite` or
|
||||
* `state` is absent, or if a state name is unknown.
|
||||
* @throws AKERR_TYPE If one of those keys has the wrong JSON type.
|
||||
* @throws AKERR_OUTOFBOUNDS If an array is indexed past its end.
|
||||
* @throws AKGL_ERR_HEAP If the string pool is exhausted.
|
||||
*/
|
||||
static akerr_ErrorContext *character_load_json_inner(json_t *json, akgl_Character *obj)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
json_t *mappings = NULL;
|
||||
@@ -120,7 +196,7 @@ static akerr_ErrorContext *akgl_character_load_json_inner(json_t *json, akgl_Cha
|
||||
akgl_String *tmpstr = NULL;
|
||||
akgl_String *tmpstr2 = NULL;
|
||||
int stateval = 0;
|
||||
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_get_json_string_value((json_t *)json, "name", &tmpstr));
|
||||
CATCH(errctx, akgl_character_initialize((akgl_Character *)obj, tmpstr->data));
|
||||
@@ -135,15 +211,15 @@ static akerr_ErrorContext *akgl_character_load_json_inner(json_t *json, akgl_Cha
|
||||
NULL
|
||||
);
|
||||
CATCH(errctx, akgl_get_json_string_value((json_t *)json, "name", &tmpstr2));
|
||||
|
||||
|
||||
CATCH(errctx, akgl_get_json_array_value((json_t *)curmapping, "state", &statearray));
|
||||
CATCH(errctx, akgl_character_load_json_state_int_from_strings(statearray, &stateval));
|
||||
|
||||
CATCH(errctx, character_load_json_state_int_from_strings(statearray, &stateval));
|
||||
|
||||
CATCH(errctx, akgl_get_json_string_value((json_t *)curmapping, "sprite", &tmpstr));
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
spriteptr,
|
||||
AKERR_NULLPOINTER,
|
||||
AKERR_NULLPOINTER,
|
||||
"Character %s for state %b references sprite %s but not found in the registry",
|
||||
tmpstr2->data,
|
||||
stateval,
|
||||
@@ -166,17 +242,16 @@ static akerr_ErrorContext *akgl_character_load_json_inner(json_t *json, akgl_Cha
|
||||
akerr_ErrorContext *akgl_character_load_json(char *filename)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
json_t *json;
|
||||
json_t *json = NULL;
|
||||
json_error_t error;
|
||||
akgl_Character *obj = NULL;
|
||||
//akgl_String *tmpstr = NULL;
|
||||
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, filename, AKERR_NULLPOINTER, "Received null filename");
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_character(&obj));
|
||||
//CATCH(errctx, akgl_heap_next_string(&tmpstr));
|
||||
//CATCH(errctx, akgl_string_initialize(tmpstr, NULL));
|
||||
//SDL_snprintf((char *)&tmpstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), filename);
|
||||
json = (json_t *)json_load_file(filename, 0, &error);
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
@@ -184,15 +259,22 @@ akerr_ErrorContext *akgl_character_load_json(char *filename)
|
||||
AKERR_NULLPOINTER,
|
||||
"Error while loading character from %s on line %d: %s", filename, error.line, error.text
|
||||
);
|
||||
CATCH(errctx, akgl_character_load_json_inner(json, obj));
|
||||
CATCH(errctx, character_load_json_inner(json, obj));
|
||||
CATCH(errctx, akgl_get_json_integer_value(json, "speedtime", (int *)&obj->speedtime));
|
||||
obj->speedtime = obj->speedtime * AKGL_TIME_ONESEC_MS;
|
||||
obj->speedtime = obj->speedtime * AKGL_TIME_ONEMS_NS;
|
||||
CATCH(errctx, akgl_get_json_number_value(json, "speed_x", &obj->sx));
|
||||
CATCH(errctx, akgl_get_json_number_value(json, "speed_y", &obj->sy));
|
||||
CATCH(errctx, akgl_get_json_number_value(json, "acceleration_x", &obj->ax));
|
||||
CATCH(errctx, akgl_get_json_number_value(json, "acceleration_y", &obj->ay));
|
||||
} CLEANUP {
|
||||
//IGNORE(akgl_heap_release_string(tmpstr));
|
||||
// The character keeps nothing that points into the document -- every
|
||||
// field above is copied out of it -- so the whole parsed tree goes back
|
||||
// here, on the success path as well as the failure one.
|
||||
if ( json != NULL ) {
|
||||
json_decref(json);
|
||||
json = NULL;
|
||||
}
|
||||
if ( errctx != NULL ) {
|
||||
if ( errctx->status != 0 ) {
|
||||
IGNORE(akgl_heap_release_character(obj));
|
||||
|
||||
465
src/controller.c
465
src/controller.c
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @file controller.c
|
||||
* @brief Implements the controller subsystem.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akerror.h>
|
||||
#include <akgl/heap.h>
|
||||
@@ -5,48 +10,249 @@
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/controller.h>
|
||||
|
||||
akgl_ControlMap GAME_ControlMaps[AKGL_MAX_CONTROL_MAPS];
|
||||
akgl_ControlMap akgl_controlmaps[AKGL_MAX_CONTROL_MAPS];
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_list_keyboards(void)
|
||||
/*
|
||||
* Keystrokes waiting for akgl_controller_poll_key() and
|
||||
* akgl_controller_poll_keystroke(). Filled by akgl_controller_handle_event()
|
||||
* before it consults the control maps, so a key that also drives an actor is
|
||||
* still delivered to a polling caller.
|
||||
*
|
||||
* head is the next slot to read, count is how many are waiting. Both index a
|
||||
* fixed array rather than a queue object, which is the whole point: a host that
|
||||
* never polls cannot make this grow.
|
||||
*/
|
||||
static akgl_Keystroke keybuffer[AKGL_CONTROLLER_KEY_BUFFER];
|
||||
static int keybuffer_head = 0;
|
||||
static int keybuffer_count = 0;
|
||||
|
||||
/*
|
||||
* Whether the newest entry is a key press that has not been given its composed
|
||||
* text yet. SDL reports one keystroke as two events -- the key going down, and
|
||||
* then the text it composed to, if it composed to anything -- so the text that
|
||||
* arrives next belongs to the press that arrived last. Without this flag a
|
||||
* press dropped by a full buffer would hand its text to whatever older press
|
||||
* happened to still be sitting at the end of the ring.
|
||||
*/
|
||||
static bool keybuffer_awaiting_text = false;
|
||||
|
||||
/**
|
||||
* @brief Length in bytes of the UTF-8 sequence @p src begins with, or 0.
|
||||
*
|
||||
* 0 both for a byte that cannot start a sequence and for a sequence the string
|
||||
* ends in the middle of. SDL should hand over neither, and reading past a
|
||||
* terminator to find out otherwise is not a risk worth taking for input that
|
||||
* arrives from a keyboard driver.
|
||||
*
|
||||
* @param src UTF-8 text, NUL terminated. Required.
|
||||
*/
|
||||
static size_t utf8_sequence_length(const char *src)
|
||||
{
|
||||
unsigned char lead = (unsigned char)src[0];
|
||||
size_t len = 0;
|
||||
size_t i = 0;
|
||||
|
||||
if ( lead < 0x80 ) {
|
||||
return 1;
|
||||
} else if ( (lead & 0xe0) == 0xc0 ) {
|
||||
len = 2;
|
||||
} else if ( (lead & 0xf0) == 0xe0 ) {
|
||||
len = 3;
|
||||
} else if ( (lead & 0xf8) == 0xf0 ) {
|
||||
len = 4;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
for ( i = 1; i < len; i++ ) {
|
||||
// A NUL fails this too, which is what stops the scan at the end of a
|
||||
// truncated string instead of past it.
|
||||
if ( ((unsigned char)src[i] & 0xc0) != 0x80 ) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return len;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Copy as much of @p src as fits in @p dest without splitting a character.
|
||||
*
|
||||
* SDL hands over whatever the platform composed, which for an input method can
|
||||
* be several characters at once, and the ring entry holds one. Truncating on a
|
||||
* byte boundary would leave a partial UTF-8 sequence that nothing downstream
|
||||
* can render, so this stops at the last code point that fits whole.
|
||||
*
|
||||
* @param dest Receives the prefix, always NUL terminated. Required.
|
||||
* @param destsize Bytes available in @p dest, terminator included.
|
||||
* @param src UTF-8 text to copy. Required.
|
||||
*/
|
||||
static void copy_utf8_prefix(char *dest, size_t destsize, const char *src)
|
||||
{
|
||||
size_t used = 0;
|
||||
size_t len = 0;
|
||||
|
||||
dest[0] = '\0';
|
||||
while ( src[used] != '\0' ) {
|
||||
len = utf8_sequence_length(&src[used]);
|
||||
if ( len == 0 ) {
|
||||
// Not valid UTF-8 from here on. Keep what is already known good.
|
||||
break;
|
||||
}
|
||||
if ( (used + len) >= destsize ) {
|
||||
break;
|
||||
}
|
||||
memcpy(&dest[used], &src[used], len);
|
||||
used += len;
|
||||
}
|
||||
dest[used] = '\0';
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Record one key press, discarding it if the buffer is already full.
|
||||
*
|
||||
* Dropping the newest rather than overwriting the oldest is deliberate. A
|
||||
* caller reading a line of input wants the characters that were typed first;
|
||||
* overwriting would hand it the tail of what the user typed and silently lose
|
||||
* the head.
|
||||
*
|
||||
* The entry starts with no text. Whatever the press composes to arrives as a
|
||||
* separate event and is attached by keybuffer_attach_text().
|
||||
*/
|
||||
static void keybuffer_push_key(SDL_Keycode key, SDL_Keymod mod)
|
||||
{
|
||||
int tail = 0;
|
||||
|
||||
if ( keybuffer_count >= AKGL_CONTROLLER_KEY_BUFFER ) {
|
||||
keybuffer_awaiting_text = false;
|
||||
return;
|
||||
}
|
||||
tail = (keybuffer_head + keybuffer_count) % AKGL_CONTROLLER_KEY_BUFFER;
|
||||
keybuffer[tail].key = key;
|
||||
keybuffer[tail].mod = mod;
|
||||
keybuffer[tail].text[0] = '\0';
|
||||
keybuffer_count += 1;
|
||||
keybuffer_awaiting_text = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Give composed text to the press it belongs to, or buffer it alone.
|
||||
*
|
||||
* An entry with no keycode is not a degenerate case to be avoided: an input
|
||||
* method or a dead key finishes a character with no key press of its own, and a
|
||||
* line editor wants that character. akgl_controller_poll_keystroke() reports it;
|
||||
* akgl_controller_poll_key(), which has no keycode to give, discards it.
|
||||
*/
|
||||
static void keybuffer_attach_text(const char *text)
|
||||
{
|
||||
int newest = 0;
|
||||
|
||||
if ( (keybuffer_awaiting_text == true) && (keybuffer_count > 0) ) {
|
||||
newest = (keybuffer_head + keybuffer_count - 1) % AKGL_CONTROLLER_KEY_BUFFER;
|
||||
copy_utf8_prefix(keybuffer[newest].text, AKGL_CONTROLLER_KEYSTROKE_TEXT, text);
|
||||
keybuffer_awaiting_text = false;
|
||||
return;
|
||||
}
|
||||
if ( keybuffer_count >= AKGL_CONTROLLER_KEY_BUFFER ) {
|
||||
return;
|
||||
}
|
||||
newest = (keybuffer_head + keybuffer_count) % AKGL_CONTROLLER_KEY_BUFFER;
|
||||
keybuffer[newest].key = 0;
|
||||
keybuffer[newest].mod = 0;
|
||||
copy_utf8_prefix(keybuffer[newest].text, AKGL_CONTROLLER_KEYSTROKE_TEXT, text);
|
||||
keybuffer_count += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Take the oldest entry, whatever it carries.
|
||||
*
|
||||
* @param dest Receives the entry. Required.
|
||||
* @return `true` when an entry was taken, `false` when the buffer was empty.
|
||||
*/
|
||||
static bool keybuffer_take(akgl_Keystroke *dest)
|
||||
{
|
||||
if ( keybuffer_count == 0 ) {
|
||||
return false;
|
||||
}
|
||||
memcpy(dest, &keybuffer[keybuffer_head], sizeof(akgl_Keystroke));
|
||||
keybuffer_head = (keybuffer_head + 1) % AKGL_CONTROLLER_KEY_BUFFER;
|
||||
keybuffer_count -= 1;
|
||||
if ( keybuffer_count == 0 ) {
|
||||
// Nothing is left for a text event to attach itself to.
|
||||
keybuffer_awaiting_text = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_controller_list_keyboards(void)
|
||||
{
|
||||
int count;
|
||||
SDL_KeyboardID *keyboards = SDL_GetKeyboards(&count);
|
||||
PREPARE_ERROR(e);
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(e, keyboards, AKERR_NULLPOINTER, "%s", SDL_GetError());
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
const char *name = SDL_GetKeyboardNameForID(keyboards[i]);
|
||||
SDL_Log("Keyboard %d: ID %u, Name: %s\n", i, keyboards[i], name);
|
||||
}
|
||||
SUCCEED_RETURN(e);
|
||||
FAIL_ZERO_RETURN(errctx, keyboards, AKERR_NULLPOINTER, "%s", SDL_GetError());
|
||||
|
||||
// The array is SDL's to allocate and ours to free -- that is the contract on
|
||||
// every SDL_Get*s() enumeration. Released in CLEANUP so the loop can fail
|
||||
// without taking the array with it.
|
||||
ATTEMPT {
|
||||
for (int i = 0; i < count; i++) {
|
||||
const char *name = SDL_GetKeyboardNameForID(keyboards[i]);
|
||||
SDL_Log("Keyboard %d: ID %u, Name: %s\n", i, keyboards[i], name);
|
||||
}
|
||||
} CLEANUP {
|
||||
SDL_free(keyboards);
|
||||
keyboards = NULL;
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_open_gamepads(void)
|
||||
akerr_ErrorContext *akgl_controller_open_gamepads(void)
|
||||
{
|
||||
int count = 0;
|
||||
int i = 0;
|
||||
SDL_JoystickID *gamepads = NULL;
|
||||
SDL_Gamepad *gamepad = NULL;
|
||||
bool openfailed = false;
|
||||
|
||||
PREPARE_ERROR(e);
|
||||
PREPARE_ERROR(errctx);
|
||||
if ( SDL_HasGamepad() ) {
|
||||
gamepads = SDL_GetGamepads(&count);
|
||||
if ( count > 0 ) {
|
||||
FAIL_ZERO_RETURN(e, gamepads, AKERR_NULLPOINTER, "%s", SDL_GetError());
|
||||
for ( i = 0; i < count ; i++ ) {
|
||||
gamepad = SDL_OpenGamepad(gamepads[i]);
|
||||
FAIL_ZERO_RETURN(e, gamepad, AKERR_NULLPOINTER, "%s", SDL_GetError());
|
||||
SDL_Log("Gamepad %d is %s", i, SDL_GetGamepadNameForID(gamepads[i]));
|
||||
}
|
||||
SDL_free(gamepads);
|
||||
// SDL_free in CLEANUP, not after the loop. A gamepad that failed to
|
||||
// open used to return straight out of here and take the enumeration
|
||||
// array with it.
|
||||
ATTEMPT {
|
||||
FAIL_ZERO_BREAK(errctx, gamepads, AKERR_NULLPOINTER, "%s", SDL_GetError());
|
||||
for ( i = 0; i < count ; i++ ) {
|
||||
gamepad = SDL_OpenGamepad(gamepads[i]);
|
||||
if ( gamepad == NULL ) {
|
||||
openfailed = true;
|
||||
break;
|
||||
}
|
||||
SDL_Log("Gamepad %d is %s", i, SDL_GetGamepadNameForID(gamepads[i]));
|
||||
}
|
||||
// Reported after the loop rather than inside it: a
|
||||
// FAIL_ZERO_BREAK in there would break the loop, not the block.
|
||||
FAIL_NONZERO_BREAK(errctx, openfailed, AKERR_NULLPOINTER, "%s", SDL_GetError());
|
||||
} CLEANUP {
|
||||
if ( gamepads != NULL ) {
|
||||
SDL_free(gamepads);
|
||||
gamepads = NULL;
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
} else {
|
||||
// SDL_GetGamepads allocates even when it reports none.
|
||||
if ( gamepads != NULL ) {
|
||||
SDL_free(gamepads);
|
||||
gamepads = NULL;
|
||||
}
|
||||
SDL_Log("No gamepads enumerated");
|
||||
}
|
||||
} else {
|
||||
SDL_Log("No gamepads connected");
|
||||
}
|
||||
SUCCEED_RETURN(e);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_controller_handle_event(void *appstate, SDL_Event *event)
|
||||
@@ -60,9 +266,21 @@ akerr_ErrorContext *akgl_controller_handle_event(void *appstate, SDL_Event *even
|
||||
FAIL_ZERO_RETURN(errctx, appstate, AKERR_NULLPOINTER, "NULL appstate");
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event");
|
||||
|
||||
// Before the control maps, not after: a key bound to an actor is still a
|
||||
// key an interpreter polling with akgl_controller_poll_key() wants to see,
|
||||
// and the scan below returns as soon as a binding claims the event.
|
||||
if ( event->type == SDL_EVENT_KEY_DOWN ) {
|
||||
keybuffer_push_key(event->key.key, event->key.mod);
|
||||
} else if ( event->type == SDL_EVENT_TEXT_INPUT ) {
|
||||
// The character SDL composed for the press recorded a moment ago. A
|
||||
// keycode cannot express it: the layout, the shift state, a compose key
|
||||
// and a dead key all have their say between the two events.
|
||||
keybuffer_attach_text(event->text.text);
|
||||
}
|
||||
|
||||
ATTEMPT {
|
||||
for ( i = 0 ; i < AKGL_MAX_CONTROL_MAPS; i++ ) {
|
||||
curmap = &GAME_ControlMaps[i];
|
||||
curmap = &akgl_controlmaps[i];
|
||||
if ( curmap->target == NULL ) {
|
||||
continue;
|
||||
}
|
||||
@@ -75,7 +293,7 @@ akerr_ErrorContext *akgl_controller_handle_event(void *appstate, SDL_Event *even
|
||||
// This controlmap processes this control
|
||||
eventButtonComboMatch = (
|
||||
((event->type == SDL_EVENT_GAMEPAD_BUTTON_DOWN ||
|
||||
event->type == SDL_EVENT_GAMEPAD_BUTTON_UP) &&
|
||||
event->type == SDL_EVENT_GAMEPAD_BUTTON_UP) &&
|
||||
event->gbutton.which == curmap->jsid &&
|
||||
event->gbutton.button == curcontrol->button) ||
|
||||
((event->type == SDL_EVENT_KEY_DOWN ||
|
||||
@@ -83,13 +301,6 @@ akerr_ErrorContext *akgl_controller_handle_event(void *appstate, SDL_Event *even
|
||||
event->key.which == curmap->kbid &&
|
||||
event->key.key == curcontrol->key)
|
||||
);
|
||||
if ( event->type == 768 && event->key.which == 11 && event->key.key == 13 ) {
|
||||
SDL_Log("Event type=%d, keyboard=%d, key=%d", event->type, event->key.which, event->key.key);
|
||||
SDL_Log("ControlMap[%d].Controls[%d] keyboard=%d, key=%d", i, j, curmap->kbid, curcontrol->key);
|
||||
SDL_Log("event %d -> control on %d off %d", event->type, curcontrol->event_on, curcontrol->event_off);
|
||||
SDL_Log("eventButtonComboMatch for controlmap %d id %d = %d",
|
||||
i, j, eventButtonComboMatch);
|
||||
}
|
||||
if ( event->type == curcontrol->event_on && eventButtonComboMatch) {
|
||||
CATCH(errctx, curcontrol->handler_on(curmap->target, event));
|
||||
goto _akgl_controller_handle_event_success;
|
||||
@@ -99,95 +310,95 @@ akerr_ErrorContext *akgl_controller_handle_event(void *appstate, SDL_Event *even
|
||||
}
|
||||
}
|
||||
}
|
||||
_akgl_controller_handle_event_success:
|
||||
_akgl_controller_handle_event_success:
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *gamepad_handle_button_down(void *appstate, SDL_Event *event)
|
||||
akerr_ErrorContext *akgl_controller_handle_button_down(void *appstate, SDL_Event *event)
|
||||
{
|
||||
akgl_Actor *player = NULL;
|
||||
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, appstate, AKERR_NULLPOINTER, "NULL appstate");
|
||||
FAIL_ZERO_RETURN(errctx, appstate, AKERR_NULLPOINTER, "NULL event");
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event");
|
||||
player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
|
||||
FAIL_ZERO_RETURN(errctx, appstate, AKERR_NULLPOINTER, "Player actor does not exist");
|
||||
FAIL_ZERO_RETURN(errctx, player, AKERR_NULLPOINTER, "Player actor does not exist");
|
||||
|
||||
if ( event->gbutton.button == SDL_GAMEPAD_BUTTON_DPAD_DOWN ||
|
||||
event->key.key == SDLK_DOWN ) {
|
||||
event->key.key == SDLK_DOWN ) {
|
||||
SDL_Log("Processing dpad down : state %d", player->state);
|
||||
AKGL_BITMASK_ADD(player->state, AKGL_ACTOR_STATE_MOVING_DOWN);
|
||||
if ( !player->movement_controls_face ) {
|
||||
AKGL_BITMASK_DEL(player->state, AKGL_ACTOR_STATE_FACE_ALL);
|
||||
AKGL_BITMASK_ADD(player->state, AKGL_ACTOR_STATE_FACE_DOWN);
|
||||
AKGL_BITMASK_DEL(player->state, AKGL_ACTOR_STATE_FACE_ALL);
|
||||
AKGL_BITMASK_ADD(player->state, AKGL_ACTOR_STATE_FACE_DOWN);
|
||||
}
|
||||
SDL_Log("New state : %d", player->state);
|
||||
} else if ( event->gbutton.button == SDL_GAMEPAD_BUTTON_DPAD_UP ||
|
||||
event->key.key == SDLK_UP ) {
|
||||
event->key.key == SDLK_UP ) {
|
||||
SDL_Log("Processing dpad up");
|
||||
AKGL_BITMASK_ADD(player->state, AKGL_ACTOR_STATE_MOVING_UP);
|
||||
if ( !player->movement_controls_face ) {
|
||||
AKGL_BITMASK_DEL(player->state, AKGL_ACTOR_STATE_FACE_ALL);
|
||||
AKGL_BITMASK_ADD(player->state, AKGL_ACTOR_STATE_FACE_UP);
|
||||
AKGL_BITMASK_DEL(player->state, AKGL_ACTOR_STATE_FACE_ALL);
|
||||
AKGL_BITMASK_ADD(player->state, AKGL_ACTOR_STATE_FACE_UP);
|
||||
}
|
||||
SDL_Log("New state : %d", player->state);
|
||||
} else if ( event->gbutton.button == SDL_GAMEPAD_BUTTON_DPAD_LEFT ||
|
||||
event->key.key == SDLK_LEFT ) {
|
||||
event->key.key == SDLK_LEFT ) {
|
||||
SDL_Log("Processing dpad left");
|
||||
AKGL_BITMASK_ADD(player->state, AKGL_ACTOR_STATE_MOVING_LEFT);
|
||||
if ( !player->movement_controls_face ) {
|
||||
AKGL_BITMASK_DEL(player->state, AKGL_ACTOR_STATE_FACE_ALL);
|
||||
AKGL_BITMASK_ADD(player->state, AKGL_ACTOR_STATE_FACE_LEFT);
|
||||
AKGL_BITMASK_DEL(player->state, AKGL_ACTOR_STATE_FACE_ALL);
|
||||
AKGL_BITMASK_ADD(player->state, AKGL_ACTOR_STATE_FACE_LEFT);
|
||||
}
|
||||
SDL_Log("New state : %d", player->state);
|
||||
} else if ( event->gbutton.button == SDL_GAMEPAD_BUTTON_DPAD_RIGHT ||
|
||||
event->key.key == SDLK_RIGHT ) {
|
||||
event->key.key == SDLK_RIGHT ) {
|
||||
SDL_Log("Processing dpad right");
|
||||
AKGL_BITMASK_ADD(player->state, AKGL_ACTOR_STATE_MOVING_RIGHT);
|
||||
if ( !player->movement_controls_face ) {
|
||||
AKGL_BITMASK_DEL(player->state, AKGL_ACTOR_STATE_FACE_ALL);
|
||||
AKGL_BITMASK_ADD(player->state, AKGL_ACTOR_STATE_FACE_RIGHT);
|
||||
AKGL_BITMASK_DEL(player->state, AKGL_ACTOR_STATE_FACE_ALL);
|
||||
AKGL_BITMASK_ADD(player->state, AKGL_ACTOR_STATE_FACE_RIGHT);
|
||||
}
|
||||
SDL_Log("New state : %d", player->state);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *gamepad_handle_button_up(void *appstate, SDL_Event *event)
|
||||
akerr_ErrorContext *akgl_controller_handle_button_up(void *appstate, SDL_Event *event)
|
||||
{
|
||||
akgl_Actor *player = NULL;
|
||||
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, appstate, AKERR_NULLPOINTER, "NULL appstate");
|
||||
FAIL_ZERO_RETURN(errctx, appstate, AKERR_NULLPOINTER, "NULL event");
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event");
|
||||
player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
|
||||
FAIL_ZERO_RETURN(errctx, appstate, AKERR_NULLPOINTER, "Player actor does not exist");
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, player, AKERR_NULLPOINTER, "Player actor does not exist");
|
||||
|
||||
if ( event->gbutton.button == SDL_GAMEPAD_BUTTON_DPAD_DOWN ||
|
||||
event->key.key == SDLK_DOWN ) {
|
||||
event->key.key == SDLK_DOWN ) {
|
||||
SDL_Log("processing down release");
|
||||
AKGL_BITMASK_DEL(player->state, AKGL_ACTOR_STATE_MOVING_DOWN);
|
||||
player->curSpriteFrameId = 0;
|
||||
SDL_Log("New state : %d", player->state);
|
||||
} else if ( event->gbutton.button == SDL_GAMEPAD_BUTTON_DPAD_UP ||
|
||||
event->key.key == SDLK_UP ) {
|
||||
event->key.key == SDLK_UP ) {
|
||||
SDL_Log("processing up release");
|
||||
AKGL_BITMASK_DEL(player->state, AKGL_ACTOR_STATE_MOVING_UP);
|
||||
player->curSpriteFrameId = 0;
|
||||
SDL_Log("New state : %d", player->state);
|
||||
} else if ( event->gbutton.button == SDL_GAMEPAD_BUTTON_DPAD_RIGHT ||
|
||||
event->key.key == SDLK_RIGHT) {
|
||||
event->key.key == SDLK_RIGHT) {
|
||||
SDL_Log("processing right release");
|
||||
AKGL_BITMASK_DEL(player->state, AKGL_ACTOR_STATE_MOVING_RIGHT);
|
||||
player->curSpriteFrameId = 0;
|
||||
SDL_Log("New state : %d", player->state);
|
||||
} else if ( event->gbutton.button == SDL_GAMEPAD_BUTTON_DPAD_LEFT ||
|
||||
event->key.key == SDLK_LEFT ) {
|
||||
event->key.key == SDLK_LEFT ) {
|
||||
SDL_Log("processing left release");
|
||||
AKGL_BITMASK_DEL(player->state, AKGL_ACTOR_STATE_MOVING_LEFT);
|
||||
player->curSpriteFrameId = 0;
|
||||
@@ -196,29 +407,29 @@ akerr_ErrorContext *gamepad_handle_button_up(void *appstate, SDL_Event *event)
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *gamepad_handle_added(void *appstate, SDL_Event *event)
|
||||
akerr_ErrorContext *akgl_controller_handle_added(void *appstate, SDL_Event *event)
|
||||
{
|
||||
SDL_JoystickID which;
|
||||
SDL_Gamepad *gamepad = NULL;
|
||||
char *mapping = NULL;
|
||||
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, appstate, AKERR_NULLPOINTER, "NULL appstate");
|
||||
FAIL_ZERO_RETURN(errctx, appstate, AKERR_NULLPOINTER, "NULL event");
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event");
|
||||
|
||||
which = event->gbutton.which;
|
||||
gamepad = SDL_GetGamepadFromID(which);
|
||||
|
||||
|
||||
if (!gamepad) {
|
||||
SDL_Log("Gamepad #%u add, but not opened: %s", (unsigned int) which, SDL_GetError());
|
||||
gamepad = SDL_OpenGamepad(which);
|
||||
SDL_Log("Gamepad #%u opened: %s", (unsigned int) which, SDL_GetError());
|
||||
mapping = SDL_GetGamepadMapping(gamepad);
|
||||
if ( mapping == NULL ) {
|
||||
SDL_Log("Gamepad #%u has no mapping!", (unsigned int) which);
|
||||
SDL_Log("Gamepad #%u has no mapping!", (unsigned int) which);
|
||||
} else if ( mapping != NULL ) {
|
||||
SDL_Log("Gamepad #%u mapping : %s", (unsigned int) which, mapping);
|
||||
SDL_free(mapping);
|
||||
SDL_Log("Gamepad #%u mapping : %s", (unsigned int) which, mapping);
|
||||
SDL_free(mapping);
|
||||
}
|
||||
} else {
|
||||
SDL_Log("Gamepad #%u ('%s') added", (unsigned int) which, SDL_GetGamepadName(gamepad));
|
||||
@@ -226,18 +437,18 @@ akerr_ErrorContext *gamepad_handle_added(void *appstate, SDL_Event *event)
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *gamepad_handle_removed(void *appstate, SDL_Event *event)
|
||||
akerr_ErrorContext *akgl_controller_handle_removed(void *appstate, SDL_Event *event)
|
||||
{
|
||||
SDL_JoystickID which;
|
||||
SDL_Gamepad *gamepad = NULL;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, appstate, AKERR_NULLPOINTER, "NULL appstate");
|
||||
FAIL_ZERO_RETURN(errctx, appstate, AKERR_NULLPOINTER, "NULL event");
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event");
|
||||
|
||||
which = event->gbutton.which;
|
||||
gamepad = SDL_GetGamepadFromID(which);
|
||||
|
||||
|
||||
if (gamepad) {
|
||||
SDL_CloseGamepad(gamepad); /* the joystick was unplugged. */
|
||||
}
|
||||
@@ -245,72 +456,72 @@ akerr_ErrorContext *gamepad_handle_removed(void *appstate, SDL_Event *event)
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_pushmap(int controlmapid, akgl_Control *control)
|
||||
akerr_ErrorContext *akgl_controller_pushmap(int controlmapid, akgl_Control *control)
|
||||
{
|
||||
int newmapid = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
ATTEMPT {
|
||||
FAIL_ZERO_RETURN(errctx, control, AKERR_NULLPOINTER, "NULL Control");
|
||||
FAIL_NONZERO_RETURN(errctx, (controlmapid >= AKGL_MAX_CONTROL_MAPS), AKERR_OUTOFBOUNDS, "ID %d exceeds maximum %d", controlmapid, AKGL_MAX_CONTROL_MAPS);
|
||||
newmapid = GAME_ControlMaps[controlmapid].nextMap;
|
||||
FAIL_ZERO_RETURN(errctx, (AKGL_MAX_CONTROLS - newmapid), AKERR_OUTOFBOUNDS, "Control map ID %d is full", controlmapid);
|
||||
memcpy((void *)&GAME_ControlMaps[controlmapid].controls[newmapid], control, sizeof(akgl_Control));
|
||||
GAME_ControlMaps[controlmapid].nextMap = newmapid + 1;
|
||||
FAIL_ZERO_BREAK(errctx, control, AKERR_NULLPOINTER, "NULL Control");
|
||||
FAIL_NONZERO_BREAK(errctx, ((controlmapid < 0) || (controlmapid >= AKGL_MAX_CONTROL_MAPS)), AKERR_OUTOFBOUNDS, "Control map id %d is outside 0..%d", controlmapid, (AKGL_MAX_CONTROL_MAPS - 1));
|
||||
newmapid = akgl_controlmaps[controlmapid].nextMap;
|
||||
FAIL_ZERO_BREAK(errctx, (AKGL_MAX_CONTROLS - newmapid), AKERR_OUTOFBOUNDS, "Control map ID %d is full", controlmapid);
|
||||
memcpy((void *)&akgl_controlmaps[controlmapid].controls[newmapid], control, sizeof(akgl_Control));
|
||||
akgl_controlmaps[controlmapid].nextMap = newmapid + 1;
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_default(int controlmapid, char *actorname, int kbid, int jsid)
|
||||
akerr_ErrorContext *akgl_controller_default(int controlmapid, char *actorname, int kbid, int jsid)
|
||||
{
|
||||
akgl_ControlMap *controlmap;
|
||||
akgl_Control control;
|
||||
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
ATTEMPT {
|
||||
// set up the control map
|
||||
FAIL_NONZERO_RETURN(errctx, (controlmapid >= AKGL_MAX_CONTROL_MAPS), AKERR_OUTOFBOUNDS, "ID %d exceeds maximum %d", controlmapid, AKGL_MAX_CONTROL_MAPS);
|
||||
FAIL_NONZERO_BREAK(errctx, ((controlmapid < 0) || (controlmapid >= AKGL_MAX_CONTROL_MAPS)), AKERR_OUTOFBOUNDS, "Control map id %d is outside 0..%d", controlmapid, (AKGL_MAX_CONTROL_MAPS - 1));
|
||||
memset((void *)&control, 0x00, sizeof(akgl_Control));
|
||||
controlmap = &GAME_ControlMaps[controlmapid];
|
||||
controlmap = &akgl_controlmaps[controlmapid];
|
||||
controlmap->kbid = kbid;
|
||||
controlmap->jsid = jsid;
|
||||
|
||||
|
||||
controlmap->target = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, actorname, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, controlmap->target, AKERR_REGISTRY, "Actor %s not found in registry", actorname);
|
||||
FAIL_ZERO_BREAK(errctx, controlmap->target, AKGL_ERR_REGISTRY, "Actor %s not found in registry", actorname);
|
||||
|
||||
// ---- KEYBOARD CONTROLS ----
|
||||
|
||||
|
||||
// Move down
|
||||
control.key = SDLK_DOWN;
|
||||
control.event_on = SDL_EVENT_KEY_DOWN;
|
||||
control.event_off = SDL_EVENT_KEY_UP;
|
||||
control.handler_on = &akgl_Actor_cmhf_down_on;
|
||||
control.handler_off = &akgl_Actor_cmhf_down_off;
|
||||
control.handler_on = &akgl_actor_cmhf_down_on;
|
||||
control.handler_off = &akgl_actor_cmhf_down_off;
|
||||
CATCH(errctx, akgl_controller_pushmap(controlmapid, &control));
|
||||
|
||||
// Move up
|
||||
control.key = SDLK_UP;
|
||||
control.event_on = SDL_EVENT_KEY_DOWN;
|
||||
control.event_off = SDL_EVENT_KEY_UP;
|
||||
control.handler_on = &akgl_Actor_cmhf_up_on;
|
||||
control.handler_off = &akgl_Actor_cmhf_up_off;
|
||||
control.handler_on = &akgl_actor_cmhf_up_on;
|
||||
control.handler_off = &akgl_actor_cmhf_up_off;
|
||||
CATCH(errctx, akgl_controller_pushmap(controlmapid, &control));
|
||||
|
||||
|
||||
// Move left
|
||||
control.key = SDLK_LEFT;
|
||||
control.event_on = SDL_EVENT_KEY_DOWN;
|
||||
control.event_off = SDL_EVENT_KEY_UP;
|
||||
control.handler_on = &akgl_Actor_cmhf_left_on;
|
||||
control.handler_off = &akgl_Actor_cmhf_left_off;
|
||||
control.handler_on = &akgl_actor_cmhf_left_on;
|
||||
control.handler_off = &akgl_actor_cmhf_left_off;
|
||||
CATCH(errctx, akgl_controller_pushmap(controlmapid, &control));
|
||||
|
||||
|
||||
// Move right
|
||||
control.key = SDLK_RIGHT;
|
||||
control.event_on = SDL_EVENT_KEY_DOWN;
|
||||
control.event_off = SDL_EVENT_KEY_UP;
|
||||
control.handler_on = &akgl_Actor_cmhf_right_on;
|
||||
control.handler_off = &akgl_Actor_cmhf_right_off;
|
||||
control.handler_on = &akgl_actor_cmhf_right_on;
|
||||
control.handler_off = &akgl_actor_cmhf_right_off;
|
||||
CATCH(errctx, akgl_controller_pushmap(controlmapid, &control));
|
||||
|
||||
control.key = 0;
|
||||
@@ -319,37 +530,83 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_default(int controlmapid, cha
|
||||
control.button = SDL_GAMEPAD_BUTTON_DPAD_DOWN;
|
||||
control.event_on = SDL_EVENT_GAMEPAD_BUTTON_DOWN;
|
||||
control.event_off = SDL_EVENT_GAMEPAD_BUTTON_UP;
|
||||
control.handler_on = &akgl_Actor_cmhf_down_on;
|
||||
control.handler_off = &akgl_Actor_cmhf_down_off;
|
||||
control.handler_on = &akgl_actor_cmhf_down_on;
|
||||
control.handler_off = &akgl_actor_cmhf_down_off;
|
||||
CATCH(errctx, akgl_controller_pushmap(controlmapid, &control));
|
||||
|
||||
// Move up
|
||||
control.button = SDL_GAMEPAD_BUTTON_DPAD_UP;
|
||||
control.event_on = SDL_EVENT_GAMEPAD_BUTTON_DOWN;
|
||||
control.event_off = SDL_EVENT_GAMEPAD_BUTTON_UP;
|
||||
control.handler_on = &akgl_Actor_cmhf_up_on;
|
||||
control.handler_off = &akgl_Actor_cmhf_up_off;
|
||||
control.handler_on = &akgl_actor_cmhf_up_on;
|
||||
control.handler_off = &akgl_actor_cmhf_up_off;
|
||||
CATCH(errctx, akgl_controller_pushmap(controlmapid, &control));
|
||||
|
||||
|
||||
// Move left
|
||||
control.button = SDL_GAMEPAD_BUTTON_DPAD_LEFT;
|
||||
control.event_on = SDL_EVENT_GAMEPAD_BUTTON_DOWN;
|
||||
control.event_off = SDL_EVENT_GAMEPAD_BUTTON_UP;
|
||||
control.handler_on = &akgl_Actor_cmhf_left_on;
|
||||
control.handler_off = &akgl_Actor_cmhf_left_off;
|
||||
control.handler_on = &akgl_actor_cmhf_left_on;
|
||||
control.handler_off = &akgl_actor_cmhf_left_off;
|
||||
CATCH(errctx, akgl_controller_pushmap(controlmapid, &control));
|
||||
|
||||
|
||||
// Move right
|
||||
control.button = SDL_GAMEPAD_BUTTON_DPAD_RIGHT;
|
||||
control.event_on = SDL_EVENT_GAMEPAD_BUTTON_DOWN;
|
||||
control.event_off = SDL_EVENT_GAMEPAD_BUTTON_UP;
|
||||
control.handler_on = &akgl_Actor_cmhf_right_on;
|
||||
control.handler_off = &akgl_Actor_cmhf_right_off;
|
||||
control.handler_on = &akgl_actor_cmhf_right_on;
|
||||
control.handler_off = &akgl_actor_cmhf_right_off;
|
||||
CATCH(errctx, akgl_controller_pushmap(controlmapid, &control));
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
// The SUCCEED_RETURN used to sit at the end of the ATTEMPT block, which
|
||||
// returned past CLEANUP and left this function with no return statement at
|
||||
// all on the path that falls out of FINISH.
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_controller_poll_key(int *keycode, bool *available)
|
||||
{
|
||||
akgl_Keystroke keystroke;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, keycode, AKERR_NULLPOINTER, "NULL keycode destination");
|
||||
FAIL_ZERO_RETURN(errctx, available, AKERR_NULLPOINTER, "NULL availability destination");
|
||||
|
||||
// An empty buffer is the ordinary case, not a failure: the caller is asking
|
||||
// whether a key is waiting, and the answer is no.
|
||||
*keycode = 0;
|
||||
*available = false;
|
||||
while ( keybuffer_take(&keystroke) == true ) {
|
||||
if ( keystroke.key == 0 ) {
|
||||
// Composed text with no key behind it. This form has no way to
|
||||
// report it, so it is dropped rather than handed back as key 0.
|
||||
continue;
|
||||
}
|
||||
*keycode = (int)keystroke.key;
|
||||
*available = true;
|
||||
break;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_controller_poll_keystroke(akgl_Keystroke *dest, bool *available)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL keystroke destination");
|
||||
FAIL_ZERO_RETURN(errctx, available, AKERR_NULLPOINTER, "NULL availability destination");
|
||||
|
||||
memset(dest, 0x00, sizeof(akgl_Keystroke));
|
||||
*available = keybuffer_take(dest);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_controller_flush_keys(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
keybuffer_head = 0;
|
||||
keybuffer_count = 0;
|
||||
keybuffer_awaiting_text = false;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
621
src/draw.c
621
src/draw.c
@@ -1,30 +1,621 @@
|
||||
/**
|
||||
* @file draw.c
|
||||
* @brief Implements the draw subsystem.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
#include <SDL3_mixer/SDL_mixer.h>
|
||||
#include <akerror.h>
|
||||
#include <akgl/draw.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
|
||||
/* Draw a Gimpish background pattern to show transparency in the image */
|
||||
void akgl_draw_background(int w, int h)
|
||||
/** @brief One horizontal run of pixels the flood fill has still to examine. */
|
||||
typedef struct {
|
||||
int x1; /**< Leftmost column of the run, inclusive. */
|
||||
int x2; /**< Rightmost column of the run, inclusive. */
|
||||
int y; /**< The row the run is on. */
|
||||
} FloodSpan;
|
||||
|
||||
/*
|
||||
* The flood fill's working stack. File scope and fixed size rather than a local
|
||||
* array because AKGL_DRAW_MAX_FLOOD_SPANS spans is 48 KB, which does not belong
|
||||
* on the stack of a function a game may call every frame. The consequence is
|
||||
* that akgl_draw_flood_fill is not reentrant -- it is a single-threaded
|
||||
* immediate-mode operation on a single render target, and so is everything else
|
||||
* that touches an SDL_Renderer.
|
||||
*/
|
||||
static FloodSpan floodspans[AKGL_DRAW_MAX_FLOOD_SPANS];
|
||||
|
||||
/**
|
||||
* @brief Remember the renderer's draw color and replace it with @p color.
|
||||
*
|
||||
* @p previous is written before anything that can fail, so a caller may restore
|
||||
* it unconditionally from a CLEANUP block.
|
||||
*
|
||||
* @param self The backend. Assumed non-`NULL` with a live `sdl_renderer`;
|
||||
* every caller has already checked both.
|
||||
* @param color The colour to install.
|
||||
* @param previous Receives the colour that was in place. Assumed non-`NULL`.
|
||||
* Pre-filled with opaque black, so it is safe to restore from
|
||||
* even if the query below fails.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKGL_ERR_SDL If the current draw colour cannot be read or the new one
|
||||
* cannot be set. The message carries `SDL_GetError()`.
|
||||
*/
|
||||
static akerr_ErrorContext *push_draw_color(akgl_RenderBackend *self, SDL_Color color, SDL_Color *previous)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
previous->r = 0x00;
|
||||
previous->g = 0x00;
|
||||
previous->b = 0x00;
|
||||
previous->a = SDL_ALPHA_OPAQUE;
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
SDL_GetRenderDrawColor(self->sdl_renderer, &previous->r, &previous->g, &previous->b, &previous->a),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
SDL_SetRenderDrawColor(self->sdl_renderer, color.r, color.g, color.b, color.a),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Put back the draw color push_draw_color() recorded.
|
||||
*
|
||||
* Called from `CLEANUP` blocks under `IGNORE()`, so its error is logged rather
|
||||
* than propagated -- failing to restore a colour must not mask the failure the
|
||||
* cleanup is unwinding from.
|
||||
*
|
||||
* @param self The backend. Assumed non-`NULL` with a live `sdl_renderer`.
|
||||
* @param previous The colour push_draw_color() recorded. Assumed non-`NULL`.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKGL_ERR_SDL If the draw colour cannot be set.
|
||||
*/
|
||||
static akerr_ErrorContext *pop_draw_color(akgl_RenderBackend *self, SDL_Color *previous)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
SDL_SetRenderDrawColor(self->sdl_renderer, previous->r, previous->g, previous->b, previous->a),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* Draw a Gimpish background pattern to show transparency in the image */
|
||||
akerr_ErrorContext *akgl_draw_background(akgl_RenderBackend *self, int w, int h)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_Color col[2] = {
|
||||
{ 0x66, 0x66, 0x66, 0xff },
|
||||
{ 0x99, 0x99, 0x99, 0xff },
|
||||
{ 0x66, 0x66, 0x66, 0xff },
|
||||
{ 0x99, 0x99, 0x99, 0xff },
|
||||
};
|
||||
int i, x, y;
|
||||
SDL_Color previous;
|
||||
SDL_FRect rect;
|
||||
const int dx = 8, dy = 8;
|
||||
bool pushed = false;
|
||||
bool drawfailed = false;
|
||||
int i = 0;
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
|
||||
|
||||
rect.w = (float)dx;
|
||||
rect.h = (float)dy;
|
||||
for (y = 0; y < h; y += dy) {
|
||||
for (x = 0; x < w; x += dx) {
|
||||
/* use an 8x8 checkerboard pattern */
|
||||
i = (((x ^ y) >> 3) & 1);
|
||||
SDL_SetRenderDrawColor(renderer.sdl_renderer, col[i].r, col[i].g, col[i].b, col[i].a);
|
||||
|
||||
rect.x = (float)x;
|
||||
rect.y = (float)y;
|
||||
SDL_RenderFillRect(renderer.sdl_renderer, &rect);
|
||||
}
|
||||
}
|
||||
ATTEMPT {
|
||||
CATCH(errctx, push_draw_color(self, col[0], &previous));
|
||||
pushed = true;
|
||||
|
||||
// The two SDL calls are checked through a flag rather than with
|
||||
// FAIL_ZERO_BREAK. Inside these loops a `break` would leave the loop,
|
||||
// not the ATTEMPT block, and the fill would carry on with the failure
|
||||
// unnoticed -- the hazard AGENTS.md describes for CATCH inside a loop.
|
||||
for ( y = 0; (y < h) && (drawfailed == false); y += dy ) {
|
||||
for ( x = 0; x < w; x += dx ) {
|
||||
/* use an 8x8 checkerboard pattern */
|
||||
i = (((x ^ y) >> 3) & 1);
|
||||
if ( SDL_SetRenderDrawColor(self->sdl_renderer, col[i].r, col[i].g, col[i].b, col[i].a) == false ) {
|
||||
drawfailed = true;
|
||||
break;
|
||||
}
|
||||
rect.x = (float)x;
|
||||
rect.y = (float)y;
|
||||
if ( SDL_RenderFillRect(self->sdl_renderer, &rect) == false ) {
|
||||
drawfailed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
FAIL_NONZERO_BREAK(errctx, drawfailed, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
} CLEANUP {
|
||||
if ( pushed == true ) {
|
||||
IGNORE(pop_draw_color(self, &previous));
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Fill the four-connected region of @p oldpixel around a seed pixel.
|
||||
*
|
||||
* A scanline fill: each entry on the stack is a run of pixels on one row that
|
||||
* still has to be examined. Finding a matching pixel expands it to the whole
|
||||
* run it belongs to, fills that run, and pushes the rows above and below.
|
||||
* Filled pixels no longer match @p oldpixel, which is what terminates it.
|
||||
*
|
||||
* @p surface must be SDL_PIXELFORMAT_RGBA32; the fill compares and writes whole
|
||||
* 32-bit words rather than going through SDL_ReadSurfacePixel per pixel.
|
||||
*
|
||||
* @p dirty is set to the bounding box of everything written, so the caller can
|
||||
* put back only the pixels that changed.
|
||||
*
|
||||
* Running out of stack leaves the region partially filled and reports
|
||||
* AKERR_OUTOFBOUNDS. There is no way to unwind a partial fill short of keeping
|
||||
* a copy of the whole surface, and the caller asked for a bounded operation.
|
||||
*
|
||||
* @param surface The pixels to fill, in SDL_PIXELFORMAT_RGBA32. Assumed
|
||||
* non-`NULL` and locked-or-lockless; the caller converts.
|
||||
* @param x Seed column. Assumed inside the surface -- akgl_draw_flood_fill
|
||||
* has already range-checked it.
|
||||
* @param y Seed row, likewise.
|
||||
* @param oldpixel The packed pixel value the region is made of. Everything else
|
||||
* is a boundary.
|
||||
* @param newpixel The packed pixel value to write. Must differ from @p oldpixel:
|
||||
* if they are equal the fill never terminates making progress,
|
||||
* which is why the caller checks that case first.
|
||||
* @param dirty Receives the bounding box of everything written, so the caller
|
||||
* can put back only the pixels that changed. Assumed non-`NULL`.
|
||||
* Left describing an empty box if nothing matched.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_OUTOFBOUNDS If the region needs more than
|
||||
* #AKGL_DRAW_MAX_FLOOD_SPANS pending spans. @p dirty is then not written
|
||||
* and the surface is partially filled.
|
||||
*/
|
||||
static akerr_ErrorContext *flood_region(SDL_Surface *surface, int x, int y, uint32_t oldpixel, uint32_t newpixel, SDL_Rect *dirty)
|
||||
{
|
||||
uint32_t *pixels = (uint32_t *)surface->pixels;
|
||||
int pitch = surface->pitch / (int)sizeof(uint32_t);
|
||||
int count = 0;
|
||||
int col = 0;
|
||||
int left = 0;
|
||||
int right = 0;
|
||||
int i = 0;
|
||||
int minx = surface->w;
|
||||
int miny = surface->h;
|
||||
int maxx = -1;
|
||||
int maxy = -1;
|
||||
FloodSpan span;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
floodspans[0].x1 = x;
|
||||
floodspans[0].x2 = x;
|
||||
floodspans[0].y = y;
|
||||
count = 1;
|
||||
|
||||
while ( count > 0 ) {
|
||||
count -= 1;
|
||||
span = floodspans[count];
|
||||
col = span.x1;
|
||||
while ( col <= span.x2 ) {
|
||||
if ( pixels[(span.y * pitch) + col] != oldpixel ) {
|
||||
col += 1;
|
||||
continue;
|
||||
}
|
||||
left = col;
|
||||
while ( left > 0 && pixels[(span.y * pitch) + (left - 1)] == oldpixel ) {
|
||||
left -= 1;
|
||||
}
|
||||
right = col;
|
||||
while ( right < (surface->w - 1) && pixels[(span.y * pitch) + (right + 1)] == oldpixel ) {
|
||||
right += 1;
|
||||
}
|
||||
for ( i = left; i <= right; i++ ) {
|
||||
pixels[(span.y * pitch) + i] = newpixel;
|
||||
}
|
||||
if ( left < minx ) {
|
||||
minx = left;
|
||||
}
|
||||
if ( right > maxx ) {
|
||||
maxx = right;
|
||||
}
|
||||
if ( span.y < miny ) {
|
||||
miny = span.y;
|
||||
}
|
||||
if ( span.y > maxy ) {
|
||||
maxy = span.y;
|
||||
}
|
||||
// Two pushes per run, so the check is for room for both.
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
((count + 2) > AKGL_DRAW_MAX_FLOOD_SPANS),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Region needs more than %d pending spans; it is partially filled",
|
||||
AKGL_DRAW_MAX_FLOOD_SPANS);
|
||||
if ( span.y > 0 ) {
|
||||
floodspans[count].x1 = left;
|
||||
floodspans[count].x2 = right;
|
||||
floodspans[count].y = span.y - 1;
|
||||
count += 1;
|
||||
}
|
||||
if ( span.y < (surface->h - 1) ) {
|
||||
floodspans[count].x1 = left;
|
||||
floodspans[count].x2 = right;
|
||||
floodspans[count].y = span.y + 1;
|
||||
count += 1;
|
||||
}
|
||||
col = right + 1;
|
||||
}
|
||||
}
|
||||
|
||||
dirty->x = minx;
|
||||
dirty->y = miny;
|
||||
dirty->w = (maxx - minx) + 1;
|
||||
dirty->h = (maxy - miny) + 1;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_draw_point(akgl_RenderBackend *self, float32_t x, float32_t y, SDL_Color color)
|
||||
{
|
||||
SDL_Color previous;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
|
||||
ATTEMPT {
|
||||
CATCH(errctx, push_draw_color(self, color, &previous));
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_RenderPoint(self->sdl_renderer, x, y),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
} CLEANUP {
|
||||
IGNORE(pop_draw_color(self, &previous));
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_draw_line(akgl_RenderBackend *self, float32_t x1, float32_t y1, float32_t x2, float32_t y2, SDL_Color color)
|
||||
{
|
||||
SDL_Color previous;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
|
||||
ATTEMPT {
|
||||
CATCH(errctx, push_draw_color(self, color, &previous));
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_RenderLine(self->sdl_renderer, x1, y1, x2, y2),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
} CLEANUP {
|
||||
IGNORE(pop_draw_color(self, &previous));
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_draw_rect(akgl_RenderBackend *self, SDL_FRect *rect, SDL_Color color)
|
||||
{
|
||||
SDL_Color previous;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
|
||||
FAIL_ZERO_RETURN(errctx, rect, AKERR_NULLPOINTER, "rect");
|
||||
ATTEMPT {
|
||||
CATCH(errctx, push_draw_color(self, color, &previous));
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_RenderRect(self->sdl_renderer, rect),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
} CLEANUP {
|
||||
IGNORE(pop_draw_color(self, &previous));
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_draw_filled_rect(akgl_RenderBackend *self, SDL_FRect *rect, SDL_Color color)
|
||||
{
|
||||
SDL_Color previous;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
|
||||
FAIL_ZERO_RETURN(errctx, rect, AKERR_NULLPOINTER, "rect");
|
||||
ATTEMPT {
|
||||
CATCH(errctx, push_draw_color(self, color, &previous));
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_RenderFillRect(self->sdl_renderer, rect),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
} CLEANUP {
|
||||
IGNORE(pop_draw_color(self, &previous));
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_draw_circle(akgl_RenderBackend *self, float32_t x, float32_t y, float32_t radius, SDL_Color color)
|
||||
{
|
||||
SDL_Color previous;
|
||||
SDL_FPoint octants[8];
|
||||
int centerx = 0;
|
||||
int centery = 0;
|
||||
int r = 0;
|
||||
int offsetx = 0;
|
||||
int offsety = 0;
|
||||
int decision = 0;
|
||||
bool plotted = true;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
|
||||
FAIL_NONZERO_RETURN(errctx, (radius < 0), AKERR_OUTOFBOUNDS, "Negative radius %f", radius);
|
||||
|
||||
centerx = (int)SDL_lroundf(x);
|
||||
centery = (int)SDL_lroundf(y);
|
||||
r = (int)SDL_lroundf(radius);
|
||||
offsety = r;
|
||||
// The midpoint decision variable, started so the first step chooses between
|
||||
// (0, r) and (1, r-1) correctly.
|
||||
decision = 1 - r;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, push_draw_color(self, color, &previous));
|
||||
while ( offsety >= offsetx ) {
|
||||
// Eight-way symmetry: one computed point in the second octant gives
|
||||
// the seven others by reflection.
|
||||
octants[0].x = (float)(centerx + offsetx);
|
||||
octants[0].y = (float)(centery + offsety);
|
||||
octants[1].x = (float)(centerx - offsetx);
|
||||
octants[1].y = (float)(centery + offsety);
|
||||
octants[2].x = (float)(centerx + offsetx);
|
||||
octants[2].y = (float)(centery - offsety);
|
||||
octants[3].x = (float)(centerx - offsetx);
|
||||
octants[3].y = (float)(centery - offsety);
|
||||
octants[4].x = (float)(centerx + offsety);
|
||||
octants[4].y = (float)(centery + offsetx);
|
||||
octants[5].x = (float)(centerx - offsety);
|
||||
octants[5].y = (float)(centery + offsetx);
|
||||
octants[6].x = (float)(centerx + offsety);
|
||||
octants[6].y = (float)(centery - offsetx);
|
||||
octants[7].x = (float)(centerx - offsety);
|
||||
octants[7].y = (float)(centery - offsetx);
|
||||
// A CATCH here would break this loop rather than leave the function,
|
||||
// so failure is recorded and reported once the loop is done.
|
||||
if ( !SDL_RenderPoints(self->sdl_renderer, octants, 8) ) {
|
||||
plotted = false;
|
||||
}
|
||||
offsetx += 1;
|
||||
if ( decision < 0 ) {
|
||||
decision += (2 * offsetx) + 1;
|
||||
} else {
|
||||
offsety -= 1;
|
||||
decision += 2 * (offsetx - offsety) + 1;
|
||||
}
|
||||
}
|
||||
FAIL_ZERO_BREAK(errctx, plotted, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
} CLEANUP {
|
||||
IGNORE(pop_draw_color(self, &previous));
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_draw_flood_fill(akgl_RenderBackend *self, int x, int y, SDL_Color color)
|
||||
{
|
||||
SDL_Surface *target = NULL;
|
||||
SDL_Surface *rgba = NULL;
|
||||
SDL_Texture *patch = NULL;
|
||||
// Only written by a successful flood_region(), and only read after one, but
|
||||
// the paths in between are far enough apart that the compiler cannot see it.
|
||||
SDL_Rect dirty = { 0, 0, 0, 0 };
|
||||
SDL_FRect src;
|
||||
SDL_FRect dest;
|
||||
uint32_t *pixels = NULL;
|
||||
uint32_t oldpixel = 0;
|
||||
uint32_t newpixel = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
|
||||
ATTEMPT {
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_GetCurrentRenderOutputSize(self->sdl_renderer, &width, &height),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
FAIL_NONZERO_BREAK(
|
||||
errctx,
|
||||
((x < 0) || (y < 0) || (x >= width) || (y >= height)),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Seed pixel %d,%d is outside the %dx%d render target",
|
||||
x, y, width, height);
|
||||
|
||||
target = SDL_RenderReadPixels(self->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, target, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
// The fill works on 32-bit words, so the layout has to be known rather
|
||||
// than whatever the render target happens to use.
|
||||
rgba = SDL_ConvertSurface(target, SDL_PIXELFORMAT_RGBA32);
|
||||
FAIL_ZERO_BREAK(errctx, rgba, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
|
||||
pixels = (uint32_t *)rgba->pixels;
|
||||
oldpixel = pixels[(y * (rgba->pitch / (int)sizeof(uint32_t))) + x];
|
||||
newpixel = SDL_MapSurfaceRGBA(rgba, color.r, color.g, color.b, color.a);
|
||||
if ( oldpixel == newpixel ) {
|
||||
// Already the requested color. Walking it would compare filled
|
||||
// pixels against themselves and find nothing, so say so up front.
|
||||
SUCCEED_BREAK(errctx);
|
||||
}
|
||||
CATCH(errctx, flood_region(rgba, x, y, oldpixel, newpixel, &dirty));
|
||||
|
||||
patch = SDL_CreateTextureFromSurface(self->sdl_renderer, rgba);
|
||||
FAIL_ZERO_BREAK(errctx, patch, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
// Replace rather than blend: this is a framebuffer operation, and the
|
||||
// pixels being put back are the ones that were just read out of it.
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_SetTextureBlendMode(patch, SDL_BLENDMODE_NONE),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
|
||||
// Only the bounding box of what changed goes back to the target.
|
||||
src.x = (float)dirty.x;
|
||||
src.y = (float)dirty.y;
|
||||
src.w = (float)dirty.w;
|
||||
src.h = (float)dirty.h;
|
||||
dest = src;
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_RenderTexture(self->sdl_renderer, patch, &src, &dest),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
} CLEANUP {
|
||||
if ( patch != NULL ) {
|
||||
SDL_DestroyTexture(patch);
|
||||
}
|
||||
if ( rgba != NULL ) {
|
||||
SDL_DestroySurface(rgba);
|
||||
}
|
||||
if ( target != NULL ) {
|
||||
SDL_DestroySurface(target);
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_draw_copy_region(akgl_RenderBackend *self, SDL_Rect *src, SDL_Surface **dest)
|
||||
{
|
||||
SDL_Surface *saved = NULL;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
|
||||
FAIL_ZERO_RETURN(errctx, src, AKERR_NULLPOINTER, "src");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
ATTEMPT {
|
||||
FAIL_NONZERO_BREAK(
|
||||
errctx,
|
||||
((src->w <= 0) || (src->h <= 0)),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Region %dx%d has no area",
|
||||
src->w, src->h);
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_GetCurrentRenderOutputSize(self->sdl_renderer, &width, &height),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
// SDL clips a read to the target and hands back a smaller surface than
|
||||
// was asked for, which a caller pasting it back would not notice.
|
||||
FAIL_NONZERO_BREAK(
|
||||
errctx,
|
||||
((src->x < 0) || (src->y < 0) ||
|
||||
((src->x + src->w) > width) || ((src->y + src->h) > height)),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Region %d,%d %dx%d does not fit inside the %dx%d render target",
|
||||
src->x, src->y, src->w, src->h, width, height);
|
||||
|
||||
saved = SDL_RenderReadPixels(self->sdl_renderer, src);
|
||||
FAIL_ZERO_BREAK(errctx, saved, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
|
||||
if ( *dest == NULL ) {
|
||||
*dest = saved;
|
||||
// Ownership has moved to the caller; CLEANUP must not free it.
|
||||
saved = NULL;
|
||||
} else {
|
||||
FAIL_NONZERO_BREAK(
|
||||
errctx,
|
||||
(((*dest)->w != src->w) || ((*dest)->h != src->h)),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Destination surface is %dx%d, region is %dx%d",
|
||||
(*dest)->w, (*dest)->h, src->w, src->h);
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_SetSurfaceBlendMode(saved, SDL_BLENDMODE_NONE),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_BlitSurface(saved, NULL, *dest, NULL),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
}
|
||||
} CLEANUP {
|
||||
if ( saved != NULL ) {
|
||||
SDL_DestroySurface(saved);
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_draw_paste_region(akgl_RenderBackend *self, SDL_Surface *src, float32_t x, float32_t y)
|
||||
{
|
||||
SDL_Texture *patch = NULL;
|
||||
SDL_FRect dest;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
|
||||
FAIL_ZERO_RETURN(errctx, src, AKERR_NULLPOINTER, "src");
|
||||
ATTEMPT {
|
||||
patch = SDL_CreateTextureFromSurface(self->sdl_renderer, src);
|
||||
FAIL_ZERO_BREAK(errctx, patch, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
// Replace what is on the target, the way GSHAPE does by default.
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_SetTextureBlendMode(patch, SDL_BLENDMODE_NONE),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
dest.x = x;
|
||||
dest.y = y;
|
||||
dest.w = (float)src->w;
|
||||
dest.h = (float)src->h;
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_RenderTexture(self->sdl_renderer, patch, NULL, &dest),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
} CLEANUP {
|
||||
if ( patch != NULL ) {
|
||||
SDL_DestroyTexture(patch);
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
24
src/error.c
Normal file
24
src/error.c
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @file error.c
|
||||
* @brief Implements the error subsystem: claims and names the libakgl status band.
|
||||
*/
|
||||
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/error.h>
|
||||
|
||||
akerr_ErrorContext *akgl_error_init(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
// Claim the whole band before naming anything in it: libakerror refuses a
|
||||
// name for a status we do not own. Any collision propagates to the caller
|
||||
// -- another component owning part of our range is an initialization
|
||||
// failure, not a warning.
|
||||
PASS(errctx, akerr_reserve_status_range(AKGL_ERR_BASE, AKGL_ERR_COUNT, AKGL_ERR_OWNER));
|
||||
PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_SDL, "SDL Error"));
|
||||
PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_REGISTRY, "Registry Error"));
|
||||
PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_HEAP, "Heap Error"));
|
||||
PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_BEHAVIOR, "Behavior Error"));
|
||||
PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_LOGICINTERRUPT, "Logic Interrupt"));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
804
src/game.c
804
src/game.c
File diff suppressed because it is too large
Load Diff
97
src/heap.c
97
src/heap.c
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @file heap.c
|
||||
* @brief Implements the heap subsystem.
|
||||
*/
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <akerror.h>
|
||||
|
||||
@@ -9,106 +14,105 @@
|
||||
#include <akgl/iterator.h>
|
||||
#include <akgl/error.h>
|
||||
|
||||
akgl_Actor HEAP_ACTOR[AKGL_MAX_HEAP_ACTOR];
|
||||
akgl_Sprite HEAP_SPRITE[AKGL_MAX_HEAP_SPRITE];
|
||||
akgl_SpriteSheet HEAP_SPRITESHEET[AKGL_MAX_HEAP_SPRITESHEET];
|
||||
akgl_Character HEAP_CHARACTER[AKGL_MAX_HEAP_CHARACTER];
|
||||
akgl_String HEAP_STRING[AKGL_MAX_HEAP_STRING];
|
||||
akgl_Actor akgl_heap_actors[AKGL_MAX_HEAP_ACTOR];
|
||||
akgl_Sprite akgl_heap_sprites[AKGL_MAX_HEAP_SPRITE];
|
||||
akgl_SpriteSheet akgl_heap_spritesheets[AKGL_MAX_HEAP_SPRITESHEET];
|
||||
akgl_Character akgl_heap_characters[AKGL_MAX_HEAP_CHARACTER];
|
||||
akgl_String akgl_heap_strings[AKGL_MAX_HEAP_STRING];
|
||||
|
||||
akerr_ErrorContext *akgl_heap_init()
|
||||
akerr_ErrorContext *akgl_heap_init(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
int i = 0;
|
||||
akerr_name_for_status(AKGL_ERR_SDL, "SDL Error");
|
||||
PASS(errctx, akgl_heap_init_actor());
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_SPRITE; i++) {
|
||||
memset(&HEAP_SPRITE[i], 0x00, sizeof(akgl_Sprite));
|
||||
memset(&akgl_heap_sprites[i], 0x00, sizeof(akgl_Sprite));
|
||||
}
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_SPRITESHEET; i++) {
|
||||
memset(&HEAP_SPRITESHEET[i], 0x00, sizeof(akgl_SpriteSheet));
|
||||
memset(&akgl_heap_spritesheets[i], 0x00, sizeof(akgl_SpriteSheet));
|
||||
}
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_CHARACTER; i++) {
|
||||
memset(&HEAP_CHARACTER[i], 0x00, sizeof(akgl_Character));
|
||||
memset(&akgl_heap_characters[i], 0x00, sizeof(akgl_Character));
|
||||
}
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_STRING; i++) {
|
||||
memset(&HEAP_STRING[i], 0x00, sizeof(akgl_String));
|
||||
memset(&akgl_heap_strings[i], 0x00, sizeof(akgl_String));
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_heap_init_actor(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
PREPARE_ERROR(errctx);
|
||||
for ( int i = 0; i < AKGL_MAX_HEAP_ACTOR; i++) {
|
||||
memset(&HEAP_ACTOR[i], 0x00, sizeof(akgl_Actor));
|
||||
memset(&akgl_heap_actors[i], 0x00, sizeof(akgl_Actor));
|
||||
}
|
||||
SUCCEED_RETURN(e);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_heap_next_actor(akgl_Actor **dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
for (int i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
|
||||
if ( HEAP_ACTOR[i].refcount != 0 ) {
|
||||
if ( akgl_heap_actors[i].refcount != 0 ) {
|
||||
continue;
|
||||
}
|
||||
*dest = &HEAP_ACTOR[i];
|
||||
*dest = &akgl_heap_actors[i];
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
FAIL_RETURN(errctx, AKERR_HEAP, "Unable to find unused actor on the heap");
|
||||
FAIL_RETURN(errctx, AKGL_ERR_HEAP, "Unable to find unused actor on the heap");
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_heap_next_sprite(akgl_Sprite **dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
for (int i = 0; i < AKGL_MAX_HEAP_SPRITE; i++ ) {
|
||||
if ( HEAP_SPRITE[i].refcount != 0 ) {
|
||||
if ( akgl_heap_sprites[i].refcount != 0 ) {
|
||||
continue;
|
||||
}
|
||||
*dest = &HEAP_SPRITE[i];
|
||||
*dest = &akgl_heap_sprites[i];
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
FAIL_RETURN(errctx, AKERR_HEAP, "Unable to find unused sprite on the heap");
|
||||
FAIL_RETURN(errctx, AKGL_ERR_HEAP, "Unable to find unused sprite on the heap");
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_heap_next_spritesheet(akgl_SpriteSheet **dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
for (int i = 0; i < AKGL_MAX_HEAP_SPRITESHEET; i++ ) {
|
||||
if ( HEAP_SPRITESHEET[i].refcount != 0 ) {
|
||||
if ( akgl_heap_spritesheets[i].refcount != 0 ) {
|
||||
continue;
|
||||
}
|
||||
*dest = &HEAP_SPRITESHEET[i];
|
||||
*dest = &akgl_heap_spritesheets[i];
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
FAIL_RETURN(errctx, AKERR_HEAP, "Unable to find unused spritesheet on the heap");
|
||||
FAIL_RETURN(errctx, AKGL_ERR_HEAP, "Unable to find unused spritesheet on the heap");
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_heap_next_character(akgl_Character **dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
for (int i = 0; i < AKGL_MAX_HEAP_CHARACTER; i++ ) {
|
||||
if ( HEAP_CHARACTER[i].refcount != 0 ) {
|
||||
if ( akgl_heap_characters[i].refcount != 0 ) {
|
||||
continue;
|
||||
}
|
||||
*dest = &HEAP_CHARACTER[i];
|
||||
*dest = &akgl_heap_characters[i];
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
FAIL_RETURN(errctx, AKERR_HEAP, "Unable to find unused character on the heap");
|
||||
FAIL_RETURN(errctx, AKGL_ERR_HEAP, "Unable to find unused character on the heap");
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_heap_next_string(akgl_String **dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
for (int i = 0; i < AKGL_MAX_HEAP_STRING; i++ ) {
|
||||
if ( HEAP_STRING[i].refcount != 0 ) {
|
||||
if ( akgl_heap_strings[i].refcount != 0 ) {
|
||||
continue;
|
||||
}
|
||||
*dest = &HEAP_STRING[i];
|
||||
HEAP_STRING[i].refcount += 1;
|
||||
*dest = &akgl_heap_strings[i];
|
||||
akgl_heap_strings[i].refcount += 1;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
FAIL_RETURN(errctx, AKERR_HEAP, "Unable to find unused string on the heap");
|
||||
FAIL_RETURN(errctx, AKGL_ERR_HEAP, "Unable to find unused string on the heap");
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_heap_release_actor(akgl_Actor *ptr)
|
||||
@@ -131,19 +135,35 @@ akerr_ErrorContext *akgl_heap_release_actor(akgl_Actor *ptr)
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_heap_release_character(akgl_Character *basechar)
|
||||
akerr_ErrorContext *akgl_heap_release_character(akgl_Character *ptr)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_Iterator opflags;
|
||||
FAIL_ZERO_RETURN(errctx, basechar, AKERR_NULLPOINTER, "NULL character reference");
|
||||
FAIL_ZERO_RETURN(errctx, ptr, AKERR_NULLPOINTER, "NULL character reference");
|
||||
AKGL_BITMASK_CLEAR(opflags.flags);
|
||||
|
||||
if ( basechar->refcount > 0 ) {
|
||||
basechar->refcount -= 1;
|
||||
|
||||
if ( ptr->refcount > 0 ) {
|
||||
ptr->refcount -= 1;
|
||||
}
|
||||
if ( basechar->refcount == 0 ) {
|
||||
SDL_ClearProperty(AKGL_REGISTRY_CHARACTER, (char *)&basechar->name);
|
||||
memset(basechar, 0x00, sizeof(akgl_Character));
|
||||
if ( ptr->refcount == 0 ) {
|
||||
// Give back every sprite reference akgl_character_sprite_add took, and
|
||||
// the property set holding the map, before the slot is zeroed. Without
|
||||
// this a game that loads and releases characters level by level
|
||||
// exhausts the sprite pool and leaks one SDL_PropertiesID per
|
||||
// character. akgl_character_state_sprites_iterate exists for exactly
|
||||
// this walk and simply was never called from here.
|
||||
if ( ptr->state_sprites != 0 ) {
|
||||
AKGL_BITMASK_ADD(opflags.flags, AKGL_ITERATOR_OP_RELEASE);
|
||||
SDL_EnumerateProperties(
|
||||
ptr->state_sprites,
|
||||
&akgl_character_state_sprites_iterate,
|
||||
(void *)&opflags
|
||||
);
|
||||
SDL_DestroyProperties(ptr->state_sprites);
|
||||
ptr->state_sprites = 0;
|
||||
}
|
||||
SDL_ClearProperty(AKGL_REGISTRY_CHARACTER, (char *)&ptr->name);
|
||||
memset(ptr, 0x00, sizeof(akgl_Character));
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
@@ -192,4 +212,3 @@ akerr_ErrorContext *akgl_heap_release_string(akgl_String *ptr)
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
/**
|
||||
* @file json_helpers.c
|
||||
* @brief Implements the json helpers subsystem.
|
||||
*/
|
||||
|
||||
#include <jansson.h>
|
||||
#include <string.h>
|
||||
#include <akerror.h>
|
||||
#include <akstdlib.h>
|
||||
|
||||
#include <akgl/json_helpers.h>
|
||||
#include <akgl/game.h>
|
||||
@@ -10,122 +16,199 @@
|
||||
|
||||
akerr_ErrorContext *akgl_get_json_object_value(json_t *obj, char *key, json_t **dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL object pointer");
|
||||
json_t *value = json_object_get(obj, key);
|
||||
FAIL_ZERO_RETURN(errctx, value, AKERR_KEY, "Key %s not found in object", key);
|
||||
FAIL_ZERO_RETURN(errctx, (json_is_object(value)), AKERR_TYPE, "Key %s in object has incorrect type", key);
|
||||
*dest = value;
|
||||
SUCCEED_RETURN(errctx);
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL object pointer");
|
||||
FAIL_ZERO_RETURN(errctx, key, AKERR_NULLPOINTER, "NULL key string");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination pointer");
|
||||
json_t *value = json_object_get(obj, key);
|
||||
FAIL_ZERO_RETURN(errctx, value, AKERR_KEY, "Key %s not found in object", key);
|
||||
FAIL_ZERO_RETURN(errctx, (json_is_object(value)), AKERR_TYPE, "Key %s in object has incorrect type", key);
|
||||
*dest = value;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_get_json_boolean_value(json_t *obj, char *key, bool *dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL object pointer");
|
||||
json_t *value = json_object_get(obj, key);
|
||||
FAIL_ZERO_RETURN(errctx, value, AKERR_KEY, "Key %s not found in object", key);
|
||||
FAIL_ZERO_RETURN(errctx, (json_is_boolean(value)), AKERR_TYPE, "Key %s in object has incorrect type", key);
|
||||
*dest = json_boolean_value(value);
|
||||
SUCCEED_RETURN(errctx);
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL object pointer");
|
||||
FAIL_ZERO_RETURN(errctx, key, AKERR_NULLPOINTER, "NULL key string");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination pointer");
|
||||
json_t *value = json_object_get(obj, key);
|
||||
FAIL_ZERO_RETURN(errctx, value, AKERR_KEY, "Key %s not found in object", key);
|
||||
FAIL_ZERO_RETURN(errctx, (json_is_boolean(value)), AKERR_TYPE, "Key %s in object has incorrect type", key);
|
||||
*dest = json_boolean_value(value);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_get_json_integer_value(json_t *obj, char *key, int *dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL object pointer");
|
||||
json_t *value = json_object_get(obj, key);
|
||||
FAIL_ZERO_RETURN(errctx, value, AKERR_KEY, "Key %s not found in object", key);
|
||||
FAIL_ZERO_RETURN(errctx, (json_is_integer(value)), AKERR_TYPE, "Key %s in object has incorrect type", key);
|
||||
*dest = json_integer_value(value);
|
||||
SUCCEED_RETURN(errctx);
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL object pointer");
|
||||
FAIL_ZERO_RETURN(errctx, key, AKERR_NULLPOINTER, "NULL key string");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination pointer");
|
||||
json_t *value = json_object_get(obj, key);
|
||||
FAIL_ZERO_RETURN(errctx, value, AKERR_KEY, "Key %s not found in object", key);
|
||||
FAIL_ZERO_RETURN(errctx, (json_is_integer(value)), AKERR_TYPE, "Key %s in object has incorrect type", key);
|
||||
*dest = json_integer_value(value);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_get_json_number_value(json_t *obj, char *key, float *dest)
|
||||
akerr_ErrorContext *akgl_get_json_number_value(json_t *obj, char *key, float32_t *dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL pointer reference");
|
||||
json_t *value = json_object_get(obj, key);
|
||||
FAIL_ZERO_RETURN(errctx, value, AKERR_KEY, "Key %s not found in object", key);
|
||||
FAIL_ZERO_RETURN(errctx, (json_is_number(value)), AKERR_TYPE, "Key %s in object has incorrect type", key);
|
||||
*dest = json_number_value(value);
|
||||
SUCCEED_RETURN(errctx);
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL pointer reference");
|
||||
FAIL_ZERO_RETURN(errctx, key, AKERR_NULLPOINTER, "NULL key string");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination pointer");
|
||||
json_t *value = json_object_get(obj, key);
|
||||
FAIL_ZERO_RETURN(errctx, value, AKERR_KEY, "Key %s not found in object", key);
|
||||
FAIL_ZERO_RETURN(errctx, (json_is_number(value)), AKERR_TYPE, "Key %s in object has incorrect type", key);
|
||||
*dest = json_number_value(value);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_get_json_double_value(json_t *obj, char *key, float64_t *dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL pointer reference");
|
||||
FAIL_ZERO_RETURN(errctx, key, AKERR_NULLPOINTER, "NULL key string");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination pointer");
|
||||
json_t *value = json_object_get(obj, key);
|
||||
FAIL_ZERO_RETURN(errctx, value, AKERR_KEY, "Key %s not found in object", key);
|
||||
FAIL_ZERO_RETURN(errctx, (json_is_number(value)), AKERR_TYPE, "Key %s in object has incorrect type", key);
|
||||
*dest = json_number_value(value);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_get_json_string_value(json_t *obj, char *key, akgl_String **dest)
|
||||
{
|
||||
json_t *value = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL pointer reference");
|
||||
FAIL_ZERO_RETURN(errctx, key, AKERR_NULLPOINTER, "NULL pointer reference");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL pointer reference");
|
||||
json_t *value = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL pointer reference");
|
||||
FAIL_ZERO_RETURN(errctx, key, AKERR_NULLPOINTER, "NULL key string");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination pointer");
|
||||
|
||||
value = json_object_get(obj, key);
|
||||
FAIL_ZERO_RETURN(errctx, value, AKERR_KEY, "Key %s not found in object", key);
|
||||
FAIL_ZERO_RETURN(errctx, (json_is_string(value)), AKERR_TYPE, "Key %s in object has incorrect type", key);
|
||||
ATTEMPT {
|
||||
if ( *dest == NULL ) {
|
||||
CATCH(errctx, akgl_heap_next_string(dest));
|
||||
CATCH(errctx, akgl_string_initialize(*dest, NULL));
|
||||
}
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, false);
|
||||
|
||||
strncpy((char *)&(*dest)->data, json_string_value(value), AKGL_MAX_STRING_LENGTH);
|
||||
SUCCEED_RETURN(errctx);
|
||||
value = json_object_get(obj, key);
|
||||
FAIL_ZERO_RETURN(errctx, value, AKERR_KEY, "Key %s not found in object", key);
|
||||
FAIL_ZERO_RETURN(errctx, (json_is_string(value)), AKERR_TYPE, "Key %s in object has incorrect type", key);
|
||||
ATTEMPT {
|
||||
if ( *dest == NULL ) {
|
||||
CATCH(errctx, akgl_heap_next_string(dest));
|
||||
CATCH(errctx, akgl_string_initialize(*dest, NULL));
|
||||
}
|
||||
// FINISH(errctx, true), not false. With `false` a failed
|
||||
// akgl_heap_next_string was swallowed and the strncpy below ran through
|
||||
// the pointer it never set, so an exhausted string pool arrived as a
|
||||
// segfault inside strncpy rather than as AKGL_ERR_HEAP.
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
PASS(errctx, aksl_strncpy(
|
||||
(char *)&(*dest)->data,
|
||||
sizeof((*dest)->data),
|
||||
json_string_value(value),
|
||||
sizeof((*dest)->data) - 1));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_get_json_array_value(json_t *obj, char *key, json_t **dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL pointer reference");
|
||||
json_t *value = json_object_get(obj, key);
|
||||
FAIL_ZERO_RETURN(errctx, value, AKERR_KEY, "Key %s not found in object", key);
|
||||
FAIL_ZERO_RETURN(errctx, (json_is_array(value)), AKERR_TYPE, "Key %s in object has incorrect type", key);
|
||||
*dest = value;
|
||||
SUCCEED_RETURN(errctx);
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "NULL pointer reference");
|
||||
FAIL_ZERO_RETURN(errctx, key, AKERR_NULLPOINTER, "NULL key string");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination pointer");
|
||||
json_t *value = json_object_get(obj, key);
|
||||
FAIL_ZERO_RETURN(errctx, value, AKERR_KEY, "Key %s not found in object", key);
|
||||
FAIL_ZERO_RETURN(errctx, (json_is_array(value)), AKERR_TYPE, "Key %s in object has incorrect type", key);
|
||||
*dest = value;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_get_json_array_index_object(json_t *array, int index, json_t **dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, array, AKERR_NULLPOINTER, "NULL pointer reference");
|
||||
json_t *value = json_array_get(array, index);
|
||||
FAIL_ZERO_RETURN(errctx, value, AKERR_OUTOFBOUNDS, "Index %d out of bounds for array", index);
|
||||
FAIL_ZERO_RETURN(errctx, (json_is_object(value)), AKERR_TYPE, "Index %d in object has incorrect type", index);
|
||||
*dest = value;
|
||||
SUCCEED_RETURN(errctx);
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, array, AKERR_NULLPOINTER, "NULL pointer reference");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination pointer");
|
||||
json_t *value = json_array_get(array, index);
|
||||
FAIL_ZERO_RETURN(errctx, value, AKERR_OUTOFBOUNDS, "Index %d out of bounds for array", index);
|
||||
FAIL_ZERO_RETURN(errctx, (json_is_object(value)), AKERR_TYPE, "Index %d in object has incorrect type", index);
|
||||
*dest = value;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_get_json_array_index_integer(json_t *array, int index, int *dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, array, AKERR_NULLPOINTER, "NULL pointer reference");
|
||||
json_t *value = json_array_get(array, index);
|
||||
FAIL_ZERO_RETURN(errctx, value, AKERR_OUTOFBOUNDS, "Index %d out of bounds for array", index);
|
||||
FAIL_ZERO_RETURN(errctx, (json_is_integer(value)), AKERR_TYPE, "Index %d in object has incorrect type", index);
|
||||
*dest = json_integer_value(value);
|
||||
SUCCEED_RETURN(errctx);
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, array, AKERR_NULLPOINTER, "NULL pointer reference");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination pointer");
|
||||
json_t *value = json_array_get(array, index);
|
||||
FAIL_ZERO_RETURN(errctx, value, AKERR_OUTOFBOUNDS, "Index %d out of bounds for array", index);
|
||||
FAIL_ZERO_RETURN(errctx, (json_is_integer(value)), AKERR_TYPE, "Index %d in object has incorrect type", index);
|
||||
*dest = json_integer_value(value);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_get_json_array_index_string(json_t *array, int index, akgl_String **dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, array, AKERR_NULLPOINTER, "NULL pointer reference");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination pointer reference");
|
||||
json_t *value = json_array_get(array, index);
|
||||
FAIL_ZERO_RETURN(errctx, value, AKERR_OUTOFBOUNDS, "Index %d out of bounds for array", index);
|
||||
FAIL_ZERO_RETURN(errctx, (json_is_string(value)), AKERR_TYPE, "Index %d in object has incorrect type", index);
|
||||
ATTEMPT {
|
||||
if ( *dest == NULL ) {
|
||||
CATCH(errctx, akgl_heap_next_string(dest));
|
||||
CATCH(errctx, akgl_string_initialize(*dest, NULL));
|
||||
}
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, false);
|
||||
|
||||
strncpy((char *)&(*dest)->data, json_string_value(value), AKGL_MAX_STRING_LENGTH);
|
||||
SUCCEED_RETURN(errctx);
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, array, AKERR_NULLPOINTER, "NULL pointer reference");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination pointer");
|
||||
json_t *value = json_array_get(array, index);
|
||||
FAIL_ZERO_RETURN(errctx, value, AKERR_OUTOFBOUNDS, "Index %d out of bounds for array", index);
|
||||
FAIL_ZERO_RETURN(errctx, (json_is_string(value)), AKERR_TYPE, "Index %d in object has incorrect type", index);
|
||||
ATTEMPT {
|
||||
if ( *dest == NULL ) {
|
||||
CATCH(errctx, akgl_heap_next_string(dest));
|
||||
CATCH(errctx, akgl_string_initialize(*dest, NULL));
|
||||
}
|
||||
// FINISH(errctx, true), not false. With `false` a failed
|
||||
// akgl_heap_next_string was swallowed and the strncpy below ran through
|
||||
// the pointer it never set, so an exhausted string pool arrived as a
|
||||
// segfault inside strncpy rather than as AKGL_ERR_HEAP.
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
PASS(errctx, aksl_strncpy(
|
||||
(char *)&(*dest)->data,
|
||||
sizeof((*dest)->data),
|
||||
json_string_value(value),
|
||||
sizeof((*dest)->data) - 1));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_get_json_with_default(akerr_ErrorContext *e, void *defval, void *dest, uint32_t defsize)
|
||||
{
|
||||
// Two contexts, and the names now say which is which: `e` is the incoming
|
||||
// one being inspected, `errctx` is this function's own. They were `err` and
|
||||
// `e`, which put the incoming context under the name the convention
|
||||
// reserves for exactly that and the local one under a name that reads like
|
||||
// it.
|
||||
PREPARE_ERROR(errctx);
|
||||
if ( e == NULL ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, defval, AKERR_NULLPOINTER, "defval");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
|
||||
ATTEMPT {
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
// All three arms fall into the memcpy: HANDLE_GROUP emits no `break`,
|
||||
// so a new status has to be added *above* the arm holding the body, not
|
||||
// below it.
|
||||
//
|
||||
// AKERR_OUTOFBOUNDS is what akgl_get_json_array_index_object, _integer
|
||||
// and _string report for a short array. Without it here, "this element
|
||||
// is optional" worked for an object member and silently did not work
|
||||
// for an array element -- the error propagated instead of being
|
||||
// replaced by the default.
|
||||
} HANDLE_GROUP(e, AKERR_KEY) {
|
||||
} HANDLE_GROUP(e, AKERR_OUTOFBOUNDS) {
|
||||
} HANDLE_GROUP(e, AKERR_INDEX) {
|
||||
memcpy(dest, defval, defsize);
|
||||
} FINISH(e, true);
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
318
src/physics.c
318
src/physics.c
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* @file physics.c
|
||||
* @brief Implements the physics subsystem.
|
||||
*/
|
||||
|
||||
#include <math.h>
|
||||
#include <akstdlib.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/actor.h>
|
||||
@@ -6,150 +12,188 @@
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/registry.h>
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_null_gravity(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt)
|
||||
akerr_ErrorContext *akgl_physics_null_gravity(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
FAIL_ZERO_RETURN(e, self, AKERR_NULLPOINTER, "self");
|
||||
SUCCEED_RETURN(e);
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor");
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_null_collide(akgl_PhysicsBackend *self, akgl_Actor *a1, akgl_Actor *a2)
|
||||
akerr_ErrorContext *akgl_physics_null_collide(akgl_PhysicsBackend *self, akgl_Actor *a1, akgl_Actor *a2)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
FAIL_ZERO_RETURN(e, self, AKERR_NULLPOINTER, "self");
|
||||
SUCCEED_RETURN(e);
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, a1, AKERR_NULLPOINTER, "a1");
|
||||
FAIL_ZERO_RETURN(errctx, a2, AKERR_NULLPOINTER, "a2");
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_null_move(struct akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt)
|
||||
akerr_ErrorContext *akgl_physics_null_move(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
FAIL_ZERO_RETURN(e, self, AKERR_NULLPOINTER, "self");
|
||||
SUCCEED_RETURN(e);
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor");
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_init_null(akgl_PhysicsBackend *self)
|
||||
akerr_ErrorContext *akgl_physics_init_null(akgl_PhysicsBackend *self)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
FAIL_ZERO_RETURN(e, self, AKERR_NULLPOINTER, "self");
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
|
||||
self->gravity = akgl_physics_null_gravity;
|
||||
self->collide = akgl_physics_null_collide;
|
||||
self->move = akgl_physics_null_move;
|
||||
self->simulate = akgl_physics_simulate;
|
||||
|
||||
SUCCEED_RETURN(e);
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_ss_gravity(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt)
|
||||
akerr_ErrorContext *akgl_physics_arcade_gravity(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
FAIL_ZERO_RETURN(e, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(e, actor, AKERR_NULLPOINTER, "actor");
|
||||
|
||||
// Assume the X origin is - (screen left)
|
||||
actor->gx -= (self->gravity_x * dt);
|
||||
// Assume Y origin is + (down screen)
|
||||
actor->gy += (self->gravity_y * dt);
|
||||
// Assume Z origin is - (behind the camera)
|
||||
actor->gz -= (self->gravity_z * dt);
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor");
|
||||
|
||||
// Counteract velocity with atmospheric drag
|
||||
actor->gx += actor->vx * self->drag_x * dt;
|
||||
actor->gy -= actor->vy * self->drag_y * dt;
|
||||
actor->gz += actor->vz * self->drag_z * dt;
|
||||
if ( self->gravity_x != 0 ) {
|
||||
// Assume the X origin is - (screen left)
|
||||
actor->ex -= (self->gravity_x * dt);
|
||||
}
|
||||
if ( self->gravity_y != 0 ) {
|
||||
// Assume Y origin is + (down screen)
|
||||
actor->ey += (self->gravity_y * dt);
|
||||
}
|
||||
if ( self->gravity_z != 0 ) {
|
||||
// Assume Z origin is - (behind the camera)
|
||||
actor->ez -= (self->gravity_z * dt);
|
||||
}
|
||||
|
||||
SUCCEED_RETURN(e);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_ss_collide(akgl_PhysicsBackend *self, akgl_Actor *a1, akgl_Actor *a2)
|
||||
akerr_ErrorContext *akgl_physics_arcade_collide(akgl_PhysicsBackend *self, akgl_Actor *a1, akgl_Actor *a2)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
FAIL_ZERO_RETURN(e, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_RETURN(e, AKERR_API, "Not implemented");
|
||||
SUCCEED_RETURN(e);
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_RETURN(errctx, AKERR_API, "Not implemented");
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_ss_move(struct akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt)
|
||||
akerr_ErrorContext *akgl_physics_arcade_move(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
FAIL_ZERO_RETURN(e, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(e, actor, AKERR_NULLPOINTER, "actor");
|
||||
if ( AKGL_BITMASK_HAS(actor->state, AKGL_ACTOR_STATE_MOVING_LEFT) ) {
|
||||
actor->x += (-actor->vx * dt) + (actor->gx * dt);
|
||||
}
|
||||
if ( AKGL_BITMASK_HAS(actor->state, AKGL_ACTOR_STATE_MOVING_RIGHT) ) {
|
||||
actor->x += (actor->vx * dt) + (actor->gx * dt);
|
||||
}
|
||||
if ( AKGL_BITMASK_HAS(actor->state, AKGL_ACTOR_STATE_MOVING_UP) ) {
|
||||
actor->y += (-actor->vy * dt) + (actor->gy * dt);
|
||||
}
|
||||
if ( AKGL_BITMASK_HAS(actor->state, AKGL_ACTOR_STATE_MOVING_DOWN) ) {
|
||||
actor->y += (actor->vy * dt) + (actor->gy * dt);
|
||||
}
|
||||
|
||||
SUCCEED_RETURN(e);
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor");
|
||||
actor->x += actor->vx * dt;
|
||||
actor->y += actor->vy * dt;
|
||||
actor->z += actor->vz * dt;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_init_sidescroller(akgl_PhysicsBackend *self)
|
||||
akerr_ErrorContext *akgl_physics_init_arcade(akgl_PhysicsBackend *self)
|
||||
{
|
||||
akgl_String *tmp;
|
||||
PREPARE_ERROR(e);
|
||||
FAIL_ZERO_RETURN(e, self, AKERR_NULLPOINTER, "self");
|
||||
PASS(e, akgl_heap_next_string(&tmp));
|
||||
|
||||
self->gravity = akgl_physics_ss_gravity;
|
||||
self->collide = akgl_physics_ss_collide;
|
||||
self->move = akgl_physics_ss_move;
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
PASS(errctx, akgl_heap_next_string(&tmp));
|
||||
|
||||
self->gravity = akgl_physics_arcade_gravity;
|
||||
self->collide = akgl_physics_arcade_collide;
|
||||
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(e, akgl_get_property("physics.gravity.x", &tmp, "0.0"));
|
||||
CATCH(e, aksl_atof(tmp->data, &self->gravity_x));
|
||||
CATCH(e, akgl_get_property("physics.gravity.y", &tmp, "0.0"));
|
||||
CATCH(e, aksl_atof(tmp->data, &self->gravity_y));
|
||||
CATCH(e, akgl_get_property("physics.gravity.z", &tmp, "0.0"));
|
||||
CATCH(e, aksl_atof(tmp->data, &self->gravity_z));
|
||||
CATCH(e, akgl_get_property("physics.drag.x", &tmp, "0.0"));
|
||||
CATCH(e, aksl_atof(tmp->data, &self->drag_x));
|
||||
CATCH(e, akgl_get_property("physics.drag.y", &tmp, "0.0"));
|
||||
CATCH(e, aksl_atof(tmp->data, &self->drag_y));
|
||||
CATCH(e, akgl_get_property("physics.drag.z", &tmp, "0.0"));
|
||||
CATCH(e, aksl_atof(tmp->data, &self->drag_z));
|
||||
CATCH(errctx, akgl_get_property("physics.gravity.x", &tmp, "0.0"));
|
||||
CATCH(errctx, aksl_atof(tmp->data, &self->gravity_x));
|
||||
CATCH(errctx, akgl_get_property("physics.gravity.y", &tmp, "0.0"));
|
||||
CATCH(errctx, aksl_atof(tmp->data, &self->gravity_y));
|
||||
CATCH(errctx, akgl_get_property("physics.gravity.z", &tmp, "0.0"));
|
||||
CATCH(errctx, aksl_atof(tmp->data, &self->gravity_z));
|
||||
CATCH(errctx, akgl_get_property("physics.drag.x", &tmp, "0.0"));
|
||||
CATCH(errctx, aksl_atof(tmp->data, &self->drag_x));
|
||||
CATCH(errctx, akgl_get_property("physics.drag.y", &tmp, "0.0"));
|
||||
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(e) {
|
||||
} FINISH(e, true);
|
||||
|
||||
SUCCEED_RETURN(e);
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_simulate(akgl_PhysicsBackend *self, akgl_Iterator *opflags)
|
||||
akerr_ErrorContext *akgl_physics_simulate(akgl_PhysicsBackend *self, akgl_Iterator *opflags)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_Iterator defflags = {
|
||||
.flags = 0,
|
||||
.layerid = 0
|
||||
};
|
||||
SDL_Time curtime = SDL_GetTicksNS();
|
||||
float32_t dt = (float32_t)(curtime - self->gravity_time) / (float32_t)AKGL_TIME_ONESEC_NS;
|
||||
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(e, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(e, self->move, AKERR_NULLPOINTER, "self->move");
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, self->move, AKERR_NULLPOINTER, "self->move");
|
||||
|
||||
// Reading the elapsed time requires self, so it cannot be hoisted above
|
||||
// the null check.
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
for ( int i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
|
||||
actor = &HEAP_ACTOR[i];
|
||||
actor = &akgl_heap_actors[i];
|
||||
if ( actor->refcount == 0 ) {
|
||||
continue;
|
||||
}
|
||||
if ( actor->parent != NULL ) {
|
||||
// Children don't move independently of their parents, they just have an offset
|
||||
actor->x = actor->parent->x + actor->vx;
|
||||
actor->y = actor->parent->y + actor->vy;
|
||||
actor->z = actor->parent->z + actor->vz;
|
||||
continue;
|
||||
} else if ( actor->basechar == NULL ) {
|
||||
continue;
|
||||
@@ -159,35 +203,95 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_physics_simulate(akgl_PhysicsBackend *se
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// thrust is a function of acceleration on a given axis
|
||||
if ( AKGL_BITMASK_HAS(actor->state, AKGL_ACTOR_STATE_MOVING_LEFT) ||
|
||||
AKGL_BITMASK_HAS(actor->state, AKGL_ACTOR_STATE_MOVING_RIGHT) ) {
|
||||
actor->tx += actor->basechar->ax * dt;
|
||||
AKGL_BITMASK_HAS(actor->state, AKGL_ACTOR_STATE_MOVING_RIGHT) ) {
|
||||
actor->tx += actor->ax * dt;
|
||||
}
|
||||
if ( AKGL_BITMASK_HAS(actor->state, AKGL_ACTOR_STATE_MOVING_UP) ||
|
||||
AKGL_BITMASK_HAS(actor->state, AKGL_ACTOR_STATE_MOVING_DOWN) ) {
|
||||
actor->ty += actor->basechar->ay * dt;
|
||||
AKGL_BITMASK_HAS(actor->state, AKGL_ACTOR_STATE_MOVING_DOWN) ) {
|
||||
actor->ty += actor->ay * dt;
|
||||
}
|
||||
// velocity equals thrust unless thrust exceeds max speed
|
||||
if ( actor->tx > actor->basechar->sx ) {
|
||||
actor->vx = actor->basechar->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->vx = actor->tx;
|
||||
actor->tx = 0.0f;
|
||||
}
|
||||
if ( actor->ty > actor->basechar->sy ) {
|
||||
actor->vy = actor->basechar->sy;
|
||||
if ( actor->sy != 0.0f ) {
|
||||
overshoot += (actor->ty / actor->sy) * (actor->ty / actor->sy);
|
||||
} else {
|
||||
actor->vy = actor->ty;
|
||||
actor->ty = 0.0f;
|
||||
}
|
||||
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(e, actor->movementlogicfunc(actor, dt));
|
||||
PASS(e, self->gravity(self, actor, dt));
|
||||
PASS(e, self->move(self, actor, dt));
|
||||
CATCH(errctx, actor->movementlogicfunc(actor, dt));
|
||||
PASS(errctx, self->gravity(self, actor, dt));
|
||||
|
||||
// Counteract velocity with atmospheric drag
|
||||
if ( self->drag_x != 0 ) {
|
||||
actor->ex -= actor->ex * self->drag_x * dt;
|
||||
}
|
||||
if ( self->drag_y != 0 ) {
|
||||
actor->ey -= actor->ey * self->drag_y * dt;
|
||||
}
|
||||
if ( self->drag_z != 0 ) {
|
||||
actor->ez -= actor->ez * self->drag_z * dt;
|
||||
}
|
||||
|
||||
actor->vx = actor->ex + actor->tx;
|
||||
actor->vy = actor->ey + actor->ty;
|
||||
actor->vz = actor->ez + actor->tz;
|
||||
|
||||
PASS(errctx, self->move(self, actor, dt));
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} HANDLE(e, AKGL_ERR_LOGICINTERRUPT) {
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE(errctx, AKGL_ERR_LOGICINTERRUPT) {
|
||||
// noop
|
||||
} FINISH(e, true);
|
||||
} FINISH(errctx, true);
|
||||
}
|
||||
self->gravity_time = curtime;
|
||||
SUCCEED_RETURN(e);
|
||||
self->gravity_time = curtime;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_physics_factory(akgl_PhysicsBackend *self, akgl_String *type)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, type, AKERR_NULLPOINTER, "type");
|
||||
|
||||
if ( strncmp(type->data, "null", 4) == 0) {
|
||||
PASS(errctx, akgl_physics_init_null(self));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
if ( strncmp(type->data, "arcade", 6) == 0) {
|
||||
PASS(errctx, akgl_physics_init_arcade(self));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
FAIL_RETURN(errctx, AKERR_KEY, "Invalid physics engine %s", type->data);
|
||||
}
|
||||
|
||||
199
src/registry.c
199
src/registry.c
@@ -1,4 +1,10 @@
|
||||
/**
|
||||
* @file registry.c
|
||||
* @brief Implements the registry subsystem.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <string.h>
|
||||
#include <akerror.h>
|
||||
#include <jansson.h>
|
||||
|
||||
@@ -19,7 +25,35 @@ SDL_PropertiesID AKGL_REGISTRY_MUSIC = 0;
|
||||
SDL_PropertiesID AKGL_REGISTRY_FONT = 0;
|
||||
SDL_PropertiesID AKGL_REGISTRY_PROPERTIES = 0;
|
||||
|
||||
akerr_ErrorContext *akgl_registry_init()
|
||||
/**
|
||||
* @brief Create one registry, replacing whatever set was held under it.
|
||||
*
|
||||
* All eight initializers go through this so they behave the same way on a
|
||||
* second call. Only akgl_registry_init_actor used to destroy the old set --
|
||||
* akgl_heap_init_actor's counterpart, so resetting actors between levels was
|
||||
* the one path that did not leak -- and the other seven abandoned their
|
||||
* SDL_PropertiesID every time they were called again.
|
||||
*
|
||||
* @param dest The registry handle to (re)create. Required.
|
||||
* @param what What it holds, for the failure message. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p dest is `NULL`, or if SDL cannot create the
|
||||
* property set.
|
||||
*/
|
||||
static akerr_ErrorContext *registry_create(SDL_PropertiesID *dest, const char *what)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
if ( *dest != 0 ) {
|
||||
SDL_DestroyProperties(*dest);
|
||||
*dest = 0;
|
||||
}
|
||||
*dest = SDL_CreateProperties();
|
||||
FAIL_ZERO_RETURN(errctx, *dest, AKERR_NULLPOINTER, "Error initializing %s registry", what);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_registry_init(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
ATTEMPT {
|
||||
@@ -30,54 +64,52 @@ akerr_ErrorContext *akgl_registry_init()
|
||||
CATCH(errctx, akgl_registry_init_actor_state_strings());
|
||||
CATCH(errctx, akgl_registry_init_font());
|
||||
CATCH(errctx, akgl_registry_init_music());
|
||||
// Missing until 0.5.0. Without it AKGL_REGISTRY_PROPERTIES stayed 0,
|
||||
// akgl_set_property was a silent no-op, akgl_get_property always
|
||||
// returned the caller's default, and akgl_physics_init_arcade and
|
||||
// akgl_render_2d_init quietly ignored their configuration. Only callers
|
||||
// going through akgl_game_init, which calls it separately, escaped that.
|
||||
CATCH(errctx, akgl_registry_init_properties());
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_registry_init_actor()
|
||||
akerr_ErrorContext *akgl_registry_init_actor(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
if ( AKGL_REGISTRY_ACTOR != 0 ) {
|
||||
SDL_DestroyProperties(AKGL_REGISTRY_ACTOR);
|
||||
}
|
||||
AKGL_REGISTRY_ACTOR = SDL_CreateProperties();
|
||||
FAIL_ZERO_RETURN(errctx, AKGL_REGISTRY_ACTOR, AKERR_NULLPOINTER, "Error initializing actor registry");
|
||||
PASS(errctx, registry_create(&AKGL_REGISTRY_ACTOR, "actor"));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_init_font()
|
||||
akerr_ErrorContext *akgl_registry_init_font(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
AKGL_REGISTRY_FONT = SDL_CreateProperties();
|
||||
FAIL_ZERO_RETURN(errctx, AKGL_REGISTRY_FONT, AKERR_NULLPOINTER, "Error initializing font registry");
|
||||
PASS(errctx, registry_create(&AKGL_REGISTRY_FONT, "font"));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_registry_init_music()
|
||||
akerr_ErrorContext *akgl_registry_init_music(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
AKGL_REGISTRY_MUSIC = SDL_CreateProperties();
|
||||
FAIL_ZERO_RETURN(errctx, AKGL_REGISTRY_MUSIC, AKERR_NULLPOINTER, "Error initializing music registry");
|
||||
PASS(errctx, registry_create(&AKGL_REGISTRY_MUSIC, "music"));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_registry_init_properties()
|
||||
akerr_ErrorContext *akgl_registry_init_properties(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
AKGL_REGISTRY_PROPERTIES = SDL_CreateProperties();
|
||||
FAIL_ZERO_RETURN(errctx, AKGL_REGISTRY_PROPERTIES, AKERR_NULLPOINTER, "Error initializing properties registry");
|
||||
PASS(errctx, registry_create(&AKGL_REGISTRY_PROPERTIES, "properties"));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_registry_init_actor_state_strings()
|
||||
akerr_ErrorContext *akgl_registry_init_actor_state_strings(void)
|
||||
{
|
||||
int i = 0;
|
||||
int flag = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
AKGL_REGISTRY_ACTOR_STATE_STRINGS = SDL_CreateProperties();
|
||||
FAIL_ZERO_RETURN(errctx, AKGL_REGISTRY_ACTOR_STATE_STRINGS, AKERR_NULLPOINTER, "Error initializing actor state strings registry");
|
||||
PASS(errctx, registry_create(&AKGL_REGISTRY_ACTOR_STATE_STRINGS, "actor state strings"));
|
||||
for ( i = 0 ; i < AKGL_ACTOR_MAX_STATES; i++ ) {
|
||||
flag = (1 << i);
|
||||
SDL_SetNumberProperty(
|
||||
@@ -88,42 +120,46 @@ akerr_ErrorContext *akgl_registry_init_actor_state_strings()
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_registry_init_sprite()
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
AKGL_REGISTRY_SPRITE = SDL_CreateProperties();
|
||||
FAIL_ZERO_RETURN(errctx, AKGL_REGISTRY_SPRITE, AKERR_NULLPOINTER, "Error initializing sprite registry");
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_registry_init_spritesheet()
|
||||
akerr_ErrorContext *akgl_registry_init_sprite(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
AKGL_REGISTRY_SPRITESHEET = SDL_CreateProperties();
|
||||
FAIL_ZERO_RETURN(errctx, AKGL_REGISTRY_SPRITESHEET, AKERR_NULLPOINTER, "Error initializing spritesheet registry");
|
||||
PASS(errctx, registry_create(&AKGL_REGISTRY_SPRITE, "sprite"));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_registry_init_character()
|
||||
akerr_ErrorContext *akgl_registry_init_spritesheet(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
AKGL_REGISTRY_CHARACTER = SDL_CreateProperties();
|
||||
FAIL_ZERO_RETURN(errctx, AKGL_REGISTRY_CHARACTER, AKERR_NULLPOINTER, "Error initializing character registry");
|
||||
PASS(errctx, registry_create(&AKGL_REGISTRY_SPRITESHEET, "spritesheet"));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_load_properties(char *fname)
|
||||
akerr_ErrorContext *akgl_registry_init_character(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, registry_create(&AKGL_REGISTRY_CHARACTER, "character"));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_registry_load_properties(char *fname)
|
||||
{
|
||||
json_t *json = NULL;
|
||||
json_t *props = NULL;
|
||||
const char *pkey = NULL;
|
||||
json_t *pvalue = NULL;
|
||||
|
||||
|
||||
json_error_t error;
|
||||
akgl_String *tmpstr;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, fname, AKERR_NULLPOINTER, "null filename");
|
||||
// One ATTEMPT around the whole function, rather than one for the parse and
|
||||
// a bare loop after it. `props` is a borrowed reference into `json` and is
|
||||
// read by the loop, so the document cannot be released until the loop is
|
||||
// done -- and with the loop outside the block, every path out of it leaked
|
||||
// the document. The per-property ATTEMPT stays nested inside the loop,
|
||||
// which is what AGENTS.md requires: a CATCH written directly in a loop
|
||||
// breaks the loop rather than leaving the function.
|
||||
ATTEMPT {
|
||||
SDL_Log("Loading from %s", fname);
|
||||
json = json_load_file(fname, 0, &error);
|
||||
@@ -136,52 +172,81 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_registry_load_properties(char *fname)
|
||||
error.line,
|
||||
error.text);
|
||||
CATCH(errctx, akgl_get_json_object_value(json, "properties", &props));
|
||||
|
||||
json_object_foreach(props, pkey, pvalue) {
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_string(&tmpstr));
|
||||
CATCH(errctx, akgl_get_json_string_value(props, (char *)pkey, &tmpstr));
|
||||
SDL_SetStringProperty(AKGL_REGISTRY_PROPERTIES, pkey, tmpstr->data);
|
||||
SDL_Log("Set property %s = %s", pkey, tmpstr->data);
|
||||
CATCH(errctx, akgl_heap_release_string(tmpstr));
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
}
|
||||
} CLEANUP {
|
||||
// Every value was copied into the property store on the way past, so
|
||||
// nothing outlives the document.
|
||||
if ( json != NULL ) {
|
||||
json_decref(json);
|
||||
json = NULL;
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
json_object_foreach(props, pkey, pvalue) {
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_string(&tmpstr));
|
||||
CATCH(errctx, akgl_get_json_string_value(props, (char *)pkey, &tmpstr));
|
||||
SDL_SetStringProperty(AKGL_REGISTRY_PROPERTIES, pkey, tmpstr->data);
|
||||
SDL_Log("Set property %s = %s", pkey, tmpstr->data);
|
||||
CATCH(errctx, akgl_heap_release_string(tmpstr));
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
}
|
||||
SDL_Log("Properties loaded");
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_set_property(char *name, char *src)
|
||||
akerr_ErrorContext *akgl_set_property(char *name, char *value)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
FAIL_ZERO_RETURN(e, name, AKERR_NULLPOINTER, "NULL char *");
|
||||
FAIL_ZERO_RETURN(e, src, AKERR_NULLPOINTER, "NULL char *");
|
||||
SDL_SetStringProperty(AKGL_REGISTRY_PROPERTIES, name, src);
|
||||
SUCCEED_RETURN(e);
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "NULL char *");
|
||||
FAIL_ZERO_RETURN(errctx, value, AKERR_NULLPOINTER, "NULL char *");
|
||||
SDL_SetStringProperty(AKGL_REGISTRY_PROPERTIES, name, value);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_get_property(char *name, akgl_String **dest, char *def)
|
||||
akerr_ErrorContext *akgl_get_property(char *name, akgl_String **dest, char *def)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
FAIL_ZERO_RETURN(e, name, AKERR_NULLPOINTER, "NULL char *");
|
||||
FAIL_ZERO_RETURN(e, dest, AKERR_NULLPOINTER, "NULL akgl_String *");
|
||||
const char *value = NULL;
|
||||
size_t valuelen = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "NULL char *");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL akgl_String *");
|
||||
ATTEMPT {
|
||||
if ( *dest == NULL ) {
|
||||
CATCH(e, akgl_heap_next_string(dest));
|
||||
CATCH(errctx, akgl_heap_next_string(dest));
|
||||
}
|
||||
CATCH(e,
|
||||
aksl_memcpy(
|
||||
(*dest)->data,
|
||||
(void *)SDL_GetStringProperty(AKGL_REGISTRY_PROPERTIES, (const char *)name, (const char *)def),
|
||||
AKGL_MAX_STRING_LENGTH)
|
||||
);
|
||||
// Copy the value's own length, not the destination's capacity. What
|
||||
// SDL hands back is its strdup of the value -- four bytes for "0.0" --
|
||||
// so copying a fixed AKGL_MAX_STRING_LENGTH read up to 4 KiB past the
|
||||
// end of somebody else's allocation on every call. It returned garbage
|
||||
// past the terminator, and it would have faulted outright on a value
|
||||
// that happened to sit at the end of a page.
|
||||
value = SDL_GetStringProperty(AKGL_REGISTRY_PROPERTIES, (const char *)name, (const char *)def);
|
||||
// An unset property with a NULL default still reports AKERR_NULLPOINTER,
|
||||
// which is what the header has always promised. It used to arrive from
|
||||
// inside aksl_memcpy; now it is raised here, before anything is measured.
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
value,
|
||||
AKERR_NULLPOINTER,
|
||||
"Property %s is not set and no default was given",
|
||||
name);
|
||||
valuelen = strlen(value);
|
||||
FAIL_NONZERO_BREAK(
|
||||
errctx,
|
||||
(valuelen >= AKGL_MAX_STRING_LENGTH),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Property %s is %zu bytes, which does not fit an akgl_String (%d)",
|
||||
name,
|
||||
valuelen,
|
||||
AKGL_MAX_STRING_LENGTH);
|
||||
CATCH(errctx, aksl_memcpy((*dest)->data, (void *)value, (valuelen + 1)));
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
|
||||
141
src/renderer.c
141
src/renderer.c
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @file renderer.c
|
||||
* @brief Implements the renderer subsystem.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <akgl/renderer.h>
|
||||
@@ -9,128 +14,154 @@
|
||||
#include <akerror.h>
|
||||
#include <akstdlib.h>
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_render_init2d(akgl_RenderBackend *self)
|
||||
akerr_ErrorContext *akgl_render_2d_init(akgl_RenderBackend *self)
|
||||
{
|
||||
akgl_String *width = NULL;
|
||||
akgl_String *height = NULL;
|
||||
int screenwidth;
|
||||
int screenheight;
|
||||
PREPARE_ERROR(e);
|
||||
FAIL_ZERO_RETURN(e, self, AKERR_NULLPOINTER, "self");
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
|
||||
// The two pooled strings come back in CLEANUP. Released after both parses,
|
||||
// as they used to be, a non-numeric game.screenwidth returned past them and
|
||||
// leaked two of the pool's 256 entries.
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_get_property("game.screenwidth", &width, "0"));
|
||||
CATCH(errctx, akgl_get_property("game.screenheight", &height, "0"));
|
||||
CATCH(errctx, aksl_atoi(width->data, &screenwidth));
|
||||
CATCH(errctx, aksl_atoi(height->data, &screenheight));
|
||||
SDL_Log("Initializing screen (%sx%s = %dx%d)", width->data, height->data, screenwidth, screenheight);
|
||||
} CLEANUP {
|
||||
if ( width != NULL ) {
|
||||
IGNORE(akgl_heap_release_string(width));
|
||||
width = NULL;
|
||||
}
|
||||
if ( height != NULL ) {
|
||||
IGNORE(akgl_heap_release_string(height));
|
||||
height = NULL;
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
PASS(e, akgl_get_property("game.screenwidth", &width, "0"));
|
||||
PASS(e, akgl_get_property("game.screenheight", &height, "0"));
|
||||
PASS(e, aksl_atoi(width->data, &screenwidth));
|
||||
PASS(e, aksl_atoi(height->data, &screenheight));
|
||||
SDL_Log("Initializing screen (%sx%s = %dx%d)", width->data, height->data, screenwidth, screenheight);
|
||||
PASS(e, akgl_heap_release_string(width));
|
||||
PASS(e, akgl_heap_release_string(height));
|
||||
|
||||
FAIL_ZERO_RETURN(
|
||||
e,
|
||||
SDL_CreateWindowAndRenderer(game.uri, screenwidth, screenheight, 0, &window, &self->sdl_renderer),
|
||||
errctx,
|
||||
SDL_CreateWindowAndRenderer(akgl_game.uri, screenwidth, screenheight, 0, &akgl_window, &self->sdl_renderer),
|
||||
AKGL_ERR_SDL,
|
||||
"Couldn't create window/renderer: %s",
|
||||
SDL_GetError());
|
||||
|
||||
camera.x = 0;
|
||||
camera.y = 0;
|
||||
camera.w = screenwidth;
|
||||
camera.h = screenheight;
|
||||
|
||||
akgl_camera->x = 0;
|
||||
akgl_camera->y = 0;
|
||||
akgl_camera->w = screenwidth;
|
||||
akgl_camera->h = screenheight;
|
||||
|
||||
PASS(errctx, akgl_render_2d_bind(self));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_render_2d_bind(akgl_RenderBackend *self)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
|
||||
// Deliberately does not touch self->sdl_renderer: a host that owns its own
|
||||
// window has already put one there, and this is the only way it gets a
|
||||
// usable backend without libakgl creating a second window.
|
||||
self->shutdown = &akgl_render_2d_shutdown;
|
||||
self->frame_start = &akgl_render_2d_frame_start;
|
||||
self->frame_end = &akgl_render_2d_frame_end;
|
||||
self->draw_texture = &akgl_render_2d_draw_texture;
|
||||
self->draw_mesh = &akgl_render_2d_draw_mesh;
|
||||
self->draw_world = &akgl_render_2d_draw_world;
|
||||
SUCCEED_RETURN(e);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_render_2d_shutdown(akgl_RenderBackend *self)
|
||||
akerr_ErrorContext *akgl_render_2d_shutdown(akgl_RenderBackend *self)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
SUCCEED_RETURN(e);
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_render_2d_frame_start(akgl_RenderBackend *self)
|
||||
akerr_ErrorContext *akgl_render_2d_frame_start(akgl_RenderBackend *self)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
FAIL_ZERO_RETURN(e, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
|
||||
SDL_SetRenderDrawColor(self->sdl_renderer, 0, 0, 0, 255);
|
||||
SDL_RenderClear(self->sdl_renderer);
|
||||
SUCCEED_RETURN(e);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_render_2d_frame_end(akgl_RenderBackend *self)
|
||||
akerr_ErrorContext *akgl_render_2d_frame_end(akgl_RenderBackend *self)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
FAIL_ZERO_RETURN(e, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
|
||||
SDL_RenderPresent(self->sdl_renderer);
|
||||
SUCCEED_RETURN(e);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_render_2d_draw_texture(akgl_RenderBackend *self, SDL_Texture *texture, SDL_FRect *src, SDL_FRect *dest, double angle, SDL_FPoint *center, SDL_FlipMode flip)
|
||||
akerr_ErrorContext *akgl_render_2d_draw_texture(akgl_RenderBackend *self, SDL_Texture *texture, SDL_FRect *src, SDL_FRect *dest, double angle, SDL_FPoint *center, SDL_FlipMode flip)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
FAIL_ZERO_RETURN(e, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(e, texture, AKERR_NULLPOINTER, "texture");
|
||||
//FAIL_ZERO_RETURN(e, src, AKERR_NULLPOINTER, "src");
|
||||
//FAIL_ZERO_RETURN(e, dest, AKERR_NULLPOINTER, "dest");
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, texture, AKERR_NULLPOINTER, "texture");
|
||||
//FAIL_ZERO_RETURN(errctx, src, AKERR_NULLPOINTER, "src");
|
||||
//FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
|
||||
if ( angle != 0 ) {
|
||||
FAIL_ZERO_RETURN(e, center, AKERR_NULLPOINTER, "center");
|
||||
FAIL_ZERO_RETURN(errctx, center, AKERR_NULLPOINTER, "center");
|
||||
FAIL_ZERO_RETURN(
|
||||
e,
|
||||
errctx,
|
||||
SDL_RenderTextureRotated(self->sdl_renderer, texture, src, dest, angle, center, flip),
|
||||
AKERR_NULLPOINTER, "%s", SDL_GetError()
|
||||
);
|
||||
} else {
|
||||
FAIL_ZERO_RETURN(
|
||||
e,
|
||||
errctx,
|
||||
SDL_RenderTexture(self->sdl_renderer, texture, src, dest),
|
||||
AKERR_NULLPOINTER, "%s", SDL_GetError()
|
||||
);
|
||||
}
|
||||
SUCCEED_RETURN(e);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_render_2d_draw_mesh(akgl_RenderBackend *self)
|
||||
akerr_ErrorContext *akgl_render_2d_draw_mesh(akgl_RenderBackend *self)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
FAIL_RETURN(e, AKERR_API, "Not implemented");
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_RETURN(errctx, AKERR_API, "Not implemented");
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_render_2d_draw_world(akgl_RenderBackend *self, akgl_Iterator *opflags)
|
||||
akerr_ErrorContext *akgl_render_2d_draw_world(akgl_RenderBackend *self, akgl_Iterator *opflags)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_Iterator defflags;
|
||||
SDL_Time curTime = SDL_GetTicksNS();
|
||||
akgl_Actor *actor = NULL;
|
||||
int j = 0;
|
||||
|
||||
FAIL_ZERO_RETURN(e, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
|
||||
if ( opflags == NULL ) {
|
||||
opflags = &defflags;
|
||||
PASS(e, aksl_memset((void *)opflags, 0x00, sizeof(akgl_Iterator)));
|
||||
PASS(errctx, aksl_memset((void *)opflags, 0x00, sizeof(akgl_Iterator)));
|
||||
}
|
||||
for ( int i = 0; i < AKGL_TILEMAP_MAX_LAYERS ; i++ ) {
|
||||
if ( i < gamemap.numlayers ) {
|
||||
PASS(e, akgl_tilemap_draw((akgl_Tilemap *)&gamemap, &camera, i));
|
||||
if ( i < akgl_gamemap->numlayers ) {
|
||||
PASS(errctx, akgl_tilemap_draw(akgl_gamemap, akgl_camera, i));
|
||||
}
|
||||
for ( int j = 0; j < AKGL_MAX_HEAP_ACTOR ; j++ ) {
|
||||
actor = &HEAP_ACTOR[j];
|
||||
actor = &akgl_heap_actors[j];
|
||||
if ( actor->refcount == 0 ) {
|
||||
continue;
|
||||
}
|
||||
if ( actor->layer != i ) {
|
||||
continue;
|
||||
}
|
||||
PASS(e, actor->renderfunc(actor));
|
||||
PASS(errctx, actor->renderfunc(actor));
|
||||
}
|
||||
}
|
||||
|
||||
SUCCEED_RETURN(e);
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
181
src/sprite.c
181
src/sprite.c
@@ -1,8 +1,14 @@
|
||||
/**
|
||||
* @file sprite.c
|
||||
* @brief Implements the sprite subsystem.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
#include <string.h>
|
||||
#include <jansson.h>
|
||||
#include <akerror.h>
|
||||
#include <akstdlib.h>
|
||||
|
||||
#include <libgen.h>
|
||||
#include <akgl/game.h>
|
||||
@@ -14,13 +20,13 @@
|
||||
#include <akgl/iterator.h>
|
||||
#include <akgl/util.h>
|
||||
|
||||
akerr_ErrorContext *akgl_sprite_sheet_coords_for_frame(akgl_Sprite *self, SDL_FRect *srccoords, uint8_t frameid)
|
||||
akerr_ErrorContext *akgl_spritesheet_coords_for_frame(akgl_Sprite *self, SDL_FRect *srccoords, uint8_t frameid)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(e, self, AKERR_NULLPOINTER, "NULL sprite");
|
||||
FAIL_ZERO_RETURN(e, srccoords, AKERR_NULLPOINTER, "NULL SDL_Rect");
|
||||
FAIL_ZERO_RETURN(e, self->sheet, AKERR_NULLPOINTER, "NULL spritesheet");
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "NULL sprite");
|
||||
FAIL_ZERO_RETURN(errctx, srccoords, AKERR_NULLPOINTER, "NULL SDL_Rect");
|
||||
FAIL_ZERO_RETURN(errctx, self->sheet, AKERR_NULLPOINTER, "NULL spritesheet");
|
||||
|
||||
srccoords->x = self->width * self->frameids[frameid];
|
||||
if ( srccoords->x >= self->sheet->texture->w ) {
|
||||
@@ -32,10 +38,37 @@ akerr_ErrorContext *akgl_sprite_sheet_coords_for_frame(akgl_Sprite *self, SDL_FR
|
||||
srccoords->w = self->width;
|
||||
srccoords->h = self->height;
|
||||
|
||||
SUCCEED_RETURN(e);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
static akerr_ErrorContext *akgl_sprite_load_json_spritesheet(json_t *json, akgl_SpriteSheet **sheet, char *relative_path)
|
||||
/**
|
||||
* @brief Find or load the spritesheet a sprite definition names.
|
||||
*
|
||||
* Resolves the `spritesheet.filename` path relative to the sprite definition's
|
||||
* own directory, then looks that resolved path up in #AKGL_REGISTRY_SPRITESHEET.
|
||||
* A hit is reused as is -- which is what keeps ten sprites cut from one image
|
||||
* down to one texture -- and a miss claims a sheet from the pool and loads it,
|
||||
* reading `frame_width` and `frame_height` only in that case.
|
||||
*
|
||||
* @param json The parsed sprite document, whose `spritesheet` object
|
||||
* this reads. Required in practice; not checked.
|
||||
* @param sheet Receives the sheet, whether found or freshly loaded.
|
||||
* Required; not checked. On the reuse path the sheet's
|
||||
* reference count is *not* incremented, so the sprite
|
||||
* borrows it.
|
||||
* @param relative_path Directory to resolve the image path against -- the
|
||||
* directory holding the sprite JSON. Required.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_KEY If the document has no `spritesheet` object, or that object
|
||||
* has no `filename` -- or, on the load path, no `frame_width` or
|
||||
* `frame_height`.
|
||||
* @throws AKERR_TYPE If one of those has the wrong JSON type.
|
||||
* @throws AKERR_OUTOFBOUNDS If the joined path is too long for a pooled string.
|
||||
* @throws ENOENT If the image path does not exist.
|
||||
* @throws AKGL_ERR_SDL If the image cannot be decoded.
|
||||
* @throws AKGL_ERR_HEAP If the spritesheet or string pool is exhausted.
|
||||
*/
|
||||
static akerr_ErrorContext *sprite_load_json_spritesheet(json_t *json, akgl_SpriteSheet **sheet, char *relative_path)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
json_t *spritesheet_json = NULL;
|
||||
@@ -58,7 +91,7 @@ static akerr_ErrorContext *akgl_sprite_load_json_spritesheet(json_t *json, akgl_
|
||||
if ( *sheet == NULL ) {
|
||||
CATCH(errctx, akgl_heap_next_spritesheet(sheet));
|
||||
CATCH(errctx, akgl_get_json_integer_value((json_t *)spritesheet_json, "frame_width", &ss_frame_width));
|
||||
CATCH(errctx, akgl_get_json_integer_value((json_t *)spritesheet_json, "frame_height", &ss_frame_width));
|
||||
CATCH(errctx, akgl_get_json_integer_value((json_t *)spritesheet_json, "frame_height", &ss_frame_height));
|
||||
CATCH(errctx,
|
||||
akgl_spritesheet_initialize(
|
||||
(akgl_SpriteSheet *)*sheet,
|
||||
@@ -72,10 +105,10 @@ static akerr_ErrorContext *akgl_sprite_load_json_spritesheet(json_t *json, akgl_
|
||||
IGNORE(akgl_heap_release_string(tmpstr));
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
|
||||
akerr_ErrorContext *akgl_sprite_load_json(char *filename)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
@@ -85,18 +118,28 @@ akerr_ErrorContext *akgl_sprite_load_json(char *filename)
|
||||
akgl_Sprite *obj = NULL;
|
||||
akgl_SpriteSheet *sheet = NULL;
|
||||
akgl_String *spritename = NULL;
|
||||
//string *tmpstr = NULL;
|
||||
akgl_String *filename_copy = NULL;
|
||||
int i = 0;
|
||||
|
||||
int framecount = 0;
|
||||
int frameid = 0;
|
||||
int jsonint = 0;
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, filename, AKERR_NULLPOINTER, "Received null filename");
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_sprite(&obj));
|
||||
CATCH(errctx, akgl_heap_next_string(&filename_copy));
|
||||
FAIL_NONZERO_BREAK(
|
||||
errctx,
|
||||
(strlen(filename) >= AKGL_MAX_STRING_LENGTH),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Sprite filename exceeds temporary string capacity"
|
||||
);
|
||||
SDL_strlcpy(filename_copy->data, filename, AKGL_MAX_STRING_LENGTH);
|
||||
//CATCH(errctx, akgl_heap_next_string(&tmpstr));
|
||||
//CATCH(errctx, akgl_string_initialize(tmpstr, NULL));
|
||||
CATCH(errctx, akgl_heap_next_string(&spritename));
|
||||
CATCH(errctx, akgl_string_initialize(spritename, NULL));
|
||||
|
||||
//SDL_snprintf((char *)&tmpstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), filename);
|
||||
json = (json_t *)json_load_file(filename, 0, &error);
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
@@ -104,8 +147,8 @@ akerr_ErrorContext *akgl_sprite_load_json(char *filename)
|
||||
AKERR_NULLPOINTER,
|
||||
"Error while loading sprite from %s on line %d: %s", filename, error.line, error.text
|
||||
);
|
||||
|
||||
CATCH(errctx, akgl_sprite_load_json_spritesheet((json_t *)json, &sheet, dirname(filename)));
|
||||
|
||||
CATCH(errctx, sprite_load_json_spritesheet((json_t *)json, &sheet, dirname(filename_copy->data)));
|
||||
CATCH(errctx, akgl_get_json_string_value((json_t *)json, "name", &spritename));
|
||||
CATCH(errctx,
|
||||
akgl_sprite_initialize(
|
||||
@@ -113,25 +156,97 @@ akerr_ErrorContext *akgl_sprite_load_json(char *filename)
|
||||
spritename->data,
|
||||
(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_ONESEC_MS;
|
||||
|
||||
// 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));
|
||||
|
||||
|
||||
CATCH(errctx, akgl_get_json_array_value((json_t *)json, "frames", &frames));
|
||||
obj->frames = json_array_size((json_t *)frames);
|
||||
for ( i = 0 ; i < obj->frames; i++ ) {
|
||||
CATCH(errctx, akgl_get_json_array_index_integer((json_t *)frames, i, (uint32_t *)&obj->frameids[i]));
|
||||
// Bounded before anything is written. frameids is
|
||||
// AKGL_SPRITE_MAX_FRAMES bytes, and this loop used to take its count
|
||||
// straight from the document -- so a definition with seventeen frames
|
||||
// wrote past the array, past the rest of akgl_Sprite, and into the
|
||||
// neighbouring pool slot.
|
||||
framecount = (int)json_array_size((json_t *)frames);
|
||||
FAIL_NONZERO_BREAK(
|
||||
errctx,
|
||||
(framecount > AKGL_SPRITE_MAX_FRAMES),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Sprite %s declares %d frames; the maximum is %d",
|
||||
(char *)&obj->name,
|
||||
framecount,
|
||||
AKGL_SPRITE_MAX_FRAMES
|
||||
);
|
||||
obj->frames = framecount;
|
||||
for ( i = 0 ; i < framecount; i++ ) {
|
||||
// Read into an int and narrow deliberately. The old form wrote
|
||||
// through a uint32_t * cast of a uint8_t *, so every element write
|
||||
// touched four bytes; it only appeared to work because the next
|
||||
// iteration overwrote the spill and the last one landed in the
|
||||
// struct's alignment padding.
|
||||
CATCH(errctx, akgl_get_json_array_index_integer((json_t *)frames, i, &frameid));
|
||||
FAIL_NONZERO_BREAK(
|
||||
errctx,
|
||||
((frameid < 0) || (frameid > UINT8_MAX)),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Sprite %s frame %d is %d; frame numbers are 0..%d",
|
||||
(char *)&obj->name,
|
||||
i,
|
||||
frameid,
|
||||
UINT8_MAX
|
||||
);
|
||||
obj->frameids[i] = (uint8_t)frameid;
|
||||
}
|
||||
} CLEANUP {
|
||||
// The sprite copies every field it wants out of the document and the
|
||||
// sheet is found by resolved path, so nothing here points into the
|
||||
// parsed tree once this block runs. Released on both paths.
|
||||
if ( json != NULL ) {
|
||||
json_decref(json);
|
||||
json = NULL;
|
||||
}
|
||||
if ( errctx != NULL && errctx->status != 0 ) {
|
||||
IGNORE(akgl_heap_release_sprite(obj));
|
||||
IGNORE(akgl_heap_release_spritesheet(sheet));
|
||||
}
|
||||
IGNORE(akgl_heap_release_string(spritename));
|
||||
IGNORE(akgl_heap_release_string(filename_copy));
|
||||
//IGNORE(akgl_heap_release_string(tmpstr));
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
@@ -144,9 +259,12 @@ akerr_ErrorContext *akgl_sprite_initialize(akgl_Sprite *spr, char *name, akgl_Sp
|
||||
FAIL_ZERO_RETURN(errctx, spr, AKERR_NULLPOINTER, "Null sprite reference");
|
||||
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "Empty sprite name");
|
||||
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,
|
||||
@@ -169,14 +287,13 @@ akerr_ErrorContext *akgl_spritesheet_initialize(akgl_SpriteSheet *sheet, int spr
|
||||
memset(sheet, 0x00, sizeof(akgl_SpriteSheet));
|
||||
|
||||
//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);
|
||||
|
||||
//snprintf((char *)&tmpstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), filename);
|
||||
sheet->texture = IMG_LoadTexture(renderer.sdl_renderer, filename);
|
||||
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());
|
||||
|
||||
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_SetPointerProperty(AKGL_REGISTRY_SPRITESHEET, (char *)sheet->name, (void *)sheet),
|
||||
|
||||
@@ -1,30 +1,48 @@
|
||||
/**
|
||||
* @file staticstring.c
|
||||
* @brief Implements the staticstring subsystem.
|
||||
*/
|
||||
|
||||
#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);
|
||||
} else {
|
||||
memset(&obj->data, 0x00, sizeof(akgl_String));
|
||||
}
|
||||
obj->refcount = 1;
|
||||
SUCCEED_RETURN(errctx);
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "Attempted to initialize NULL string reference");
|
||||
if ( init != NULL ) {
|
||||
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
|
||||
// from the start of the buffer ran four bytes past the end of the
|
||||
// object -- into the next pool slot's refcount, which is what makes a
|
||||
// free slot look claimed or a claimed one look free.
|
||||
memset(&obj->data, 0x00, sizeof(obj->data));
|
||||
}
|
||||
obj->refcount = 1;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_string_copy(akgl_String *src, akgl_String *dst, int count)
|
||||
akerr_ErrorContext *akgl_string_copy(akgl_String *src, akgl_String *dest, int count)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
FAIL_ZERO_RETURN(e, src, AKERR_NULLPOINTER, "NULL argument");
|
||||
FAIL_ZERO_RETURN(e, dst, AKERR_NULLPOINTER, "NULL argument");
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, src, AKERR_NULLPOINTER, "NULL argument");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL argument");
|
||||
if ( count == 0 ) {
|
||||
count = AKGL_MAX_STRING_LENGTH;
|
||||
}
|
||||
if ( (char *)dst->data != strncpy((char *)&dst->data, (char *)&src->data, count) ) {
|
||||
FAIL_RETURN(e, errno, "strncpy");
|
||||
}
|
||||
SUCCEED_RETURN(e);
|
||||
// Both buffers are exactly AKGL_MAX_STRING_LENGTH bytes, so a larger count
|
||||
// walks off the end of two pool slots at once. Refused rather than
|
||||
// documented, which is what it used to be.
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
((count < 0) || (count > AKGL_MAX_STRING_LENGTH)),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Copy count %d is outside 0..%d",
|
||||
count,
|
||||
AKGL_MAX_STRING_LENGTH);
|
||||
PASS(errctx, aksl_strncpy((char *)&dest->data, sizeof(dest->data), (char *)&src->data, count));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
158
src/text.c
158
src/text.c
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @file text.c
|
||||
* @brief Implements the text subsystem.
|
||||
*/
|
||||
|
||||
#include <akerror.h>
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_ttf/SDL_ttf.h>
|
||||
@@ -5,15 +10,22 @@
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/game.h>
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_text_loadfont(char *name, char *filepath, int size)
|
||||
akerr_ErrorContext *akgl_text_loadfont(char *name, char *filepath, int size)
|
||||
{
|
||||
TTF_Font *font = NULL;
|
||||
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "Null font name");
|
||||
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "Null filepath");
|
||||
FAIL_ZERO_RETURN(errctx, filepath, AKERR_NULLPOINTER, "Null filepath");
|
||||
font = TTF_OpenFont(filepath, size);
|
||||
FAIL_ZERO_RETURN(errctx, font, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
// Loading over an existing name used to abandon the font it displaced --
|
||||
// there was nothing in the library that could close one. The old font goes
|
||||
// back first now, and only once the new one has opened, so a failed load
|
||||
// leaves the caller with the font they already had.
|
||||
if ( SDL_GetPointerProperty(AKGL_REGISTRY_FONT, name, NULL) != NULL ) {
|
||||
PASS(errctx, akgl_text_unloadfont(name));
|
||||
}
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
SDL_SetPointerProperty(AKGL_REGISTRY_FONT, name, (void *)font),
|
||||
@@ -25,15 +37,80 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_text_loadfont(char *name, char *filepath
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_text_rendertextat(TTF_Font *font, char *text, SDL_Color color, int wraplength, int x, int y)
|
||||
akerr_ErrorContext *akgl_text_unloadfont(char *name)
|
||||
{
|
||||
TTF_Font *font = NULL;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "Null font name");
|
||||
|
||||
font = (TTF_Font *)SDL_GetPointerProperty(AKGL_REGISTRY_FONT, name, NULL);
|
||||
FAIL_ZERO_RETURN(errctx, font, AKERR_KEY, "No font named %s in the registry", name);
|
||||
|
||||
// Cleared before the close, so a font is never reachable through the
|
||||
// registry after it has been handed back to SDL_ttf.
|
||||
SDL_ClearProperty(AKGL_REGISTRY_FONT, name);
|
||||
TTF_CloseFont(font);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief SDL_EnumerateProperties callback: close one registered font.
|
||||
*
|
||||
* The properties set cannot be modified while it is being enumerated, so this
|
||||
* only closes; akgl_text_unloadallfonts clears the whole set afterwards.
|
||||
*/
|
||||
static void unload_font_iterate(void *userdata, SDL_PropertiesID props, const char *name)
|
||||
{
|
||||
TTF_Font *font = NULL;
|
||||
|
||||
if ( name == NULL ) {
|
||||
return;
|
||||
}
|
||||
font = (TTF_Font *)SDL_GetPointerProperty(props, name, NULL);
|
||||
if ( font != NULL ) {
|
||||
TTF_CloseFont(font);
|
||||
}
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_text_unloadallfonts(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
if ( AKGL_REGISTRY_FONT == 0 ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
SDL_EnumerateProperties(AKGL_REGISTRY_FONT, &unload_font_iterate, NULL);
|
||||
SDL_DestroyProperties(AKGL_REGISTRY_FONT);
|
||||
AKGL_REGISTRY_FONT = 0;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_text_rendertextat(TTF_Font *font, char *text, SDL_Color color, int wraplength, int x, int y)
|
||||
{
|
||||
SDL_Surface *textsurf = NULL;
|
||||
SDL_Texture *texture = NULL;
|
||||
SDL_FRect dest;
|
||||
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, font, AKERR_NULLPOINTER, "NULL font");
|
||||
FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "NULL text string");
|
||||
// Checked before anything is rasterized, and checked at all because a
|
||||
// backend that has an SDL_Renderer but was never run through
|
||||
// akgl_render_2d_bind has a NULL draw_texture -- which is exactly the state
|
||||
// akgl_render_2d_init used to be the only escape from.
|
||||
FAIL_ZERO_RETURN(errctx, akgl_renderer, AKERR_NULLPOINTER, "No renderer backend");
|
||||
FAIL_ZERO_RETURN(errctx, akgl_renderer->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
|
||||
FAIL_ZERO_RETURN(errctx, akgl_renderer->draw_texture, AKERR_NULLPOINTER, "Renderer backend has no draw_texture");
|
||||
// Drawing nothing is not a failure. SDL_ttf returns NULL with "Text has
|
||||
// zero width" from both rasterizers for "", so this used to report
|
||||
// AKERR_NULLPOINTER for what a caller means as "draw an empty line" --
|
||||
// while akgl_text_measure("") is documented as legal and returns 0 wide by
|
||||
// one line high. Two halves of one header disagreeing about one string.
|
||||
if ( text[0] == '\0' ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
if ( wraplength > 0 ) {
|
||||
textsurf = TTF_RenderText_Blended_Wrapped(
|
||||
font,
|
||||
@@ -49,13 +126,68 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_text_rendertextat(TTF_Font *font, char *
|
||||
color);
|
||||
}
|
||||
FAIL_ZERO_RETURN(errctx, textsurf, AKERR_NULLPOINTER, "%s", SDL_GetError());
|
||||
texture = SDL_CreateTextureFromSurface(renderer.sdl_renderer, textsurf);
|
||||
FAIL_ZERO_RETURN(errctx, texture, AKERR_NULLPOINTER, "%s", SDL_GetError());
|
||||
dest.x = x;
|
||||
dest.y = y;
|
||||
SDL_GetTextureSize(texture, &dest.w, &dest.h);
|
||||
PASS(errctx, renderer.draw_texture(&renderer, texture, NULL, &dest, 0, NULL, SDL_FLIP_NONE));
|
||||
SDL_DestroyTexture(texture);
|
||||
SDL_DestroySurface(textsurf);
|
||||
|
||||
// Both were destroyed only after the draw succeeded, so a failed texture
|
||||
// upload leaked the surface and a failed draw leaked both -- on a HUD line
|
||||
// redrawn every frame, that is a leak per frame.
|
||||
ATTEMPT {
|
||||
texture = SDL_CreateTextureFromSurface(akgl_renderer->sdl_renderer, textsurf);
|
||||
FAIL_ZERO_BREAK(errctx, texture, AKERR_NULLPOINTER, "%s", SDL_GetError());
|
||||
dest.x = x;
|
||||
dest.y = y;
|
||||
SDL_GetTextureSize(texture, &dest.w, &dest.h);
|
||||
CATCH(errctx, akgl_renderer->draw_texture(akgl_renderer, texture, NULL, &dest, 0, NULL, SDL_FLIP_NONE));
|
||||
} CLEANUP {
|
||||
if ( texture != NULL ) {
|
||||
SDL_DestroyTexture(texture);
|
||||
texture = NULL;
|
||||
}
|
||||
SDL_DestroySurface(textsurf);
|
||||
textsurf = NULL;
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_text_measure(TTF_Font *font, char *text, int *w, int *h)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, font, AKERR_NULLPOINTER, "NULL font");
|
||||
FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "NULL text string");
|
||||
FAIL_ZERO_RETURN(errctx, w, AKERR_NULLPOINTER, "NULL width destination");
|
||||
FAIL_ZERO_RETURN(errctx, h, AKERR_NULLPOINTER, "NULL height destination");
|
||||
// A zero length means "the string is null terminated", not "the empty
|
||||
// string" -- an empty text measures 0 wide and one line high.
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
TTF_GetStringSize(font, text, 0, w, h),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_text_measure_wrapped(TTF_Font *font, char *text, int wraplength, int *w, int *h)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, font, AKERR_NULLPOINTER, "NULL font");
|
||||
FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "NULL text string");
|
||||
FAIL_ZERO_RETURN(errctx, w, AKERR_NULLPOINTER, "NULL width destination");
|
||||
FAIL_ZERO_RETURN(errctx, h, AKERR_NULLPOINTER, "NULL height destination");
|
||||
// SDL_ttf takes the wrap width as an int and reads a negative one as a
|
||||
// very large unsigned width, which silently disables wrapping instead of
|
||||
// reporting anything. Refuse it here rather than return a wrong measurement.
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
(wraplength < 0),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Wrap length %d is negative",
|
||||
wraplength);
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
TTF_GetStringSizeWrapped(font, text, 0, wraplength, w, h),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
404
src/tilemap.c
404
src/tilemap.c
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @file tilemap.c
|
||||
* @brief Implements the tilemap subsystem.
|
||||
*/
|
||||
|
||||
#include <string.h>
|
||||
#include <libgen.h>
|
||||
|
||||
@@ -25,6 +30,7 @@ akerr_ErrorContext *akgl_get_json_tilemap_property(json_t *obj, char *key, char
|
||||
json_t *property = NULL;
|
||||
akgl_String *tmpstr = NULL;
|
||||
akgl_String *typestr = NULL;
|
||||
bool found = false;
|
||||
int i = 0;
|
||||
// This is not a generic JSON helper. It assumes we are receiving an object with a 'properties' key
|
||||
// inside of it. That key is an array of objects, and each object has a name, type, and value.
|
||||
@@ -34,9 +40,13 @@ akerr_ErrorContext *akgl_get_json_tilemap_property(json_t *obj, char *key, char
|
||||
CATCH(errctx, akgl_get_json_array_value(obj, "properties", &properties));
|
||||
for (i = 0; i < json_array_size(properties); i++) {
|
||||
CATCH(errctx, akgl_get_json_array_index_object(properties, i, &property));
|
||||
// One scratch string for the whole scan. akgl_get_json_string_value
|
||||
// reuses a non-NULL *dest without taking another reference, so
|
||||
// releasing it per iteration -- which this used to do -- dropped the
|
||||
// refcount to zero while the slot was still in use, and the next
|
||||
// claim anywhere could have been handed the same one.
|
||||
CATCH(errctx, akgl_get_json_string_value(property, "name", &tmpstr));
|
||||
if ( strcmp(tmpstr->data, key) != 0 ) {
|
||||
CATCH(errctx, akgl_heap_release_string(tmpstr));
|
||||
continue;
|
||||
}
|
||||
CATCH(errctx, akgl_get_json_string_value(property, "type", &typestr));
|
||||
@@ -44,7 +54,8 @@ akerr_ErrorContext *akgl_get_json_tilemap_property(json_t *obj, char *key, char
|
||||
FAIL_BREAK(errctx, AKERR_TYPE, "Property %s is present but is incorrect type(expected %s got %s)", key, type, (char *)typestr->data);
|
||||
}
|
||||
*dest = property;
|
||||
SUCCEED_RETURN(errctx);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
} CLEANUP {
|
||||
if ( tmpstr != NULL ) {
|
||||
@@ -55,7 +66,14 @@ akerr_ErrorContext *akgl_get_json_tilemap_property(json_t *obj, char *key, char
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
|
||||
// The success path used to be a SUCCEED_RETURN from inside the ATTEMPT
|
||||
// block, which returned past CLEANUP and leaked both scratch strings on
|
||||
// every successful lookup. The pool is 256 entries and a map load does this
|
||||
// many times per layer.
|
||||
if ( found == true ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
FAIL_RETURN(errctx, AKERR_KEY, "Property not found in properties map");
|
||||
}
|
||||
|
||||
@@ -63,16 +81,12 @@ akerr_ErrorContext *akgl_get_json_properties_string(json_t *obj, char *key, akgl
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
json_t *property;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_get_json_tilemap_property(obj, key, "string", &property));
|
||||
CATCH(errctx, akgl_heap_next_string(dest));
|
||||
CATCH(errctx, akgl_string_initialize(*dest, NULL));
|
||||
CATCH(errctx, akgl_get_json_string_value(property, "value", dest));
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
|
||||
PASS(errctx, akgl_get_json_tilemap_property(obj, key, "string", &property));
|
||||
PASS(errctx, akgl_heap_next_string(dest));
|
||||
PASS(errctx, akgl_string_initialize(*dest, NULL));
|
||||
PASS(errctx, akgl_get_json_string_value(property, "value", dest));
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
@@ -80,82 +94,86 @@ akerr_ErrorContext *akgl_get_json_properties_integer(json_t *obj, char *key, int
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
json_t *property = NULL;
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_get_json_tilemap_property(obj, key, "int", &property));
|
||||
CATCH(errctx, akgl_get_json_integer_value(property, "value", dest));
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
PASS(errctx, akgl_get_json_tilemap_property(obj, key, "int", &property));
|
||||
PASS(errctx, akgl_get_json_integer_value(property, "value", dest));
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_get_json_properties_number(json_t *obj, char *key, float *dest)
|
||||
akerr_ErrorContext *akgl_get_json_properties_number(json_t *obj, char *key, float32_t *dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
json_t *property = NULL;
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_get_json_tilemap_property(obj, key, "number", &property));
|
||||
CATCH(errctx, akgl_get_json_number_value(property, "value", dest));
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
PASS(errctx, akgl_get_json_tilemap_property(obj, key, "number", &property));
|
||||
PASS(errctx, akgl_get_json_number_value(property, "value", dest));
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_get_json_properties_float(json_t *obj, char *key, float *dest)
|
||||
akerr_ErrorContext *akgl_get_json_properties_float(json_t *obj, char *key, float32_t *dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
json_t *property = NULL;
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_get_json_tilemap_property(obj, key, "float", &property));
|
||||
CATCH(errctx, akgl_get_json_number_value(property, "value", dest));
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
PASS(errctx, akgl_get_json_tilemap_property(obj, key, "float", &property));
|
||||
PASS(errctx, akgl_get_json_number_value(property, "value", dest));
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_get_json_properties_double(json_t *obj, char *key, float64_t *dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
json_t *property = NULL;
|
||||
PASS(errctx, akgl_get_json_tilemap_property(obj, key, "float", &property));
|
||||
PASS(errctx, akgl_get_json_double_value(property, "value", dest));
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_tilemap_load_tilesets_each(json_t *tileset, akgl_Tilemap *dest, int tsidx, akgl_String *dirname)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_String *tmpstr = NULL;
|
||||
akgl_String *tmppath = NULL;
|
||||
|
||||
PASS(e, akgl_get_json_integer_value((json_t *)tileset, "columns", &dest->tilesets[tsidx].columns));
|
||||
PASS(e, akgl_get_json_integer_value((json_t *)tileset, "firstgid", &dest->tilesets[tsidx].firstgid));
|
||||
PASS(e, akgl_get_json_integer_value((json_t *)tileset, "imageheight", &dest->tilesets[tsidx].imageheight));
|
||||
PASS(e, akgl_get_json_integer_value((json_t *)tileset, "imagewidth", &dest->tilesets[tsidx].imagewidth));
|
||||
PASS(e, akgl_get_json_integer_value((json_t *)tileset, "margin", &dest->tilesets[tsidx].margin));
|
||||
PASS(e, akgl_get_json_integer_value((json_t *)tileset, "spacing", &dest->tilesets[tsidx].spacing));
|
||||
PASS(e, akgl_get_json_integer_value((json_t *)tileset, "tilecount", &dest->tilesets[tsidx].tilecount));
|
||||
PASS(e, akgl_get_json_integer_value((json_t *)tileset, "tileheight", &dest->tilesets[tsidx].tileheight));
|
||||
PASS(e, akgl_get_json_integer_value((json_t *)tileset, "tilewidth", &dest->tilesets[tsidx].tilewidth));
|
||||
|
||||
PASS(e, akgl_get_json_string_value((json_t *)tileset, "name", &tmpstr));
|
||||
PASS(e, akgl_heap_next_string(&tmpstr));
|
||||
PASS(e, akgl_heap_next_string(&tmppath));
|
||||
PASS(errctx, akgl_get_json_integer_value((json_t *)tileset, "columns", &dest->tilesets[tsidx].columns));
|
||||
PASS(errctx, akgl_get_json_integer_value((json_t *)tileset, "firstgid", &dest->tilesets[tsidx].firstgid));
|
||||
PASS(errctx, akgl_get_json_integer_value((json_t *)tileset, "imageheight", &dest->tilesets[tsidx].imageheight));
|
||||
PASS(errctx, akgl_get_json_integer_value((json_t *)tileset, "imagewidth", &dest->tilesets[tsidx].imagewidth));
|
||||
PASS(errctx, akgl_get_json_integer_value((json_t *)tileset, "margin", &dest->tilesets[tsidx].margin));
|
||||
PASS(errctx, akgl_get_json_integer_value((json_t *)tileset, "spacing", &dest->tilesets[tsidx].spacing));
|
||||
PASS(errctx, akgl_get_json_integer_value((json_t *)tileset, "tilecount", &dest->tilesets[tsidx].tilecount));
|
||||
PASS(errctx, akgl_get_json_integer_value((json_t *)tileset, "tileheight", &dest->tilesets[tsidx].tileheight));
|
||||
PASS(errctx, akgl_get_json_integer_value((json_t *)tileset, "tilewidth", &dest->tilesets[tsidx].tilewidth));
|
||||
|
||||
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(e, akgl_get_json_string_value((json_t *)tileset, "image", &tmpstr));
|
||||
CATCH(e, 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].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));
|
||||
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));
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
|
||||
dest->tilesets[tsidx].texture = IMG_LoadTexture(renderer.sdl_renderer, (char *)&dest->tilesets[tsidx].imagefilename);
|
||||
FAIL_ZERO_RETURN(e, dest->tilesets[tsidx].texture, AKERR_NULLPOINTER, "Failed loading tileset image : %s", SDL_GetError());
|
||||
|
||||
SUCCEED_RETURN(e);
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
dest->tilesets[tsidx].texture = IMG_LoadTexture(akgl_renderer->sdl_renderer, (char *)&dest->tilesets[tsidx].imagefilename);
|
||||
FAIL_ZERO_RETURN(errctx, dest->tilesets[tsidx].texture, AKERR_NULLPOINTER, "Failed loading tileset image : %s", SDL_GetError());
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_tilemap_compute_tileset_offsets(akgl_Tilemap *dest, int tilesetidx)
|
||||
@@ -177,7 +195,7 @@ akerr_ErrorContext *akgl_tilemap_compute_tileset_offsets(akgl_Tilemap *dest, int
|
||||
for (j = 0; j <= (dest->tilesets[tilesetidx].tilecount); j++) {
|
||||
/*
|
||||
* For a given 8x2 tilemap like this with 10x10 tiles and 0 spacing and 0 margin
|
||||
*
|
||||
*
|
||||
* 01234567
|
||||
* 89ABCDEF
|
||||
*
|
||||
@@ -199,7 +217,7 @@ akerr_ErrorContext *akgl_tilemap_compute_tileset_offsets(akgl_Tilemap *dest, int
|
||||
x_offset = (j * (dest->tilesets[tilesetidx].tilewidth + dest->tilesets[tilesetidx].spacing));
|
||||
y_offset = dest->tilesets[tilesetidx].spacing;
|
||||
}
|
||||
|
||||
|
||||
dest->tilesets[tilesetidx].tile_offsets[j][0] = x_offset;
|
||||
dest->tilesets[tilesetidx].tile_offsets[j][1] = y_offset;
|
||||
/* SDL_Log("Tileset %s index (%d, %d) is offset (%d, %d)",
|
||||
@@ -211,8 +229,8 @@ akerr_ErrorContext *akgl_tilemap_compute_tileset_offsets(akgl_Tilemap *dest, int
|
||||
// SDL_Log("Processed %d total tiles for tileset", j);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_tilemap_load_tilesets(akgl_Tilemap *dest, json_t *root, akgl_String *dirname)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
@@ -222,11 +240,22 @@ akerr_ErrorContext *akgl_tilemap_load_tilesets(akgl_Tilemap *dest, json_t *root,
|
||||
json_t *tilesets = NULL;
|
||||
json_t *jstileset = NULL;
|
||||
int i;
|
||||
|
||||
|
||||
dest->numtilesets = 0;
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_get_json_array_value(root, "tilesets", &tilesets))
|
||||
for (i = 0; i < json_array_size((json_t *)tilesets); i++) {
|
||||
// The bound goes at the top of the body, matching
|
||||
// akgl_tilemap_load_layers. Without it a map with seventeen
|
||||
// tilesets wrote past the fixed table -- and unlike the layer
|
||||
// count, nothing anywhere else was checking this one.
|
||||
FAIL_NONZERO_BREAK(
|
||||
errctx,
|
||||
(i >= AKGL_TILEMAP_MAX_TILESETS),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Map declares more than %d tilesets",
|
||||
AKGL_TILEMAP_MAX_TILESETS
|
||||
);
|
||||
CATCH(errctx, akgl_get_json_array_index_object((json_t *)tilesets, i, &jstileset));
|
||||
CATCH(errctx, akgl_tilemap_load_tilesets_each(jstileset, dest, i, dirname));
|
||||
CATCH(errctx, akgl_tilemap_compute_tileset_offsets(dest, i));
|
||||
@@ -235,7 +264,7 @@ akerr_ErrorContext *akgl_tilemap_load_tilesets(akgl_Tilemap *dest, json_t *root,
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
@@ -244,7 +273,7 @@ akerr_ErrorContext *akgl_tilemap_load_layer_object_actor(akgl_TilemapObject *cur
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_String *tmpstr = NULL;
|
||||
akgl_Actor *actorobj = NULL;
|
||||
|
||||
|
||||
curobj->type = AKGL_TILEMAP_OBJECT_TYPE_ACTOR;
|
||||
if ( strlen((char *)&curobj->name) == 0 ) {
|
||||
FAIL_RETURN(errctx, AKERR_KEY, "Actor in tile object layer cannot have empty name");
|
||||
@@ -273,7 +302,7 @@ akerr_ErrorContext *akgl_tilemap_load_layer_object_actor(akgl_TilemapObject *cur
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
|
||||
actorobj->layer = layerid;
|
||||
actorobj->x = curobj->x;
|
||||
actorobj->y = curobj->y;
|
||||
@@ -297,19 +326,42 @@ akerr_ErrorContext *akgl_tilemap_load_layer_objects(akgl_Tilemap *dest, json_t *
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination tilemap reference");
|
||||
FAIL_ZERO_RETURN(errctx, root, AKERR_NULLPOINTER, "NULL tilemap root reference");
|
||||
|
||||
PASS(errctx, akgl_get_json_array_value(root, "objects", &layerdata));
|
||||
len = json_array_size((json_t *)layerdata);
|
||||
curlayer = &dest->layers[layerid];
|
||||
|
||||
// The loop is the whole ATTEMPT body on purpose. A CATCH inside a loop
|
||||
// breaks the loop rather than the block, which is only safe because there
|
||||
// is nothing after it -- CLEANUP, PROCESS and FINISH still run and still
|
||||
// propagate. Anything added after this loop has to account for that.
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_get_json_array_value(root, "objects", &layerdata));
|
||||
len = json_array_size((json_t *)layerdata);
|
||||
curlayer = &dest->layers[layerid];
|
||||
for ( j = 0; j < len; j++ ) {
|
||||
FAIL_NONZERO_BREAK(
|
||||
errctx,
|
||||
(j >= AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Object layer %d has more than %d objects",
|
||||
layerid,
|
||||
AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER
|
||||
);
|
||||
CATCH(errctx, akgl_get_json_array_index_object((json_t *)layerdata, j, &layerdatavalue));
|
||||
curobj = &curlayer->objects[j];
|
||||
// One scratch string for the whole walk, released once in CLEANUP.
|
||||
// Releasing it after the name read -- which is what this used to do
|
||||
// -- dropped its refcount to zero while the very next line went on
|
||||
// using the same slot, because akgl_get_json_string_value reuses a
|
||||
// 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, akgl_heap_release_string(tmpstr));
|
||||
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));
|
||||
CATCH(errctx, akgl_get_json_boolean_value((json_t *)layerdatavalue, "visible", &curobj->visible));
|
||||
|
||||
CATCH(errctx, akgl_get_json_string_value((json_t *)layerdatavalue, "type", &tmpstr));
|
||||
if ( strcmp(tmpstr->data, "actor") == 0 ) {
|
||||
@@ -324,15 +376,17 @@ akerr_ErrorContext *akgl_tilemap_load_layer_objects(akgl_Tilemap *dest, json_t *
|
||||
dest->p_vanishing_y = curobj->y;
|
||||
CATCH(errctx, akgl_get_json_integer_value((json_t *)layerdatavalue, "height", &dest->p_vanishing_h));
|
||||
CATCH(errctx, akgl_get_json_properties_float((json_t *)layerdatavalue, "scale", &dest->p_vanishing_scale));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
layerdatavalue = NULL;
|
||||
}
|
||||
} CLEANUP {
|
||||
if ( tmpstr != NULL ) {
|
||||
IGNORE(akgl_heap_release_string(tmpstr));
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
@@ -361,7 +415,7 @@ akerr_ErrorContext *akgl_tilemap_load_layer_tile(akgl_Tilemap *dest, json_t *roo
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
@@ -370,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");
|
||||
@@ -380,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
|
||||
|
||||
dest->layers[layerid].texture = IMG_LoadTexture(renderer.sdl_renderer, (char *)fpath->data);
|
||||
// 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);
|
||||
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;
|
||||
@@ -398,7 +460,7 @@ akerr_ErrorContext *akgl_tilemap_load_layer_image(akgl_Tilemap *dest, json_t *ro
|
||||
IGNORE(akgl_heap_release_string(fpath));
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
@@ -414,7 +476,7 @@ akerr_ErrorContext *akgl_tilemap_load_layers(akgl_Tilemap *dest, json_t *root, a
|
||||
int i;
|
||||
int layerid = 0;
|
||||
int tmpint = 0;
|
||||
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_get_json_array_value(root, "layers", &layers));
|
||||
dest->numlayers = json_array_size((json_t *)layers);
|
||||
@@ -446,11 +508,99 @@ akerr_ErrorContext *akgl_tilemap_load_layers(akgl_Tilemap *dest, json_t *root, a
|
||||
layerid += 1;
|
||||
}
|
||||
} CLEANUP {
|
||||
// One scratch string for every layer's type, given back once. It was
|
||||
// claimed by the first accessor call and never released, which is one
|
||||
// of the two pool strings a map load used to keep for good.
|
||||
if ( tmpstr != NULL ) {
|
||||
IGNORE(akgl_heap_release_string(tmpstr));
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_tilemap_load_physics(akgl_Tilemap *dest, json_t *root)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
json_t *props = NULL;
|
||||
akgl_String *tmpval = NULL;
|
||||
double defzero = 0.0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_string(&tmpval));
|
||||
CATCH(errctx, akgl_get_json_array_value((json_t *)root, "properties", &props));
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_string(tmpval));
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE(errctx, AKERR_KEY) {
|
||||
// Map has no properties, do nothing
|
||||
SDL_Log("Map has no properties");
|
||||
SUCCEED_RETURN(errctx);
|
||||
} FINISH(errctx, true);
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_string(&tmpval));
|
||||
CATCH(errctx, akgl_get_json_properties_string(
|
||||
root,
|
||||
"physics.model",
|
||||
&tmpval
|
||||
)
|
||||
);
|
||||
PASS(errctx, akgl_physics_factory(&dest->physics, tmpval));
|
||||
dest->use_own_physics = true;
|
||||
|
||||
CATCH(errctx, akgl_get_json_with_default(
|
||||
akgl_get_json_properties_double(
|
||||
root, "physics.gravity.x", &dest->physics.gravity_x
|
||||
),
|
||||
(void *)&defzero,
|
||||
(void *)&dest->physics.gravity_x,
|
||||
sizeof(double)));
|
||||
CATCH(errctx, akgl_get_json_with_default(
|
||||
akgl_get_json_properties_double(
|
||||
root, "physics.gravity.y", &dest->physics.gravity_y
|
||||
),
|
||||
(void *)&defzero,
|
||||
(void *)&dest->physics.gravity_y,
|
||||
sizeof(double)));
|
||||
CATCH(errctx, akgl_get_json_with_default(
|
||||
akgl_get_json_properties_double(
|
||||
root, "physics.gravity.z", &dest->physics.gravity_z
|
||||
),
|
||||
(void *)&defzero,
|
||||
(void *)&dest->physics.gravity_z,
|
||||
sizeof(double)));
|
||||
CATCH(errctx, akgl_get_json_with_default(
|
||||
akgl_get_json_properties_double(
|
||||
root, "physics.drag.x", &dest->physics.drag_x
|
||||
),
|
||||
(void *)&defzero,
|
||||
(void *)&dest->physics.drag_x,
|
||||
sizeof(double)));
|
||||
CATCH(errctx, akgl_get_json_with_default(
|
||||
akgl_get_json_properties_double(
|
||||
root, "physics.drag.y", &dest->physics.drag_y
|
||||
),
|
||||
(void *)&defzero,
|
||||
(void *)&dest->physics.drag_y,
|
||||
sizeof(double)));
|
||||
CATCH(errctx, akgl_get_json_with_default(
|
||||
akgl_get_json_properties_double(
|
||||
root, "physics.drag.z", &dest->physics.drag_z
|
||||
),
|
||||
(void *)&defzero,
|
||||
(void *)&dest->physics.drag_z,
|
||||
sizeof(double)));
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_string(tmpval));
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE(errctx, AKERR_KEY) {
|
||||
SDL_Log("Map uses game physics");
|
||||
} FINISH(errctx, true);
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_tilemap_load(char *fname, akgl_Tilemap *dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
@@ -458,7 +608,7 @@ akerr_ErrorContext *akgl_tilemap_load(char *fname, akgl_Tilemap *dest)
|
||||
//akgl_String *tmpstr = NULL;
|
||||
json_error_t error;
|
||||
akgl_String *dirnamestr = NULL;
|
||||
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, fname, AKERR_NULLPOINTER, "load_tilemap received null filename");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "load_tilemap received null tilemap");
|
||||
|
||||
@@ -470,10 +620,9 @@ akerr_ErrorContext *akgl_tilemap_load(char *fname, akgl_Tilemap *dest)
|
||||
ATTEMPT {
|
||||
//CATCH(errctx, akgl_heap_next_string(&tmpstr));
|
||||
//CATCH(errctx, akgl_string_initialize(tmpstr, NULL));
|
||||
//SDL_snprintf(tmpstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), fname);
|
||||
CATCH(errctx, aksl_realpath(fname, (char *)&dirnamestr->data));
|
||||
CATCH(errctx, aksl_realpath(fname, (char *)&dirnamestr->data, sizeof(dirnamestr->data)));
|
||||
dirname((char *)&dirnamestr->data);
|
||||
|
||||
|
||||
json = json_load_file(fname, 0, &error);
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
@@ -484,6 +633,7 @@ akerr_ErrorContext *akgl_tilemap_load(char *fname, akgl_Tilemap *dest)
|
||||
error.line,
|
||||
error.text
|
||||
);
|
||||
CATCH(errctx, akgl_tilemap_load_physics(dest, json));
|
||||
CATCH(errctx, akgl_get_json_integer_value((json_t *)json, "tileheight", &dest->tileheight));
|
||||
CATCH(errctx, akgl_get_json_integer_value((json_t *)json, "tilewidth", &dest->tilewidth));
|
||||
CATCH(errctx, akgl_get_json_integer_value((json_t *)json, "height", &dest->height));
|
||||
@@ -491,7 +641,7 @@ akerr_ErrorContext *akgl_tilemap_load(char *fname, akgl_Tilemap *dest)
|
||||
|
||||
dest->orientation = 0;
|
||||
if ( (dest->width * dest->height) >= (AKGL_TILEMAP_MAX_WIDTH * AKGL_TILEMAP_MAX_HEIGHT) ) {
|
||||
FAIL_RETURN(errctx, AKERR_OUTOFBOUNDS, "Map exceeds the maximum size");
|
||||
FAIL_BREAK(errctx, AKERR_OUTOFBOUNDS, "Map exceeds the maximum size");
|
||||
}
|
||||
|
||||
CATCH(errctx, akgl_tilemap_load_layers((akgl_Tilemap *)dest, (json_t *)json, dirnamestr));
|
||||
@@ -511,7 +661,21 @@ akerr_ErrorContext *akgl_tilemap_load(char *fname, akgl_Tilemap *dest)
|
||||
SDL_Log("Map perspective rate is %f", dest->p_rate);
|
||||
}
|
||||
} CLEANUP {
|
||||
//IGNORE(akgl_heap_release_string(tmpstr));
|
||||
// The map is built entirely out of copies -- layer data, tileset
|
||||
// geometry, object names -- so the document is dead the moment the
|
||||
// loaders above return, whether they succeeded or not. It is also the
|
||||
// largest of the four: a map's JSON is the size of its layer data.
|
||||
if ( json != NULL ) {
|
||||
json_decref(json);
|
||||
json = NULL;
|
||||
}
|
||||
// And the directory the map's relative paths resolve against, which
|
||||
// was claimed above and never released -- the other of the two pool
|
||||
// strings a map load kept.
|
||||
if ( dirnamestr != NULL ) {
|
||||
IGNORE(akgl_heap_release_string(dirnamestr));
|
||||
dirnamestr = NULL;
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
@@ -570,13 +734,13 @@ akerr_ErrorContext *akgl_tilemap_draw(akgl_Tilemap *map, SDL_FRect *viewport, in
|
||||
dest.x = 0;
|
||||
dest.y = 0;
|
||||
src.w = map->layers[layeridx].width;
|
||||
src.h = map->layers[layeridx].height;
|
||||
src.h = map->layers[layeridx].height;
|
||||
dest.w = map->layers[layeridx].width;
|
||||
dest.h = map->layers[layeridx].height;
|
||||
PASS(errctx, renderer.draw_texture(&renderer, map->layers[layeridx].texture, &src, &dest, 0, NULL, SDL_FLIP_NONE));
|
||||
PASS(errctx, akgl_renderer->draw_texture(akgl_renderer, map->layers[layeridx].texture, &src, &dest, 0, NULL, SDL_FLIP_NONE));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
|
||||
dest.x = 0;
|
||||
dest.y = 0;
|
||||
dest.w = map->tilewidth;
|
||||
@@ -628,7 +792,7 @@ akerr_ErrorContext *akgl_tilemap_draw(akgl_Tilemap *map, SDL_FRect *viewport, in
|
||||
dest.y,
|
||||
dest.w,
|
||||
dest.h);*/
|
||||
PASS(errctx, renderer.draw_texture(&renderer, map->tilesets[tilesetidx].texture, &src, &dest, 0, NULL, SDL_FLIP_NONE));
|
||||
PASS(errctx, akgl_renderer->draw_texture(akgl_renderer, map->tilesets[tilesetidx].texture, &src, &dest, 0, NULL, SDL_FLIP_NONE));
|
||||
}
|
||||
}
|
||||
dest.x += map->tilewidth;
|
||||
@@ -652,7 +816,7 @@ akerr_ErrorContext *akgl_tilemap_draw_tileset(akgl_Tilemap *map, int tilesetidx)
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, map, AKERR_NULLPOINTER, "akgl_tilemap_draw_tileset received NULL pointer to tilemap");
|
||||
FAIL_NONZERO_RETURN(errctx, (tilesetidx >= map->numtilesets), AKERR_OUTOFBOUNDS, "akgl_tilemap_draw_tileset received a tileset index out of bounds");
|
||||
|
||||
|
||||
for ( tilenum = 0; tilenum < map->tilesets[tilesetidx].tilecount; tilenum++) {
|
||||
// Render this tile to the correct screen position
|
||||
// FIXME: These conditionals are probably not very efficient. Need a better way of getting
|
||||
@@ -683,17 +847,17 @@ akerr_ErrorContext *akgl_tilemap_draw_tileset(akgl_Tilemap *map, int tilesetidx)
|
||||
dest.y,
|
||||
dest.w,
|
||||
dest.h);*/
|
||||
PASS(errctx, renderer.draw_texture(&renderer, map->tilesets[tilesetidx].texture, &src, &dest, 0, NULL, SDL_FLIP_NONE));
|
||||
PASS(errctx, akgl_renderer->draw_texture(akgl_renderer, map->tilesets[tilesetidx].texture, &src, &dest, 0, NULL, SDL_FLIP_NONE));
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_tilemap_scale_actor(akgl_Tilemap *map, akgl_Actor *actor)
|
||||
akerr_ErrorContext *akgl_tilemap_scale_actor(akgl_Tilemap *map, akgl_Actor *actor)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(e, map, AKERR_NULLPOINTER, "NULL map");
|
||||
FAIL_ZERO_RETURN(e, actor, AKERR_NULLPOINTER, "NULL actor");
|
||||
FAIL_ZERO_RETURN(errctx, map, AKERR_NULLPOINTER, "NULL map");
|
||||
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "NULL actor");
|
||||
|
||||
if ( actor->y <= map->p_vanishing_y ) {
|
||||
actor->scale = map->p_vanishing_scale;
|
||||
@@ -702,26 +866,32 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_tilemap_scale_actor(akgl_Tilemap *map, a
|
||||
} else {
|
||||
actor->scale = map->p_foreground_scale - (map->p_rate * (map->p_foreground_y - actor->y));
|
||||
}
|
||||
SUCCEED_RETURN(e);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_tilemap_release(akgl_Tilemap *dest)
|
||||
akerr_ErrorContext *akgl_tilemap_release(akgl_Tilemap *dest)
|
||||
{
|
||||
// Release all tileset textures
|
||||
// Release all image layer textures
|
||||
// Memset to zero
|
||||
PREPARE_ERROR(e);
|
||||
FAIL_ZERO_RETURN(e, dest, AKERR_NULLPOINTER, "NULL map");
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL map");
|
||||
int i = 0;
|
||||
// Each pointer is cleared as it goes. Without that a second release
|
||||
// destroys textures SDL has already freed, and the second loop used to
|
||||
// destroy `tilesets[i]` while testing `layers[i]` -- so every tileset
|
||||
// texture was freed twice and no image layer's texture was freed at all.
|
||||
for ( i = 0; i < AKGL_TILEMAP_MAX_TILESETS; i++ ) {
|
||||
if ( dest->tilesets[i].texture != NULL ) {
|
||||
SDL_DestroyTexture(dest->tilesets[i].texture);
|
||||
dest->tilesets[i].texture = NULL;
|
||||
}
|
||||
}
|
||||
for ( i = 0; i < AKGL_TILEMAP_MAX_LAYERS; i++ ) {
|
||||
if ( dest->layers[i].texture != NULL ) {
|
||||
SDL_DestroyTexture(dest->tilesets[i].texture);
|
||||
SDL_DestroyTexture(dest->layers[i].texture);
|
||||
dest->layers[i].texture = NULL;
|
||||
}
|
||||
}
|
||||
SUCCEED_RETURN(e);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
348
src/util.c
348
src/util.c
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @file util.c
|
||||
* @brief Implements the util subsystem.
|
||||
*/
|
||||
|
||||
#include <limits.h>
|
||||
#include <stdlib.h>
|
||||
#include <errno.h>
|
||||
@@ -15,176 +20,234 @@
|
||||
|
||||
#include <akstdlib.h>
|
||||
|
||||
akerr_ErrorContext *akgl_path_relative_root(char *root, char *path, akgl_String *dst)
|
||||
/**
|
||||
* @brief Resolve @p path against @p root and write the absolute result to @p dest.
|
||||
*
|
||||
* The second half of akgl_path_relative: joins the two with a `/` and resolves
|
||||
* the join, so symlinks and `..` are folded out and the result is absolute. It
|
||||
* is the fallback, reached only once resolving @p path on its own has failed.
|
||||
*
|
||||
* `static`: it is reachable only through akgl_path_relative, which is the only
|
||||
* caller and the only sensible one.
|
||||
*
|
||||
* @param root Directory to resolve against, normally `dirname` of the file that
|
||||
* named @p path. Required. A trailing `/` is harmless; the join adds
|
||||
* one unconditionally and `realpath` collapses the double.
|
||||
* @param path Relative path to resolve. Required. An absolute @p path still gets
|
||||
* @p root pasted in front of it, which will not exist -- so callers
|
||||
* must not send absolute paths down this branch.
|
||||
* @param dest Receives the resolved absolute path. Required, and must already be
|
||||
* a claimed pool string.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p root, @p path, or @p dest is `NULL`.
|
||||
* @throws AKERR_OUTOFBOUNDS If `root + path` is at least #AKGL_MAX_STRING_LENGTH
|
||||
* bytes. The message reports both the combined length and the limit.
|
||||
* @throws ENOENT If the joined path does not exist. Any other `errno`
|
||||
* `realpath(3)` raises -- EACCES, ELOOP, ENOTDIR -- propagates likewise.
|
||||
* @throws AKGL_ERR_HEAP If the string pool cannot supply the two scratch buffers.
|
||||
*
|
||||
* @note The AKERR_OUTOFBOUNDS check uses `FAIL_RETURN` from inside the `ATTEMPT`
|
||||
* block, which returns past `CLEANUP` -- so on that one path the two
|
||||
* scratch strings are never released. This is exactly the hazard AGENTS.md
|
||||
* warns about; it wants a `FAIL_BREAK`.
|
||||
*/
|
||||
static akerr_ErrorContext *path_relative_root(char *root, char *path, akgl_String *dest)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_String *pathbuf;
|
||||
akgl_String *strbuf;
|
||||
char *result;
|
||||
int rootlen;
|
||||
int pathlen;
|
||||
int count;
|
||||
|
||||
FAIL_ZERO_RETURN(e, root, AKERR_NULLPOINTER, "NULL argument");
|
||||
FAIL_ZERO_RETURN(e, path, AKERR_NULLPOINTER, "NULL argument");
|
||||
FAIL_ZERO_RETURN(e, dst, AKERR_NULLPOINTER, "NULL argument");
|
||||
|
||||
PASS(e, akgl_heap_next_string(&strbuf));
|
||||
PASS(e, akgl_heap_next_string(&pathbuf));
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, root, AKERR_NULLPOINTER, "NULL argument");
|
||||
FAIL_ZERO_RETURN(errctx, path, AKERR_NULLPOINTER, "NULL argument");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL argument");
|
||||
|
||||
PASS(errctx, akgl_heap_next_string(&strbuf));
|
||||
PASS(errctx, akgl_heap_next_string(&pathbuf));
|
||||
|
||||
ATTEMPT {
|
||||
// Is it relative to the root?
|
||||
rootlen = strlen(root);
|
||||
pathlen = strlen(path);
|
||||
if ( (rootlen + pathlen) >= AKGL_MAX_STRING_LENGTH ) {
|
||||
FAIL_RETURN(e, AKERR_OUTOFBOUNDS, "Total path length (%d) is greater than maximum akgl_String length (%d)", (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(e, aksl_sprintf(&count, (char *)&pathbuf->data, "%s/%s", root, path));
|
||||
RESTORE_GCC_WARNINGS
|
||||
CATCH(e, aksl_realpath((char *)&pathbuf->data, (char *)&strbuf->data));
|
||||
CATCH(e, akgl_string_copy(strbuf, dst, 0));
|
||||
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));
|
||||
IGNORE(akgl_heap_release_string(pathbuf));
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_path_relative(char *root, char *path, akgl_String *dst)
|
||||
akerr_ErrorContext *akgl_path_relative(char *root, char *path, akgl_String *dest)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_String *strbuf;
|
||||
char *result;
|
||||
|
||||
FAIL_ZERO_RETURN(e, root, AKERR_NULLPOINTER, "NULL argument");
|
||||
FAIL_ZERO_RETURN(e, path, AKERR_NULLPOINTER, "NULL argument");
|
||||
FAIL_ZERO_RETURN(e, dst, AKERR_NULLPOINTER, "NULL argument");
|
||||
bool relative_to_root = false;
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, root, AKERR_NULLPOINTER, "NULL argument");
|
||||
FAIL_ZERO_RETURN(errctx, path, AKERR_NULLPOINTER, "NULL argument");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL argument");
|
||||
|
||||
PASS(errctx, akgl_heap_next_string(&strbuf));
|
||||
|
||||
PASS(e, akgl_heap_next_string(&strbuf));
|
||||
|
||||
ATTEMPT {
|
||||
// Is path relative to our current working directory?
|
||||
CATCH(e, aksl_realpath(path, (char *)&strbuf->data));
|
||||
CATCH(errctx, aksl_realpath(path, (char *)&strbuf->data, sizeof(strbuf->data)));
|
||||
// Yes it is. strbuf->data contains the absolute path.
|
||||
CATCH(e, akgl_string_copy(strbuf, dst, 0));
|
||||
CATCH(errctx, akgl_string_copy(strbuf, dest, 0));
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_string(strbuf));
|
||||
} PROCESS(e) {
|
||||
} HANDLE(e, ENOENT) {
|
||||
// Path is not relative to our current working directory
|
||||
// Noop - execution proceeds after the break
|
||||
return akgl_path_relative_root(root, path, dst);
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE(errctx, ENOENT) {
|
||||
// Path is not relative to our current working directory. Resolve it
|
||||
// against root instead -- but after FINISH, not from in here. Returning
|
||||
// from inside a HANDLE block skips the RELEASE_ERROR that FINISH ends
|
||||
// with, so the handled context is never given back to
|
||||
// AKERR_ARRAY_ERROR. That leaks one context per call, and the 129th
|
||||
// call takes the whole process down with "Unable to pull an error
|
||||
// context from the array!". Every map load resolves several paths this
|
||||
// way.
|
||||
relative_to_root = true;
|
||||
} FINISH(errctx, true);
|
||||
|
||||
if ( relative_to_root == true ) {
|
||||
PASS(errctx, path_relative_root(root, path, dest));
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_path_relative_from(char *path, char *from, akgl_String **dst)
|
||||
akerr_ErrorContext *akgl_rectangle_points(akgl_RectanglePoints *dest, SDL_FRect *rect)
|
||||
{
|
||||
akgl_String *dirnamestr;
|
||||
PREPARE_ERROR(e);
|
||||
FAIL_ZERO_RETURN(e, path, AKERR_NULLPOINTER, "path");
|
||||
FAIL_ZERO_RETURN(e, from, AKERR_NULLPOINTER, "from");
|
||||
PASS(e, akgl_heap_next_string(&dirnamestr));
|
||||
PASS(e, aksl_realpath(from, (char *)&dirnamestr->data));
|
||||
dirname((char *)&dirnamestr->data);
|
||||
|
||||
SUCCEED_RETURN(e);
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL akgl_RectanglePoints reference");
|
||||
FAIL_ZERO_RETURN(errctx, rect, AKERR_NULLPOINTER, "NULL Rectangle reference");
|
||||
dest->topleft.x = rect->x;
|
||||
dest->topleft.y = rect->y;
|
||||
dest->bottomleft.x = rect->x;
|
||||
dest->bottomleft.y = rect->y + rect->h;
|
||||
dest->topright.x = rect->x + rect->w;
|
||||
dest->topright.y = rect->y;
|
||||
dest->bottomright.x = rect->x + rect->w;
|
||||
dest->bottomright.y = rect->y + rect->h;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_rectangle_points(RectanglePoints *dest, SDL_FRect *rect)
|
||||
akerr_ErrorContext *akgl_collide_point_rectangle(akgl_Point *p, akgl_RectanglePoints *rp, bool *collide)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL RectanglePoints reference");
|
||||
FAIL_ZERO_RETURN(errctx, rect, AKERR_NULLPOINTER, "NULL Rectangle reference");
|
||||
dest->topleft.x = rect->x;
|
||||
dest->topleft.y = rect->y;
|
||||
dest->bottomleft.x = rect->x;
|
||||
dest->bottomleft.y = rect->y + rect->h;
|
||||
dest->topright.x = rect->x + rect->w;
|
||||
dest->topright.y = rect->y;
|
||||
dest->bottomright.x = rect->x + rect->w;
|
||||
dest->bottomright.y = rect->y + rect->h;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_collide_point_rectangle(point *p, RectanglePoints *rp, bool *collide)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, p, AKERR_NULLPOINTER, "NULL Point reference");
|
||||
FAIL_ZERO_RETURN(errctx, rp, AKERR_NULLPOINTER, "NULL RectanglePoints reference");
|
||||
FAIL_ZERO_RETURN(errctx, collide, AKERR_NULLPOINTER, "NULL boolean reference");
|
||||
if ( (p->x >= rp->topleft.x) && (p->y >= rp->topleft.y) &&
|
||||
(p->x <= rp->bottomright.x) && (p->y <= rp->bottomright.y) ) {
|
||||
*collide = true;
|
||||
} else {
|
||||
*collide = false;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, p, AKERR_NULLPOINTER, "NULL Point reference");
|
||||
FAIL_ZERO_RETURN(errctx, rp, AKERR_NULLPOINTER, "NULL akgl_RectanglePoints reference");
|
||||
FAIL_ZERO_RETURN(errctx, collide, AKERR_NULLPOINTER, "NULL boolean reference");
|
||||
if ( (p->x >= rp->topleft.x) && (p->y >= rp->topleft.y) &&
|
||||
(p->x <= rp->bottomright.x) && (p->y <= rp->bottomright.y) ) {
|
||||
*collide = true;
|
||||
} else {
|
||||
*collide = false;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_collide_rectangles(SDL_FRect *r1, SDL_FRect *r2, bool *collide)
|
||||
{
|
||||
RectanglePoints r1p;
|
||||
RectanglePoints r2p;
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, r1, AKERR_NULLPOINTER, "NULL rectangle reference");
|
||||
FAIL_ZERO_RETURN(errctx, r2, AKERR_NULLPOINTER, "NULL rectangle reference");
|
||||
FAIL_ZERO_RETURN(errctx, collide, AKERR_NULLPOINTER, "NULL collision flag reference");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_rectangle_points(&r1p, r1));
|
||||
CATCH(errctx, akgl_rectangle_points(&r2p, r2));
|
||||
|
||||
// is the upper left corner of r1 contacting r2?
|
||||
CATCH(errctx, akgl_collide_point_rectangle(&r1p.topleft, &r2p, collide));
|
||||
if ( *collide == true ) { SUCCEED_RETURN(errctx); }
|
||||
|
||||
// is the upper left corner of r2 contacting r1?
|
||||
CATCH(errctx, akgl_collide_point_rectangle(&r2p.topleft, &r1p, collide));
|
||||
if ( *collide == true ) { SUCCEED_RETURN(errctx); }
|
||||
|
||||
// is the top right corner of r1 contacting r2?
|
||||
CATCH(errctx, akgl_collide_point_rectangle(&r1p.topright, &r2p, collide));
|
||||
if ( *collide == true ) { SUCCEED_RETURN(errctx); }
|
||||
|
||||
// is the top right corner of r2 contacting r1?
|
||||
CATCH(errctx, akgl_collide_point_rectangle(&r2p.topright, &r1p, collide));
|
||||
if ( *collide == true ) { SUCCEED_RETURN(errctx); }
|
||||
|
||||
// is the bottom left corner of r1 contacting r2?
|
||||
CATCH(errctx, akgl_collide_point_rectangle(&r1p.bottomleft, &r2p, collide));
|
||||
if ( *collide == true ) { SUCCEED_RETURN(errctx); }
|
||||
|
||||
// is the bottom left corner of r2 contacting r1?
|
||||
CATCH(errctx, akgl_collide_point_rectangle(&r2p.bottomleft, &r1p, collide));
|
||||
if ( *collide == true ) { SUCCEED_RETURN(errctx); }
|
||||
|
||||
// is the bottom right corner of r1 contacting r2?
|
||||
CATCH(errctx, akgl_collide_point_rectangle(&r1p.bottomright, &r2p, collide));
|
||||
if ( *collide == true ) { SUCCEED_RETURN(errctx); }
|
||||
|
||||
// is the bottom right corner of r2 contacting r1?
|
||||
CATCH(errctx, akgl_collide_point_rectangle(&r2p.bottomright, &r1p, collide));
|
||||
if ( *collide == true ) { SUCCEED_RETURN(errctx); }
|
||||
akgl_RectanglePoints r1p;
|
||||
akgl_RectanglePoints r2p;
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, r1, AKERR_NULLPOINTER, "NULL rectangle reference");
|
||||
FAIL_ZERO_RETURN(errctx, r2, AKERR_NULLPOINTER, "NULL rectangle reference");
|
||||
FAIL_ZERO_RETURN(errctx, collide, AKERR_NULLPOINTER, "NULL collision flag reference");
|
||||
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
*collide = false;
|
||||
SUCCEED_RETURN(errctx);
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_rectangle_points(&r1p, r1));
|
||||
CATCH(errctx, akgl_rectangle_points(&r2p, r2));
|
||||
|
||||
// is the upper left corner of r1 contacting r2?
|
||||
CATCH(errctx, akgl_collide_point_rectangle(&r1p.topleft, &r2p, collide));
|
||||
if ( *collide == true ) { break; }
|
||||
|
||||
// is the upper left corner of r2 contacting r1?
|
||||
CATCH(errctx, akgl_collide_point_rectangle(&r2p.topleft, &r1p, collide));
|
||||
if ( *collide == true ) { break; }
|
||||
|
||||
// is the top right corner of r1 contacting r2?
|
||||
CATCH(errctx, akgl_collide_point_rectangle(&r1p.topright, &r2p, collide));
|
||||
if ( *collide == true ) { break; }
|
||||
|
||||
// is the top right corner of r2 contacting r1?
|
||||
CATCH(errctx, akgl_collide_point_rectangle(&r2p.topright, &r1p, collide));
|
||||
if ( *collide == true ) { break; }
|
||||
|
||||
// is the bottom left corner of r1 contacting r2?
|
||||
CATCH(errctx, akgl_collide_point_rectangle(&r1p.bottomleft, &r2p, collide));
|
||||
if ( *collide == true ) { break; }
|
||||
|
||||
// is the bottom left corner of r2 contacting r1?
|
||||
CATCH(errctx, akgl_collide_point_rectangle(&r2p.bottomleft, &r1p, collide));
|
||||
if ( *collide == true ) { break; }
|
||||
|
||||
// is the bottom right corner of r1 contacting r2?
|
||||
CATCH(errctx, akgl_collide_point_rectangle(&r1p.bottomright, &r2p, collide));
|
||||
if ( *collide == true ) { break; }
|
||||
|
||||
// is the bottom right corner of r2 contacting r1?
|
||||
CATCH(errctx, akgl_collide_point_rectangle(&r2p.bottomright, &r1p, collide));
|
||||
if ( *collide == true ) { break; }
|
||||
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
// No trailing `*collide = false;` here. Each corner test writes the flag
|
||||
// itself, so the eighth one leaves it false when nothing hit; assigning
|
||||
// after FINISH would overwrite the hit that broke out of the block early.
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
|
||||
akerr_ErrorContext *akgl_compare_sdl_surfaces(SDL_Surface *s1, SDL_Surface *s2)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, s1, AKERR_NULLPOINTER, "NULL Surface pointer");
|
||||
FAIL_ZERO_RETURN(errctx, s2, AKERR_NULLPOINTER, "NULL Surface pointer");
|
||||
FAIL_NONZERO_RETURN(errctx, memcmp(s1->pixels, s2->pixels, (s1->pitch * s1->h)), AKERR_VALUE, "Comparison surfaces are not equal");
|
||||
SUCCEED_RETURN(errctx);
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, s1, AKERR_NULLPOINTER, "NULL Surface pointer");
|
||||
FAIL_ZERO_RETURN(errctx, s2, AKERR_NULLPOINTER, "NULL Surface pointer");
|
||||
// Geometry first. The memcmp reads s1->pitch * s1->h bytes out of *both*,
|
||||
// so a smaller s2 was read past its end rather than reported as a
|
||||
// mismatch -- and a differing pitch or format made the comparison
|
||||
// meaningless even when it did not run off.
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
((s1->w != s2->w) || (s1->h != s2->h)),
|
||||
AKERR_VALUE,
|
||||
"Comparison surfaces differ in size: %dx%d against %dx%d",
|
||||
s1->w, s1->h, s2->w, s2->h);
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
(s1->pitch != s2->pitch),
|
||||
AKERR_VALUE,
|
||||
"Comparison surfaces differ in pitch: %d against %d",
|
||||
s1->pitch, s2->pitch);
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
(s1->format != s2->format),
|
||||
AKERR_VALUE,
|
||||
"Comparison surfaces differ in pixel format: %s against %s",
|
||||
SDL_GetPixelFormatName(s1->format), SDL_GetPixelFormatName(s2->format));
|
||||
FAIL_ZERO_RETURN(errctx, s1->pixels, AKERR_NULLPOINTER, "First surface has no pixels");
|
||||
FAIL_ZERO_RETURN(errctx, s2->pixels, AKERR_NULLPOINTER, "Second surface has no pixels");
|
||||
FAIL_NONZERO_RETURN(errctx, memcmp(s1->pixels, s2->pixels, (s1->pitch * s1->h)), AKERR_VALUE, "Comparison surfaces are not equal");
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_render_and_compare(SDL_Texture *t1, SDL_Texture *t2, int x, int y, int w, int h, char *writeout)
|
||||
@@ -195,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 {
|
||||
@@ -202,13 +266,19 @@ akerr_ErrorContext *akgl_render_and_compare(SDL_Texture *t1, SDL_Texture *t2, in
|
||||
FAIL_ZERO_BREAK(errctx, t2, AKERR_NULLPOINTER, "NULL texture");
|
||||
|
||||
CATCH(errctx, akgl_heap_next_string(&tmpstring));
|
||||
SDL_RenderClear(renderer.sdl_renderer);
|
||||
CATCH(errctx, renderer.draw_texture(&renderer, t1, &src, &dest, 0, NULL, SDL_FLIP_NONE));
|
||||
s1 = SDL_RenderReadPixels(renderer.sdl_renderer, &read);
|
||||
SDL_RenderClear(akgl_renderer->sdl_renderer);
|
||||
CATCH(errctx, akgl_renderer->draw_texture(akgl_renderer, t1, &src, &dest, 0, NULL, SDL_FLIP_NONE));
|
||||
s1 = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, &read);
|
||||
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),
|
||||
@@ -217,13 +287,13 @@ akerr_ErrorContext *akgl_render_and_compare(SDL_Texture *t1, SDL_Texture *t2, in
|
||||
(char *)&tmpstring->data,
|
||||
SDL_GetError());
|
||||
}
|
||||
|
||||
SDL_RenderClear(renderer.sdl_renderer);
|
||||
|
||||
CATCH(errctx, renderer.draw_texture(&renderer, t1, &src, &dest, 0, NULL, SDL_FLIP_NONE));
|
||||
s2 = SDL_RenderReadPixels(renderer.sdl_renderer, &read);
|
||||
|
||||
SDL_RenderClear(akgl_renderer->sdl_renderer);
|
||||
|
||||
CATCH(errctx, akgl_renderer->draw_texture(akgl_renderer, t2, &src, &dest, 0, NULL, SDL_FLIP_NONE));
|
||||
s2 = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, &read);
|
||||
FAIL_ZERO_BREAK(errctx, s2, AKGL_ERR_SDL, "Failed to read pixels from renderer");
|
||||
|
||||
|
||||
CATCH(errctx, akgl_compare_sdl_surfaces(s1, s2));
|
||||
} CLEANUP {
|
||||
if ( s1 != NULL )
|
||||
|
||||
15
src/version.c
Normal file
15
src/version.c
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @file version.c
|
||||
* @brief Implements the runtime half of the version API.
|
||||
*/
|
||||
|
||||
#include <akgl/version.h>
|
||||
|
||||
const char *akgl_version(void)
|
||||
{
|
||||
// AKGL_VERSION is baked in when this translation unit is compiled, so what
|
||||
// comes back is the version of the shared library the caller linked -- not
|
||||
// the version of the header the caller built against. That asymmetry is the
|
||||
// whole point: comparing the two detects a stale libakgl on the loader path.
|
||||
return AKGL_VERSION;
|
||||
}
|
||||
802
tests/actor.c
802
tests/actor.c
@@ -1,7 +1,8 @@
|
||||
#define UNHANDLED_ERROR_TERMINATION_BEHAVIOR \
|
||||
#define UNHANDLED_ERROR_TERMINATION_BEHAVIOR \
|
||||
handle_unhandled_error(errctx);
|
||||
|
||||
#include <akerror.h>
|
||||
#include <akgl/error.h>
|
||||
|
||||
#define UNHANDLED_ERROR_EXIT 0
|
||||
#define UNHANDLED_ERROR_SET 1
|
||||
@@ -12,7 +13,13 @@
|
||||
#include <akgl/iterator.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/character.h>
|
||||
#include <akgl/sprite.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/renderer.h>
|
||||
|
||||
#include "testutil.h"
|
||||
|
||||
int UNHANDLED_ERROR_BEHAVIOR;
|
||||
akerr_ErrorContext *unhandled_error_context;
|
||||
@@ -23,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;
|
||||
@@ -42,7 +51,7 @@ akerr_ErrorContext *akgl_actor_update_noop(akgl_Actor *obj)
|
||||
|
||||
// Currently the renderer assumes there is a global variable named `renderer`
|
||||
int akgl_actor_rendered;
|
||||
akerr_ErrorContext *akgl_actor_render_noop(akgl_Actor *obj, SDL_Renderer *r)
|
||||
akerr_ErrorContext *akgl_actor_render_noop(akgl_Actor *obj)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_actor_rendered = 1;
|
||||
@@ -52,7 +61,7 @@ akerr_ErrorContext *akgl_actor_render_noop(akgl_Actor *obj, SDL_Renderer *r)
|
||||
akerr_ErrorContext *test_registry_actor_iterator_nullpointers(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
|
||||
akerr_ErrorUnhandledErrorHandler defaulthandler = akerr_handler_unhandled_error;
|
||||
|
||||
akerr_handler_unhandled_error = handle_unhandled_error_noexit;
|
||||
@@ -129,13 +138,13 @@ akerr_ErrorContext *test_registry_actor_iterator_updaterender(void)
|
||||
FAIL_ZERO_BREAK(
|
||||
unhandled_error_context,
|
||||
akgl_actor_updated,
|
||||
AKERR_BEHAVIOR,
|
||||
AKGL_ERR_BEHAVIOR,
|
||||
"actor->updatefunc not called by the iterator"
|
||||
);
|
||||
FAIL_ZERO_BREAK(
|
||||
unhandled_error_context,
|
||||
akgl_actor_rendered,
|
||||
AKERR_BEHAVIOR,
|
||||
AKGL_ERR_BEHAVIOR,
|
||||
"actor->renderfunc not called by the iterator"
|
||||
);
|
||||
} CLEANUP {
|
||||
@@ -145,7 +154,7 @@ akerr_ErrorContext *test_registry_actor_iterator_updaterender(void)
|
||||
} FINISH(unhandled_error_context, true);
|
||||
|
||||
akerr_handler_unhandled_error = defaulthandler;
|
||||
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
@@ -153,7 +162,7 @@ akerr_ErrorContext *test_akgl_actor_set_character(void)
|
||||
{
|
||||
akgl_Actor *testactor = NULL;
|
||||
akgl_Character *testchar = NULL;
|
||||
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
@@ -161,10 +170,10 @@ akerr_ErrorContext *test_akgl_actor_set_character(void)
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE(errctx, AKERR_NULLPOINTER) {
|
||||
printf("Handled\n");
|
||||
printf("Handled\n");
|
||||
} FINISH(errctx, true);
|
||||
|
||||
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_actor(&testactor));
|
||||
|
||||
@@ -172,13 +181,13 @@ akerr_ErrorContext *test_akgl_actor_set_character(void)
|
||||
testactor->layer = 0;
|
||||
testactor->updatefunc = &akgl_actor_update_noop;
|
||||
testactor->renderfunc = &akgl_actor_render_noop;
|
||||
|
||||
|
||||
CATCH(errctx, akgl_actor_set_character(testactor, "test"));
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_actor(testactor));
|
||||
IGNORE(akgl_heap_release_actor(testactor));
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE(errctx, AKERR_NULLPOINTER) {
|
||||
printf("Handled\n");
|
||||
printf("Handled\n");
|
||||
} FINISH(errctx, true);
|
||||
|
||||
ATTEMPT {
|
||||
@@ -189,13 +198,13 @@ akerr_ErrorContext *test_akgl_actor_set_character(void)
|
||||
testactor->layer = 0;
|
||||
testactor->updatefunc = &akgl_actor_update_noop;
|
||||
testactor->renderfunc = &akgl_actor_render_noop;
|
||||
|
||||
|
||||
CATCH(errctx, akgl_character_initialize(testchar, "test"));
|
||||
|
||||
|
||||
CATCH(errctx, akgl_actor_set_character(testactor, "test"));
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_actor(testactor));
|
||||
IGNORE(akgl_heap_release_character(testchar));
|
||||
IGNORE(akgl_heap_release_actor(testactor));
|
||||
IGNORE(akgl_heap_release_character(testchar));
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
@@ -235,20 +244,20 @@ akerr_ErrorContext *test_actor_manage_children(void)
|
||||
CATCH(errctx, parent->addchild(parent, child));
|
||||
} CLEANUP {
|
||||
if ( errctx == NULL ) {
|
||||
FAIL(errctx, AKERR_BEHAVIOR, "addchild does not throw AKERR_RELATIONSHIP when child already has a parent");
|
||||
FAIL(errctx, AKGL_ERR_BEHAVIOR, "addchild does not throw AKERR_RELATIONSHIP when child already has a parent");
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE(errctx, AKERR_RELATIONSHIP) {
|
||||
// Expected behavior
|
||||
SDL_Log("addchild throws AKERR_RELATIONSHIP when child already has a parent");
|
||||
} FINISH(errctx, true);
|
||||
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_actor(&child));
|
||||
CATCH(errctx, parent->addchild(parent, child));
|
||||
} CLEANUP {
|
||||
if ( errctx == NULL ) {
|
||||
FAIL(errctx, AKERR_BEHAVIOR, "addchild does not throw AKERR_OUTOFBOUNDS when all children already set");
|
||||
FAIL(errctx, AKGL_ERR_BEHAVIOR, "addchild does not throw AKERR_OUTOFBOUNDS when all children already set");
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE(errctx, AKERR_OUTOFBOUNDS) {
|
||||
@@ -260,10 +269,10 @@ akerr_ErrorContext *test_actor_manage_children(void)
|
||||
CATCH(errctx, akgl_heap_release_actor(parent));
|
||||
// All actor objects on the heap should be empty now
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++) {
|
||||
FAIL_NONZERO_BREAK(errctx, HEAP_ACTOR[i].refcount, AKERR_VALUE, "Actor not properly cleared");
|
||||
FAIL_NONZERO_BREAK(errctx, HEAP_ACTOR[i].parent, AKERR_VALUE, "Actor not properly cleared");
|
||||
FAIL_NONZERO_BREAK(errctx, akgl_heap_actors[i].refcount, AKERR_VALUE, "Actor not properly cleared");
|
||||
FAIL_NONZERO_BREAK(errctx, akgl_heap_actors[i].parent, AKERR_VALUE, "Actor not properly cleared");
|
||||
for ( j = 0 ; j < AKGL_ACTOR_MAX_CHILDREN; j++) {
|
||||
if ( HEAP_ACTOR[i].children[j] != NULL ) {
|
||||
if ( akgl_heap_actors[i].children[j] != NULL ) {
|
||||
FAIL(errctx, AKERR_VALUE, "Actor not properly cleared");
|
||||
goto _test_actor_addchild_heaprelease_cleanup;
|
||||
}
|
||||
@@ -278,7 +287,7 @@ akerr_ErrorContext *test_actor_manage_children(void)
|
||||
"Child %s was not removed from the registry",
|
||||
(char *)&tmpstring->data);
|
||||
}
|
||||
_test_actor_addchild_heaprelease_cleanup:
|
||||
_test_actor_addchild_heaprelease_cleanup:
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
@@ -303,27 +312,770 @@ _test_actor_addchild_heaprelease_cleanup:
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Build an actor bound to a character, without touching the renderer.
|
||||
*
|
||||
* The sprite and spritesheet are taken straight off the heap and populated by
|
||||
* hand, because akgl_sprite_load_json() would need a live renderer to build a
|
||||
* texture and none of the logic under test reads one.
|
||||
*/
|
||||
static akerr_ErrorContext *make_bound_actor(
|
||||
akgl_Actor **actor,
|
||||
akgl_Character **basechar,
|
||||
akgl_Sprite **sprite,
|
||||
char *name,
|
||||
int state)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_SpriteSheet *sheet = NULL;
|
||||
char spritename[AKGL_SPRITE_MAX_NAME_LENGTH];
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, akgl_heap_next_character(basechar));
|
||||
CATCH(e, akgl_character_initialize(*basechar, name));
|
||||
|
||||
CATCH(e, akgl_heap_next_spritesheet(&sheet));
|
||||
sheet->refcount += 1;
|
||||
|
||||
snprintf((char *)&spritename, AKGL_SPRITE_MAX_NAME_LENGTH, "%s_sprite", name);
|
||||
CATCH(e, akgl_heap_next_sprite(sprite));
|
||||
CATCH(e, akgl_sprite_initialize(*sprite, (char *)&spritename, sheet));
|
||||
(*sprite)->width = 32;
|
||||
(*sprite)->height = 32;
|
||||
(*sprite)->frames = 4;
|
||||
(*sprite)->speed = 100;
|
||||
CATCH(e, akgl_character_sprite_add(*basechar, *sprite, state));
|
||||
|
||||
CATCH(e, akgl_heap_next_actor(actor));
|
||||
CATCH(e, akgl_actor_initialize(*actor, name));
|
||||
(*actor)->basechar = *basechar;
|
||||
(*actor)->state = state;
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_actor_control_handlers_on(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_Actor actor;
|
||||
akgl_Character basechar;
|
||||
SDL_Event event;
|
||||
|
||||
ATTEMPT {
|
||||
memset(&event, 0x00, sizeof(SDL_Event));
|
||||
memset(&basechar, 0x00, sizeof(akgl_Character));
|
||||
basechar.ax = 7.0f;
|
||||
basechar.ay = 11.0f;
|
||||
|
||||
// Left: clears every facing and movement bit, then sets its own pair and
|
||||
// takes acceleration from the base character with the sign reversed.
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
actor.basechar = &basechar;
|
||||
actor.state = (AKGL_ACTOR_STATE_FACE_RIGHT | AKGL_ACTOR_STATE_MOVING_RIGHT | AKGL_ACTOR_STATE_ALIVE);
|
||||
TEST_EXPECT_OK(e, akgl_actor_cmhf_left_on(&actor, &event), "left on");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_MOVING_LEFT),
|
||||
"left on did not set MOVING_LEFT (state %d)", actor.state);
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_FACE_LEFT),
|
||||
"left on did not set FACE_LEFT (state %d)", actor.state);
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_MOVING_RIGHT),
|
||||
"left on left MOVING_RIGHT set (state %d)", actor.state);
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_FACE_RIGHT),
|
||||
"left on left FACE_RIGHT set (state %d)", actor.state);
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_ALIVE),
|
||||
"left on cleared an unrelated state bit (state %d)", actor.state);
|
||||
TEST_ASSERT_FEQ(e, actor.ax, -7.0f, "left on set ax to %f, expected -7", actor.ax);
|
||||
|
||||
// Right: same, with the sign preserved.
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
actor.basechar = &basechar;
|
||||
TEST_EXPECT_OK(e, akgl_actor_cmhf_right_on(&actor, &event), "right on");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_MOVING_RIGHT),
|
||||
"right on did not set MOVING_RIGHT (state %d)", actor.state);
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_FACE_RIGHT),
|
||||
"right on did not set FACE_RIGHT (state %d)", actor.state);
|
||||
TEST_ASSERT_FEQ(e, actor.ax, 7.0f, "right on set ax to %f, expected 7", actor.ax);
|
||||
|
||||
// Up reverses the Y acceleration, because Y grows downward.
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
actor.basechar = &basechar;
|
||||
TEST_EXPECT_OK(e, akgl_actor_cmhf_up_on(&actor, &event), "up on");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_MOVING_UP),
|
||||
"up on did not set MOVING_UP (state %d)", actor.state);
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_FACE_UP),
|
||||
"up on did not set FACE_UP (state %d)", actor.state);
|
||||
TEST_ASSERT_FEQ(e, actor.ay, -11.0f, "up on set ay to %f, expected -11", actor.ay);
|
||||
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
actor.basechar = &basechar;
|
||||
TEST_EXPECT_OK(e, akgl_actor_cmhf_down_on(&actor, &event), "down on");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_MOVING_DOWN),
|
||||
"down on did not set MOVING_DOWN (state %d)", actor.state);
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_FACE_DOWN),
|
||||
"down on did not set FACE_DOWN (state %d)", actor.state);
|
||||
TEST_ASSERT_FEQ(e, actor.ay, 11.0f, "down on set ay to %f, expected 11", actor.ay);
|
||||
|
||||
// Each direction is exclusive: turning right after left leaves only right.
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
actor.basechar = &basechar;
|
||||
TEST_EXPECT_OK(e, akgl_actor_cmhf_left_on(&actor, &event), "left on before turning");
|
||||
TEST_EXPECT_OK(e, akgl_actor_cmhf_right_on(&actor, &event), "right on after left");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_MOVING_LEFT),
|
||||
"turning right left MOVING_LEFT set (state %d)", actor.state);
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_actor_control_handlers_off(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_Actor actor;
|
||||
akgl_Character basechar;
|
||||
SDL_Event event;
|
||||
|
||||
ATTEMPT {
|
||||
memset(&event, 0x00, sizeof(SDL_Event));
|
||||
memset(&basechar, 0x00, sizeof(akgl_Character));
|
||||
basechar.ax = 7.0f;
|
||||
basechar.ay = 11.0f;
|
||||
|
||||
// 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;
|
||||
actor.ay = 5; actor.ey = 5; actor.ty = 5; actor.vy = 5;
|
||||
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.tx, 0.0f, "left off left tx at %f", actor.tx);
|
||||
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.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.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);
|
||||
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
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.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);
|
||||
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
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.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 {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_actor_control_handlers_nullpointers(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_Actor actor;
|
||||
SDL_Event event;
|
||||
|
||||
ATTEMPT {
|
||||
memset(&event, 0x00, sizeof(SDL_Event));
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_cmhf_left_on(NULL, &event), "left on, NULL actor");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_cmhf_left_off(NULL, &event), "left off, NULL actor");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_cmhf_right_on(NULL, &event), "right on, NULL actor");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_cmhf_right_off(NULL, &event), "right off, NULL actor");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_cmhf_up_on(NULL, &event), "up on, NULL actor");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_cmhf_up_off(NULL, &event), "up off, NULL actor");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_cmhf_down_on(NULL, &event), "down on, NULL actor");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_cmhf_down_off(NULL, &event), "down off, NULL actor");
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_cmhf_left_on(&actor, NULL), "left on, NULL event");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_cmhf_left_off(&actor, NULL), "left off, NULL event");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_cmhf_right_on(&actor, NULL), "right on, NULL event");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_cmhf_right_off(&actor, NULL), "right off, NULL event");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_cmhf_up_on(&actor, NULL), "up on, NULL event");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_cmhf_up_off(&actor, NULL), "up off, NULL event");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_cmhf_down_on(&actor, NULL), "down on, NULL event");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_cmhf_down_off(&actor, NULL), "down off, NULL event");
|
||||
|
||||
// A movement handler reads acceleration off the base character, so an
|
||||
// actor without one has to be reported rather than dereferenced.
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_cmhf_left_on(&actor, &event),
|
||||
"left on, actor with no base character");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_cmhf_right_on(&actor, &event),
|
||||
"right on, actor with no base character");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_cmhf_up_on(&actor, &event),
|
||||
"up on, actor with no base character");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_cmhf_down_on(&actor, &event),
|
||||
"down on, actor with no base character");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_actor_automatic_face(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_Actor actor;
|
||||
|
||||
ATTEMPT {
|
||||
// With the flag off, facing is left entirely to the caller.
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
actor.movement_controls_face = false;
|
||||
actor.state = (AKGL_ACTOR_STATE_MOVING_LEFT | AKGL_ACTOR_STATE_FACE_RIGHT);
|
||||
TEST_EXPECT_OK(e, akgl_actor_automatic_face(&actor), "automatic face with the flag off");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_FACE_RIGHT),
|
||||
"automatic face changed facing while disabled (state %d)", actor.state);
|
||||
|
||||
// With the flag on, facing follows movement.
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
actor.movement_controls_face = true;
|
||||
actor.state = (AKGL_ACTOR_STATE_MOVING_LEFT | AKGL_ACTOR_STATE_FACE_RIGHT);
|
||||
TEST_EXPECT_OK(e, akgl_actor_automatic_face(&actor), "automatic face while moving left");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_FACE_LEFT),
|
||||
"automatic face did not turn the actor left (state %d)", actor.state);
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_FACE_RIGHT),
|
||||
"automatic face left the stale facing set (state %d)", actor.state);
|
||||
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
actor.movement_controls_face = true;
|
||||
actor.state = AKGL_ACTOR_STATE_MOVING_RIGHT;
|
||||
TEST_EXPECT_OK(e, akgl_actor_automatic_face(&actor), "automatic face while moving right");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_FACE_RIGHT),
|
||||
"automatic face did not turn the actor right (state %d)", actor.state);
|
||||
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
actor.movement_controls_face = true;
|
||||
actor.state = AKGL_ACTOR_STATE_MOVING_UP;
|
||||
TEST_EXPECT_OK(e, akgl_actor_automatic_face(&actor), "automatic face while moving up");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_FACE_UP),
|
||||
"automatic face did not turn the actor up (state %d)", actor.state);
|
||||
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
actor.movement_controls_face = true;
|
||||
actor.state = AKGL_ACTOR_STATE_MOVING_DOWN;
|
||||
TEST_EXPECT_OK(e, akgl_actor_automatic_face(&actor), "automatic face while moving down");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_FACE_DOWN),
|
||||
"automatic face did not turn the actor down (state %d)", actor.state);
|
||||
|
||||
// Left wins over up when both are set, per the order of the checks.
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
actor.movement_controls_face = true;
|
||||
actor.state = (AKGL_ACTOR_STATE_MOVING_LEFT | AKGL_ACTOR_STATE_MOVING_UP);
|
||||
TEST_EXPECT_OK(e, akgl_actor_automatic_face(&actor), "automatic face while moving diagonally");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_FACE_LEFT),
|
||||
"diagonal movement did not resolve to the left facing (state %d)", actor.state);
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_FACE_UP),
|
||||
"diagonal movement set two facings at once (state %d)", actor.state);
|
||||
|
||||
// A stationary actor ends up facing nowhere.
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
actor.movement_controls_face = true;
|
||||
actor.state = (AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_UP);
|
||||
TEST_EXPECT_OK(e, akgl_actor_automatic_face(&actor), "automatic face while stationary");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HASNOT(actor.state, AKGL_ACTOR_STATE_FACE_ALL),
|
||||
"a stationary actor kept a facing bit (state %d)", actor.state);
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(actor.state, AKGL_ACTOR_STATE_ALIVE),
|
||||
"automatic face cleared an unrelated state bit (state %d)", actor.state);
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_automatic_face(NULL),
|
||||
"automatic face with a NULL actor");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_actor_logic_movement(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_Actor actor;
|
||||
akgl_Character basechar;
|
||||
|
||||
ATTEMPT {
|
||||
memset(&basechar, 0x00, sizeof(akgl_Character));
|
||||
basechar.ax = 3.0f; basechar.ay = 4.0f;
|
||||
basechar.sx = 30.0f; basechar.sy = 40.0f; basechar.sz = 50.0f;
|
||||
|
||||
// Max speed is always refreshed from the base character.
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
actor.basechar = &basechar;
|
||||
TEST_EXPECT_OK(e, akgl_actor_logic_movement(&actor, 1.0f), "movement logic while idle");
|
||||
TEST_ASSERT_FEQ(e, actor.sx, 30.0f, "sx copied as %f, expected 30", actor.sx);
|
||||
TEST_ASSERT_FEQ(e, actor.sy, 40.0f, "sy copied as %f, expected 40", actor.sy);
|
||||
TEST_ASSERT_FEQ(e, actor.sz, 50.0f, "sz copied as %f, expected 50", actor.sz);
|
||||
|
||||
actor.state = AKGL_ACTOR_STATE_MOVING_LEFT;
|
||||
TEST_EXPECT_OK(e, akgl_actor_logic_movement(&actor, 1.0f), "movement logic while moving left");
|
||||
TEST_ASSERT_FEQ(e, actor.ax, -3.0f, "moving left set ax to %f, expected -3", actor.ax);
|
||||
|
||||
actor.state = AKGL_ACTOR_STATE_MOVING_RIGHT;
|
||||
TEST_EXPECT_OK(e, akgl_actor_logic_movement(&actor, 1.0f), "movement logic while moving right");
|
||||
TEST_ASSERT_FEQ(e, actor.ax, 3.0f, "moving right set ax to %f, expected 3", actor.ax);
|
||||
|
||||
actor.state = AKGL_ACTOR_STATE_MOVING_UP;
|
||||
TEST_EXPECT_OK(e, akgl_actor_logic_movement(&actor, 1.0f), "movement logic while moving up");
|
||||
TEST_ASSERT_FEQ(e, actor.ay, -4.0f, "moving up set ay to %f, expected -4", actor.ay);
|
||||
|
||||
actor.state = AKGL_ACTOR_STATE_MOVING_DOWN;
|
||||
TEST_EXPECT_OK(e, akgl_actor_logic_movement(&actor, 1.0f), "movement logic while moving down");
|
||||
TEST_ASSERT_FEQ(e, actor.ay, 4.0f, "moving down set ay to %f, expected 4", actor.ay);
|
||||
|
||||
// Both axes at once.
|
||||
actor.state = (AKGL_ACTOR_STATE_MOVING_LEFT | AKGL_ACTOR_STATE_MOVING_DOWN);
|
||||
TEST_EXPECT_OK(e, akgl_actor_logic_movement(&actor, 1.0f), "movement logic while moving diagonally");
|
||||
TEST_ASSERT_FEQ(e, actor.ax, -3.0f, "diagonal movement set ax to %f, expected -3", actor.ax);
|
||||
TEST_ASSERT_FEQ(e, actor.ay, 4.0f, "diagonal movement set ay to %f, expected 4", actor.ay);
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_logic_movement(NULL, 1.0f),
|
||||
"movement logic with a NULL actor");
|
||||
|
||||
// Every value the movement logic writes is read off the base character,
|
||||
// so an actor without one has to be reported rather than dereferenced.
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_logic_movement(&actor, 1.0f),
|
||||
"movement logic with no base character");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_actor_logic_changeframe(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_Actor actor;
|
||||
akgl_Sprite sprite;
|
||||
|
||||
ATTEMPT {
|
||||
memset(&sprite, 0x00, sizeof(akgl_Sprite));
|
||||
sprite.frames = 4;
|
||||
|
||||
// Mid-animation, the frame simply advances.
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
actor.curSpriteFrameId = 1;
|
||||
TEST_EXPECT_OK(e, akgl_actor_logic_changeframe(&actor, &sprite, 0), "advancing mid-animation");
|
||||
TEST_ASSERT(e, actor.curSpriteFrameId == 2,
|
||||
"frame advanced to %d, expected 2", actor.curSpriteFrameId);
|
||||
|
||||
// At the last frame without looping, it wraps to the start.
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
actor.curSpriteFrameId = 3;
|
||||
sprite.loop = false;
|
||||
sprite.loopReverse = false;
|
||||
TEST_EXPECT_OK(e, akgl_actor_logic_changeframe(&actor, &sprite, 0), "wrapping a non-looping sprite");
|
||||
TEST_ASSERT(e, actor.curSpriteFrameId == 0,
|
||||
"the last frame wrapped to %d, expected 0", actor.curSpriteFrameId);
|
||||
|
||||
// A forward-looping sprite behaves the same way at the end.
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
actor.curSpriteFrameId = 3;
|
||||
sprite.loop = true;
|
||||
sprite.loopReverse = false;
|
||||
TEST_EXPECT_OK(e, akgl_actor_logic_changeframe(&actor, &sprite, 0), "wrapping a forward-looping sprite");
|
||||
TEST_ASSERT(e, actor.curSpriteFrameId == 0,
|
||||
"the forward loop wrapped to %d, expected 0", actor.curSpriteFrameId);
|
||||
|
||||
// A ping-pong sprite turns around at the end instead of wrapping.
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
actor.curSpriteFrameId = 3;
|
||||
sprite.loop = true;
|
||||
sprite.loopReverse = true;
|
||||
TEST_EXPECT_OK(e, akgl_actor_logic_changeframe(&actor, &sprite, 0), "reversing at the end");
|
||||
TEST_ASSERT(e, actor.curSpriteReversing == true,
|
||||
"the sprite did not enter its reverse phase at the last frame");
|
||||
TEST_ASSERT(e, actor.curSpriteFrameId == 2,
|
||||
"reversing stepped to frame %d, expected 2", actor.curSpriteFrameId);
|
||||
|
||||
// While reversing, the frame counts back down.
|
||||
TEST_EXPECT_OK(e, akgl_actor_logic_changeframe(&actor, &sprite, 0), "stepping back while reversing");
|
||||
TEST_ASSERT(e, actor.curSpriteFrameId == 1,
|
||||
"reversing stepped to frame %d, expected 1", actor.curSpriteFrameId);
|
||||
|
||||
// At frame zero it turns around again and resumes going forward.
|
||||
actor.curSpriteFrameId = 0;
|
||||
actor.curSpriteReversing = true;
|
||||
TEST_EXPECT_OK(e, akgl_actor_logic_changeframe(&actor, &sprite, 0), "turning around at frame zero");
|
||||
TEST_ASSERT(e, actor.curSpriteReversing == false,
|
||||
"the sprite stayed in its reverse phase at frame zero");
|
||||
TEST_ASSERT(e, actor.curSpriteFrameId == 1,
|
||||
"turning around stepped to frame %d, expected 1", actor.curSpriteFrameId);
|
||||
|
||||
// A single-frame sprite has nowhere to advance to.
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
sprite.frames = 1;
|
||||
sprite.loop = false;
|
||||
sprite.loopReverse = false;
|
||||
TEST_EXPECT_OK(e, akgl_actor_logic_changeframe(&actor, &sprite, 0), "advancing a single-frame sprite");
|
||||
TEST_ASSERT(e, actor.curSpriteFrameId == 0,
|
||||
"a single-frame sprite moved to frame %d, expected 0", actor.curSpriteFrameId);
|
||||
|
||||
sprite.frames = 4;
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_logic_changeframe(NULL, &sprite, 0),
|
||||
"changeframe with a NULL actor");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
/** @brief Records calls made to the changeframe stub. */
|
||||
static int changeframe_calls = 0;
|
||||
|
||||
/** @brief Changeframe stub that records its invocation without advancing. */
|
||||
static akerr_ErrorContext *stub_changeframe(akgl_Actor *obj, akgl_Sprite *curSprite, SDL_Time curtimems)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
changeframe_calls += 1;
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_actor_update(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_Actor *actor = NULL;
|
||||
akgl_Character *basechar = NULL;
|
||||
akgl_Sprite *sprite = NULL;
|
||||
SDL_Time now = 0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, akgl_registry_init_actor());
|
||||
CATCH(e, akgl_registry_init_sprite());
|
||||
CATCH(e, akgl_registry_init_spritesheet());
|
||||
CATCH(e, akgl_registry_init_character());
|
||||
CATCH(e, akgl_heap_init());
|
||||
|
||||
CATCH(e, make_bound_actor(&actor, &basechar, &sprite, "updatable", AKGL_ACTOR_STATE_ALIVE));
|
||||
actor->changeframefunc = &stub_changeframe;
|
||||
|
||||
// Long enough since the last frame change, so the sprite advances.
|
||||
SDL_GetCurrentTime(&now);
|
||||
sprite->speed = 1;
|
||||
actor->curSpriteFrameTimer = 0;
|
||||
changeframe_calls = 0;
|
||||
TEST_EXPECT_OK(e, akgl_actor_update(actor), "updating an actor whose frame is due");
|
||||
TEST_ASSERT(e, changeframe_calls == 1,
|
||||
"a due frame change fired %d times, expected 1", changeframe_calls);
|
||||
TEST_ASSERT(e, actor->curSpriteFrameTimer != 0,
|
||||
"the frame timer was not restamped after a frame change");
|
||||
|
||||
// Too soon since the last change, so nothing happens.
|
||||
SDL_GetCurrentTime(&now);
|
||||
sprite->speed = 1000000000;
|
||||
actor->curSpriteFrameTimer = now;
|
||||
changeframe_calls = 0;
|
||||
TEST_EXPECT_OK(e, akgl_actor_update(actor), "updating an actor whose frame is not due");
|
||||
TEST_ASSERT(e, changeframe_calls == 0,
|
||||
"an early frame change fired %d times, expected 0", changeframe_calls);
|
||||
|
||||
// An actor in a state with no sprite bound is skipped, not failed: the
|
||||
// missing binding is reported as AKERR_KEY and swallowed by update.
|
||||
actor->state = AKGL_ACTOR_STATE_DEAD;
|
||||
changeframe_calls = 0;
|
||||
TEST_EXPECT_OK(e, akgl_actor_update(actor),
|
||||
"updating an actor whose state has no sprite");
|
||||
TEST_ASSERT(e, changeframe_calls == 0,
|
||||
"an actor with no sprite for its state still changed frames");
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_update(NULL),
|
||||
"updating a NULL actor");
|
||||
|
||||
// An actor with no base character cannot resolve a sprite at all.
|
||||
actor->basechar = NULL;
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_actor_update(actor),
|
||||
"updating an actor with no base character");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_actor_character_sprite_binding(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_Actor *actor = NULL;
|
||||
akgl_Character *basechar = NULL;
|
||||
akgl_Sprite *sprite = NULL;
|
||||
akgl_Sprite *found = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, akgl_registry_init_actor());
|
||||
CATCH(e, akgl_registry_init_sprite());
|
||||
CATCH(e, akgl_registry_init_spritesheet());
|
||||
CATCH(e, akgl_registry_init_character());
|
||||
CATCH(e, akgl_heap_init());
|
||||
|
||||
CATCH(e, make_bound_actor(&actor, &basechar, &sprite, "bound", AKGL_ACTOR_STATE_ALIVE));
|
||||
|
||||
TEST_EXPECT_OK(e, akgl_character_sprite_get(basechar, AKGL_ACTOR_STATE_ALIVE, &found),
|
||||
"reading back a bound sprite");
|
||||
TEST_ASSERT(e, found == sprite, "the bound sprite came back as a different object");
|
||||
|
||||
// A composite state is a distinct key from either of its components.
|
||||
TEST_EXPECT_STATUS(e, AKERR_KEY,
|
||||
akgl_character_sprite_get(basechar,
|
||||
(AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_UP),
|
||||
&found),
|
||||
"reading a composite state that was never bound");
|
||||
TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_character_sprite_get(basechar, 0, &found),
|
||||
"reading state zero, which was never bound");
|
||||
|
||||
// Binding a composite state makes it resolvable.
|
||||
TEST_EXPECT_OK(e, akgl_character_sprite_add(basechar, sprite,
|
||||
(AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_UP)),
|
||||
"binding a sprite to a composite state");
|
||||
TEST_EXPECT_OK(e, akgl_character_sprite_get(basechar,
|
||||
(AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_UP),
|
||||
&found),
|
||||
"reading back the composite binding");
|
||||
TEST_ASSERT(e, found == sprite, "the composite binding resolved to a different sprite");
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_character_sprite_get(NULL, 0, &found),
|
||||
"sprite_get with a NULL character");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER,
|
||||
akgl_character_sprite_get(basechar, AKGL_ACTOR_STATE_ALIVE, NULL),
|
||||
"sprite_get with a NULL destination");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_character_sprite_add(NULL, sprite, 0),
|
||||
"sprite_add with a NULL character");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_character_sprite_add(basechar, NULL, 0),
|
||||
"sprite_add with a NULL sprite");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_actor_sprite_sheet_coords(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_Sprite sprite;
|
||||
SDL_FRect coords;
|
||||
|
||||
ATTEMPT {
|
||||
memset(&sprite, 0x00, sizeof(akgl_Sprite));
|
||||
sprite.width = 32;
|
||||
sprite.height = 32;
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_spritesheet_coords_for_frame(NULL, &coords, 0),
|
||||
"sheet coords with a NULL sprite");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_spritesheet_coords_for_frame(&sprite, NULL, 0),
|
||||
"sheet coords with a NULL rectangle");
|
||||
// The sprite has no sheet attached, so there is no texture to measure against.
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_spritesheet_coords_for_frame(&sprite, &coords, 0),
|
||||
"sheet coords with a NULL spritesheet");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
/*
|
||||
* A render backend whose draw_texture records what it was asked to draw instead
|
||||
* of drawing it. akgl_actor_render's whole output is the rectangle it hands to
|
||||
* draw_texture, so that rectangle is the only thing there is to assert on.
|
||||
*/
|
||||
static akgl_RenderBackend testbackend;
|
||||
static SDL_FRect lastdrawdest;
|
||||
static SDL_FRect lastdrawsrc;
|
||||
static int drawcalls = 0;
|
||||
|
||||
static akerr_ErrorContext *record_draw_texture(
|
||||
akgl_RenderBackend *self,
|
||||
SDL_Texture *texture,
|
||||
SDL_FRect *src,
|
||||
SDL_FRect *dest,
|
||||
double angle,
|
||||
SDL_FPoint *center,
|
||||
SDL_FlipMode flip)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
if ( src != NULL ) {
|
||||
lastdrawsrc = *src;
|
||||
}
|
||||
if ( dest != NULL ) {
|
||||
lastdrawdest = *dest;
|
||||
}
|
||||
drawcalls += 1;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief akgl_actor_render must take the drawn height from the sprite's height.
|
||||
*
|
||||
* It read `curSprite->width` for both, so every actor was drawn square and a
|
||||
* non-square sprite was stretched or squashed. Invisible in the fixtures
|
||||
* because they are square, which is exactly why this test uses a sprite that
|
||||
* is not: 48 wide by 24 high, so a regression puts 48 in both.
|
||||
*
|
||||
* This is also the first test of akgl_actor_render at all -- everything else in
|
||||
* this file stubs renderfunc out.
|
||||
*/
|
||||
akerr_ErrorContext *test_actor_render_uses_sprite_height(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_Actor *actor = NULL;
|
||||
akgl_Character *basechar = NULL;
|
||||
akgl_Sprite *sprite = NULL;
|
||||
SDL_Texture *sheettexture = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, make_bound_actor(&actor, &basechar, &sprite, "renderactor", AKGL_ACTOR_STATE_ALIVE));
|
||||
|
||||
// akgl_spritesheet_coords_for_frame reads sheet->texture->w, so the
|
||||
// sheet needs a real one even though nothing is rasterized.
|
||||
sheettexture = SDL_CreateTexture(
|
||||
testbackend.sdl_renderer,
|
||||
SDL_PIXELFORMAT_RGBA8888,
|
||||
SDL_TEXTUREACCESS_TARGET,
|
||||
96, 48);
|
||||
FAIL_ZERO_BREAK(e, sheettexture, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
sprite->sheet->texture = sheettexture;
|
||||
|
||||
sprite->width = 48;
|
||||
sprite->height = 24;
|
||||
sprite->frames = 2;
|
||||
actor->curSpriteFrameId = 0;
|
||||
actor->visible = true;
|
||||
actor->scale = 1.0f;
|
||||
actor->x = 0;
|
||||
actor->y = 0;
|
||||
|
||||
akgl_camera->x = 0;
|
||||
akgl_camera->y = 0;
|
||||
akgl_camera->w = 640;
|
||||
akgl_camera->h = 480;
|
||||
|
||||
drawcalls = 0;
|
||||
TEST_EXPECT_OK(e, akgl_actor_render(actor), "rendering a non-square actor");
|
||||
TEST_ASSERT(e, drawcalls == 1, "actor render made %d draw calls, expected 1", drawcalls);
|
||||
|
||||
TEST_ASSERT_FEQ(e, lastdrawdest.w, 48.0f,
|
||||
"drawn width is %f, expected the sprite's 48", lastdrawdest.w);
|
||||
TEST_ASSERT_FEQ(e, lastdrawdest.h, 24.0f,
|
||||
"drawn height is %f, expected the sprite's 24 -- it used to take the width",
|
||||
lastdrawdest.h);
|
||||
|
||||
// The source rectangle has always come from the sprite properly; assert
|
||||
// it too, so a regression cannot move the bug one line up.
|
||||
TEST_ASSERT_FEQ(e, lastdrawsrc.w, 48.0f, "source width is %f, expected 48", lastdrawsrc.w);
|
||||
TEST_ASSERT_FEQ(e, lastdrawsrc.h, 24.0f, "source height is %f, expected 24", lastdrawsrc.h);
|
||||
|
||||
// Scale multiplies both, and must not collapse them back together.
|
||||
actor->scale = 2.0f;
|
||||
drawcalls = 0;
|
||||
TEST_EXPECT_OK(e, akgl_actor_render(actor), "rendering a scaled non-square actor");
|
||||
TEST_ASSERT_FEQ(e, lastdrawdest.w, 96.0f, "scaled width is %f, expected 96", lastdrawdest.w);
|
||||
TEST_ASSERT_FEQ(e, lastdrawdest.h, 48.0f, "scaled height is %f, expected 48", lastdrawdest.h);
|
||||
} CLEANUP {
|
||||
if ( sprite != NULL && sprite->sheet != NULL ) {
|
||||
sprite->sheet->texture = NULL;
|
||||
}
|
||||
if ( sheettexture != NULL ) {
|
||||
SDL_DestroyTexture(sheettexture);
|
||||
}
|
||||
if ( actor != NULL ) {
|
||||
IGNORE(akgl_heap_release_actor(actor));
|
||||
}
|
||||
if ( basechar != NULL ) {
|
||||
IGNORE(akgl_heap_release_character(basechar));
|
||||
}
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
akgl_actor_updated = 0;
|
||||
akgl_actor_rendered = 0;
|
||||
UNHANDLED_ERROR_BEHAVIOR = UNHANDLED_ERROR_EXIT;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
CATCH(errctx, akgl_registry_init_actor());
|
||||
CATCH(errctx, akgl_registry_init_sprite());
|
||||
CATCH(errctx, akgl_registry_init_spritesheet());
|
||||
CATCH(errctx, akgl_registry_init_character());
|
||||
|
||||
// A renderer, so akgl_actor_render can be driven at all. The backend is
|
||||
// bound and then has its draw_texture replaced, which is the point:
|
||||
// what the function computes is only visible in what it hands over.
|
||||
if ( !SDL_Init(SDL_INIT_VIDEO) ) {
|
||||
FAIL_BREAK(errctx, AKGL_ERR_SDL, "Couldn't initialize SDL: %s", SDL_GetError());
|
||||
}
|
||||
memset(&testbackend, 0x00, sizeof(akgl_RenderBackend));
|
||||
if ( !SDL_CreateWindowAndRenderer("net/aklabs/libakgl/test_actor", 640, 480,
|
||||
SDL_WINDOW_HIDDEN, &akgl_window, &testbackend.sdl_renderer) ) {
|
||||
FAIL_BREAK(errctx, AKGL_ERR_SDL, "Couldn't create window/renderer: %s", SDL_GetError());
|
||||
}
|
||||
CATCH(errctx, akgl_render_2d_bind(&testbackend));
|
||||
testbackend.draw_texture = &record_draw_texture;
|
||||
akgl_renderer = &testbackend;
|
||||
akgl_camera = &akgl_default_camera;
|
||||
|
||||
CATCH(errctx, test_registry_actor_iterator_nullpointers());
|
||||
CATCH(errctx, test_registry_actor_iterator_missingactor());
|
||||
CATCH(errctx, test_registry_actor_iterator_updaterender());
|
||||
CATCH(errctx, test_akgl_actor_set_character());
|
||||
CATCH(errctx, test_actor_manage_children());
|
||||
|
||||
CATCH(errctx, test_actor_control_handlers_on());
|
||||
CATCH(errctx, test_actor_control_handlers_off());
|
||||
CATCH(errctx, test_actor_control_handlers_nullpointers());
|
||||
CATCH(errctx, test_actor_automatic_face());
|
||||
CATCH(errctx, test_actor_logic_movement());
|
||||
CATCH(errctx, test_actor_logic_changeframe());
|
||||
CATCH(errctx, test_actor_update());
|
||||
CATCH(errctx, test_actor_character_sprite_binding());
|
||||
CATCH(errctx, test_actor_sprite_sheet_coords());
|
||||
CATCH(errctx, test_actor_render_uses_sprite_height());
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
|
||||
121
tests/assets/akgl_test_mono.LICENSE.txt
Normal file
121
tests/assets/akgl_test_mono.LICENSE.txt
Normal file
@@ -0,0 +1,121 @@
|
||||
tests/assets/akgl_test_mono.ttf
|
||||
================================
|
||||
|
||||
A subset of Liberation Mono Regular, cut down to printable ASCII (U+0020 to
|
||||
U+007E) so the text suite has a font fixture that is 10 KB rather than 320 KB.
|
||||
It is monospaced, which is what the measurement tests rely on: the width of an
|
||||
N-character string is exactly N times the width of one character, in any font
|
||||
size, so the assertions do not have to hardcode glyph metrics.
|
||||
|
||||
Generated with:
|
||||
|
||||
pyftsubset /usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf \
|
||||
--unicodes=U+0020-007E --layout-features='' --no-hinting \
|
||||
--desubroutinize --name-IDs='*' --output-file=akgl_test_mono.ttf
|
||||
|
||||
then renamed to "AKGL Test Mono" through the fontTools name table. The rename is
|
||||
required, not cosmetic: "Liberation" is a Reserved Font Name under the license
|
||||
below, and a modified copy may not carry it.
|
||||
|
||||
Copyright (c) 2012 Red Hat, Inc. with Reserved Font Name Liberation.
|
||||
Digitized data copyright (c) 2010 Google Corporation with Reserved Font Arimo,
|
||||
Tinos and Cousine.
|
||||
|
||||
Licensed under the SIL Open Font License, Version 1.1, reproduced in full below.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
|
||||
PREAMBLE The goals of the Open Font License (OFL) are to stimulate
|
||||
worldwide development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to provide
|
||||
a free and open framework in which fonts may be shared and improved in
|
||||
partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves.
|
||||
The fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such.
|
||||
This may include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components
|
||||
as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting ? in part or in whole ?
|
||||
any of the components of the Original Version, by changing formats or
|
||||
by porting the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical writer
|
||||
or other person who contributed to the Font Software.
|
||||
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole, must
|
||||
be distributed entirely under this license, and must not be distributed
|
||||
under any other license. The requirement for fonts to remain under
|
||||
this license does not apply to any document created using the Font
|
||||
Software.
|
||||
|
||||
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
|
||||
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
tests/assets/akgl_test_mono.ttf
Normal file
BIN
tests/assets/akgl_test_mono.ttf
Normal file
Binary file not shown.
19
tests/assets/snippets/test_json_helpers.json
Normal file
19
tests/assets/snippets/test_json_helpers.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "json helper fixture",
|
||||
"count": 42,
|
||||
"ratio": 2.5,
|
||||
"negative": -17,
|
||||
"enabled": true,
|
||||
"disabled": false,
|
||||
"nested": {
|
||||
"inner": "value",
|
||||
"innercount": 7
|
||||
},
|
||||
"integers": [10, 20, 30],
|
||||
"strings": ["alpha", "beta"],
|
||||
"objects": [
|
||||
{ "id": 1 },
|
||||
{ "id": 2 }
|
||||
],
|
||||
"mixed": [1, "two", { "three": 3 }]
|
||||
}
|
||||
1549
tests/assets/snippets/test_tilemap_max_objects.json
Normal file
1549
tests/assets/snippets/test_tilemap_max_objects.json
Normal file
File diff suppressed because it is too large
Load Diff
1561
tests/assets/snippets/test_tilemap_too_many_objects.json
Normal file
1561
tests/assets/snippets/test_tilemap_too_many_objects.json
Normal file
File diff suppressed because it is too large
Load Diff
225
tests/assets/snippets/test_tilemap_too_many_tilesets.json
Normal file
225
tests/assets/snippets/test_tilemap_too_many_tilesets.json
Normal file
@@ -0,0 +1,225 @@
|
||||
{
|
||||
"tilesets": [
|
||||
{
|
||||
"columns": 48,
|
||||
"firstgid": 1,
|
||||
"image": "assets/World_A1.png",
|
||||
"imageheight": 576,
|
||||
"imagewidth": 768,
|
||||
"margin": 0,
|
||||
"name": "World_A1_0",
|
||||
"spacing": 0,
|
||||
"tilecount": 1728,
|
||||
"tileheight": 16,
|
||||
"tilewidth": 16
|
||||
},
|
||||
{
|
||||
"columns": 48,
|
||||
"firstgid": 1729,
|
||||
"image": "assets/World_A1.png",
|
||||
"imageheight": 576,
|
||||
"imagewidth": 768,
|
||||
"margin": 0,
|
||||
"name": "World_A1_1",
|
||||
"spacing": 0,
|
||||
"tilecount": 1728,
|
||||
"tileheight": 16,
|
||||
"tilewidth": 16
|
||||
},
|
||||
{
|
||||
"columns": 48,
|
||||
"firstgid": 3457,
|
||||
"image": "assets/World_A1.png",
|
||||
"imageheight": 576,
|
||||
"imagewidth": 768,
|
||||
"margin": 0,
|
||||
"name": "World_A1_2",
|
||||
"spacing": 0,
|
||||
"tilecount": 1728,
|
||||
"tileheight": 16,
|
||||
"tilewidth": 16
|
||||
},
|
||||
{
|
||||
"columns": 48,
|
||||
"firstgid": 5185,
|
||||
"image": "assets/World_A1.png",
|
||||
"imageheight": 576,
|
||||
"imagewidth": 768,
|
||||
"margin": 0,
|
||||
"name": "World_A1_3",
|
||||
"spacing": 0,
|
||||
"tilecount": 1728,
|
||||
"tileheight": 16,
|
||||
"tilewidth": 16
|
||||
},
|
||||
{
|
||||
"columns": 48,
|
||||
"firstgid": 6913,
|
||||
"image": "assets/World_A1.png",
|
||||
"imageheight": 576,
|
||||
"imagewidth": 768,
|
||||
"margin": 0,
|
||||
"name": "World_A1_4",
|
||||
"spacing": 0,
|
||||
"tilecount": 1728,
|
||||
"tileheight": 16,
|
||||
"tilewidth": 16
|
||||
},
|
||||
{
|
||||
"columns": 48,
|
||||
"firstgid": 8641,
|
||||
"image": "assets/World_A1.png",
|
||||
"imageheight": 576,
|
||||
"imagewidth": 768,
|
||||
"margin": 0,
|
||||
"name": "World_A1_5",
|
||||
"spacing": 0,
|
||||
"tilecount": 1728,
|
||||
"tileheight": 16,
|
||||
"tilewidth": 16
|
||||
},
|
||||
{
|
||||
"columns": 48,
|
||||
"firstgid": 10369,
|
||||
"image": "assets/World_A1.png",
|
||||
"imageheight": 576,
|
||||
"imagewidth": 768,
|
||||
"margin": 0,
|
||||
"name": "World_A1_6",
|
||||
"spacing": 0,
|
||||
"tilecount": 1728,
|
||||
"tileheight": 16,
|
||||
"tilewidth": 16
|
||||
},
|
||||
{
|
||||
"columns": 48,
|
||||
"firstgid": 12097,
|
||||
"image": "assets/World_A1.png",
|
||||
"imageheight": 576,
|
||||
"imagewidth": 768,
|
||||
"margin": 0,
|
||||
"name": "World_A1_7",
|
||||
"spacing": 0,
|
||||
"tilecount": 1728,
|
||||
"tileheight": 16,
|
||||
"tilewidth": 16
|
||||
},
|
||||
{
|
||||
"columns": 48,
|
||||
"firstgid": 13825,
|
||||
"image": "assets/World_A1.png",
|
||||
"imageheight": 576,
|
||||
"imagewidth": 768,
|
||||
"margin": 0,
|
||||
"name": "World_A1_8",
|
||||
"spacing": 0,
|
||||
"tilecount": 1728,
|
||||
"tileheight": 16,
|
||||
"tilewidth": 16
|
||||
},
|
||||
{
|
||||
"columns": 48,
|
||||
"firstgid": 15553,
|
||||
"image": "assets/World_A1.png",
|
||||
"imageheight": 576,
|
||||
"imagewidth": 768,
|
||||
"margin": 0,
|
||||
"name": "World_A1_9",
|
||||
"spacing": 0,
|
||||
"tilecount": 1728,
|
||||
"tileheight": 16,
|
||||
"tilewidth": 16
|
||||
},
|
||||
{
|
||||
"columns": 48,
|
||||
"firstgid": 17281,
|
||||
"image": "assets/World_A1.png",
|
||||
"imageheight": 576,
|
||||
"imagewidth": 768,
|
||||
"margin": 0,
|
||||
"name": "World_A1_10",
|
||||
"spacing": 0,
|
||||
"tilecount": 1728,
|
||||
"tileheight": 16,
|
||||
"tilewidth": 16
|
||||
},
|
||||
{
|
||||
"columns": 48,
|
||||
"firstgid": 19009,
|
||||
"image": "assets/World_A1.png",
|
||||
"imageheight": 576,
|
||||
"imagewidth": 768,
|
||||
"margin": 0,
|
||||
"name": "World_A1_11",
|
||||
"spacing": 0,
|
||||
"tilecount": 1728,
|
||||
"tileheight": 16,
|
||||
"tilewidth": 16
|
||||
},
|
||||
{
|
||||
"columns": 48,
|
||||
"firstgid": 20737,
|
||||
"image": "assets/World_A1.png",
|
||||
"imageheight": 576,
|
||||
"imagewidth": 768,
|
||||
"margin": 0,
|
||||
"name": "World_A1_12",
|
||||
"spacing": 0,
|
||||
"tilecount": 1728,
|
||||
"tileheight": 16,
|
||||
"tilewidth": 16
|
||||
},
|
||||
{
|
||||
"columns": 48,
|
||||
"firstgid": 22465,
|
||||
"image": "assets/World_A1.png",
|
||||
"imageheight": 576,
|
||||
"imagewidth": 768,
|
||||
"margin": 0,
|
||||
"name": "World_A1_13",
|
||||
"spacing": 0,
|
||||
"tilecount": 1728,
|
||||
"tileheight": 16,
|
||||
"tilewidth": 16
|
||||
},
|
||||
{
|
||||
"columns": 48,
|
||||
"firstgid": 24193,
|
||||
"image": "assets/World_A1.png",
|
||||
"imageheight": 576,
|
||||
"imagewidth": 768,
|
||||
"margin": 0,
|
||||
"name": "World_A1_14",
|
||||
"spacing": 0,
|
||||
"tilecount": 1728,
|
||||
"tileheight": 16,
|
||||
"tilewidth": 16
|
||||
},
|
||||
{
|
||||
"columns": 48,
|
||||
"firstgid": 25921,
|
||||
"image": "assets/World_A1.png",
|
||||
"imageheight": 576,
|
||||
"imagewidth": 768,
|
||||
"margin": 0,
|
||||
"name": "World_A1_15",
|
||||
"spacing": 0,
|
||||
"tilecount": 1728,
|
||||
"tileheight": 16,
|
||||
"tilewidth": 16
|
||||
},
|
||||
{
|
||||
"columns": 48,
|
||||
"firstgid": 27649,
|
||||
"image": "assets/World_A1.png",
|
||||
"imageheight": 576,
|
||||
"imagewidth": 768,
|
||||
"margin": 0,
|
||||
"name": "World_A1_16",
|
||||
"spacing": 0,
|
||||
"tilecount": 1728,
|
||||
"tileheight": 16,
|
||||
"tilewidth": 16
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"name": "testcharacter",
|
||||
"velocity_x": 0.20,
|
||||
"velocity_y": 0.20,
|
||||
"movementspeed": 1,
|
||||
"speedtime": 1,
|
||||
"speed_x": 0.20,
|
||||
"speed_y": 0.20,
|
||||
"acceleration_x": 0.20,
|
||||
"acceleration_y": 0.20,
|
||||
"sprite_mappings": [
|
||||
{
|
||||
"state": [
|
||||
@@ -12,7 +16,7 @@
|
||||
},
|
||||
{
|
||||
"state": [
|
||||
"ACTOR_STATE_DEAD"
|
||||
"AKGL_ACTOR_STATE_DEAD"
|
||||
],
|
||||
"sprite": "testsprite2"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"spritesheet": {
|
||||
"filename": "assets/spritesheet.png",
|
||||
"filename": "spritesheet.png",
|
||||
"frame_width": 48,
|
||||
"frame_height": 48
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"spritesheet": {
|
||||
"filename": "assets/spritesheet.png",
|
||||
"filename": "spritesheet.png",
|
||||
"frame_width": 48,
|
||||
"frame_height": 48
|
||||
},
|
||||
|
||||
31
tests/assets/testsprite_maxframes.json
Normal file
31
tests/assets/testsprite_maxframes.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"spritesheet": {
|
||||
"filename": "spritesheet.png",
|
||||
"frame_width": 48,
|
||||
"frame_height": 48
|
||||
},
|
||||
"name": "testsprite_maxframes",
|
||||
"width": 48,
|
||||
"height": 48,
|
||||
"speed": 100,
|
||||
"loop": true,
|
||||
"loopReverse": true,
|
||||
"frames": [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
13,
|
||||
14,
|
||||
15
|
||||
]
|
||||
}
|
||||
32
tests/assets/testsprite_toomanyframes.json
Normal file
32
tests/assets/testsprite_toomanyframes.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"spritesheet": {
|
||||
"filename": "spritesheet.png",
|
||||
"frame_width": 48,
|
||||
"frame_height": 48
|
||||
},
|
||||
"name": "testsprite_toomanyframes",
|
||||
"width": 48,
|
||||
"height": 48,
|
||||
"speed": 100,
|
||||
"loop": true,
|
||||
"loopReverse": true,
|
||||
"frames": [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
13,
|
||||
14,
|
||||
15,
|
||||
16
|
||||
]
|
||||
}
|
||||
17
tests/assets/testsprite_widecount.json
Normal file
17
tests/assets/testsprite_widecount.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"spritesheet": {
|
||||
"filename": "spritesheet.png",
|
||||
"frame_width": 48,
|
||||
"frame_height": 48
|
||||
},
|
||||
"name": "testsprite_widecount",
|
||||
"width": 48,
|
||||
"height": 48,
|
||||
"speed": 100,
|
||||
"loop": true,
|
||||
"loopReverse": true,
|
||||
"frames": [
|
||||
0,
|
||||
256
|
||||
]
|
||||
}
|
||||
625
tests/audio.c
Normal file
625
tests/audio.c
Normal file
@@ -0,0 +1,625 @@
|
||||
/**
|
||||
* @file audio.c
|
||||
* @brief Unit tests for the tone generator.
|
||||
*
|
||||
* Almost everything here drives akgl_audio_mix() by hand rather than opening a
|
||||
* device. That is not a workaround: a device pulls samples on SDL's audio
|
||||
* thread at whatever rate it likes, so a test that opened one and then asserted
|
||||
* on voice state would be racing the callback. Pulling the samples ourselves
|
||||
* makes the synthesis deterministic -- the same input produces the same
|
||||
* waveform, sample for sample, every run.
|
||||
*
|
||||
* The device is opened once, at the end, to prove akgl_audio_init() and
|
||||
* akgl_audio_shutdown() work against the dummy driver.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <math.h>
|
||||
#include <string.h>
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/audio.h>
|
||||
#include <akgl/error.h>
|
||||
|
||||
#include "testutil.h"
|
||||
|
||||
/**
|
||||
* @brief Frames of output most tests generate at a time.
|
||||
*
|
||||
* Enough to hold a 10 ms attack and a 10 ms decay back to back at 44100 frames
|
||||
* per second, which is the longest single stretch any test here inspects.
|
||||
*/
|
||||
#define TEST_MIX_FRAMES 1024
|
||||
|
||||
/** @brief 441 Hz at 44100 frames per second is exactly 100 frames per cycle. */
|
||||
#define TEST_TONE_HZ 441.0f
|
||||
/** @brief Frames in one cycle of TEST_TONE_HZ. */
|
||||
#define TEST_TONE_PERIOD 100
|
||||
|
||||
/** @brief Somewhere to mix into. */
|
||||
static float32_t samples[TEST_MIX_FRAMES];
|
||||
|
||||
/** @brief Silence every voice and put the master level back. */
|
||||
static akerr_ErrorContext *reset_audio(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
for ( i = 0; i < AKGL_AUDIO_MAX_VOICES; i++ ) {
|
||||
CATCH(errctx, akgl_audio_stop(i));
|
||||
CATCH(errctx, akgl_audio_waveform(i, AKGL_AUDIO_WAVE_SQUARE));
|
||||
CATCH(errctx, akgl_audio_envelope(i, 0, 0, 1.0f, 0));
|
||||
}
|
||||
CATCH(errctx, akgl_audio_volume(1.0f));
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/** @brief Report the largest absolute sample in the first @p frames. */
|
||||
static float32_t peak_of(int frames)
|
||||
{
|
||||
float32_t peak = 0.0f;
|
||||
int i = 0;
|
||||
|
||||
for ( i = 0; i < frames; i++ ) {
|
||||
if ( fabsf(samples[i]) > peak ) {
|
||||
peak = fabsf(samples[i]);
|
||||
}
|
||||
}
|
||||
return peak;
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_audio_defaults(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
// Deliberately the first thing this file does, and deliberately without
|
||||
// a call to reset_audio(): a voice nobody has configured has to make a
|
||||
// sound. A zeroed voice would have a sustain of 0.0 and be silent, with
|
||||
// no error to say why, which is exactly the trap this defends against.
|
||||
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
|
||||
TEST_ASSERT_FEQ(errctx, samples[0], 1.0f,
|
||||
"an unconfigured voice mixed to %f, expected a full-level square wave",
|
||||
samples[0]);
|
||||
TEST_ASSERT_FEQ(errctx, samples[50], -1.0f,
|
||||
"an unconfigured voice is not a square wave (%f half a cycle in)",
|
||||
samples[50]);
|
||||
} CLEANUP {
|
||||
IGNORE(reset_audio());
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_audio_silence(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
bool silent = true;
|
||||
bool active = true;
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, reset_audio());
|
||||
|
||||
// With nothing sounding, the mixer produces silence rather than
|
||||
// whatever was left in the buffer.
|
||||
SDL_memset((void *)&samples, 0xff, sizeof(samples));
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES), "mixing an idle voice table");
|
||||
for ( i = 0; i < TEST_MIX_FRAMES; i++ ) {
|
||||
TEST_ASSERT_FLAG(silent, samples[i] == 0.0f);
|
||||
}
|
||||
TEST_ASSERT(errctx, silent == true, "an idle voice table did not mix to silence");
|
||||
|
||||
for ( i = 0; i < AKGL_AUDIO_MAX_VOICES; i++ ) {
|
||||
CATCH(errctx, akgl_audio_voice_active(i, &active));
|
||||
TEST_ASSERT(errctx, active == false, "voice %d reports active with nothing playing", i);
|
||||
}
|
||||
|
||||
// Zero frames is a legal request that writes nothing.
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_mix(samples, 0), "mixing zero frames");
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_audio_mix(NULL, 16),
|
||||
"mixing into a NULL destination");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_mix(samples, -1),
|
||||
"mixing a negative number of frames");
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_audio_square_tone(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
bool firsthalf = true;
|
||||
bool secondhalf = true;
|
||||
bool active = false;
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, reset_audio());
|
||||
|
||||
// A square wave with a flat envelope is the one waveform whose every
|
||||
// sample is known exactly: +1 for the first half of each cycle, -1 for
|
||||
// the second.
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000), "sounding a tone");
|
||||
CATCH(errctx, akgl_audio_voice_active(0, &active));
|
||||
TEST_ASSERT(errctx, active == true, "a sounded voice does not report itself active");
|
||||
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES), "mixing a square wave");
|
||||
for ( i = 0; i < (TEST_TONE_PERIOD / 2); i++ ) {
|
||||
TEST_ASSERT_FLAG(firsthalf, samples[i] == 1.0f);
|
||||
}
|
||||
for ( i = (TEST_TONE_PERIOD / 2); i < TEST_TONE_PERIOD; i++ ) {
|
||||
TEST_ASSERT_FLAG(secondhalf, samples[i] == -1.0f);
|
||||
}
|
||||
TEST_ASSERT(errctx, firsthalf == true,
|
||||
"the first half cycle of a square wave is not at full positive level");
|
||||
TEST_ASSERT(errctx, secondhalf == true,
|
||||
"the second half cycle of a square wave is not at full negative level");
|
||||
|
||||
// The wave repeats: the second cycle matches the first.
|
||||
TEST_ASSERT(errctx, samples[TEST_TONE_PERIOD] == samples[0],
|
||||
"the wave did not repeat after one period");
|
||||
TEST_ASSERT(errctx, samples[TEST_TONE_PERIOD + 60] == samples[60],
|
||||
"the wave did not repeat after one period");
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_tone(0, 0.0f, 100),
|
||||
"sounding a tone at zero hertz");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_tone(0, -100.0f, 100),
|
||||
"sounding a tone at a negative frequency");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_tone(0, TEST_TONE_HZ, 0),
|
||||
"sounding a tone with no duration");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_tone(-1, TEST_TONE_HZ, 100),
|
||||
"sounding a tone on a negative voice");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_audio_tone(AKGL_AUDIO_MAX_VOICES, TEST_TONE_HZ, 100),
|
||||
"sounding a tone on a voice past the last one");
|
||||
} CLEANUP {
|
||||
IGNORE(reset_audio());
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_audio_waveforms(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
bool inrange = true;
|
||||
int i = 0;
|
||||
int w = 0;
|
||||
akgl_AudioWaveform shapes[4] = {
|
||||
AKGL_AUDIO_WAVE_TRIANGLE,
|
||||
AKGL_AUDIO_WAVE_SAWTOOTH,
|
||||
AKGL_AUDIO_WAVE_NOISE,
|
||||
AKGL_AUDIO_WAVE_SINE,
|
||||
};
|
||||
|
||||
ATTEMPT {
|
||||
// Every shape has to stay inside full scale and actually move. The
|
||||
// exact sample values differ per shape; these two properties do not.
|
||||
for ( w = 0; w < 4; w++ ) {
|
||||
CATCH(errctx, reset_audio());
|
||||
CATCH(errctx, akgl_audio_waveform(0, shapes[w]));
|
||||
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
|
||||
for ( i = 0; i < TEST_MIX_FRAMES; i++ ) {
|
||||
TEST_ASSERT_FLAG(inrange, (samples[i] >= -1.0f) && (samples[i] <= 1.0f));
|
||||
}
|
||||
TEST_ASSERT_FLAG(inrange, peak_of(TEST_MIX_FRAMES) > 0.5f);
|
||||
}
|
||||
TEST_ASSERT(errctx, inrange == true,
|
||||
"a waveform either left full scale or produced nothing");
|
||||
|
||||
// The triangle is symmetric about the middle of its cycle, which the
|
||||
// square and sawtooth are not -- enough to tell it was really selected.
|
||||
CATCH(errctx, reset_audio());
|
||||
CATCH(errctx, akgl_audio_waveform(0, AKGL_AUDIO_WAVE_TRIANGLE));
|
||||
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
|
||||
TEST_ASSERT_FEQ(errctx, samples[25], 0.0f,
|
||||
"a triangle wave is at %f a quarter of the way up, expected 0",
|
||||
samples[25]);
|
||||
TEST_ASSERT_FEQ(errctx, samples[50], 1.0f,
|
||||
"a triangle wave is at %f at its peak, expected 1", samples[50]);
|
||||
TEST_ASSERT_FEQ(errctx, samples[75], 0.0f,
|
||||
"a triangle wave is at %f three quarters through, expected 0",
|
||||
samples[75]);
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_audio_waveform(0, (akgl_AudioWaveform)(AKGL_AUDIO_WAVE_SINE + 1)),
|
||||
"selecting a waveform past the last one");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_audio_waveform(AKGL_AUDIO_MAX_VOICES, AKGL_AUDIO_WAVE_SINE),
|
||||
"selecting a waveform on a voice past the last one");
|
||||
} CLEANUP {
|
||||
IGNORE(reset_audio());
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_audio_envelope(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
bool rising = true;
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, reset_audio());
|
||||
|
||||
// A 10 ms attack at 44100 is 441 frames, so the level should climb from
|
||||
// nothing to full across the first 441 samples of a square wave and
|
||||
// then hold at the sustain level.
|
||||
CATCH(errctx, akgl_audio_envelope(0, 10, 0, 1.0f, 0));
|
||||
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
|
||||
|
||||
TEST_ASSERT_FEQ(errctx, samples[0], 0.0f,
|
||||
"an attack does not start from silence (first sample %f)", samples[0]);
|
||||
TEST_ASSERT(errctx, fabsf(samples[100]) > fabsf(samples[10]),
|
||||
"the attack is not climbing (%f at frame 10, %f at frame 100)",
|
||||
samples[10], samples[100]);
|
||||
// Frames 0..49 are the positive half of the square, so their level is
|
||||
// the envelope value directly.
|
||||
for ( i = 1; i < 50; i++ ) {
|
||||
TEST_ASSERT_FLAG(rising, samples[i] > samples[i - 1]);
|
||||
}
|
||||
TEST_ASSERT(errctx, rising == true, "the attack ramp is not monotonic");
|
||||
|
||||
// Half a millisecond of decay to a half sustain level, no attack: the
|
||||
// very first sample is at full level and it settles at the sustain.
|
||||
CATCH(errctx, reset_audio());
|
||||
CATCH(errctx, akgl_audio_envelope(0, 0, 10, 0.5f, 0));
|
||||
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
|
||||
TEST_ASSERT_FEQ(errctx, samples[0], 1.0f,
|
||||
"with no attack the first sample is %f, expected full level", samples[0]);
|
||||
TEST_ASSERT_FEQ(errctx, samples[450], -0.5f,
|
||||
"after the decay the level is %f, expected the 0.5 sustain", samples[450]);
|
||||
|
||||
// Attack and decay together, which is the case where the decay has to
|
||||
// measure from the end of the attack rather than from the start of the
|
||||
// note. 10 ms of each is 441 frames of each at 44100.
|
||||
CATCH(errctx, reset_audio());
|
||||
CATCH(errctx, akgl_audio_envelope(0, 10, 10, 0.5f, 0));
|
||||
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
|
||||
TEST_ASSERT_FEQ(errctx, samples[441], 1.0f,
|
||||
"at the end of the attack the level is %f, expected full", samples[441]);
|
||||
TEST_ASSERT_FEQ(errctx, samples[661], -(1.0f - (0.5f * (220.0f / 441.0f))),
|
||||
"halfway through the decay the level is %f, expected the halfway ramp",
|
||||
samples[661]);
|
||||
TEST_ASSERT_FEQ(errctx, samples[900], 0.5f,
|
||||
"after the decay the level is %f, expected the 0.5 sustain", samples[900]);
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_envelope(0, 1, 1, -0.1f, 1),
|
||||
"setting a negative sustain level");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_envelope(0, 1, 1, 1.5f, 1),
|
||||
"setting a sustain level past full");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_audio_envelope(AKGL_AUDIO_MAX_VOICES, 1, 1, 1.0f, 1),
|
||||
"setting an envelope on a voice past the last one");
|
||||
} CLEANUP {
|
||||
IGNORE(reset_audio());
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_audio_duration_and_release(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
bool active = false;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, reset_audio());
|
||||
|
||||
// A 10 ms gate is 441 frames. The voice is still sounding while they
|
||||
// are being generated and goes quiet on its own afterwards, without the
|
||||
// caller having to stop it.
|
||||
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 10));
|
||||
CATCH(errctx, akgl_audio_mix(samples, 440));
|
||||
CATCH(errctx, akgl_audio_voice_active(0, &active));
|
||||
TEST_ASSERT(errctx, active == true, "the voice went quiet before its gate closed");
|
||||
|
||||
CATCH(errctx, akgl_audio_mix(samples, 8));
|
||||
CATCH(errctx, akgl_audio_voice_active(0, &active));
|
||||
TEST_ASSERT(errctx, active == false, "the voice is still sounding past its gate");
|
||||
CATCH(errctx, akgl_audio_mix(samples, 64));
|
||||
TEST_ASSERT_FEQ(errctx, peak_of(64), 0.0f,
|
||||
"a finished voice is still producing sound (peak %f)", peak_of(64));
|
||||
|
||||
// With a release, the voice outlives its gate and fades rather than
|
||||
// stopping dead.
|
||||
CATCH(errctx, reset_audio());
|
||||
CATCH(errctx, akgl_audio_envelope(0, 0, 0, 1.0f, 10));
|
||||
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 10));
|
||||
CATCH(errctx, akgl_audio_mix(samples, 441));
|
||||
CATCH(errctx, akgl_audio_voice_active(0, &active));
|
||||
TEST_ASSERT(errctx, active == true, "a voice with a release stopped when its gate closed");
|
||||
// The release starts from the level the gate left off at and falls from
|
||||
// there, so the end of this window has to be quieter than its start.
|
||||
CATCH(errctx, akgl_audio_mix(samples, 220));
|
||||
TEST_ASSERT(errctx, fabsf(samples[219]) < fabsf(samples[0]),
|
||||
"the release is not attenuating (%f at its start, %f 220 frames later)",
|
||||
samples[0], samples[219]);
|
||||
TEST_ASSERT(errctx, peak_of(220) > 0.0f, "the release went silent immediately");
|
||||
CATCH(errctx, akgl_audio_mix(samples, 250));
|
||||
CATCH(errctx, akgl_audio_voice_active(0, &active));
|
||||
TEST_ASSERT(errctx, active == false, "the voice outlived its gate and its release");
|
||||
|
||||
// Stopping cuts a voice off where it stands, release and all.
|
||||
CATCH(errctx, reset_audio());
|
||||
CATCH(errctx, akgl_audio_envelope(0, 0, 0, 1.0f, 1000));
|
||||
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_mix(samples, 64));
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_stop(0), "stopping a sounding voice");
|
||||
CATCH(errctx, akgl_audio_voice_active(0, &active));
|
||||
TEST_ASSERT(errctx, active == false, "a stopped voice still reports itself active");
|
||||
CATCH(errctx, akgl_audio_mix(samples, 64));
|
||||
TEST_ASSERT_FEQ(errctx, peak_of(64), 0.0f,
|
||||
"a stopped voice is still producing sound (peak %f)", peak_of(64));
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_stop(AKGL_AUDIO_MAX_VOICES),
|
||||
"stopping a voice past the last one");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_audio_voice_active(AKGL_AUDIO_MAX_VOICES, &active),
|
||||
"asking about a voice past the last one");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_audio_voice_active(0, NULL),
|
||||
"asking about a voice with nowhere to put the answer");
|
||||
} CLEANUP {
|
||||
IGNORE(reset_audio());
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/** @brief Mix @p ticks whole sweep steps, one step per call. */
|
||||
static akerr_ErrorContext *mix_sweep_ticks(int ticks)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
for ( i = 0; i < ticks; i++ ) {
|
||||
CATCH(errctx, akgl_audio_mix(samples, AKGL_AUDIO_SWEEP_TICK_FRAMES));
|
||||
}
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_audio_sweep(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
bool active = false;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, reset_audio());
|
||||
|
||||
// 400 Hz climbing to 500 in 25 Hz steps: four steps, at 60 steps per
|
||||
// second. The pitch is advanced by the mixer's own frame counter, so
|
||||
// these assertions are about exact frames rather than about how often
|
||||
// the caller happened to ask for samples.
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_sweep(0, 400.0f, 500.0f, 25.0f, 1000),
|
||||
"sounding a rising sweep");
|
||||
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, 400.0f,
|
||||
"a sweep starts at %f, expected its start frequency",
|
||||
akgl_audio_voices[0].hz);
|
||||
|
||||
// One tick's worth of frames is not yet a step: the last frame of that
|
||||
// window is still the first tick.
|
||||
CATCH(errctx, mix_sweep_ticks(1));
|
||||
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, 400.0f,
|
||||
"the pitch moved at %f before the first step was due",
|
||||
akgl_audio_voices[0].hz);
|
||||
TEST_ASSERT(errctx, peak_of(AKGL_AUDIO_SWEEP_TICK_FRAMES) > 0.5f,
|
||||
"a sweep is not making a sound (peak %f)",
|
||||
peak_of(AKGL_AUDIO_SWEEP_TICK_FRAMES));
|
||||
|
||||
// The frame after it is.
|
||||
CATCH(errctx, akgl_audio_mix(samples, 1));
|
||||
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, 425.0f,
|
||||
"one step in, the pitch is %f, expected 425",
|
||||
akgl_audio_voices[0].hz);
|
||||
|
||||
// It stops when it arrives rather than running past the target.
|
||||
CATCH(errctx, mix_sweep_ticks(8));
|
||||
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, 500.0f,
|
||||
"a finished sweep sits at %f, expected its target 500",
|
||||
akgl_audio_voices[0].hz);
|
||||
|
||||
// Downward is the same sweep with the frequencies the other way round.
|
||||
// The step stays positive; the direction is not its sign.
|
||||
CATCH(errctx, reset_audio());
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_sweep(0, 500.0f, 400.0f, 25.0f, 1000),
|
||||
"sounding a falling sweep");
|
||||
CATCH(errctx, mix_sweep_ticks(1));
|
||||
CATCH(errctx, akgl_audio_mix(samples, 1));
|
||||
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, 475.0f,
|
||||
"one step down, the pitch is %f, expected 475",
|
||||
akgl_audio_voices[0].hz);
|
||||
CATCH(errctx, mix_sweep_ticks(8));
|
||||
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, 400.0f,
|
||||
"a finished falling sweep sits at %f, expected its target 400",
|
||||
akgl_audio_voices[0].hz);
|
||||
|
||||
// Equal frequencies are a held tone, not an error: a caller translating
|
||||
// a statement that computes its own limits does not have to special
|
||||
// case them.
|
||||
CATCH(errctx, reset_audio());
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_sweep(0, 440.0f, 440.0f, 10.0f, 1000),
|
||||
"sweeping between two identical frequencies");
|
||||
CATCH(errctx, mix_sweep_ticks(4));
|
||||
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, 440.0f,
|
||||
"a sweep with nowhere to go moved to %f", akgl_audio_voices[0].hz);
|
||||
|
||||
// The gate still governs: a sweep with further to go than the note lasts
|
||||
// ends with the note, part way up. 30 ms is 1323 frames, which is one
|
||||
// whole step and most of a second.
|
||||
CATCH(errctx, reset_audio());
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_sweep(0, 400.0f, 4000.0f, 25.0f, 30),
|
||||
"sounding a sweep longer than its gate");
|
||||
CATCH(errctx, mix_sweep_ticks(3));
|
||||
CATCH(errctx, akgl_audio_voice_active(0, &active));
|
||||
TEST_ASSERT(errctx, active == false, "a swept voice outlived its gate");
|
||||
TEST_ASSERT(errctx, akgl_audio_voices[0].hz > 400.0f,
|
||||
"a sweep cut off by its gate never stepped at all (%f)",
|
||||
akgl_audio_voices[0].hz);
|
||||
TEST_ASSERT(errctx, akgl_audio_voices[0].hz < 4000.0f,
|
||||
"a sweep cut off by its gate reached %f anyway",
|
||||
akgl_audio_voices[0].hz);
|
||||
|
||||
// A voice reused for a plain tone stops sweeping.
|
||||
CATCH(errctx, reset_audio());
|
||||
CATCH(errctx, akgl_audio_sweep(0, 400.0f, 800.0f, 25.0f, 1000));
|
||||
CATCH(errctx, mix_sweep_ticks(2));
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000),
|
||||
"sounding a plain tone on a voice that was sweeping");
|
||||
CATCH(errctx, mix_sweep_ticks(4));
|
||||
TEST_ASSERT_FEQ(errctx, akgl_audio_voices[0].hz, TEST_TONE_HZ,
|
||||
"a plain tone on a swept voice drifted to %f",
|
||||
akgl_audio_voices[0].hz);
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_audio_sweep(AKGL_AUDIO_MAX_VOICES, 400.0f, 500.0f, 25.0f, 100),
|
||||
"sweeping a voice past the last one");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_audio_sweep(0, 0.0f, 500.0f, 25.0f, 100),
|
||||
"sweeping from zero hertz");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_audio_sweep(0, 400.0f, -1.0f, 25.0f, 100),
|
||||
"sweeping to a negative frequency");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_audio_sweep(0, 400.0f, 500.0f, 0.0f, 100),
|
||||
"sweeping with no step");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_audio_sweep(0, 500.0f, 400.0f, -25.0f, 100),
|
||||
"sweeping down with a negative step");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_audio_sweep(0, 400.0f, 500.0f, 25.0f, 0),
|
||||
"sweeping with no duration");
|
||||
} CLEANUP {
|
||||
IGNORE(reset_audio());
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_audio_volume_and_mixing(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, reset_audio());
|
||||
|
||||
// The master level scales everything.
|
||||
CATCH(errctx, akgl_audio_volume(0.25f));
|
||||
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
|
||||
TEST_ASSERT_FEQ(errctx, samples[0], 0.25f,
|
||||
"at a quarter volume the first sample is %f, expected 0.25", samples[0]);
|
||||
|
||||
CATCH(errctx, akgl_audio_volume(0.0f));
|
||||
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
|
||||
TEST_ASSERT_FEQ(errctx, peak_of(TEST_MIX_FRAMES), 0.0f,
|
||||
"at zero volume the peak is %f, expected silence",
|
||||
peak_of(TEST_MIX_FRAMES));
|
||||
|
||||
// Voices sum, and the sum is clamped rather than wrapped: three square
|
||||
// waves in phase at full level would be 3.0 without the clamp.
|
||||
CATCH(errctx, reset_audio());
|
||||
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_tone(1, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_tone(2, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
|
||||
TEST_ASSERT_FEQ(errctx, samples[0], 1.0f,
|
||||
"three voices in phase mixed to %f, expected a clamp at 1.0", samples[0]);
|
||||
TEST_ASSERT_FEQ(errctx, samples[50], -1.0f,
|
||||
"three voices in phase mixed to %f, expected a clamp at -1.0", samples[50]);
|
||||
|
||||
// Two voices at different levels sum to their total rather than to
|
||||
// either one of them, which is only visible below the clamp.
|
||||
CATCH(errctx, reset_audio());
|
||||
CATCH(errctx, akgl_audio_volume(0.5f));
|
||||
CATCH(errctx, akgl_audio_envelope(1, 0, 0, 0.5f, 0));
|
||||
CATCH(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_tone(1, TEST_TONE_HZ, 1000));
|
||||
CATCH(errctx, akgl_audio_mix(samples, TEST_MIX_FRAMES));
|
||||
TEST_ASSERT_FEQ(errctx, samples[0], 0.75f,
|
||||
"a full voice and a half voice at half volume mixed to %f, expected 0.75",
|
||||
samples[0]);
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_volume(-0.1f),
|
||||
"setting a negative master volume");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, akgl_audio_volume(1.1f),
|
||||
"setting a master volume past full");
|
||||
} CLEANUP {
|
||||
IGNORE(reset_audio());
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_audio_device(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
bool active = true;
|
||||
|
||||
ATTEMPT {
|
||||
// Everything above ran without a device. This is the one test that
|
||||
// opens one, so nothing that asserts on voice state runs after it.
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_init(), "opening an audio device");
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_init(), "opening an audio device a second time");
|
||||
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_tone(0, TEST_TONE_HZ, 10), "sounding a tone on a device");
|
||||
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_shutdown(), "closing the audio device");
|
||||
CATCH(errctx, akgl_audio_voice_active(0, &active));
|
||||
TEST_ASSERT(errctx, active == false, "shutting down left a voice sounding");
|
||||
|
||||
TEST_EXPECT_OK(errctx, akgl_audio_shutdown(), "closing an audio device that is not open");
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_audio_shutdown());
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_Init(SDL_INIT_AUDIO),
|
||||
AKGL_ERR_SDL,
|
||||
"Couldn't initialize SDL: %s",
|
||||
SDL_GetError());
|
||||
|
||||
CATCH(errctx, test_audio_defaults());
|
||||
CATCH(errctx, test_audio_silence());
|
||||
CATCH(errctx, test_audio_square_tone());
|
||||
CATCH(errctx, test_audio_waveforms());
|
||||
CATCH(errctx, test_audio_envelope());
|
||||
CATCH(errctx, test_audio_duration_and_release());
|
||||
CATCH(errctx, test_audio_sweep());
|
||||
CATCH(errctx, test_audio_volume_and_mixing());
|
||||
CATCH(errctx, test_audio_device());
|
||||
} CLEANUP {
|
||||
SDL_Quit();
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
}
|
||||
336
tests/benchutil.h
Normal file
336
tests/benchutil.h
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* @file benchutil.h
|
||||
* @brief The timing harness behind the perf suites: run it, record it, report it, hold it to a budget.
|
||||
*
|
||||
* A benchmark here is a timed region: bench_start() takes a timestamp,
|
||||
* bench_stop() takes another and divides by the number of units of work that
|
||||
* happened in between. The unit is whatever the measurement is *about* -- one
|
||||
* call, one actor, one tile, one frame -- and it is printed alongside the
|
||||
* number so nobody has to guess what "42 ns" was 42 nanoseconds of.
|
||||
*
|
||||
* Two things make the numbers usable rather than merely present:
|
||||
*
|
||||
* 1. **Best of #AKGL_BENCH_REPETITIONS.** Calling bench_start() again with a
|
||||
* name already in the table folds the new run into the old entry and keeps
|
||||
* the *lowest* ns/op seen. A benchmark loop therefore repeats itself and the
|
||||
* harness reports the run that was interrupted least. The mean is the wrong
|
||||
* statistic here: every source of noise on a shared machine makes a run
|
||||
* slower and none makes it faster.
|
||||
* 2. **The timed region contains no error checking.** `PASS` and `CATCH` both
|
||||
* call `akerr_valid_error_address`, which walks `AKERR_ARRAY_ERROR` looking
|
||||
* for a match -- more work than several of the calls being measured. Use
|
||||
* #BENCH_LOOP, which stashes the context and stops at the first failure, and
|
||||
* hand it to `PASS` after the clock has stopped.
|
||||
*
|
||||
* A budget is a per-op ceiling in nanoseconds, and a benchmark that exceeds it
|
||||
* fails the suite. Budgets are set at roughly ten times the measured baseline:
|
||||
* loose enough that a busy machine does not turn CI red, tight enough that an
|
||||
* algorithmic regression -- a linear scan that becomes quadratic, a per-frame
|
||||
* allocation that becomes a per-actor one -- cannot hide. They are enforced
|
||||
* only in an optimized build at full scale; a coverage build measures the
|
||||
* instrumentation, not the library, and reports without judging.
|
||||
*
|
||||
* Set `AKGL_BENCH_SCALE` in the environment to change how long the suite runs:
|
||||
* 0.1 for a tenth of the iterations, 10 for ten times as many. Below 1.0 the
|
||||
* budgets are not enforced, because a short run is a noisy one.
|
||||
*
|
||||
* **Under valgrind these suites become the memory-check suites**, and the scale
|
||||
* drops itself to #AKGL_BENCH_VALGRIND_SCALE without being asked. That is the
|
||||
* whole reason `ctest -T memcheck` does not need benchmark programs of its own:
|
||||
* a leak check wants every path walked *once*, and a benchmark is a program that
|
||||
* walks one path a hundred thousand times. Divide the iteration counts by two
|
||||
* thousand and the same binary is exactly the right shape for memcheck --
|
||||
* broader coverage of asset loading, drawing, and the frame loop than any unit
|
||||
* suite, at a runtime valgrind can survive. Timings from such a run are
|
||||
* meaningless and the report says so.
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_BENCHUTIL_H_
|
||||
#define _AKGL_BENCHUTIL_H_
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akerror.h>
|
||||
#include <akgl/error.h>
|
||||
|
||||
/** @brief How many benchmarks one suite can record. */
|
||||
#define AKGL_BENCH_MAX_RESULTS 48
|
||||
/** @brief Longest benchmark name kept, including the terminator. */
|
||||
#define AKGL_BENCH_MAX_NAME 56
|
||||
/** @brief Longest unit name kept, including the terminator. */
|
||||
#define AKGL_BENCH_MAX_UNIT 12
|
||||
/** @brief How many times a benchmark is repeated before the best run is reported. */
|
||||
#define AKGL_BENCH_REPETITIONS 5
|
||||
/** @brief Environment variable scaling every iteration count. */
|
||||
#define AKGL_BENCH_SCALE_ENV "AKGL_BENCH_SCALE"
|
||||
/** @brief Scale forced on when running under valgrind. Enough to walk every path, few enough to finish. */
|
||||
#define AKGL_BENCH_VALGRIND_SCALE 0.0005
|
||||
|
||||
/** @brief One row of the report: what was measured, how fast, and what it was allowed to cost. */
|
||||
typedef struct akgl_Benchmark {
|
||||
char name[AKGL_BENCH_MAX_NAME]; /**< What was measured. Also the key runs are merged under. */
|
||||
char unit[AKGL_BENCH_MAX_UNIT]; /**< What one op is: "call", "actor", "tile", "frame". */
|
||||
double budget_ns; /**< Per-op ceiling in nanoseconds. 0 reports without judging. */
|
||||
double best_ns; /**< Lowest ns/op seen across every run under this name. */
|
||||
uint64_t ops; /**< Units of work in the run that produced best_ns. */
|
||||
uint64_t elapsed_ns; /**< Wall time of that run. */
|
||||
int runs; /**< How many runs were folded in. */
|
||||
} akgl_Benchmark;
|
||||
|
||||
/** @brief Every benchmark this suite has recorded, in the order it first ran. */
|
||||
static akgl_Benchmark bench_results[AKGL_BENCH_MAX_RESULTS];
|
||||
/** @brief How many entries of #bench_results are in use. */
|
||||
static int bench_result_count = 0;
|
||||
/** @brief The entry bench_stop() will write to, claimed by bench_start(). */
|
||||
static akgl_Benchmark *bench_running = NULL;
|
||||
/** @brief `SDL_GetTicksNS()` at the last bench_start(). */
|
||||
static uint64_t bench_started_ns = 0;
|
||||
|
||||
/**
|
||||
* @brief Report whether this process is running under valgrind.
|
||||
*
|
||||
* Detected from `LD_PRELOAD`, which valgrind fills with its own
|
||||
* `vgpreload_*.so` before handing the process over. That is a deliberate choice
|
||||
* over `RUNNING_ON_VALGRIND` from `valgrind/valgrind.h`: the macro is exact, but
|
||||
* it makes the test suite fail to compile anywhere the valgrind headers are not
|
||||
* installed, and a benchmark is not worth a build dependency. The cost of the
|
||||
* check being wrong is a slow run or a fast one, never a wrong answer.
|
||||
*/
|
||||
static bool bench_under_valgrind(void)
|
||||
{
|
||||
static int detected = -1;
|
||||
const char *preload = NULL;
|
||||
|
||||
if ( detected >= 0 ) {
|
||||
return ( detected == 1 );
|
||||
}
|
||||
detected = 0;
|
||||
preload = SDL_getenv("LD_PRELOAD");
|
||||
if ( preload != NULL && strstr(preload, "vgpreload") != NULL ) {
|
||||
detected = 1;
|
||||
}
|
||||
return ( detected == 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Multiplier applied to every iteration count, from `AKGL_BENCH_SCALE`.
|
||||
*
|
||||
* Read once and cached. Anything unparseable, negative, or absent gives 1.0.
|
||||
* Under valgrind the result is capped at #AKGL_BENCH_VALGRIND_SCALE -- the
|
||||
* smaller of the two wins, so asking for an even shorter run still works.
|
||||
*/
|
||||
static double bench_scale(void)
|
||||
{
|
||||
static double scale = -1.0;
|
||||
const char *env = NULL;
|
||||
|
||||
if ( scale >= 0.0 ) {
|
||||
return scale;
|
||||
}
|
||||
scale = 1.0;
|
||||
env = SDL_getenv(AKGL_BENCH_SCALE_ENV);
|
||||
if ( env != NULL ) {
|
||||
scale = SDL_atof(env);
|
||||
if ( scale <= 0.0 ) {
|
||||
scale = 1.0;
|
||||
}
|
||||
}
|
||||
if ( bench_under_valgrind() == true && scale > AKGL_BENCH_VALGRIND_SCALE ) {
|
||||
scale = AKGL_BENCH_VALGRIND_SCALE;
|
||||
}
|
||||
return scale;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Scale a nominal iteration count, never down to zero.
|
||||
* @param count The count the benchmark was written for.
|
||||
* @return @p count times the scale factor, at least 1.
|
||||
*/
|
||||
static int bench_iterations(int count)
|
||||
{
|
||||
int scaled = (int)((double)count * bench_scale());
|
||||
|
||||
if ( scaled < 1 ) {
|
||||
scaled = 1;
|
||||
}
|
||||
return scaled;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Report whether budgets are being enforced in this run.
|
||||
*
|
||||
* An unoptimized build measures the instrumentation rather than the library, a
|
||||
* scaled-down run is too short to trust, and a run under valgrind is measuring
|
||||
* valgrind. All three report without failing.
|
||||
*/
|
||||
static bool bench_budgets_enforced(void)
|
||||
{
|
||||
if ( bench_under_valgrind() == true ) {
|
||||
return false;
|
||||
}
|
||||
#ifdef __OPTIMIZE__
|
||||
return ( bench_scale() >= 1.0 );
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Start timing, claiming or reusing the table entry named @p name.
|
||||
*
|
||||
* @param name What is being measured. Truncated at #AKGL_BENCH_MAX_NAME.
|
||||
* Reusing a name folds this run into that entry.
|
||||
* @param unit What one op is. Truncated at #AKGL_BENCH_MAX_UNIT.
|
||||
* @param budget_ns Per-op ceiling in nanoseconds; 0 to report only. The value
|
||||
* from the first run under a name wins.
|
||||
*
|
||||
* @note The table is fixed. Past #AKGL_BENCH_MAX_RESULTS entries the run is
|
||||
* dropped with a message rather than overwriting somebody else's row.
|
||||
*/
|
||||
static void bench_start(char *name, char *unit, double budget_ns)
|
||||
{
|
||||
int i = 0;
|
||||
|
||||
bench_running = NULL;
|
||||
for ( i = 0; i < bench_result_count; i++ ) {
|
||||
if ( strncmp(bench_results[i].name, name, AKGL_BENCH_MAX_NAME - 1) == 0 ) {
|
||||
bench_running = &bench_results[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ( bench_running == NULL ) {
|
||||
if ( bench_result_count >= AKGL_BENCH_MAX_RESULTS ) {
|
||||
SDL_Log("benchutil: no room for benchmark '%s', raise AKGL_BENCH_MAX_RESULTS", name);
|
||||
return;
|
||||
}
|
||||
bench_running = &bench_results[bench_result_count];
|
||||
bench_result_count += 1;
|
||||
memset(bench_running, 0x00, sizeof(akgl_Benchmark));
|
||||
strncpy(bench_running->name, name, AKGL_BENCH_MAX_NAME - 1);
|
||||
strncpy(bench_running->unit, unit, AKGL_BENCH_MAX_UNIT - 1);
|
||||
bench_running->budget_ns = budget_ns;
|
||||
bench_running->best_ns = -1.0;
|
||||
}
|
||||
bench_started_ns = SDL_GetTicksNS();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Stop timing and keep the run if it beat every previous one.
|
||||
* @param ops Units of work done since bench_start(). Zero is ignored -- a
|
||||
* benchmark that did nothing has no rate to report.
|
||||
*/
|
||||
static void bench_stop(uint64_t ops)
|
||||
{
|
||||
uint64_t elapsed = SDL_GetTicksNS() - bench_started_ns;
|
||||
double per_op = 0.0;
|
||||
|
||||
if ( bench_running == NULL || ops == 0 ) {
|
||||
return;
|
||||
}
|
||||
per_op = (double)elapsed / (double)ops;
|
||||
bench_running->runs += 1;
|
||||
if ( bench_running->best_ns < 0.0 || per_op < bench_running->best_ns ) {
|
||||
bench_running->best_ns = per_op;
|
||||
bench_running->ops = ops;
|
||||
bench_running->elapsed_ns = elapsed;
|
||||
}
|
||||
bench_running = NULL;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Run @p stmt @p count times, stopping at the first error.
|
||||
*
|
||||
* The timed loop deliberately holds no `PASS` or `CATCH`: their validity check
|
||||
* walks the whole error array and costs more than some of the calls being
|
||||
* measured. Hand @p errvar to `PASS` once the clock has stopped.
|
||||
*
|
||||
* @param errvar Receives the first non-`NULL` context returned, or `NULL`.
|
||||
* @param i Loop variable, declared by the caller.
|
||||
* @param count How many times to run @p stmt.
|
||||
* @param stmt The call under test. Must evaluate to an `akerr_ErrorContext *`.
|
||||
*/
|
||||
#define BENCH_LOOP(errvar, i, count, stmt) \
|
||||
for ( i = 0; i < (count); i++ ) { \
|
||||
errvar = (stmt); \
|
||||
if ( errvar != NULL ) { \
|
||||
break; \
|
||||
} \
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Print the recorded benchmarks as a table and count the ones over budget.
|
||||
*
|
||||
* The table goes to stdout so `ctest --output-on-failure` and a plain run of the
|
||||
* executable both show it. Rates are printed as ops per second, which is the
|
||||
* number a frame budget is actually built from.
|
||||
*
|
||||
* @return How many benchmarks exceeded their budget. Always 0 when
|
||||
* bench_budgets_enforced() is false.
|
||||
*/
|
||||
static int bench_report(void)
|
||||
{
|
||||
int i = 0;
|
||||
int over = 0;
|
||||
bool enforced = bench_budgets_enforced();
|
||||
char *verdict = NULL;
|
||||
|
||||
printf("\n");
|
||||
if ( bench_under_valgrind() == true ) {
|
||||
printf("running under valgrind: this is a memory check, not a measurement.\n");
|
||||
printf("The timings below are valgrind's and mean nothing about libakgl.\n");
|
||||
}
|
||||
printf("scale %.4gx, best of %d runs, budgets %s\n",
|
||||
bench_scale(),
|
||||
AKGL_BENCH_REPETITIONS,
|
||||
enforced ? "enforced" : "reported only");
|
||||
printf("%-54s %-6s %9s %12s %16s %10s %s\n",
|
||||
"benchmark", "unit", "ops", "ns/op", "ops/sec", "budget", "verdict");
|
||||
printf("%-54s %-6s %9s %12s %16s %10s %s\n",
|
||||
"------------------------------------------------------",
|
||||
"------", "---------", "------------", "----------------", "----------", "-------");
|
||||
for ( i = 0; i < bench_result_count; i++ ) {
|
||||
verdict = "-";
|
||||
if ( bench_results[i].budget_ns > 0.0 ) {
|
||||
if ( bench_results[i].best_ns > bench_results[i].budget_ns ) {
|
||||
verdict = enforced ? "OVER" : "over";
|
||||
if ( enforced ) {
|
||||
over += 1;
|
||||
}
|
||||
} else {
|
||||
verdict = "ok";
|
||||
}
|
||||
}
|
||||
printf("%-54s %-6s %9llu %12.1f %16.0f %10.0f %s\n",
|
||||
bench_results[i].name,
|
||||
bench_results[i].unit,
|
||||
(unsigned long long)bench_results[i].ops,
|
||||
bench_results[i].best_ns,
|
||||
( bench_results[i].best_ns > 0.0 ) ? (1000000000.0 / bench_results[i].best_ns) : 0.0,
|
||||
bench_results[i].budget_ns,
|
||||
verdict);
|
||||
}
|
||||
printf("\n");
|
||||
fflush(stdout);
|
||||
return over;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Fail the enclosing ATTEMPT block if any benchmark blew its budget.
|
||||
*
|
||||
* The report is printed either way -- a run that fails still has to say what the
|
||||
* numbers were.
|
||||
*/
|
||||
#define BENCH_REPORT_BREAK(e) \
|
||||
{ \
|
||||
int __bench_over = bench_report(); \
|
||||
if ( __bench_over > 0 ) { \
|
||||
FAIL_BREAK( \
|
||||
e, \
|
||||
AKGL_ERR_BEHAVIOR, \
|
||||
"%d benchmark(s) exceeded their budget", \
|
||||
__bench_over); \
|
||||
} \
|
||||
}
|
||||
|
||||
#endif // _AKGL_BENCHUTIL_H_
|
||||
127
tests/bitmasks.c
127
tests/bitmasks.c
@@ -1,31 +1,108 @@
|
||||
/**
|
||||
* @file bitmasks.c
|
||||
* @brief The AKGL_BITMASK_* macros, including the ways an unparenthesized one breaks.
|
||||
*
|
||||
* These are macros, so the only thing that checks them is a call site. Most of
|
||||
* this file is the ordinary arithmetic; the composition cases at the bottom are
|
||||
* the ones that matter, because until 0.5.0 the macros expanded to bare
|
||||
* expressions and every one of them was a silent misparse waiting for a caller.
|
||||
*/
|
||||
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/actor.h>
|
||||
|
||||
int main(void)
|
||||
{
|
||||
int mask = 0;
|
||||
AKGL_BITMASK_ADD(mask, AKGL_ACTOR_STATE_ALIVE);
|
||||
if ( mask != AKGL_ACTOR_STATE_ALIVE )
|
||||
return 1;
|
||||
AKGL_BITMASK_ADD(mask, AKGL_ACTOR_STATE_FACE_LEFT);
|
||||
if ( mask != (AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_LEFT) )
|
||||
return 1;
|
||||
AKGL_BITMASK_DEL(mask, AKGL_ACTOR_STATE_ALIVE);
|
||||
if ( mask != (AKGL_ACTOR_STATE_FACE_LEFT) )
|
||||
return 1;
|
||||
AKGL_BITMASK_CLEAR(mask);
|
||||
if ( mask != 0 )
|
||||
return 1;
|
||||
AKGL_BITMASK_ADD(mask, AKGL_ACTOR_STATE_FACE_LEFT);
|
||||
if ( !(AKGL_BITMASK_HAS(mask, AKGL_ACTOR_STATE_FACE_LEFT)) )
|
||||
return 1;
|
||||
mask = AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_UP;
|
||||
AKGL_BITMASK_DEL(mask, AKGL_ACTOR_STATE_FACE_ALL);
|
||||
if ( mask != AKGL_ACTOR_STATE_ALIVE )
|
||||
return 1;
|
||||
AKGL_BITMASK_ADD(mask, AKGL_ACTOR_STATE_MOVING_DOWN);
|
||||
AKGL_BITMASK_ADD(mask, AKGL_ACTOR_STATE_FACE_DOWN);
|
||||
if ( mask != (AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_MOVING_DOWN | AKGL_ACTOR_STATE_FACE_DOWN) )
|
||||
return 1;
|
||||
return 0;
|
||||
int mask = 0;
|
||||
int counter = 0;
|
||||
|
||||
AKGL_BITMASK_ADD(mask, AKGL_ACTOR_STATE_ALIVE);
|
||||
if ( mask != AKGL_ACTOR_STATE_ALIVE )
|
||||
return 1;
|
||||
AKGL_BITMASK_ADD(mask, AKGL_ACTOR_STATE_FACE_LEFT);
|
||||
if ( mask != (AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_LEFT) )
|
||||
return 1;
|
||||
AKGL_BITMASK_DEL(mask, AKGL_ACTOR_STATE_ALIVE);
|
||||
if ( mask != (AKGL_ACTOR_STATE_FACE_LEFT) )
|
||||
return 1;
|
||||
AKGL_BITMASK_CLEAR(mask);
|
||||
if ( mask != 0 )
|
||||
return 1;
|
||||
AKGL_BITMASK_ADD(mask, AKGL_ACTOR_STATE_FACE_LEFT);
|
||||
if ( !(AKGL_BITMASK_HAS(mask, AKGL_ACTOR_STATE_FACE_LEFT)) )
|
||||
return 1;
|
||||
mask = AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_UP;
|
||||
AKGL_BITMASK_DEL(mask, AKGL_ACTOR_STATE_FACE_ALL);
|
||||
if ( mask != AKGL_ACTOR_STATE_ALIVE )
|
||||
return 1;
|
||||
AKGL_BITMASK_ADD(mask, AKGL_ACTOR_STATE_MOVING_DOWN);
|
||||
AKGL_BITMASK_ADD(mask, AKGL_ACTOR_STATE_FACE_DOWN);
|
||||
if ( mask != (AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_MOVING_DOWN | AKGL_ACTOR_STATE_FACE_DOWN) )
|
||||
return 1;
|
||||
|
||||
// ---- composition, which is what the parentheses are for ----
|
||||
|
||||
// Negation. Unparenthesized, AKGL_BITMASK_HAS expanded to a bare
|
||||
// `(x & y) == y`, so `!` bound to the `&` and the whole thing read as
|
||||
// `!(mask & bit) == bit`.
|
||||
//
|
||||
// Note which case catches that and which does not. For a bit that *is*
|
||||
// set, `!(nonzero)` is 0 and `0 == bit` is false -- the same answer the
|
||||
// correct parse gives, so a test written that way passes either way. It
|
||||
// only diverges for a bit that is *not* set and whose value is not 1:
|
||||
// `!(0)` is 1, and `1 == 64` is false where the answer should be true.
|
||||
// That is the shape below, and it is why this was worth a real case rather
|
||||
// than an obvious one.
|
||||
mask = AKGL_ACTOR_STATE_ALIVE;
|
||||
if ( !AKGL_BITMASK_HAS(mask, AKGL_ACTOR_STATE_ALIVE) )
|
||||
return 1;
|
||||
if ( AKGL_BITMASK_HAS(mask, AKGL_ACTOR_STATE_DEAD) )
|
||||
return 1;
|
||||
if ( !AKGL_BITMASK_HAS(mask, AKGL_ACTOR_STATE_DEAD) ) {
|
||||
counter = 1;
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
if ( counter != 1 )
|
||||
return 1;
|
||||
|
||||
// The same divergence through AKGL_BITMASK_HASNOT, which was written the
|
||||
// same way: `!(0) != 64` is true where the answer should be false.
|
||||
if ( !AKGL_BITMASK_HASNOT(mask, AKGL_ACTOR_STATE_DEAD) )
|
||||
return 1;
|
||||
if ( AKGL_BITMASK_HASNOT(mask, AKGL_ACTOR_STATE_ALIVE) )
|
||||
return 1;
|
||||
|
||||
// As an operand of a lower-precedence operator.
|
||||
if ( AKGL_BITMASK_HAS(mask, AKGL_ACTOR_STATE_ALIVE) == false )
|
||||
return 1;
|
||||
if ( (AKGL_BITMASK_HAS(mask, AKGL_ACTOR_STATE_ALIVE) & 1) != 1 )
|
||||
return 1;
|
||||
|
||||
// The mutating macros as expressions rather than statements, and with an
|
||||
// argument that is itself an expression: `x |= y` unparenthesized cannot be
|
||||
// used in either position.
|
||||
mask = 0;
|
||||
counter = (AKGL_BITMASK_ADD(mask, AKGL_ACTOR_STATE_DYING));
|
||||
if ( counter != AKGL_ACTOR_STATE_DYING )
|
||||
return 1;
|
||||
if ( (AKGL_BITMASK_CLEAR(mask)) != 0 )
|
||||
return 1;
|
||||
if ( mask != 0 )
|
||||
return 1;
|
||||
|
||||
// AKGL_BITMASK_CLEAR carries no trailing semicolon, so it is a statement
|
||||
// like any other and does not swallow the one after it when it is the whole
|
||||
// body of an unbraced `if`.
|
||||
mask = AKGL_ACTOR_STATE_ALIVE;
|
||||
counter = 0;
|
||||
if ( mask != 0 )
|
||||
AKGL_BITMASK_CLEAR(mask);
|
||||
counter = 1;
|
||||
if ( mask != 0 )
|
||||
return 1;
|
||||
if ( counter != 1 )
|
||||
return 1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,21 @@
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/iterator.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/renderer.h>
|
||||
|
||||
SDL_Window *window;
|
||||
SDL_Renderer *renderer;
|
||||
#include "testutil.h"
|
||||
|
||||
/*
|
||||
* This suite used to declare its own file-scope `window` and `renderer`, back
|
||||
* when the library's globals carried those bare names too. Both definitions had
|
||||
* external linkage and the same spelling, so the executable's preempted the
|
||||
* shared library's: akgl_sprite_load_json read the test's `SDL_Renderer *`
|
||||
* through an `akgl_RenderBackend *` and happened not to crash. That is the
|
||||
* collision the akgl_ prefix on exported globals exists to prevent, and it was
|
||||
* invisible until they were renamed. The suite now binds a real backend, the
|
||||
* way tests/sprite.c and tests/text.c do.
|
||||
*/
|
||||
|
||||
akerr_ErrorContext *test_akgl_character_initialize()
|
||||
{
|
||||
@@ -50,6 +62,7 @@ akerr_ErrorContext *test_character_sprite_mgmt()
|
||||
PREPARE_ERROR(errctx);
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_character(&testchar));
|
||||
CATCH(errctx, akgl_character_initialize(testchar, "testchar"));
|
||||
CATCH(errctx, akgl_sprite_load_json("assets/testsprite.json"));
|
||||
testsprite = SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite", NULL);
|
||||
FAIL_ZERO_BREAK(
|
||||
@@ -85,10 +98,11 @@ akerr_ErrorContext *test_character_iterate_state_sprites()
|
||||
akgl_Sprite *testsprite = NULL;
|
||||
akgl_Sprite *testsprite2 = NULL;
|
||||
akgl_Iterator opflags = {.flags = AKGL_ITERATOR_OP_RELEASE, .layerid = 0};
|
||||
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_character(&testchar));
|
||||
CATCH(errctx, akgl_character_initialize(testchar, "testchar"));
|
||||
CATCH(errctx, akgl_sprite_load_json("assets/testsprite.json"));
|
||||
testsprite = SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite", NULL);
|
||||
FAIL_ZERO_BREAK(
|
||||
@@ -124,7 +138,7 @@ akerr_ErrorContext *test_character_iterate_state_sprites()
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_sprite(testsprite));
|
||||
IGNORE(akgl_heap_release_sprite(testsprite2));
|
||||
IGNORE(akgl_heap_release_character(testchar));
|
||||
IGNORE(akgl_heap_release_character(testchar));
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
@@ -138,7 +152,7 @@ akerr_ErrorContext *test_akgl_character_load_json()
|
||||
akgl_Sprite *comparesprite = NULL;
|
||||
int tsrc = 0;
|
||||
int tsrc2 = 0;
|
||||
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_character(&testcharacter));
|
||||
@@ -156,7 +170,7 @@ akerr_ErrorContext *test_akgl_character_load_json()
|
||||
testsprite2,
|
||||
AKERR_KEY,
|
||||
"Sprite 2 loaded from json but not in registry");
|
||||
|
||||
|
||||
CATCH(errctx, akgl_character_load_json("assets/testcharacter.json"));
|
||||
testcharacter = SDL_GetPointerProperty(AKGL_REGISTRY_CHARACTER, "testcharacter", NULL);
|
||||
FAIL_ZERO_BREAK(
|
||||
@@ -169,8 +183,8 @@ akerr_ErrorContext *test_akgl_character_load_json()
|
||||
CATCH(errctx, testcharacter->sprite_get(testcharacter, AKGL_ACTOR_STATE_DEAD, &comparesprite));
|
||||
FAIL_ZERO_BREAK(errctx, (comparesprite == testsprite2), AKERR_VALUE, "Wrong sprite for state AKGL_ACTOR_STATE_DEAD");
|
||||
|
||||
FAIL_ZERO_BREAK(errctx, (testcharacter->vx != 0.200000003), AKERR_VALUE, "Wrong X velocity for test character");
|
||||
FAIL_ZERO_BREAK(errctx, (testcharacter->vy != 0.200000003), AKERR_VALUE, "Wrong Y velocity for test character");
|
||||
FAIL_ZERO_BREAK(errctx, (testcharacter->ax != 0.200000003), AKERR_VALUE, "Wrong X acceleration for test character");
|
||||
FAIL_ZERO_BREAK(errctx, (testcharacter->ay != 0.200000003), AKERR_VALUE, "Wrong Y acceleration for test character");
|
||||
|
||||
// Release our handles on the sprites so the character's heap_release can reduce them to 0
|
||||
CATCH(errctx, akgl_heap_release_sprite(testsprite));
|
||||
@@ -189,19 +203,101 @@ akerr_ErrorContext *test_akgl_character_load_json()
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Rebinding a state must release the sprite it displaces.
|
||||
*
|
||||
* akgl_character_sprite_add took a reference for every binding and wrote over
|
||||
* any existing entry without releasing the one it replaced, so a character that
|
||||
* rebinds a state while alive leaked a sprite slot per rebind. Releasing at
|
||||
* teardown does not cover it: the map only holds the last binding by then, so
|
||||
* the earlier ones are unreachable.
|
||||
*
|
||||
* The write itself was also unchecked, so a failure to record the binding was
|
||||
* reported as success -- with the reference already taken.
|
||||
*/
|
||||
akerr_ErrorContext *test_character_sprite_rebind_releases_displaced(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_Character *testchar = NULL;
|
||||
akgl_Sprite *first = NULL;
|
||||
akgl_Sprite *second = NULL;
|
||||
akgl_Sprite *found = NULL;
|
||||
int firstbefore = 0;
|
||||
int secondbefore = 0;
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_character(&testchar));
|
||||
CATCH(errctx, akgl_character_initialize(testchar, "rebindchar"));
|
||||
// Real sprites out of the fixtures: akgl_sprite_initialize refuses a
|
||||
// NULL spritesheet, and a hand-built sprite would not be a sprite.
|
||||
CATCH(errctx, akgl_sprite_load_json("assets/testsprite.json"));
|
||||
CATCH(errctx, akgl_sprite_load_json("assets/testsprite2.json"));
|
||||
first = SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite", NULL);
|
||||
second = SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite2", NULL);
|
||||
FAIL_ZERO_BREAK(errctx, first, AKERR_KEY, "testsprite is not in the registry");
|
||||
FAIL_ZERO_BREAK(errctx, second, AKERR_KEY, "testsprite2 is not in the registry");
|
||||
|
||||
firstbefore = first->refcount;
|
||||
secondbefore = second->refcount;
|
||||
|
||||
CATCH(errctx, akgl_character_sprite_add(testchar, first, AKGL_ACTOR_STATE_ALIVE));
|
||||
TEST_ASSERT(errctx, first->refcount == (firstbefore + 1),
|
||||
"binding a sprite did not take a reference (%d, expected %d)",
|
||||
first->refcount, firstbefore + 1);
|
||||
|
||||
// Rebind the same state to a different sprite.
|
||||
CATCH(errctx, akgl_character_sprite_add(testchar, second, AKGL_ACTOR_STATE_ALIVE));
|
||||
TEST_ASSERT(errctx, second->refcount == (secondbefore + 1),
|
||||
"rebinding did not take a reference on the new sprite (%d, expected %d)",
|
||||
second->refcount, secondbefore + 1);
|
||||
TEST_ASSERT(errctx, first->refcount == firstbefore,
|
||||
"rebinding did not release the displaced sprite (%d, expected %d)",
|
||||
first->refcount, firstbefore);
|
||||
|
||||
CATCH(errctx, testchar->sprite_get(testchar, AKGL_ACTOR_STATE_ALIVE, &found));
|
||||
TEST_ASSERT(errctx, found == second, "the state is not bound to the new sprite");
|
||||
|
||||
// Rebinding a state to the sprite already there must be a no-op on the
|
||||
// count rather than a release followed by a claim of the same slot.
|
||||
CATCH(errctx, akgl_character_sprite_add(testchar, second, AKGL_ACTOR_STATE_ALIVE));
|
||||
TEST_ASSERT(errctx, second->refcount == (secondbefore + 2),
|
||||
"rebinding a state to the sprite it already held mishandled the count (%d)",
|
||||
second->refcount);
|
||||
|
||||
// And a long run of rebinds must not drift.
|
||||
for ( i = 0; i < 200; i++ ) {
|
||||
CATCH(errctx, akgl_character_sprite_add(testchar, first, AKGL_ACTOR_STATE_DEAD));
|
||||
CATCH(errctx, akgl_character_sprite_add(testchar, second, AKGL_ACTOR_STATE_DEAD));
|
||||
}
|
||||
TEST_ASSERT(errctx, first->refcount <= (firstbefore + 1),
|
||||
"200 rebinds left the first sprite at refcount %d", first->refcount);
|
||||
} CLEANUP {
|
||||
if ( testchar != NULL ) {
|
||||
IGNORE(akgl_heap_release_character(testchar));
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
|
||||
SDL_SetAppMetadata("SDL3-GameTest", "0.1", "net.aklabs.sdl3-gametest");
|
||||
|
||||
|
||||
if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_JOYSTICK | SDL_INIT_AUDIO )) {
|
||||
FAIL_BREAK(errctx, AKGL_ERR_SDL, "Couldn't initialize SDL: %s", SDL_GetError());
|
||||
}
|
||||
|
||||
if (!SDL_CreateWindowAndRenderer("net/aklabs/libakgl/test_character", 640, 480, SDL_WINDOW_HIDDEN, &window, &renderer)) {
|
||||
|
||||
if (!SDL_CreateWindowAndRenderer("net/aklabs/libakgl/test_character", 640, 480, SDL_WINDOW_HIDDEN, &akgl_window, &akgl_renderer->sdl_renderer)) {
|
||||
FAIL_BREAK(errctx, AKGL_ERR_SDL, "Couldn't create window/renderer: %s", SDL_GetError());
|
||||
}
|
||||
CATCH(errctx, akgl_render_2d_bind(akgl_renderer));
|
||||
|
||||
CATCH(errctx, akgl_heap_init());
|
||||
CATCH(errctx, akgl_registry_init());
|
||||
@@ -209,6 +305,7 @@ int main(void)
|
||||
CATCH(errctx, test_character_sprite_mgmt());
|
||||
CATCH(errctx, test_character_iterate_state_sprites());
|
||||
CATCH(errctx, test_akgl_character_load_json());
|
||||
CATCH(errctx, test_character_sprite_rebind_releases_displaced());
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
|
||||
@@ -19,14 +19,14 @@
|
||||
|
||||
int numsprites = 8;
|
||||
char *spritepaths[] = {
|
||||
"assets/sprites/little_guy_walking_left.json",
|
||||
"assets/sprites/little_guy_walking_right.json",
|
||||
"assets/sprites/little_guy_walking_up.json",
|
||||
"assets/sprites/little_guy_walking_down.json",
|
||||
"assets/sprites/little_guy_facing_left.json",
|
||||
"assets/sprites/little_guy_facing_right.json",
|
||||
"assets/sprites/little_guy_facing_up.json",
|
||||
"assets/sprites/little_guy_facing_down.json"
|
||||
"assets/sprites/little_guy_walking_left.json",
|
||||
"assets/sprites/little_guy_walking_right.json",
|
||||
"assets/sprites/little_guy_walking_up.json",
|
||||
"assets/sprites/little_guy_walking_down.json",
|
||||
"assets/sprites/little_guy_facing_left.json",
|
||||
"assets/sprites/little_guy_facing_right.json",
|
||||
"assets/sprites/little_guy_facing_up.json",
|
||||
"assets/sprites/little_guy_facing_down.json"
|
||||
};
|
||||
|
||||
int main(void)
|
||||
@@ -34,15 +34,15 @@ int main(void)
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL3GControlMap *controlmap;
|
||||
actor *actorptr = NULL;
|
||||
|
||||
|
||||
ATTEMPT {
|
||||
|
||||
|
||||
SDL_SetAppMetadata("SDL3-GameTest", "0.1", "net.aklabs.sdl3-gametest");
|
||||
|
||||
|
||||
if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_JOYSTICK | SDL_INIT_AUDIO )) {
|
||||
FAIL_BREAK(errctx, AKGL_ERR_SDL, "Couldn't initialize SDL: %s", SDL_GetError());
|
||||
}
|
||||
|
||||
|
||||
if (!SDL_CreateWindowAndRenderer("net/aklabs/libakgl/test_sprite", 640, 480, 0, &window, &renderer)) {
|
||||
FAIL_BREAK(errctx, AKGL_ERR_SDL, "Couldn't create window/renderer: %s", SDL_GetError());
|
||||
}
|
||||
@@ -55,7 +55,7 @@ int main(void)
|
||||
strcpy((char *)&game.uri, "net.aklabs.libakgl.charviewer");
|
||||
game.screenwidth = 640;
|
||||
game.screenheight = 480;
|
||||
|
||||
|
||||
CATCH(errctx, akgl_GAME_init());
|
||||
|
||||
for ( int i = 0; i < numsprites ; i++) {
|
||||
@@ -68,7 +68,7 @@ int main(void)
|
||||
AKGL_REGISTRY_CHARACTER,
|
||||
"little guy",
|
||||
NULL);
|
||||
FAIL_ZERO_BREAK(errctx, actorptr->basechar, AKERR_REGISTRY, "Can't load character 'little guy' from the registry");
|
||||
FAIL_ZERO_BREAK(errctx, actorptr->basechar, AKGL_ERR_REGISTRY, "Can't load character 'little guy' from the registry");
|
||||
actorptr->movement_controls_face = false;
|
||||
actorptr->state = (AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_LEFT);
|
||||
actorptr->x = 320;
|
||||
@@ -76,7 +76,7 @@ int main(void)
|
||||
actorptr->visible = true;
|
||||
|
||||
// set up the control map
|
||||
controlmap = &GAME_ControlMaps[0];
|
||||
controlmap = &akgl_controlmaps[0];
|
||||
controlmap->kbid = 0;
|
||||
controlmap->target = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
|
||||
// Move down
|
||||
@@ -96,7 +96,7 @@ int main(void)
|
||||
controlmap->controls[1].target_del_state_off = AKGL_ACTOR_STATE_MOVING_UP;
|
||||
controlmap->controls[1].event_on = SDL_EVENT_KEY_DOWN;
|
||||
controlmap->controls[1].event_off = SDL_EVENT_KEY_UP;
|
||||
|
||||
|
||||
// Move left
|
||||
controlmap->controls[2].key = SDLK_LEFT;
|
||||
//controlmap->controls[2].target_state_gate = AKGL_ACTOR_STATE_MOVING_LEFT;
|
||||
@@ -105,13 +105,13 @@ int main(void)
|
||||
controlmap->controls[2].target_del_state_off = AKGL_ACTOR_STATE_MOVING_LEFT;
|
||||
controlmap->controls[2].event_on = SDL_EVENT_KEY_DOWN;
|
||||
controlmap->controls[2].event_off = SDL_EVENT_KEY_UP;
|
||||
|
||||
|
||||
// Move right
|
||||
controlmap->controls[3].key = SDLK_RIGHT;
|
||||
//controlmap->controls[3].target_state_gate = AKGL_ACTOR_STATE_MOVING_RIGHT;
|
||||
controlmap->controls[3].target_add_state_on = AKGL_ACTOR_STATE_MOVING_RIGHT | AKGL_ACTOR_STATE_FACE_RIGHT;
|
||||
controlmap->controls[3].target_del_state_on = AKGL_ACTOR_STATE_MOVING_LEFT | AKGL_ACTOR_STATE_FACE_ALL;
|
||||
controlmap->controls[3].target_del_state_off = AKGL_ACTOR_STATE_MOVING_RIGHT;
|
||||
controlmap->controls[3].target_del_state_off = AKGL_ACTOR_STATE_MOVING_RIGHT;
|
||||
controlmap->controls[3].event_on = SDL_EVENT_KEY_DOWN;
|
||||
controlmap->controls[3].event_off = SDL_EVENT_KEY_UP;
|
||||
} CLEANUP {
|
||||
@@ -120,6 +120,6 @@ int main(void)
|
||||
LOG_ERROR(errctx);
|
||||
return 1;
|
||||
} FINISH_NORETURN(errctx);
|
||||
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
886
tests/controller.c
Normal file
886
tests/controller.c
Normal file
@@ -0,0 +1,886 @@
|
||||
/**
|
||||
* @file controller.c
|
||||
* @brief Unit tests for control maps and SDL input dispatch.
|
||||
*
|
||||
* SDL events are synthesized directly rather than pumped through the event
|
||||
* queue, so none of this needs a physical keyboard or gamepad. The device
|
||||
* enumeration helpers are exercised against the dummy drivers, where the
|
||||
* expected outcome is "no devices, no crash".
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <string.h>
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/error.h>
|
||||
// For the akgl_actor_cmhf_* handlers these tests bind. akgl/controller.h pulls
|
||||
// actor.h in for itself now; tests/headers.c is what keeps it doing so.
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/character.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/controller.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/registry.h>
|
||||
|
||||
#include "testutil.h"
|
||||
|
||||
/** @brief Keyboard id the tests bind their control maps to. */
|
||||
#define TEST_KBID 11
|
||||
/** @brief Gamepad id the tests bind their control maps to. */
|
||||
#define TEST_JSID 22
|
||||
|
||||
/** @brief Stand-in for the application state pointer SDL hands to callbacks. */
|
||||
static int appstate_placeholder = 0;
|
||||
|
||||
/** @brief The actor every control map in this file targets. */
|
||||
static akgl_Actor *player = NULL;
|
||||
/** @brief Base character supplying the player's acceleration constants. */
|
||||
static akgl_Character *playerchar = NULL;
|
||||
|
||||
/** @brief Clear every control map so each test starts from an empty binding set. */
|
||||
static void reset_control_maps(void)
|
||||
{
|
||||
int i = 0;
|
||||
for ( i = 0; i < AKGL_MAX_CONTROL_MAPS; i++ ) {
|
||||
memset(&akgl_controlmaps[i], 0x00, sizeof(akgl_ControlMap));
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief Register the "player" actor the gamepad handlers look up by name. */
|
||||
static akerr_ErrorContext *make_player(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
ATTEMPT {
|
||||
CATCH(e, akgl_registry_init_actor());
|
||||
CATCH(e, akgl_registry_init_character());
|
||||
CATCH(e, akgl_heap_init());
|
||||
|
||||
CATCH(e, akgl_heap_next_character(&playerchar));
|
||||
CATCH(e, akgl_character_initialize(playerchar, "playerchar"));
|
||||
playerchar->ax = 6.0f;
|
||||
playerchar->ay = 8.0f;
|
||||
|
||||
CATCH(e, akgl_heap_next_actor(&player));
|
||||
CATCH(e, akgl_actor_initialize(player, "player"));
|
||||
player->basechar = playerchar;
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
/** @brief Build a synthetic keyboard event. */
|
||||
static void make_key_event(SDL_Event *event, uint32_t type, SDL_KeyboardID which, SDL_Keycode key)
|
||||
{
|
||||
memset(event, 0x00, sizeof(SDL_Event));
|
||||
event->type = type;
|
||||
event->key.which = which;
|
||||
event->key.key = key;
|
||||
}
|
||||
|
||||
/** @brief Build a synthetic keyboard event carrying modifier state. */
|
||||
static void make_key_event_mod(SDL_Event *event, uint32_t type, SDL_KeyboardID which, SDL_Keycode key, SDL_Keymod mod)
|
||||
{
|
||||
make_key_event(event, type, which, key);
|
||||
event->key.mod = mod;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Build the text input event SDL sends after a key press composes.
|
||||
*
|
||||
* @p text is not copied by SDL or by the library at this point, so callers pass
|
||||
* a string literal.
|
||||
*/
|
||||
static void make_text_event(SDL_Event *event, const char *text)
|
||||
{
|
||||
memset(event, 0x00, sizeof(SDL_Event));
|
||||
event->type = SDL_EVENT_TEXT_INPUT;
|
||||
event->text.text = text;
|
||||
}
|
||||
|
||||
/** @brief Build a synthetic gamepad button event. */
|
||||
static void make_button_event(SDL_Event *event, uint32_t type, SDL_JoystickID which, uint8_t button)
|
||||
{
|
||||
memset(event, 0x00, sizeof(SDL_Event));
|
||||
event->type = type;
|
||||
event->gbutton.which = which;
|
||||
event->gbutton.button = button;
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_controller_pushmap(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_Control control;
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
reset_control_maps();
|
||||
memset(&control, 0x00, sizeof(akgl_Control));
|
||||
control.key = SDLK_SPACE;
|
||||
control.event_on = SDL_EVENT_KEY_DOWN;
|
||||
control.event_off = SDL_EVENT_KEY_UP;
|
||||
control.handler_on = &akgl_actor_cmhf_left_on;
|
||||
control.handler_off = &akgl_actor_cmhf_left_off;
|
||||
|
||||
TEST_EXPECT_OK(e, akgl_controller_pushmap(0, &control), "pushing one control");
|
||||
TEST_ASSERT(e, akgl_controlmaps[0].nextMap == 1,
|
||||
"pushing one control left nextMap at %d, expected 1",
|
||||
akgl_controlmaps[0].nextMap);
|
||||
TEST_ASSERT(e, akgl_controlmaps[0].controls[0].key == SDLK_SPACE,
|
||||
"the pushed control did not land in slot 0");
|
||||
TEST_ASSERT(e, akgl_controlmaps[0].controls[0].handler_on == &akgl_actor_cmhf_left_on,
|
||||
"the pushed control lost its press handler");
|
||||
|
||||
// Pushes accumulate rather than overwrite.
|
||||
TEST_EXPECT_OK(e, akgl_controller_pushmap(0, &control), "pushing a second control");
|
||||
TEST_ASSERT(e, akgl_controlmaps[0].nextMap == 2,
|
||||
"pushing a second control left nextMap at %d, expected 2",
|
||||
akgl_controlmaps[0].nextMap);
|
||||
|
||||
// Maps are independent of each other.
|
||||
TEST_EXPECT_OK(e, akgl_controller_pushmap(3, &control), "pushing into a different map");
|
||||
TEST_ASSERT(e, akgl_controlmaps[3].nextMap == 1,
|
||||
"map 3 nextMap is %d, expected 1", akgl_controlmaps[3].nextMap);
|
||||
TEST_ASSERT(e, akgl_controlmaps[0].nextMap == 2,
|
||||
"pushing into map 3 disturbed map 0 (nextMap %d)", akgl_controlmaps[0].nextMap);
|
||||
|
||||
// Filling a map exactly to capacity still succeeds.
|
||||
reset_control_maps();
|
||||
for ( i = 0; i < AKGL_MAX_CONTROLS; i++ ) {
|
||||
akerr_ErrorContext *pushresult = akgl_controller_pushmap(0, &control);
|
||||
if ( pushresult != NULL ) {
|
||||
pushresult->handled = true;
|
||||
pushresult = akerr_release_error(pushresult);
|
||||
FAIL_BREAK(e, AKGL_ERR_BEHAVIOR, "pushing control %d of %d failed", i, AKGL_MAX_CONTROLS);
|
||||
}
|
||||
}
|
||||
TEST_ASSERT(e, akgl_controlmaps[0].nextMap == AKGL_MAX_CONTROLS,
|
||||
"a full map reports nextMap %d, expected %d",
|
||||
akgl_controlmaps[0].nextMap, AKGL_MAX_CONTROLS);
|
||||
|
||||
// One past capacity is refused.
|
||||
TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_controller_pushmap(0, &control),
|
||||
"pushing into a full control map");
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_controller_pushmap(0, NULL),
|
||||
"pushing a NULL control");
|
||||
TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS,
|
||||
akgl_controller_pushmap(AKGL_MAX_CONTROL_MAPS, &control),
|
||||
"pushing into a control map id at the limit");
|
||||
TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS,
|
||||
akgl_controller_pushmap(AKGL_MAX_CONTROL_MAPS + 5, &control),
|
||||
"pushing into a control map id past the limit");
|
||||
|
||||
// Negative ids index *before* akgl_controlmaps. Both entry points
|
||||
// checked only the upper bound until 0.5.0.
|
||||
TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS,
|
||||
akgl_controller_pushmap(-1, &control),
|
||||
"pushing into a negative control map id");
|
||||
TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS,
|
||||
akgl_controller_pushmap(-4096, &control),
|
||||
"pushing into a far negative control map id");
|
||||
TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS,
|
||||
akgl_controller_default(-1, "player", TEST_KBID, TEST_JSID),
|
||||
"defaulting a negative control map id");
|
||||
} CLEANUP {
|
||||
reset_control_maps();
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_controller_default_bindings(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
|
||||
ATTEMPT {
|
||||
reset_control_maps();
|
||||
CATCH(e, make_player());
|
||||
|
||||
TEST_EXPECT_OK(e, akgl_controller_default(0, "player", TEST_KBID, TEST_JSID),
|
||||
"installing the default control map");
|
||||
|
||||
TEST_ASSERT(e, akgl_controlmaps[0].target == player,
|
||||
"the default map did not target the player actor");
|
||||
TEST_ASSERT(e, akgl_controlmaps[0].kbid == TEST_KBID,
|
||||
"the default map recorded keyboard %d, expected %d",
|
||||
akgl_controlmaps[0].kbid, TEST_KBID);
|
||||
TEST_ASSERT(e, akgl_controlmaps[0].jsid == TEST_JSID,
|
||||
"the default map recorded gamepad %d, expected %d",
|
||||
akgl_controlmaps[0].jsid, TEST_JSID);
|
||||
|
||||
// Four keyboard bindings then four gamepad bindings.
|
||||
TEST_ASSERT(e, akgl_controlmaps[0].nextMap == 8,
|
||||
"the default map installed %d controls, expected 8",
|
||||
akgl_controlmaps[0].nextMap);
|
||||
|
||||
TEST_ASSERT(e, akgl_controlmaps[0].controls[0].key == SDLK_DOWN,
|
||||
"the first default binding is not the down arrow");
|
||||
TEST_ASSERT(e, akgl_controlmaps[0].controls[0].handler_on == &akgl_actor_cmhf_down_on,
|
||||
"the down arrow is not bound to the down handler");
|
||||
TEST_ASSERT(e, akgl_controlmaps[0].controls[1].key == SDLK_UP,
|
||||
"the second default binding is not the up arrow");
|
||||
TEST_ASSERT(e, akgl_controlmaps[0].controls[2].key == SDLK_LEFT,
|
||||
"the third default binding is not the left arrow");
|
||||
TEST_ASSERT(e, akgl_controlmaps[0].controls[3].key == SDLK_RIGHT,
|
||||
"the fourth default binding is not the right arrow");
|
||||
|
||||
// The gamepad half binds buttons and leaves the keycode clear, so a
|
||||
// keyboard event cannot accidentally match a dpad binding.
|
||||
TEST_ASSERT(e, akgl_controlmaps[0].controls[4].button == SDL_GAMEPAD_BUTTON_DPAD_DOWN,
|
||||
"the fifth default binding is not the dpad down button");
|
||||
TEST_ASSERT(e, akgl_controlmaps[0].controls[4].key == 0,
|
||||
"a gamepad binding also carries a keycode");
|
||||
TEST_ASSERT(e, akgl_controlmaps[0].controls[7].button == SDL_GAMEPAD_BUTTON_DPAD_RIGHT,
|
||||
"the eighth default binding is not the dpad right button");
|
||||
|
||||
// An unknown actor name is a registry error, not a crash.
|
||||
reset_control_maps();
|
||||
TEST_EXPECT_STATUS(e, AKGL_ERR_REGISTRY,
|
||||
akgl_controller_default(1, "no_such_actor", TEST_KBID, TEST_JSID),
|
||||
"installing a default map for an unregistered actor");
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS,
|
||||
akgl_controller_default(AKGL_MAX_CONTROL_MAPS, "player", TEST_KBID, TEST_JSID),
|
||||
"installing a default map at a control map id past the limit");
|
||||
} CLEANUP {
|
||||
reset_control_maps();
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_controller_handle_keyboard_events(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
SDL_Event event;
|
||||
|
||||
ATTEMPT {
|
||||
reset_control_maps();
|
||||
CATCH(e, make_player());
|
||||
CATCH(e, akgl_controller_default(0, "player", TEST_KBID, TEST_JSID));
|
||||
|
||||
// Pressing left routes to the left press handler.
|
||||
player->state = 0;
|
||||
make_key_event(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_LEFT);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_event(&appstate_placeholder, &event),
|
||||
"dispatching a left key press");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(player->state, AKGL_ACTOR_STATE_MOVING_LEFT),
|
||||
"a left key press did not start the actor moving left (state %d)", player->state);
|
||||
TEST_ASSERT_FEQ(e, player->ax, -6.0f,
|
||||
"a left key press set ax to %f, expected -6", player->ax);
|
||||
|
||||
// Releasing it routes to the release handler.
|
||||
make_key_event(&event, SDL_EVENT_KEY_UP, TEST_KBID, SDLK_LEFT);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_event(&appstate_placeholder, &event),
|
||||
"dispatching a left key release");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HASNOT(player->state, AKGL_ACTOR_STATE_MOVING_LEFT),
|
||||
"a left key release did not stop the actor (state %d)", player->state);
|
||||
|
||||
// The other three directions route the same way.
|
||||
player->state = 0;
|
||||
make_key_event(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_RIGHT);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_event(&appstate_placeholder, &event),
|
||||
"dispatching a right key press");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(player->state, AKGL_ACTOR_STATE_MOVING_RIGHT),
|
||||
"a right key press did not start the actor moving right (state %d)", player->state);
|
||||
|
||||
player->state = 0;
|
||||
make_key_event(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_UP);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_event(&appstate_placeholder, &event),
|
||||
"dispatching an up key press");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(player->state, AKGL_ACTOR_STATE_MOVING_UP),
|
||||
"an up key press did not start the actor moving up (state %d)", player->state);
|
||||
|
||||
player->state = 0;
|
||||
make_key_event(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_DOWN);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_event(&appstate_placeholder, &event),
|
||||
"dispatching a down key press");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(player->state, AKGL_ACTOR_STATE_MOVING_DOWN),
|
||||
"a down key press did not start the actor moving down (state %d)", player->state);
|
||||
|
||||
// An unbound key is ignored.
|
||||
player->state = 0;
|
||||
make_key_event(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_F12);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_event(&appstate_placeholder, &event),
|
||||
"dispatching an unbound key");
|
||||
TEST_ASSERT(e, player->state == 0,
|
||||
"an unbound key changed the actor state to %d", player->state);
|
||||
|
||||
// A bound key from a different keyboard is ignored, so split-keyboard
|
||||
// local multiplayer does not cross-talk.
|
||||
make_key_event(&event, SDL_EVENT_KEY_DOWN, TEST_KBID + 1, SDLK_LEFT);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_event(&appstate_placeholder, &event),
|
||||
"dispatching a bound key from an unbound keyboard");
|
||||
TEST_ASSERT(e, player->state == 0,
|
||||
"a key from another keyboard changed the actor state to %d", player->state);
|
||||
} CLEANUP {
|
||||
reset_control_maps();
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_controller_handle_gamepad_events(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
SDL_Event event;
|
||||
|
||||
ATTEMPT {
|
||||
reset_control_maps();
|
||||
CATCH(e, make_player());
|
||||
CATCH(e, akgl_controller_default(0, "player", TEST_KBID, TEST_JSID));
|
||||
|
||||
player->state = 0;
|
||||
make_button_event(&event, SDL_EVENT_GAMEPAD_BUTTON_DOWN, TEST_JSID, SDL_GAMEPAD_BUTTON_DPAD_LEFT);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_event(&appstate_placeholder, &event),
|
||||
"dispatching a dpad left press");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(player->state, AKGL_ACTOR_STATE_MOVING_LEFT),
|
||||
"a dpad left press did not start the actor moving left (state %d)", player->state);
|
||||
|
||||
make_button_event(&event, SDL_EVENT_GAMEPAD_BUTTON_UP, TEST_JSID, SDL_GAMEPAD_BUTTON_DPAD_LEFT);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_event(&appstate_placeholder, &event),
|
||||
"dispatching a dpad left release");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HASNOT(player->state, AKGL_ACTOR_STATE_MOVING_LEFT),
|
||||
"a dpad left release did not stop the actor (state %d)", player->state);
|
||||
|
||||
player->state = 0;
|
||||
make_button_event(&event, SDL_EVENT_GAMEPAD_BUTTON_DOWN, TEST_JSID, SDL_GAMEPAD_BUTTON_DPAD_UP);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_event(&appstate_placeholder, &event),
|
||||
"dispatching a dpad up press");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(player->state, AKGL_ACTOR_STATE_MOVING_UP),
|
||||
"a dpad up press did not start the actor moving up (state %d)", player->state);
|
||||
|
||||
// A press from an unbound gamepad is ignored.
|
||||
player->state = 0;
|
||||
make_button_event(&event, SDL_EVENT_GAMEPAD_BUTTON_DOWN, TEST_JSID + 1, SDL_GAMEPAD_BUTTON_DPAD_LEFT);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_event(&appstate_placeholder, &event),
|
||||
"dispatching a dpad press from an unbound gamepad");
|
||||
TEST_ASSERT(e, player->state == 0,
|
||||
"a press from another gamepad changed the actor state to %d", player->state);
|
||||
|
||||
// An unbound button on the bound gamepad is ignored.
|
||||
make_button_event(&event, SDL_EVENT_GAMEPAD_BUTTON_DOWN, TEST_JSID, SDL_GAMEPAD_BUTTON_START);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_event(&appstate_placeholder, &event),
|
||||
"dispatching an unbound gamepad button");
|
||||
TEST_ASSERT(e, player->state == 0,
|
||||
"an unbound gamepad button changed the actor state to %d", player->state);
|
||||
} CLEANUP {
|
||||
reset_control_maps();
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_controller_handle_event_edge_cases(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
SDL_Event event;
|
||||
|
||||
ATTEMPT {
|
||||
reset_control_maps();
|
||||
CATCH(e, make_player());
|
||||
|
||||
// With no control maps installed at all, every event is a no-op.
|
||||
make_key_event(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_LEFT);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_event(&appstate_placeholder, &event),
|
||||
"dispatching into an empty control map table");
|
||||
|
||||
// A map in a later slot is still reached, so the scan does not stop at
|
||||
// the first unpopulated entry.
|
||||
CATCH(e, akgl_controller_default(5, "player", TEST_KBID, TEST_JSID));
|
||||
player->state = 0;
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_event(&appstate_placeholder, &event),
|
||||
"dispatching to a control map in a later slot");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(player->state, AKGL_ACTOR_STATE_MOVING_LEFT),
|
||||
"a control map in slot 5 was not consulted (state %d)", player->state);
|
||||
|
||||
// An event type that no binding uses falls through harmlessly.
|
||||
memset(&event, 0x00, sizeof(SDL_Event));
|
||||
event.type = SDL_EVENT_MOUSE_MOTION;
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_event(&appstate_placeholder, &event),
|
||||
"dispatching an event type with no bindings");
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_controller_handle_event(NULL, &event),
|
||||
"dispatching with a NULL appstate");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER,
|
||||
akgl_controller_handle_event(&appstate_placeholder, NULL),
|
||||
"dispatching a NULL event");
|
||||
} CLEANUP {
|
||||
reset_control_maps();
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_controller_gamepad_button_handlers(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
SDL_Event event;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, make_player());
|
||||
|
||||
// These handlers find their actor by looking up "player" in the registry
|
||||
// rather than taking it as an argument.
|
||||
player->state = 0;
|
||||
player->movement_controls_face = false;
|
||||
make_button_event(&event, SDL_EVENT_GAMEPAD_BUTTON_DOWN, TEST_JSID, SDL_GAMEPAD_BUTTON_DPAD_DOWN);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_button_down(&appstate_placeholder, &event),
|
||||
"dpad down press handler");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(player->state, AKGL_ACTOR_STATE_MOVING_DOWN),
|
||||
"the dpad down handler did not set MOVING_DOWN (state %d)", player->state);
|
||||
// With automatic facing off, the handler sets the facing itself.
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(player->state, AKGL_ACTOR_STATE_FACE_DOWN),
|
||||
"the dpad down handler did not set FACE_DOWN (state %d)", player->state);
|
||||
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_button_up(&appstate_placeholder, &event),
|
||||
"dpad down release handler");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HASNOT(player->state, AKGL_ACTOR_STATE_MOVING_DOWN),
|
||||
"the dpad down release handler did not clear MOVING_DOWN (state %d)", player->state);
|
||||
TEST_ASSERT(e, player->curSpriteFrameId == 0,
|
||||
"the release handler did not reset the animation frame");
|
||||
|
||||
player->state = 0;
|
||||
make_button_event(&event, SDL_EVENT_GAMEPAD_BUTTON_DOWN, TEST_JSID, SDL_GAMEPAD_BUTTON_DPAD_UP);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_button_down(&appstate_placeholder, &event), "dpad up press handler");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(player->state, AKGL_ACTOR_STATE_MOVING_UP),
|
||||
"the dpad up handler did not set MOVING_UP (state %d)", player->state);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_button_up(&appstate_placeholder, &event), "dpad up release handler");
|
||||
|
||||
player->state = 0;
|
||||
make_button_event(&event, SDL_EVENT_GAMEPAD_BUTTON_DOWN, TEST_JSID, SDL_GAMEPAD_BUTTON_DPAD_LEFT);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_button_down(&appstate_placeholder, &event), "dpad left press handler");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(player->state, AKGL_ACTOR_STATE_MOVING_LEFT),
|
||||
"the dpad left handler did not set MOVING_LEFT (state %d)", player->state);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_button_up(&appstate_placeholder, &event), "dpad left release handler");
|
||||
|
||||
player->state = 0;
|
||||
make_button_event(&event, SDL_EVENT_GAMEPAD_BUTTON_DOWN, TEST_JSID, SDL_GAMEPAD_BUTTON_DPAD_RIGHT);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_button_down(&appstate_placeholder, &event), "dpad right press handler");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(player->state, AKGL_ACTOR_STATE_MOVING_RIGHT),
|
||||
"the dpad right handler did not set MOVING_RIGHT (state %d)", player->state);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_button_up(&appstate_placeholder, &event), "dpad right release handler");
|
||||
|
||||
// With automatic facing on, the handler leaves facing to the actor's own
|
||||
// face function.
|
||||
player->state = 0;
|
||||
player->movement_controls_face = true;
|
||||
make_button_event(&event, SDL_EVENT_GAMEPAD_BUTTON_DOWN, TEST_JSID, SDL_GAMEPAD_BUTTON_DPAD_DOWN);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_button_down(&appstate_placeholder, &event),
|
||||
"dpad down press with automatic facing on");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HASNOT(player->state, AKGL_ACTOR_STATE_FACE_DOWN),
|
||||
"the handler set facing even though the actor faces automatically (state %d)",
|
||||
player->state);
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_controller_handle_button_down(NULL, &event),
|
||||
"dpad press handler with a NULL appstate");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER,
|
||||
akgl_controller_handle_button_down(&appstate_placeholder, NULL),
|
||||
"dpad press handler with a NULL event");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_controller_handle_button_up(NULL, &event),
|
||||
"dpad release handler with a NULL appstate");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER,
|
||||
akgl_controller_handle_button_up(&appstate_placeholder, NULL),
|
||||
"dpad release handler with a NULL event");
|
||||
|
||||
// With no actor named "player" registered there is nothing to drive.
|
||||
CATCH(e, akgl_registry_init_actor());
|
||||
make_button_event(&event, SDL_EVENT_GAMEPAD_BUTTON_DOWN, TEST_JSID, SDL_GAMEPAD_BUTTON_DPAD_DOWN);
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER,
|
||||
akgl_controller_handle_button_down(&appstate_placeholder, &event),
|
||||
"dpad press handler with no player actor registered");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER,
|
||||
akgl_controller_handle_button_up(&appstate_placeholder, &event),
|
||||
"dpad release handler with no player actor registered");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_controller_device_events(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
SDL_Event event;
|
||||
|
||||
ATTEMPT {
|
||||
// The dummy drivers present no gamepads, so the add and remove handlers
|
||||
// take their "unknown device" paths. Neither may crash.
|
||||
make_button_event(&event, SDL_EVENT_GAMEPAD_ADDED, 999, 0);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_added(&appstate_placeholder, &event),
|
||||
"handling an add for a device that cannot be opened");
|
||||
|
||||
make_button_event(&event, SDL_EVENT_GAMEPAD_REMOVED, 999, 0);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_removed(&appstate_placeholder, &event),
|
||||
"handling a remove for a device that was never open");
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_controller_handle_added(NULL, &event),
|
||||
"add handler with a NULL appstate");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER,
|
||||
akgl_controller_handle_added(&appstate_placeholder, NULL),
|
||||
"add handler with a NULL event");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_controller_handle_removed(NULL, &event),
|
||||
"remove handler with a NULL appstate");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER,
|
||||
akgl_controller_handle_removed(&appstate_placeholder, NULL),
|
||||
"remove handler with a NULL event");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_controller_device_enumeration(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
|
||||
ATTEMPT {
|
||||
// Enumeration has to succeed on a machine with no input devices, which
|
||||
// is the normal state under the dummy drivers and in CI.
|
||||
TEST_EXPECT_OK(e, akgl_controller_list_keyboards(), "listing keyboards");
|
||||
TEST_EXPECT_OK(e, akgl_controller_open_gamepads(), "opening gamepads");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_controller_poll_key(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
SDL_Event event;
|
||||
int keycode = -1;
|
||||
bool available = true;
|
||||
|
||||
ATTEMPT {
|
||||
reset_control_maps();
|
||||
CATCH(e, make_player());
|
||||
CATCH(e, akgl_controller_flush_keys());
|
||||
|
||||
// An empty buffer answers "no key waiting" and succeeds.
|
||||
TEST_EXPECT_OK(e, akgl_controller_poll_key(&keycode, &available),
|
||||
"polling an empty key buffer");
|
||||
TEST_ASSERT(e, available == false,
|
||||
"polling an empty buffer reported a key was available");
|
||||
TEST_ASSERT(e, keycode == 0,
|
||||
"polling an empty buffer left keycode at %d, expected 0", keycode);
|
||||
|
||||
// A key press pumped through the handler is drained by the poller,
|
||||
// exactly once.
|
||||
make_key_event(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_A);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_event(&appstate_placeholder, &event),
|
||||
"dispatching a key press with no control maps installed");
|
||||
TEST_EXPECT_OK(e, akgl_controller_poll_key(&keycode, &available), "polling for that key");
|
||||
TEST_ASSERT(e, available == true, "the pressed key was not available to the poller");
|
||||
TEST_ASSERT(e, keycode == SDLK_A,
|
||||
"the poller returned keycode %d, expected %d", keycode, (int)SDLK_A);
|
||||
TEST_EXPECT_OK(e, akgl_controller_poll_key(&keycode, &available), "polling again");
|
||||
TEST_ASSERT(e, available == false, "the same keystroke was delivered twice");
|
||||
|
||||
// Key releases are not keystrokes.
|
||||
make_key_event(&event, SDL_EVENT_KEY_UP, TEST_KBID, SDLK_A);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_event(&appstate_placeholder, &event),
|
||||
"dispatching a key release");
|
||||
TEST_EXPECT_OK(e, akgl_controller_poll_key(&keycode, &available), "polling after a release");
|
||||
TEST_ASSERT(e, available == false, "a key release was buffered as a keystroke");
|
||||
|
||||
// Keys come back in the order they were pressed.
|
||||
CATCH(e, akgl_controller_flush_keys());
|
||||
make_key_event(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_1);
|
||||
CATCH(e, akgl_controller_handle_event(&appstate_placeholder, &event));
|
||||
make_key_event(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_2);
|
||||
CATCH(e, akgl_controller_handle_event(&appstate_placeholder, &event));
|
||||
make_key_event(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_3);
|
||||
CATCH(e, akgl_controller_handle_event(&appstate_placeholder, &event));
|
||||
|
||||
TEST_EXPECT_OK(e, akgl_controller_poll_key(&keycode, &available), "draining the first key");
|
||||
TEST_ASSERT(e, keycode == SDLK_1, "the first key out was %d, expected %d",
|
||||
keycode, (int)SDLK_1);
|
||||
TEST_EXPECT_OK(e, akgl_controller_poll_key(&keycode, &available), "draining the second key");
|
||||
TEST_ASSERT(e, keycode == SDLK_2, "the second key out was %d, expected %d",
|
||||
keycode, (int)SDLK_2);
|
||||
TEST_EXPECT_OK(e, akgl_controller_poll_key(&keycode, &available), "draining the third key");
|
||||
TEST_ASSERT(e, keycode == SDLK_3, "the third key out was %d, expected %d",
|
||||
keycode, (int)SDLK_3);
|
||||
|
||||
// A key that a control map also acts on still reaches the poller, so a
|
||||
// game and an embedded interpreter can share one keyboard.
|
||||
CATCH(e, akgl_controller_flush_keys());
|
||||
CATCH(e, akgl_controller_default(0, "player", TEST_KBID, TEST_JSID));
|
||||
player->state = 0;
|
||||
make_key_event(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_LEFT);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_event(&appstate_placeholder, &event),
|
||||
"dispatching a bound key press");
|
||||
TEST_ASSERT(e, AKGL_BITMASK_HAS(player->state, AKGL_ACTOR_STATE_MOVING_LEFT),
|
||||
"the bound key stopped driving the actor (state %d)", player->state);
|
||||
TEST_EXPECT_OK(e, akgl_controller_poll_key(&keycode, &available),
|
||||
"polling for a key a control map claimed");
|
||||
TEST_ASSERT(e, available == true, "a bound key never reached the key buffer");
|
||||
TEST_ASSERT(e, keycode == SDLK_LEFT, "the poller returned keycode %d, expected %d",
|
||||
keycode, (int)SDLK_LEFT);
|
||||
|
||||
// Flushing discards the backlog.
|
||||
CATCH(e, akgl_controller_flush_keys());
|
||||
make_key_event(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_B);
|
||||
CATCH(e, akgl_controller_handle_event(&appstate_placeholder, &event));
|
||||
TEST_EXPECT_OK(e, akgl_controller_flush_keys(), "flushing a buffer with a key in it");
|
||||
TEST_EXPECT_OK(e, akgl_controller_poll_key(&keycode, &available), "polling after a flush");
|
||||
TEST_ASSERT(e, available == false, "a flush left a keystroke behind");
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_controller_poll_key(NULL, &available),
|
||||
"polling into a NULL keycode");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_controller_poll_key(&keycode, NULL),
|
||||
"polling into a NULL availability flag");
|
||||
} CLEANUP {
|
||||
reset_control_maps();
|
||||
IGNORE(akgl_controller_flush_keys());
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_controller_poll_key_overflow(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
SDL_Event event;
|
||||
int keycode = -1;
|
||||
bool available = true;
|
||||
bool ordered = true;
|
||||
bool pumped = true;
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
reset_control_maps();
|
||||
CATCH(e, akgl_controller_flush_keys());
|
||||
|
||||
// Fill the buffer exactly, then press one more. The overflowing key is
|
||||
// the one that is dropped -- what was typed first survives.
|
||||
for ( i = 0; i < AKGL_CONTROLLER_KEY_BUFFER + 1; i++ ) {
|
||||
akerr_ErrorContext *pumpresult = NULL;
|
||||
make_key_event(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_A + i);
|
||||
pumpresult = akgl_controller_handle_event(&appstate_placeholder, &event);
|
||||
if ( pumpresult != NULL ) {
|
||||
pumpresult->handled = true;
|
||||
pumpresult = akerr_release_error(pumpresult);
|
||||
pumped = false;
|
||||
}
|
||||
}
|
||||
TEST_ASSERT(e, pumped == true, "dispatching the overflow key presses failed");
|
||||
|
||||
for ( i = 0; i < AKGL_CONTROLLER_KEY_BUFFER; i++ ) {
|
||||
akerr_ErrorContext *pollresult = akgl_controller_poll_key(&keycode, &available);
|
||||
if ( pollresult != NULL ) {
|
||||
pollresult->handled = true;
|
||||
pollresult = akerr_release_error(pollresult);
|
||||
ordered = false;
|
||||
}
|
||||
TEST_ASSERT_FLAG(ordered, available == true);
|
||||
TEST_ASSERT_FLAG(ordered, keycode == (int)(SDLK_A + i));
|
||||
}
|
||||
TEST_ASSERT(e, ordered == true,
|
||||
"a full buffer did not return the first %d keys in order",
|
||||
AKGL_CONTROLLER_KEY_BUFFER);
|
||||
|
||||
TEST_EXPECT_OK(e, akgl_controller_poll_key(&keycode, &available),
|
||||
"polling after draining a full buffer");
|
||||
TEST_ASSERT(e, available == false,
|
||||
"the key pressed past capacity was buffered anyway (keycode %d)", keycode);
|
||||
|
||||
// The buffer is reusable after an overflow rather than wedged.
|
||||
make_key_event(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_Z);
|
||||
TEST_EXPECT_OK(e, akgl_controller_handle_event(&appstate_placeholder, &event),
|
||||
"dispatching a key after an overflow");
|
||||
TEST_EXPECT_OK(e, akgl_controller_poll_key(&keycode, &available),
|
||||
"polling after an overflow");
|
||||
TEST_ASSERT(e, available == true, "the buffer stayed full after being drained");
|
||||
TEST_ASSERT(e, keycode == SDLK_Z, "the poller returned keycode %d, expected %d",
|
||||
keycode, (int)SDLK_Z);
|
||||
} CLEANUP {
|
||||
reset_control_maps();
|
||||
IGNORE(akgl_controller_flush_keys());
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_controller_poll_keystroke(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_Event event;
|
||||
akgl_Keystroke keystroke;
|
||||
int keycode = -1;
|
||||
bool available = true;
|
||||
|
||||
ATTEMPT {
|
||||
reset_control_maps();
|
||||
CATCH(errctx, akgl_controller_flush_keys());
|
||||
|
||||
// An empty buffer answers "nothing waiting" and zeroes the destination.
|
||||
memset(&keystroke, 0xff, sizeof(akgl_Keystroke));
|
||||
TEST_EXPECT_OK(errctx, akgl_controller_poll_keystroke(&keystroke, &available),
|
||||
"polling an empty keystroke buffer");
|
||||
TEST_ASSERT(errctx, available == false,
|
||||
"polling an empty buffer reported a keystroke was available");
|
||||
TEST_ASSERT(errctx, keystroke.key == 0,
|
||||
"polling an empty buffer left a keycode of %d behind", (int)keystroke.key);
|
||||
TEST_ASSERT(errctx, keystroke.text[0] == '\0',
|
||||
"polling an empty buffer left text behind");
|
||||
|
||||
// The case the whole thing exists for: a shifted key. The keycode is
|
||||
// the unshifted one, so the composed character is the only place the
|
||||
// double quote can come from.
|
||||
make_key_event_mod(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_APOSTROPHE, SDL_KMOD_LSHIFT);
|
||||
CATCH(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));
|
||||
make_text_event(&event, "\"");
|
||||
CATCH(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));
|
||||
|
||||
TEST_EXPECT_OK(errctx, akgl_controller_poll_keystroke(&keystroke, &available),
|
||||
"polling for a shifted key");
|
||||
TEST_ASSERT(errctx, available == true, "a shifted key press never reached the buffer");
|
||||
TEST_ASSERT(errctx, keystroke.key == SDLK_APOSTROPHE,
|
||||
"the keystroke reported keycode %d, expected %d",
|
||||
(int)keystroke.key, (int)SDLK_APOSTROPHE);
|
||||
TEST_ASSERT(errctx, (keystroke.mod & SDL_KMOD_SHIFT) != 0,
|
||||
"the keystroke lost its shift state (mod %d)", (int)keystroke.mod);
|
||||
TEST_ASSERT(errctx, strcmp(keystroke.text, "\"") == 0,
|
||||
"the keystroke composed to \"%s\", expected a double quote", keystroke.text);
|
||||
|
||||
// A key that composes to nothing still arrives, with empty text -- that
|
||||
// is how a line editor recognises Backspace and the arrows.
|
||||
CATCH(errctx, akgl_controller_flush_keys());
|
||||
make_key_event(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_LEFT);
|
||||
CATCH(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));
|
||||
TEST_EXPECT_OK(errctx, akgl_controller_poll_keystroke(&keystroke, &available),
|
||||
"polling for a non-printing key");
|
||||
TEST_ASSERT(errctx, keystroke.key == SDLK_LEFT,
|
||||
"a non-printing key reported keycode %d, expected %d",
|
||||
(int)keystroke.key, (int)SDLK_LEFT);
|
||||
TEST_ASSERT(errctx, keystroke.text[0] == '\0',
|
||||
"a non-printing key composed to \"%s\", expected nothing", keystroke.text);
|
||||
|
||||
// Text with no key press behind it -- an input method, or a character
|
||||
// finished by a dead key -- is buffered on its own rather than dropped.
|
||||
CATCH(errctx, akgl_controller_flush_keys());
|
||||
make_text_event(&event, "e");
|
||||
CATCH(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));
|
||||
TEST_EXPECT_OK(errctx, akgl_controller_poll_keystroke(&keystroke, &available),
|
||||
"polling for composed text with no key press");
|
||||
TEST_ASSERT(errctx, available == true, "composed text with no key press was dropped");
|
||||
TEST_ASSERT(errctx, keystroke.key == 0,
|
||||
"text with no key press reported keycode %d, expected 0", (int)keystroke.key);
|
||||
TEST_ASSERT(errctx, strcmp(keystroke.text, "e") == 0,
|
||||
"text with no key press composed to \"%s\", expected \"e\"", keystroke.text);
|
||||
TEST_ASSERT(errctx, keystroke.mod == 0,
|
||||
"a text-only entry reported modifiers %d; the character it carries already "
|
||||
"reflects them", (int)keystroke.mod);
|
||||
|
||||
// The keycode form has nowhere to report that entry, so it discards it
|
||||
// on the way past rather than handing back a keystroke with no key.
|
||||
CATCH(errctx, akgl_controller_flush_keys());
|
||||
make_text_event(&event, "e");
|
||||
CATCH(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));
|
||||
make_key_event(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_B);
|
||||
CATCH(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));
|
||||
TEST_EXPECT_OK(errctx, akgl_controller_poll_key(&keycode, &available),
|
||||
"polling by keycode past a text-only entry");
|
||||
TEST_ASSERT(errctx, available == true, "the key behind a text-only entry was lost");
|
||||
TEST_ASSERT(errctx, keycode == SDLK_B,
|
||||
"polling by keycode returned %d, expected %d", keycode, (int)SDLK_B);
|
||||
|
||||
// Both pollers drain the same buffer.
|
||||
CATCH(errctx, akgl_controller_flush_keys());
|
||||
make_key_event(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_C);
|
||||
CATCH(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));
|
||||
CATCH(errctx, akgl_controller_poll_keystroke(&keystroke, &available));
|
||||
TEST_EXPECT_OK(errctx, akgl_controller_poll_key(&keycode, &available),
|
||||
"polling by keycode after the same key was taken as a keystroke");
|
||||
TEST_ASSERT(errctx, available == false, "one keystroke was delivered to both pollers");
|
||||
|
||||
// Text that arrives after its press has already been drained becomes an
|
||||
// entry of its own instead of being attached to somebody else's key.
|
||||
CATCH(errctx, akgl_controller_flush_keys());
|
||||
make_key_event(&event, SDL_EVENT_KEY_DOWN, TEST_KBID, SDLK_D);
|
||||
CATCH(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));
|
||||
CATCH(errctx, akgl_controller_poll_keystroke(&keystroke, &available));
|
||||
make_text_event(&event, "d");
|
||||
CATCH(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));
|
||||
TEST_EXPECT_OK(errctx, akgl_controller_poll_keystroke(&keystroke, &available),
|
||||
"polling for text that outlived its key press");
|
||||
TEST_ASSERT(errctx, available == true, "text that outlived its key press was dropped");
|
||||
TEST_ASSERT(errctx, keystroke.key == 0,
|
||||
"text that outlived its key press was attached to keycode %d",
|
||||
(int)keystroke.key);
|
||||
|
||||
// More text than an entry holds is cut on a character boundary, never
|
||||
// through the middle of one. Each of these is two bytes, so three fit
|
||||
// in the eight-byte field and the fourth does not.
|
||||
CATCH(errctx, akgl_controller_flush_keys());
|
||||
make_text_event(&event, "\xce\xb1\xce\xb1\xce\xb1\xce\xb1");
|
||||
CATCH(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));
|
||||
TEST_EXPECT_OK(errctx, akgl_controller_poll_keystroke(&keystroke, &available),
|
||||
"polling for text longer than one entry holds");
|
||||
TEST_ASSERT(errctx, strcmp(keystroke.text, "\xce\xb1\xce\xb1\xce\xb1") == 0,
|
||||
"oversized text was truncated to \"%s\", expected three whole characters",
|
||||
keystroke.text);
|
||||
|
||||
// A sequence the string ends in the middle of is dropped rather than
|
||||
// copied by reading past the terminator to find its other half.
|
||||
CATCH(errctx, akgl_controller_flush_keys());
|
||||
make_text_event(&event, "a\xce");
|
||||
CATCH(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));
|
||||
TEST_EXPECT_OK(errctx, akgl_controller_poll_keystroke(&keystroke, &available),
|
||||
"polling for text that ends mid-character");
|
||||
TEST_ASSERT(errctx, strcmp(keystroke.text, "a") == 0,
|
||||
"a truncated character came back as \"%s\", expected just the whole one",
|
||||
keystroke.text);
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_controller_poll_keystroke(NULL, &available),
|
||||
"polling into a NULL keystroke");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_controller_poll_keystroke(&keystroke, NULL),
|
||||
"polling into a NULL availability flag");
|
||||
} CLEANUP {
|
||||
reset_control_maps();
|
||||
IGNORE(akgl_controller_flush_keys());
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMEPAD),
|
||||
AKGL_ERR_SDL,
|
||||
"Couldn't initialize SDL: %s",
|
||||
SDL_GetError());
|
||||
|
||||
CATCH(errctx, akgl_heap_init());
|
||||
CATCH(errctx, akgl_registry_init());
|
||||
|
||||
CATCH(errctx, test_controller_pushmap());
|
||||
CATCH(errctx, test_controller_default_bindings());
|
||||
CATCH(errctx, test_controller_handle_keyboard_events());
|
||||
CATCH(errctx, test_controller_handle_gamepad_events());
|
||||
CATCH(errctx, test_controller_handle_event_edge_cases());
|
||||
CATCH(errctx, test_controller_gamepad_button_handlers());
|
||||
CATCH(errctx, test_controller_device_events());
|
||||
CATCH(errctx, test_controller_device_enumeration());
|
||||
CATCH(errctx, test_controller_poll_key());
|
||||
CATCH(errctx, test_controller_poll_key_overflow());
|
||||
CATCH(errctx, test_controller_poll_keystroke());
|
||||
} CLEANUP {
|
||||
SDL_Quit();
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
}
|
||||
675
tests/draw.c
Normal file
675
tests/draw.c
Normal file
@@ -0,0 +1,675 @@
|
||||
/**
|
||||
* @file draw.c
|
||||
* @brief Unit tests for the immediate-mode drawing primitives.
|
||||
*
|
||||
* Everything here draws into a small software renderer under the dummy video
|
||||
* driver and then reads the target back with SDL_RenderReadPixels, so the
|
||||
* assertions are about pixels that actually changed rather than about SDL
|
||||
* having been called. No window is shown and no display is required.
|
||||
*
|
||||
* The target is deliberately tiny: at 64x64 a full readback is 16 KB, which
|
||||
* makes it cheap to read the whole thing back after every operation.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <string.h>
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/draw.h>
|
||||
#include <akgl/renderer.h>
|
||||
|
||||
#include "testutil.h"
|
||||
|
||||
/** @brief Width and height of the offscreen target every test draws into. */
|
||||
#define TEST_TARGET_SIZE 64
|
||||
|
||||
/** @brief Opaque black, what each test clears the target to. */
|
||||
static const SDL_Color testblack = { 0x00, 0x00, 0x00, 0xff };
|
||||
/** @brief The color most tests draw with. */
|
||||
static const SDL_Color testred = { 0xff, 0x00, 0x00, 0xff };
|
||||
/** @brief A second color, for tests that need to tell two marks apart. */
|
||||
static const SDL_Color testgreen = { 0x00, 0xff, 0x00, 0xff };
|
||||
|
||||
/** @brief Clear the whole target to opaque black. */
|
||||
static akerr_ErrorContext *clear_target(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
SDL_SetRenderDrawColor(akgl_renderer->sdl_renderer, 0x00, 0x00, 0x00, 0xff),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
SDL_RenderClear(akgl_renderer->sdl_renderer),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Report whether one pixel of @p shot carries @p color.
|
||||
*
|
||||
* Alpha is not compared: the render target's own alpha depends on the format
|
||||
* SDL picked for it, and none of these tests draw translucently.
|
||||
*/
|
||||
static bool pixel_is(SDL_Surface *shot, int x, int y, SDL_Color color)
|
||||
{
|
||||
uint8_t r = 0;
|
||||
uint8_t g = 0;
|
||||
uint8_t b = 0;
|
||||
uint8_t a = 0;
|
||||
|
||||
if ( shot == NULL ) {
|
||||
return false;
|
||||
}
|
||||
if ( !SDL_ReadSurfacePixel(shot, x, y, &r, &g, &b, &a) ) {
|
||||
return false;
|
||||
}
|
||||
return ((r == color.r) && (g == color.g) && (b == color.b));
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_draw_point(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_Surface *shot = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, clear_target());
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_point(akgl_renderer, 10.0f, 20.0f, testred),
|
||||
"plotting one pixel");
|
||||
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 10, 20, testred),
|
||||
"the plotted pixel at 10,20 is not the color it was drawn with");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 11, 20, testblack),
|
||||
"plotting one pixel also changed its neighbour at 11,20");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 10, 21, testblack),
|
||||
"plotting one pixel also changed the pixel below it");
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_point(NULL, 0.0f, 0.0f, testred),
|
||||
"plotting through a NULL backend");
|
||||
} CLEANUP {
|
||||
if ( shot != NULL ) {
|
||||
SDL_DestroySurface(shot);
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_draw_line(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_Surface *shot = NULL;
|
||||
bool onthe_line = true;
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, clear_target());
|
||||
// A vertical line, so every pixel of it is known without reasoning
|
||||
// about how SDL rasterises a diagonal.
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_line(akgl_renderer, 5.0f, 4.0f, 5.0f, 12.0f, testred),
|
||||
"drawing a vertical line");
|
||||
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
for ( i = 4; i <= 12; i++ ) {
|
||||
TEST_ASSERT_FLAG(onthe_line, pixel_is(shot, 5, i, testred));
|
||||
}
|
||||
TEST_ASSERT(errctx, onthe_line == true,
|
||||
"the vertical line from 5,4 to 5,12 has a gap in it");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 5, 3, testblack),
|
||||
"the line ran past its first endpoint");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 5, 13, testblack),
|
||||
"the line ran past its second endpoint");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 6, 8, testblack),
|
||||
"the line is wider than one pixel");
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_line(NULL, 0.0f, 0.0f, 1.0f, 1.0f, testred),
|
||||
"drawing a line through a NULL backend");
|
||||
} CLEANUP {
|
||||
if ( shot != NULL ) {
|
||||
SDL_DestroySurface(shot);
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_draw_rects(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_Surface *shot = NULL;
|
||||
SDL_FRect box;
|
||||
|
||||
ATTEMPT {
|
||||
box.x = 8.0f;
|
||||
box.y = 8.0f;
|
||||
box.w = 16.0f;
|
||||
box.h = 16.0f;
|
||||
|
||||
// The outline touches the border and leaves the middle alone.
|
||||
CATCH(errctx, clear_target());
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_rect(akgl_renderer, &box, testred), "outlining a rectangle");
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 8, 8, testred), "the outline is missing its top left corner");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 23, 23, testred),
|
||||
"the outline is missing its bottom right corner");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 16, 8, testred), "the outline is missing its top edge");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 16, 16, testblack), "the outline filled its interior");
|
||||
SDL_DestroySurface(shot);
|
||||
shot = NULL;
|
||||
|
||||
// The filled form covers the interior as well.
|
||||
CATCH(errctx, clear_target());
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_filled_rect(akgl_renderer, &box, testred), "filling a rectangle");
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 16, 16, testred), "the fill left its interior empty");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 8, 8, testred), "the fill missed its top left corner");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 24, 24, testblack),
|
||||
"the fill ran one pixel past its bottom right corner");
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_draw_rect(NULL, &box, testred),
|
||||
"outlining through a NULL backend");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_draw_rect(akgl_renderer, NULL, testred),
|
||||
"outlining a NULL rectangle");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_draw_filled_rect(NULL, &box, testred),
|
||||
"filling through a NULL backend");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_draw_filled_rect(akgl_renderer, NULL, testred),
|
||||
"filling a NULL rectangle");
|
||||
} CLEANUP {
|
||||
if ( shot != NULL ) {
|
||||
SDL_DestroySurface(shot);
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_draw_circle(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_Surface *shot = NULL;
|
||||
bool symmetric = true;
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, clear_target());
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_circle(akgl_renderer, 32.0f, 32.0f, 10.0f, testred),
|
||||
"drawing a circle");
|
||||
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
// The four axis points are exact for any correct midpoint circle.
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 42, 32, testred), "the circle is missing its rightmost pixel");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 22, 32, testred), "the circle is missing its leftmost pixel");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 32, 42, testred), "the circle is missing its bottom pixel");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 32, 22, testred), "the circle is missing its top pixel");
|
||||
// It is an outline, not a disc.
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 32, 32, testblack), "the circle filled its center");
|
||||
// ...and nothing lands outside the radius.
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 43, 32, testred) == false,
|
||||
"the circle drew a pixel one past its radius");
|
||||
|
||||
// Every plotted pixel has a mirror in the other three quadrants. The
|
||||
// circle is drawn one octant at a time and reflected seven ways, so a
|
||||
// sign error in any single reflection breaks this and nothing else --
|
||||
// the four axis points above stay put either way.
|
||||
for ( y = 22; y <= 42; y++ ) {
|
||||
for ( x = 22; x <= 42; x++ ) {
|
||||
if ( !pixel_is(shot, x, y, testred) ) {
|
||||
continue;
|
||||
}
|
||||
TEST_ASSERT_FLAG(symmetric, pixel_is(shot, 64 - x, y, testred));
|
||||
TEST_ASSERT_FLAG(symmetric, pixel_is(shot, x, 64 - y, testred));
|
||||
TEST_ASSERT_FLAG(symmetric, pixel_is(shot, 64 - x, 64 - y, testred));
|
||||
}
|
||||
}
|
||||
TEST_ASSERT(errctx, symmetric == true,
|
||||
"the circle is not symmetric about its center; an octant is reflected wrong");
|
||||
SDL_DestroySurface(shot);
|
||||
shot = NULL;
|
||||
|
||||
// A zero radius is the degenerate case, not an error: one pixel.
|
||||
CATCH(errctx, clear_target());
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_circle(akgl_renderer, 5.0f, 5.0f, 0.0f, testred),
|
||||
"drawing a circle of radius zero");
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 5, 5, testred),
|
||||
"a circle of radius zero did not plot its center");
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_draw_circle(akgl_renderer, 5.0f, 5.0f, -1.0f, testred),
|
||||
"drawing a circle of negative radius");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_circle(NULL, 5.0f, 5.0f, 4.0f, testred),
|
||||
"drawing a circle through a NULL backend");
|
||||
} CLEANUP {
|
||||
if ( shot != NULL ) {
|
||||
SDL_DestroySurface(shot);
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_draw_flood_fill(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_Surface *shot = NULL;
|
||||
SDL_FRect box;
|
||||
|
||||
ATTEMPT {
|
||||
// A red box outline on black. Filling inside it must stay inside it,
|
||||
// which is the whole contract of PAINT.
|
||||
box.x = 10.0f;
|
||||
box.y = 10.0f;
|
||||
box.w = 20.0f;
|
||||
box.h = 20.0f;
|
||||
CATCH(errctx, clear_target());
|
||||
CATCH(errctx, akgl_draw_rect(akgl_renderer, &box, testred));
|
||||
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_flood_fill(akgl_renderer, 20, 20, testgreen),
|
||||
"flooding the inside of a box");
|
||||
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 20, 20, testgreen), "the seed pixel was not filled");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 11, 11, testgreen),
|
||||
"the fill did not reach the top left of the interior");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 28, 28, testgreen),
|
||||
"the fill did not reach the bottom right of the interior");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 10, 10, testred), "the fill overwrote the boundary");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 20, 10, testred), "the fill overwrote the top edge");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 20, 5, testblack), "the fill leaked outside the box");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 40, 40, testblack),
|
||||
"the fill leaked into the rest of the target");
|
||||
SDL_DestroySurface(shot);
|
||||
shot = NULL;
|
||||
|
||||
// Filling a region that is already the requested color changes nothing
|
||||
// and is not an error.
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_flood_fill(akgl_renderer, 20, 20, testgreen),
|
||||
"flooding a region that is already that color");
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 20, 20, testgreen),
|
||||
"refilling a region disturbed it");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 10, 10, testred),
|
||||
"refilling a region disturbed its boundary");
|
||||
SDL_DestroySurface(shot);
|
||||
shot = NULL;
|
||||
|
||||
// Flooding the outside reaches every pixel that is not the box.
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_flood_fill(akgl_renderer, 0, 0, testgreen),
|
||||
"flooding the area around a box");
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 0, 0, testgreen), "the seed pixel was not filled");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, TEST_TARGET_SIZE - 1, TEST_TARGET_SIZE - 1, testgreen),
|
||||
"the fill did not reach the far corner of the target");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 10, 10, testred),
|
||||
"the fill from outside overwrote the boundary");
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_draw_flood_fill(akgl_renderer, -1, 0, testred),
|
||||
"flooding from a seed left of the target");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_draw_flood_fill(akgl_renderer, 0, TEST_TARGET_SIZE, testred),
|
||||
"flooding from a seed below the target");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_flood_fill(NULL, 0, 0, testred),
|
||||
"flooding through a NULL backend");
|
||||
} CLEANUP {
|
||||
if ( shot != NULL ) {
|
||||
SDL_DestroySurface(shot);
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_draw_copy_and_paste_region(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_Surface *shot = NULL;
|
||||
SDL_Surface *saved = NULL;
|
||||
SDL_Surface *reused = NULL;
|
||||
SDL_Rect region;
|
||||
SDL_FRect box;
|
||||
|
||||
ATTEMPT {
|
||||
// Put something recognisable in the top left corner and save it.
|
||||
box.x = 0.0f;
|
||||
box.y = 0.0f;
|
||||
box.w = 8.0f;
|
||||
box.h = 8.0f;
|
||||
CATCH(errctx, clear_target());
|
||||
CATCH(errctx, akgl_draw_filled_rect(akgl_renderer, &box, testred));
|
||||
|
||||
region.x = 0;
|
||||
region.y = 0;
|
||||
region.w = 8;
|
||||
region.h = 8;
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_copy_region(akgl_renderer, ®ion, &saved),
|
||||
"saving a region of the target");
|
||||
TEST_ASSERT(errctx, saved != NULL, "akgl_draw_copy_region did not allocate a surface");
|
||||
TEST_ASSERT(errctx, saved->w == 8 && saved->h == 8,
|
||||
"the saved surface is %dx%d, expected 8x8", saved->w, saved->h);
|
||||
|
||||
// Wipe the screen and put it back somewhere else.
|
||||
CATCH(errctx, clear_target());
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_paste_region(akgl_renderer, saved, 32.0f, 32.0f),
|
||||
"pasting a saved region");
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 32, 32, testred),
|
||||
"the pasted region did not land at its destination");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 39, 39, testred),
|
||||
"the pasted region is smaller than what was saved");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 40, 40, testblack),
|
||||
"the pasted region is larger than what was saved");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 0, 0, testblack),
|
||||
"pasting also redrew the region at its original position");
|
||||
SDL_DestroySurface(shot);
|
||||
shot = NULL;
|
||||
|
||||
// A surface the caller already owns is reused rather than replaced, so
|
||||
// saving the same region repeatedly does not churn allocations.
|
||||
reused = SDL_CreateSurface(8, 8, SDL_PIXELFORMAT_RGBA32);
|
||||
FAIL_ZERO_BREAK(errctx, reused, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
region.x = 32;
|
||||
region.y = 32;
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_copy_region(akgl_renderer, ®ion, &reused),
|
||||
"saving into a caller-owned surface");
|
||||
TEST_ASSERT(errctx, pixel_is(reused, 0, 0, testred),
|
||||
"the caller-owned surface did not receive the region");
|
||||
|
||||
// Wrong-sized destinations and regions off the edge of the target are
|
||||
// refused rather than silently clipped.
|
||||
region.w = 4;
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_draw_copy_region(akgl_renderer, ®ion, &reused),
|
||||
"saving into a destination of the wrong size");
|
||||
region.x = TEST_TARGET_SIZE - 4;
|
||||
region.y = 0;
|
||||
region.w = 8;
|
||||
region.h = 8;
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_draw_copy_region(akgl_renderer, ®ion, &reused),
|
||||
"saving a region that runs off the right edge");
|
||||
region.x = 0;
|
||||
region.w = 0;
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_draw_copy_region(akgl_renderer, ®ion, &reused),
|
||||
"saving a region with no area");
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_copy_region(NULL, ®ion, &reused),
|
||||
"saving through a NULL backend");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_copy_region(akgl_renderer, NULL, &reused),
|
||||
"saving a NULL region");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_copy_region(akgl_renderer, ®ion, NULL),
|
||||
"saving into a NULL destination");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_paste_region(NULL, saved, 0.0f, 0.0f),
|
||||
"pasting through a NULL backend");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_paste_region(akgl_renderer, NULL, 0.0f, 0.0f),
|
||||
"pasting a NULL surface");
|
||||
} CLEANUP {
|
||||
if ( shot != NULL ) {
|
||||
SDL_DestroySurface(shot);
|
||||
}
|
||||
if ( saved != NULL ) {
|
||||
SDL_DestroySurface(saved);
|
||||
}
|
||||
if ( reused != NULL ) {
|
||||
SDL_DestroySurface(reused);
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_draw_preserves_render_draw_color(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_FRect box;
|
||||
uint8_t r = 0;
|
||||
uint8_t g = 0;
|
||||
uint8_t b = 0;
|
||||
uint8_t a = 0;
|
||||
|
||||
ATTEMPT {
|
||||
box.x = 0.0f;
|
||||
box.y = 0.0f;
|
||||
box.w = 4.0f;
|
||||
box.h = 4.0f;
|
||||
|
||||
// Drawing must not leave the renderer's color set to whatever it drew
|
||||
// with, or the host's next SDL_RenderClear() paints the wrong color.
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_SetRenderDrawColor(akgl_renderer->sdl_renderer, 0x11, 0x22, 0x33, 0x44),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
CATCH(errctx, akgl_draw_point(akgl_renderer, 1.0f, 1.0f, testred));
|
||||
CATCH(errctx, akgl_draw_line(akgl_renderer, 0.0f, 0.0f, 3.0f, 3.0f, testred));
|
||||
CATCH(errctx, akgl_draw_rect(akgl_renderer, &box, testred));
|
||||
CATCH(errctx, akgl_draw_filled_rect(akgl_renderer, &box, testred));
|
||||
CATCH(errctx, akgl_draw_circle(akgl_renderer, 20.0f, 20.0f, 4.0f, testred));
|
||||
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_GetRenderDrawColor(akgl_renderer->sdl_renderer, &r, &g, &b, &a),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
TEST_ASSERT(errctx, (r == 0x11) && (g == 0x22) && (b == 0x33) && (a == 0x44),
|
||||
"drawing left the render draw color at %02x%02x%02x%02x, expected 11223344",
|
||||
r, g, b, a);
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_draw_backend_without_a_renderer(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_RenderBackend empty;
|
||||
SDL_Surface *saved = NULL;
|
||||
SDL_Surface *scratch = NULL;
|
||||
SDL_FRect box;
|
||||
SDL_Rect region;
|
||||
|
||||
ATTEMPT {
|
||||
// A backend that exists but was never given an SDL_Renderer. Every
|
||||
// entry point has to say so rather than dereference it -- this is the
|
||||
// state a host is in between allocating a backend and initializing it.
|
||||
memset(&empty, 0x00, sizeof(akgl_RenderBackend));
|
||||
box.x = 0.0f;
|
||||
box.y = 0.0f;
|
||||
box.w = 4.0f;
|
||||
box.h = 4.0f;
|
||||
region.x = 0;
|
||||
region.y = 0;
|
||||
region.w = 4;
|
||||
region.h = 4;
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_point(&empty, 0.0f, 0.0f, testred),
|
||||
"plotting through an uninitialized backend");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_line(&empty, 0.0f, 0.0f, 1.0f, 1.0f, testred),
|
||||
"drawing a line through an uninitialized backend");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_rect(&empty, &box, testred),
|
||||
"outlining through an uninitialized backend");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_filled_rect(&empty, &box, testred),
|
||||
"filling through an uninitialized backend");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_circle(&empty, 4.0f, 4.0f, 2.0f, testred),
|
||||
"drawing a circle through an uninitialized backend");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_flood_fill(&empty, 0, 0, testred),
|
||||
"flooding through an uninitialized backend");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_copy_region(&empty, ®ion, &saved),
|
||||
"saving through an uninitialized backend");
|
||||
TEST_ASSERT(errctx, saved == NULL,
|
||||
"a refused save still wrote something to the destination");
|
||||
|
||||
scratch = SDL_CreateSurface(4, 4, SDL_PIXELFORMAT_RGBA32);
|
||||
FAIL_ZERO_BREAK(errctx, scratch, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_paste_region(&empty, scratch, 0.0f, 0.0f),
|
||||
"pasting through an uninitialized backend");
|
||||
} CLEANUP {
|
||||
if ( saved != NULL ) {
|
||||
SDL_DestroySurface(saved);
|
||||
}
|
||||
if ( scratch != NULL ) {
|
||||
SDL_DestroySurface(scratch);
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief akgl_draw_background paints its checkerboard and restores the draw colour.
|
||||
*
|
||||
* Until 0.5.0 this function returned `void`, drew through the global renderer
|
||||
* with no check on it, and left the draw colour changed. TODO.md listed it
|
||||
* under "needs the offscreen renderer harness" purely because of that global;
|
||||
* taking a backend is what makes it testable here alongside everything else in
|
||||
* this file.
|
||||
*/
|
||||
akerr_ErrorContext *test_draw_background(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_Surface *shot = NULL;
|
||||
SDL_Color after;
|
||||
SDL_Color light = { 0x99, 0x99, 0x99, 0xff };
|
||||
SDL_Color dark = { 0x66, 0x66, 0x66, 0xff };
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, clear_target());
|
||||
// A deliberately odd colour, so "restored" cannot be confused with
|
||||
// "happened to already be black".
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_SetRenderDrawColor(akgl_renderer->sdl_renderer, 0x12, 0x34, 0x56, 0x78),
|
||||
AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
|
||||
TEST_EXPECT_OK(errctx,
|
||||
akgl_draw_background(akgl_renderer, TEST_TARGET_SIZE, TEST_TARGET_SIZE),
|
||||
"painting the transparency checkerboard");
|
||||
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
|
||||
// The pattern is 8x8 cells alternating on ((x ^ y) >> 3) & 1, so (0,0)
|
||||
// and (8,8) share a colour and (8,0) is the other one.
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 0, 0, dark), "cell (0,0) is not the first checker colour");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 7, 7, dark), "cell (0,0) is not filled to its edge");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 8, 0, light), "cell (1,0) is not the second checker colour");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 0, 8, light), "cell (0,1) is not the second checker colour");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 8, 8, dark), "cell (1,1) is not the first checker colour");
|
||||
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_GetRenderDrawColor(akgl_renderer->sdl_renderer, &after.r, &after.g, &after.b, &after.a),
|
||||
AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx,
|
||||
(after.r == 0x12) && (after.g == 0x34) && (after.b == 0x56) && (after.a == 0x78),
|
||||
"the draw colour was left at %02x%02x%02x%02x rather than restored",
|
||||
after.r, after.g, after.b, after.a);
|
||||
|
||||
// Degenerate sizes paint nothing and are not an error.
|
||||
CATCH(errctx, clear_target());
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_background(akgl_renderer, 0, 0),
|
||||
"painting a zero-sized background");
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_background(akgl_renderer, -8, -8),
|
||||
"painting a negative-sized background");
|
||||
SDL_DestroySurface(shot);
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 0, 0, testblack),
|
||||
"a zero or negative sized background painted something");
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_background(NULL, 8, 8),
|
||||
"painting a background through a NULL backend");
|
||||
} CLEANUP {
|
||||
if ( shot != NULL ) {
|
||||
SDL_DestroySurface(shot);
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_Init(SDL_INIT_VIDEO),
|
||||
AKGL_ERR_SDL,
|
||||
"Couldn't initialize SDL: %s",
|
||||
SDL_GetError());
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_CreateWindowAndRenderer(
|
||||
"net/aklabs/libakgl/test_draw",
|
||||
TEST_TARGET_SIZE,
|
||||
TEST_TARGET_SIZE,
|
||||
0,
|
||||
&akgl_window,
|
||||
&akgl_renderer->sdl_renderer),
|
||||
AKGL_ERR_SDL,
|
||||
"Couldn't create window/renderer: %s",
|
||||
SDL_GetError());
|
||||
|
||||
CATCH(errctx, test_draw_point());
|
||||
CATCH(errctx, test_draw_line());
|
||||
CATCH(errctx, test_draw_rects());
|
||||
CATCH(errctx, test_draw_circle());
|
||||
CATCH(errctx, test_draw_flood_fill());
|
||||
CATCH(errctx, test_draw_copy_and_paste_region());
|
||||
CATCH(errctx, test_draw_preserves_render_draw_color());
|
||||
CATCH(errctx, test_draw_backend_without_a_renderer());
|
||||
CATCH(errctx, test_draw_background());
|
||||
} CLEANUP {
|
||||
SDL_Quit();
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
}
|
||||
105
tests/error.c
Normal file
105
tests/error.c
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @file error.c
|
||||
* @brief Unit tests for the libakgl status band: reservation, ownership and names.
|
||||
*
|
||||
* The libakerror registry is process-global, so these tests assert against
|
||||
* whatever akgl_error_init() left behind rather than building their own state.
|
||||
*/
|
||||
|
||||
#include <string.h>
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/error.h>
|
||||
|
||||
#include "testutil.h"
|
||||
|
||||
/**
|
||||
* @brief akgl_error_init() must own the libakgl status band and name every code in it.
|
||||
*
|
||||
* A code whose name never registered degrades to "Unknown Error" in every stack
|
||||
* trace that carries it, and a band we never reserved is one another component
|
||||
* can name out from under us. Both stay silent until something has already gone
|
||||
* wrong, so assert them directly rather than waiting to read a useless trace.
|
||||
*/
|
||||
akerr_ErrorContext *test_error_init_owns_the_status_band(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
static const struct {
|
||||
int status;
|
||||
const char *name;
|
||||
} expected[] = {
|
||||
{ AKGL_ERR_SDL, "SDL Error" },
|
||||
{ AKGL_ERR_REGISTRY, "Registry Error" },
|
||||
{ AKGL_ERR_HEAP, "Heap Error" },
|
||||
{ AKGL_ERR_BEHAVIOR, "Behavior Error" },
|
||||
{ AKGL_ERR_LOGICINTERRUPT, "Logic Interrupt" }
|
||||
};
|
||||
bool named = true;
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, akgl_error_init());
|
||||
|
||||
TEST_ASSERT(e, (int)(sizeof(expected) / sizeof(expected[0])) == AKGL_ERR_COUNT,
|
||||
"the libakgl status band holds %d codes but %d are named here",
|
||||
AKGL_ERR_COUNT, (int)(sizeof(expected) / sizeof(expected[0])));
|
||||
|
||||
for ( i = 0; i < (int)(sizeof(expected) / sizeof(expected[0])); i++ ) {
|
||||
TEST_ASSERT_FLAG(named,
|
||||
strcmp(akerr_name_for_status(expected[i].status, NULL),
|
||||
expected[i].name) == 0);
|
||||
}
|
||||
TEST_ASSERT(e, named,
|
||||
"akgl_error_init did not register the expected name for every AKGL_ERR_* code");
|
||||
|
||||
// The reservation is what makes those names ours. Without it the
|
||||
// registrations above would still succeed for anyone who asked.
|
||||
TEST_EXPECT_STATUS(e, AKERR_STATUS_NAME_FOREIGN,
|
||||
akerr_register_status_name("not-libakgl", AKGL_ERR_HEAP, "Squatter"),
|
||||
"a foreign owner was allowed to rename a libakgl status");
|
||||
TEST_EXPECT_STATUS(e, AKERR_STATUS_RANGE_OVERLAP,
|
||||
akerr_reserve_status_range(AKGL_ERR_BASE, AKGL_ERR_COUNT, "not-libakgl"),
|
||||
"a foreign owner was allowed to reserve the libakgl status band");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Calling akgl_error_init() twice must be a no-op, not a self-collision.
|
||||
*
|
||||
* Nothing in libakgl orders initialization for an embedding program, so a second
|
||||
* call has to be harmless. libakerror only treats an *identical* reservation as
|
||||
* a repeat -- a subset or superset raises -- which makes this a real constraint
|
||||
* on AKGL_ERR_BASE and AKGL_ERR_COUNT, not a triviality.
|
||||
*/
|
||||
akerr_ErrorContext *test_error_init_is_idempotent(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
|
||||
ATTEMPT {
|
||||
TEST_EXPECT_OK(e, akgl_error_init(), "the second akgl_error_init failed");
|
||||
TEST_EXPECT_OK(e, akgl_error_init(), "the third akgl_error_init failed");
|
||||
TEST_ASSERT(e, strcmp(akerr_name_for_status(AKGL_ERR_SDL, NULL), "SDL Error") == 0,
|
||||
"re-running akgl_error_init lost the name for AKGL_ERR_SDL");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
// Unlike every other suite, this one has no akgl_error_init() in
|
||||
// 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());
|
||||
CATCH(errctx, test_error_init_is_idempotent());
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
}
|
||||
799
tests/game.c
Normal file
799
tests/game.c
Normal file
@@ -0,0 +1,799 @@
|
||||
/**
|
||||
* @file game.c
|
||||
* @brief Unit tests for savegame serialization, version gating, and frame accounting.
|
||||
*
|
||||
* akgl_game_init() and akgl_game_update() need a window and a live frame loop,
|
||||
* so they are out of scope here. Everything else in the game module is either
|
||||
* pure logic or file IO and is covered below.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/character.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/sprite.h>
|
||||
#include <akgl/staticstring.h>
|
||||
#include <akgl/renderer.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/iterator.h>
|
||||
|
||||
#include "testutil.h"
|
||||
|
||||
/** @brief Scratch savegame path, created and removed by the tests that use it. */
|
||||
static char savepath[] = "akgl_test_savegame.bin";
|
||||
/** @brief Scratch path for deliberately malformed savegames. */
|
||||
static char truncatedpath[] = "akgl_test_truncated.bin";
|
||||
|
||||
/** @brief Populate the process-wide game record with a valid identity. */
|
||||
static void set_game_identity(void)
|
||||
{
|
||||
memset(&akgl_game, 0x00, sizeof(akgl_Game));
|
||||
strncpy((char *)&akgl_game.libversion, AKGL_VERSION, 31);
|
||||
strncpy((char *)&akgl_game.version, "1.2.3", 31);
|
||||
strncpy((char *)&akgl_game.name, "libakgl test game", 255);
|
||||
strncpy((char *)&akgl_game.uri, "https://example.invalid/akgl-test", 255);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_game_load_versioncmp_matching(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
|
||||
ATTEMPT {
|
||||
TEST_EXPECT_OK(e, akgl_game_load_versioncmp("library", "1.2.3", "1.2.3"),
|
||||
"identical versions must be compatible");
|
||||
TEST_EXPECT_OK(e, akgl_game_load_versioncmp("library", "0.1.0", "0.1.0"),
|
||||
"identical zero-major versions must be compatible");
|
||||
TEST_EXPECT_OK(e, akgl_game_load_versioncmp("game", "10.20.30", "10.20.30"),
|
||||
"identical multi-digit versions must be compatible");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_game_load_versioncmp_mismatched(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
|
||||
ATTEMPT {
|
||||
// A savegame from a different build is refused on any component.
|
||||
TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load_versioncmp("library", "2.2.3", "1.2.3"),
|
||||
"a differing major version must be refused");
|
||||
TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load_versioncmp("library", "1.3.3", "1.2.3"),
|
||||
"a differing minor version must be refused");
|
||||
TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load_versioncmp("library", "1.2.4", "1.2.3"),
|
||||
"a differing patch version must be refused");
|
||||
|
||||
// Unparseable versions are a value error, distinct from a mismatch.
|
||||
TEST_EXPECT_STATUS(e, AKERR_VALUE, akgl_game_load_versioncmp("library", "1.2.3", "not-a-version"),
|
||||
"an unparseable current version must be refused");
|
||||
TEST_EXPECT_STATUS(e, AKERR_VALUE, akgl_game_load_versioncmp("library", "not-a-version", "1.2.3"),
|
||||
"an unparseable savegame version must be refused");
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_load_versioncmp(NULL, "1.2.3", "1.2.3"),
|
||||
"versioncmp with a NULL version type");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_load_versioncmp("library", NULL, "1.2.3"),
|
||||
"versioncmp with a NULL new version");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_load_versioncmp("library", "1.2.3", NULL),
|
||||
"versioncmp with a NULL current version");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_game_load_versioncmp_releases_semver(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
int i = 0;
|
||||
bool leaked = false;
|
||||
|
||||
ATTEMPT {
|
||||
// semver_parse allocates; the comparison must free both sides on the
|
||||
// success and the failure path or a long session will drift.
|
||||
for ( i = 0; i < 2000; i++ ) {
|
||||
akerr_ErrorContext *result = akgl_game_load_versioncmp("library", "1.2.3", "1.2.3");
|
||||
if ( result != NULL ) {
|
||||
result->handled = true;
|
||||
result = akerr_release_error(result);
|
||||
leaked = true;
|
||||
}
|
||||
result = akgl_game_load_versioncmp("library", "9.9.9", "1.2.3");
|
||||
if ( result != NULL ) {
|
||||
result->handled = true;
|
||||
result = akerr_release_error(result);
|
||||
}
|
||||
}
|
||||
TEST_ASSERT(e, leaked == false,
|
||||
"a matching version comparison started failing partway through a long run");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_game_save_roundtrip(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_Game expected;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, akgl_registry_init());
|
||||
CATCH(e, akgl_heap_init());
|
||||
set_game_identity();
|
||||
akgl_game.fps = 60;
|
||||
akgl_game.framesSinceUpdate = 7;
|
||||
memcpy(&expected, &akgl_game, sizeof(akgl_Game));
|
||||
|
||||
TEST_EXPECT_OK(e, akgl_game_save((char *)&savepath), "saving a game");
|
||||
|
||||
// Scribble over the live state so a successful load has to restore it.
|
||||
akgl_game.fps = 0;
|
||||
akgl_game.framesSinceUpdate = 0;
|
||||
|
||||
TEST_EXPECT_OK(e, akgl_game_load((char *)&savepath), "loading the game back");
|
||||
TEST_ASSERT(e, akgl_game.fps == 60, "fps restored as %d, expected 60", akgl_game.fps);
|
||||
TEST_ASSERT(e, akgl_game.framesSinceUpdate == 7,
|
||||
"framesSinceUpdate restored as %d, expected 7", akgl_game.framesSinceUpdate);
|
||||
TEST_ASSERT(e, strncmp((char *)&akgl_game.name, (char *)&expected.name, 256) == 0,
|
||||
"the game name was not preserved across a save and load");
|
||||
TEST_ASSERT(e, strncmp((char *)&akgl_game.version, (char *)&expected.version, 32) == 0,
|
||||
"the game version was not preserved across a save and load");
|
||||
} CLEANUP {
|
||||
unlink((char *)&savepath);
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
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);
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, akgl_registry_init());
|
||||
CATCH(e, akgl_heap_init());
|
||||
|
||||
// A save written by a different game must not load into this one.
|
||||
set_game_identity();
|
||||
CATCH(e, akgl_game_save((char *)&savepath));
|
||||
strncpy((char *)&akgl_game.name, "a completely different game", 255);
|
||||
TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load((char *)&savepath),
|
||||
"a savegame with a foreign game name must be refused");
|
||||
unlink((char *)&savepath);
|
||||
|
||||
// Same for a differing URI.
|
||||
set_game_identity();
|
||||
CATCH(e, akgl_game_save((char *)&savepath));
|
||||
strncpy((char *)&akgl_game.uri, "https://example.invalid/other", 255);
|
||||
TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load((char *)&savepath),
|
||||
"a savegame with a foreign URI must be refused");
|
||||
unlink((char *)&savepath);
|
||||
|
||||
// A save written against a different library version must be refused.
|
||||
set_game_identity();
|
||||
strncpy((char *)&akgl_game.libversion, "99.98.97", 31);
|
||||
CATCH(e, akgl_game_save((char *)&savepath));
|
||||
set_game_identity();
|
||||
TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load((char *)&savepath),
|
||||
"a savegame from a different library version must be refused");
|
||||
unlink((char *)&savepath);
|
||||
|
||||
// And one written against a different game version.
|
||||
set_game_identity();
|
||||
strncpy((char *)&akgl_game.version, "4.5.6", 31);
|
||||
CATCH(e, akgl_game_save((char *)&savepath));
|
||||
set_game_identity();
|
||||
TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load((char *)&savepath),
|
||||
"a savegame from a different game version must be refused");
|
||||
} CLEANUP {
|
||||
unlink((char *)&savepath);
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_game_save_load_nullpointers(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
|
||||
ATTEMPT {
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_save(NULL),
|
||||
"akgl_game_save(NULL)");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_load(NULL),
|
||||
"akgl_game_load(NULL)");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_save_actors(NULL),
|
||||
"akgl_game_save_actors(NULL)");
|
||||
|
||||
// A path under a directory that does not exist cannot be opened.
|
||||
TEST_EXPECT_ANY_ERROR(e, akgl_game_save("no_such_directory/save.bin"),
|
||||
"saving into a nonexistent directory");
|
||||
TEST_EXPECT_ANY_ERROR(e, akgl_game_load("no_such_file_anywhere.bin"),
|
||||
"loading a nonexistent savegame");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_game_load_truncated_table(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
FILE *fp = NULL;
|
||||
char partial[64];
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, akgl_registry_init());
|
||||
CATCH(e, akgl_heap_init());
|
||||
set_game_identity();
|
||||
|
||||
// A valid header followed by a table that ends before its sentinel. The
|
||||
// name-map reader loops until it sees the sentinel, so it has to notice
|
||||
// EOF instead of spinning.
|
||||
memset(&partial, 0x00, sizeof(partial));
|
||||
fp = fopen((char *)&truncatedpath, "wb");
|
||||
FAIL_ZERO_BREAK(e, fp, AKERR_IO, "unable to create the truncated savegame fixture");
|
||||
FAIL_ZERO_BREAK(e, fwrite(&akgl_game, 1, sizeof(akgl_Game), fp), AKERR_IO,
|
||||
"unable to write the truncated savegame header");
|
||||
FAIL_ZERO_BREAK(e, fwrite(&partial, 1, sizeof(partial), fp), AKERR_IO,
|
||||
"unable to write the truncated savegame body");
|
||||
fclose(fp);
|
||||
fp = NULL;
|
||||
|
||||
TEST_EXPECT_ANY_ERROR(e, akgl_game_load((char *)&truncatedpath),
|
||||
"loading a savegame whose name table is truncated");
|
||||
} CLEANUP {
|
||||
if ( fp != NULL ) {
|
||||
fclose(fp);
|
||||
}
|
||||
unlink((char *)&truncatedpath);
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_game_save_writes_name_tables(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_Actor *actor = NULL;
|
||||
FILE *fp = NULL;
|
||||
long filesize = 0;
|
||||
long minimum = 0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, akgl_registry_init());
|
||||
CATCH(e, akgl_heap_init());
|
||||
set_game_identity();
|
||||
|
||||
// One registered actor, so the actor table has a real entry ahead of its
|
||||
// terminating sentinel.
|
||||
CATCH(e, akgl_heap_next_actor(&actor));
|
||||
CATCH(e, akgl_actor_initialize(actor, "saved_actor"));
|
||||
|
||||
TEST_EXPECT_OK(e, akgl_game_save((char *)&savepath), "saving a game with one actor");
|
||||
|
||||
fp = fopen((char *)&savepath, "rb");
|
||||
FAIL_ZERO_BREAK(e, fp, AKERR_IO, "unable to reopen the savegame");
|
||||
fseek(fp, 0, SEEK_END);
|
||||
filesize = ftell(fp);
|
||||
|
||||
// The header, then four name tables each ending in a name-sized and a
|
||||
// pointer-sized sentinel, plus the one real actor entry.
|
||||
minimum = (long)sizeof(akgl_Game)
|
||||
+ (long)(AKGL_ACTOR_MAX_NAME_LENGTH + sizeof(akgl_Actor *)) * 2
|
||||
+ (long)(AKGL_SPRITE_MAX_NAME_LENGTH + sizeof(akgl_Sprite *))
|
||||
+ (long)(AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH + sizeof(akgl_SpriteSheet *))
|
||||
+ (long)(AKGL_CHARACTER_MAX_NAME_LENGTH + sizeof(akgl_Character *));
|
||||
|
||||
TEST_ASSERT(e, filesize >= minimum,
|
||||
"the savegame is %ld bytes, expected at least %ld for the header and four name tables",
|
||||
filesize, minimum);
|
||||
} CLEANUP {
|
||||
if ( fp != NULL ) {
|
||||
fclose(fp);
|
||||
}
|
||||
unlink((char *)&savepath);
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_game_state_lock(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
|
||||
ATTEMPT {
|
||||
set_game_identity();
|
||||
akgl_game.statelock = SDL_CreateMutex();
|
||||
FAIL_ZERO_BREAK(e, akgl_game.statelock, AKGL_ERR_SDL, "unable to create the state mutex");
|
||||
|
||||
TEST_EXPECT_OK(e, akgl_game_state_lock(), "taking the state lock");
|
||||
TEST_EXPECT_OK(e, akgl_game_state_unlock(), "releasing the state lock");
|
||||
|
||||
// The lock is reusable after a matched unlock.
|
||||
TEST_EXPECT_OK(e, akgl_game_state_lock(), "retaking the state lock");
|
||||
TEST_EXPECT_OK(e, akgl_game_state_unlock(), "releasing the state lock again");
|
||||
} CLEANUP {
|
||||
if ( akgl_game.statelock != NULL ) {
|
||||
SDL_DestroyMutex(akgl_game.statelock);
|
||||
akgl_game.statelock = NULL;
|
||||
}
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief akgl_game_update_fps must not call a lowfpsfunc nobody installed.
|
||||
*
|
||||
* `game.fps` is 0 for the first second of the process, which is under the
|
||||
* threshold, so this fires on frame one. Only akgl_game_init installs the
|
||||
* default -- and renderer.h documents the other path deliberately: a host that
|
||||
* owns its own window calls akgl_render_2d_bind instead. Such an embedder
|
||||
* crashed here on its first frame, through a NULL function pointer.
|
||||
*/
|
||||
akerr_ErrorContext *test_game_updateFPS_without_a_lowfps_handler(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
|
||||
ATTEMPT {
|
||||
set_game_identity();
|
||||
// Exactly the state a host that never called akgl_game_init is in.
|
||||
akgl_game.lowfpsfunc = NULL;
|
||||
akgl_game.fps = 0;
|
||||
akgl_game.framesSinceUpdate = 0;
|
||||
akgl_game.lastFPSTime = SDL_GetTicksNS();
|
||||
|
||||
akgl_game_update_fps();
|
||||
|
||||
TEST_ASSERT(e, akgl_game.lowfpsfunc != NULL,
|
||||
"akgl_game_update_fps left lowfpsfunc NULL");
|
||||
TEST_ASSERT(e, akgl_game.lowfpsfunc == &akgl_game_lowfps,
|
||||
"akgl_game_update_fps installed something other than the default");
|
||||
|
||||
// And it keeps working on the frames after.
|
||||
akgl_game_update_fps();
|
||||
TEST_ASSERT(e, akgl_game.framesSinceUpdate == 2,
|
||||
"frames counted as %d over two updates, expected 2",
|
||||
akgl_game.framesSinceUpdate);
|
||||
} CLEANUP {
|
||||
akgl_game.lowfpsfunc = &akgl_game_lowfps;
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
/** @brief Cleared while the helper thread should keep holding the state mutex. */
|
||||
static SDL_AtomicInt lockholder_release;
|
||||
|
||||
/** @brief Takes the state mutex and sits on it until lockholder_release is set. */
|
||||
static int SDLCALL lockholder_thread(void *userdata)
|
||||
{
|
||||
SDL_Mutex *statelock = (SDL_Mutex *)userdata;
|
||||
|
||||
SDL_LockMutex(statelock);
|
||||
while ( SDL_GetAtomicInt(&lockholder_release) == 0 ) {
|
||||
SDL_Delay(10);
|
||||
}
|
||||
SDL_UnlockMutex(statelock);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief akgl_game_state_lock must give up on a contended mutex in about a second.
|
||||
*
|
||||
* The uncontended path above never reaches the retry loop, which is where the
|
||||
* budget lives and where the defect was: the loop counted against a constant
|
||||
* named "one second in milliseconds" that held 1000000, so it retried 10,000
|
||||
* times at 100 ms and blocked for roughly sixteen minutes before reporting
|
||||
* failure. The upper bound below is the assertion that matters. The lower bound
|
||||
* is there so a build that gave up immediately -- reporting failure without
|
||||
* waiting at all -- cannot pass either.
|
||||
*/
|
||||
akerr_ErrorContext *test_game_state_lock_budget(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
SDL_Thread *holder = NULL;
|
||||
Uint64 started = 0;
|
||||
Uint64 elapsed = 0;
|
||||
|
||||
ATTEMPT {
|
||||
set_game_identity();
|
||||
akgl_game.statelock = SDL_CreateMutex();
|
||||
FAIL_ZERO_BREAK(e, akgl_game.statelock, AKGL_ERR_SDL, "unable to create the state mutex");
|
||||
|
||||
SDL_SetAtomicInt(&lockholder_release, 0);
|
||||
holder = SDL_CreateThread(lockholder_thread, "akgl_test_lockholder", (void *)akgl_game.statelock);
|
||||
FAIL_ZERO_BREAK(e, holder, AKGL_ERR_SDL, "unable to start the lock-holding thread");
|
||||
|
||||
// Wait until the helper actually owns the mutex. Without this the
|
||||
// measurement races the thread start and the lock is taken on the first
|
||||
// try, which measures nothing.
|
||||
while ( SDL_TryLockMutex(akgl_game.statelock) == true ) {
|
||||
SDL_UnlockMutex(akgl_game.statelock);
|
||||
SDL_Delay(1);
|
||||
}
|
||||
|
||||
started = SDL_GetTicksNS();
|
||||
TEST_EXPECT_STATUS(
|
||||
e,
|
||||
AKGL_ERR_SDL,
|
||||
akgl_game_state_lock(),
|
||||
"taking a state lock another thread is holding");
|
||||
elapsed = SDL_GetTicksNS() - started;
|
||||
|
||||
TEST_ASSERT(
|
||||
e,
|
||||
elapsed >= (AKGL_TIME_ONESEC_NS / 2),
|
||||
"state lock gave up after %" SDL_PRIu64 " ns without waiting out its budget",
|
||||
elapsed);
|
||||
TEST_ASSERT(
|
||||
e,
|
||||
elapsed < (5 * (Uint64)AKGL_TIME_ONESEC_NS),
|
||||
"state lock waited %" SDL_PRIu64 " ns on a %d ms budget",
|
||||
elapsed,
|
||||
AKGL_GAME_STATE_LOCK_BUDGET_MS);
|
||||
} CLEANUP {
|
||||
SDL_SetAtomicInt(&lockholder_release, 1);
|
||||
if ( holder != NULL ) {
|
||||
SDL_WaitThread(holder, NULL);
|
||||
}
|
||||
if ( akgl_game.statelock != NULL ) {
|
||||
SDL_DestroyMutex(akgl_game.statelock);
|
||||
akgl_game.statelock = NULL;
|
||||
}
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
/** @brief Counts calls made to the low-FPS callback. */
|
||||
static int lowfps_calls = 0;
|
||||
|
||||
/** @brief Low-FPS callback stub that only records that it fired. */
|
||||
static void stub_lowfps(void)
|
||||
{
|
||||
lowfps_calls += 1;
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_game_updateFPS(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
int16_t framesbefore = 0;
|
||||
|
||||
ATTEMPT {
|
||||
set_game_identity();
|
||||
akgl_game.lowfpsfunc = &stub_lowfps;
|
||||
|
||||
// Below the 30 FPS floor, every update notifies the callback.
|
||||
akgl_game.fps = 10;
|
||||
akgl_game.lastFPSTime = SDL_GetTicksNS();
|
||||
lowfps_calls = 0;
|
||||
framesbefore = akgl_game.framesSinceUpdate;
|
||||
akgl_game_update_fps();
|
||||
TEST_ASSERT(e, lowfps_calls == 1,
|
||||
"a sub-30 FPS update fired the low-FPS callback %d times, expected 1", lowfps_calls);
|
||||
TEST_ASSERT(e, akgl_game.framesSinceUpdate == (framesbefore + 1),
|
||||
"updateFPS did not count the frame (%d, expected %d)",
|
||||
akgl_game.framesSinceUpdate, framesbefore + 1);
|
||||
TEST_ASSERT(e, akgl_game.lastIterTime != 0, "updateFPS did not stamp lastIterTime");
|
||||
|
||||
// At or above the floor, the callback stays quiet.
|
||||
akgl_game.fps = 60;
|
||||
akgl_game.lastFPSTime = SDL_GetTicksNS();
|
||||
lowfps_calls = 0;
|
||||
akgl_game_update_fps();
|
||||
TEST_ASSERT(e, lowfps_calls == 0,
|
||||
"a 60 FPS update fired the low-FPS callback %d times, expected 0", lowfps_calls);
|
||||
|
||||
// Once a full second has elapsed, the frame counter rolls into fps.
|
||||
akgl_game.fps = 60;
|
||||
akgl_game.framesSinceUpdate = 45;
|
||||
akgl_game.lastFPSTime = SDL_GetTicksNS() - (2 * (SDL_Time)AKGL_TIME_ONESEC_NS);
|
||||
akgl_game_update_fps();
|
||||
TEST_ASSERT(e, akgl_game.fps == 45,
|
||||
"after a second elapsed, fps rolled over as %d, expected 45", akgl_game.fps);
|
||||
TEST_ASSERT(e, akgl_game.framesSinceUpdate == 1,
|
||||
"the frame counter restarted at %d, expected 1", akgl_game.framesSinceUpdate);
|
||||
|
||||
// The shipped default callback only logs, so it just has to not crash.
|
||||
akgl_game.fps = 1;
|
||||
akgl_game_lowfps();
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief A save with a registered spritesheet must read back.
|
||||
*
|
||||
* The four name tables carry no length prefix, so the reader finds each entry
|
||||
* by stepping a fixed width. The writer used each object's own maximum name
|
||||
* length -- 512 for a spritesheet, which is a filename -- and the reader used
|
||||
* AKGL_ACTOR_MAX_NAME_LENGTH for all four. The other three are 128 as well, so
|
||||
* only the spritesheet table was wrong, and that was enough: every entry after
|
||||
* it was read out of the middle of its neighbour.
|
||||
*
|
||||
* The existing roundtrip test passed because empty registries write nothing but
|
||||
* the zeroed sentinel. This one puts a name in each of the four registries, and
|
||||
* a long one in the spritesheet registry, so the widths actually have to agree.
|
||||
*
|
||||
* The registry values are placeholder pointers rather than real objects: the
|
||||
* save tables record name-to-address pairs and the loader looks each name up in
|
||||
* the live registry, so what the pointers point at never matters here.
|
||||
*/
|
||||
akerr_ErrorContext *test_game_save_roundtrip_with_a_spritesheet(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
char longsheetname[AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH];
|
||||
akgl_Actor placeholder_actor;
|
||||
akgl_Sprite placeholder_sprite;
|
||||
akgl_SpriteSheet placeholder_sheet;
|
||||
akgl_Character placeholder_character;
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, akgl_registry_init());
|
||||
CATCH(e, akgl_heap_init());
|
||||
set_game_identity();
|
||||
|
||||
// A spritesheet name that does not fit the width the reader used to
|
||||
// assume. Filled to just under the field so the terminator still fits.
|
||||
memset(&longsheetname, 0x00, sizeof(longsheetname));
|
||||
for ( i = 0; i < (AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH - 1); i++ ) {
|
||||
longsheetname[i] = 'a' + (i % 26);
|
||||
}
|
||||
|
||||
FAIL_ZERO_BREAK(e, SDL_SetPointerProperty(AKGL_REGISTRY_ACTOR, "roundtrip_actor",
|
||||
(void *)&placeholder_actor),
|
||||
AKERR_KEY, "could not register the actor");
|
||||
FAIL_ZERO_BREAK(e, SDL_SetPointerProperty(AKGL_REGISTRY_SPRITE, "roundtrip_sprite",
|
||||
(void *)&placeholder_sprite),
|
||||
AKERR_KEY, "could not register the sprite");
|
||||
FAIL_ZERO_BREAK(e, SDL_SetPointerProperty(AKGL_REGISTRY_SPRITESHEET, (char *)&longsheetname,
|
||||
(void *)&placeholder_sheet),
|
||||
AKERR_KEY, "could not register the spritesheet");
|
||||
FAIL_ZERO_BREAK(e, SDL_SetPointerProperty(AKGL_REGISTRY_CHARACTER, "roundtrip_character",
|
||||
(void *)&placeholder_character),
|
||||
AKERR_KEY, "could not register the character");
|
||||
|
||||
TEST_EXPECT_OK(e, akgl_game_save((char *)&savepath),
|
||||
"saving a game with all four registries populated");
|
||||
|
||||
// The load is what walks the four tables in order. If any width
|
||||
// disagrees with the writer's, the table after it starts mid-entry and
|
||||
// the read runs off the end of the file.
|
||||
TEST_EXPECT_OK(e, akgl_game_load((char *)&savepath),
|
||||
"loading back a save with a registered spritesheet");
|
||||
|
||||
TEST_ASSERT(e, strncmp((char *)&akgl_game.name, "libakgl test game", 256) == 0,
|
||||
"the game identity did not survive the roundtrip");
|
||||
} CLEANUP {
|
||||
unlink((char *)&savepath);
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
/** @brief Counts akgl_game_update calls into each actor's updatefunc, by actor index. */
|
||||
static int updatecounts[AKGL_MAX_HEAP_ACTOR];
|
||||
|
||||
/** @brief updatefunc stub: record that this actor was updated. */
|
||||
static akerr_ErrorContext *counting_updatefunc(akgl_Actor *obj)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
int i = 0;
|
||||
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
|
||||
if ( &akgl_heap_actors[i] == obj ) {
|
||||
updatecounts[i] += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
/** @brief Physics backend stub: akgl_game_update calls simulate, and it must not matter here. */
|
||||
static akerr_ErrorContext *stub_simulate(akgl_PhysicsBackend *self, akgl_Iterator *opflags)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
/** @brief Render backend stub: akgl_game_update calls draw_world; drawing is not under test. */
|
||||
static akerr_ErrorContext *stub_draw_world(akgl_RenderBackend *self, akgl_Iterator *opflags)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief akgl_game_update must call each live actor's updatefunc exactly once.
|
||||
*
|
||||
* The sweep used to sit inside a walk over AKGL_TILEMAP_MAX_LAYERS and never
|
||||
* compared actor->layer to the layer it was on, so every live actor updated
|
||||
* sixteen times a frame -- 70 microseconds of work to do 4.4 of it, and every
|
||||
* bit of per-frame actor logic running sixteen times over. It hid behind a
|
||||
* software rasterizer and would not have hidden behind a GPU backend.
|
||||
*
|
||||
* Counting is the assertion. A timing test would measure the machine.
|
||||
*/
|
||||
akerr_ErrorContext *test_game_update_visits_each_actor_once(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_PhysicsBackend stubphysics;
|
||||
akgl_RenderBackend stubrenderer;
|
||||
akgl_Iterator opflags;
|
||||
akgl_Actor *actors[3] = { NULL, NULL, NULL };
|
||||
int layers[3] = { 0, 1, 1 };
|
||||
int i = 0;
|
||||
int live = 0;
|
||||
|
||||
ATTEMPT {
|
||||
set_game_identity();
|
||||
akgl_game.statelock = SDL_CreateMutex();
|
||||
FAIL_ZERO_BREAK(e, akgl_game.statelock, AKGL_ERR_SDL, "unable to create the state mutex");
|
||||
akgl_game.lowfpsfunc = &akgl_game_lowfps;
|
||||
|
||||
CATCH(e, akgl_heap_init());
|
||||
CATCH(e, akgl_registry_init_actor());
|
||||
|
||||
memset(&stubphysics, 0x00, sizeof(akgl_PhysicsBackend));
|
||||
memset(&stubrenderer, 0x00, sizeof(akgl_RenderBackend));
|
||||
stubphysics.simulate = &stub_simulate;
|
||||
stubrenderer.draw_world = &stub_draw_world;
|
||||
akgl_physics = &stubphysics;
|
||||
akgl_renderer = &stubrenderer;
|
||||
akgl_gamemap = &akgl_default_gamemap;
|
||||
|
||||
for ( i = 0; i < 3; i++ ) {
|
||||
char name[32];
|
||||
snprintf((char *)&name, sizeof(name), "sweepactor%d", i);
|
||||
CATCH(e, akgl_heap_next_actor(&actors[i]));
|
||||
CATCH(e, akgl_actor_initialize(actors[i], (char *)&name));
|
||||
actors[i]->updatefunc = &counting_updatefunc;
|
||||
actors[i]->layer = layers[i];
|
||||
}
|
||||
|
||||
// The default sweep: every live actor, once.
|
||||
memset(&updatecounts, 0x00, sizeof(updatecounts));
|
||||
TEST_EXPECT_OK(e, akgl_game_update(NULL), "one default game update");
|
||||
live = 0;
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
|
||||
if ( akgl_heap_actors[i].refcount == 0 ) {
|
||||
TEST_ASSERT(e, updatecounts[i] == 0,
|
||||
"a free actor slot %d was updated %d times", i, updatecounts[i]);
|
||||
continue;
|
||||
}
|
||||
live += 1;
|
||||
TEST_ASSERT(e, updatecounts[i] == 1,
|
||||
"actor %d updated %d times in one frame, expected 1",
|
||||
i, updatecounts[i]);
|
||||
}
|
||||
TEST_ASSERT(e, live == 3, "%d live actors, expected 3", live);
|
||||
|
||||
// Two frames means two updates, not thirty-two.
|
||||
memset(&updatecounts, 0x00, sizeof(updatecounts));
|
||||
TEST_EXPECT_OK(e, akgl_game_update(NULL), "the second game update");
|
||||
TEST_EXPECT_OK(e, akgl_game_update(NULL), "the third game update");
|
||||
for ( i = 0; i < 3; i++ ) {
|
||||
TEST_ASSERT(e, updatecounts[i] == 2,
|
||||
"actor %d updated %d times over two frames, expected 2",
|
||||
i, updatecounts[i]);
|
||||
}
|
||||
|
||||
// AKGL_ITERATOR_OP_LAYERMASK now means what it says: only layer 1.
|
||||
memset(&updatecounts, 0x00, sizeof(updatecounts));
|
||||
AKGL_BITMASK_CLEAR(opflags.flags);
|
||||
AKGL_BITMASK_ADD(opflags.flags, AKGL_ITERATOR_OP_UPDATE);
|
||||
AKGL_BITMASK_ADD(opflags.flags, AKGL_ITERATOR_OP_LAYERMASK);
|
||||
opflags.layerid = 1;
|
||||
TEST_EXPECT_OK(e, akgl_game_update(&opflags), "a layer-masked game update");
|
||||
TEST_ASSERT(e, updatecounts[0] == 0,
|
||||
"the layer 0 actor updated %d times under a layer 1 mask", updatecounts[0]);
|
||||
TEST_ASSERT(e, updatecounts[1] == 1,
|
||||
"the first layer 1 actor updated %d times, expected 1", updatecounts[1]);
|
||||
TEST_ASSERT(e, updatecounts[2] == 1,
|
||||
"the second layer 1 actor updated %d times, expected 1", updatecounts[2]);
|
||||
} CLEANUP {
|
||||
for ( i = 0; i < 3; i++ ) {
|
||||
if ( actors[i] != NULL ) {
|
||||
IGNORE(akgl_heap_release_actor(actors[i]));
|
||||
}
|
||||
}
|
||||
if ( akgl_game.statelock != NULL ) {
|
||||
SDL_DestroyMutex(akgl_game.statelock);
|
||||
akgl_game.statelock = NULL;
|
||||
}
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
CATCH(errctx, akgl_heap_init());
|
||||
CATCH(errctx, akgl_registry_init());
|
||||
|
||||
CATCH(errctx, test_game_load_versioncmp_matching());
|
||||
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());
|
||||
CATCH(errctx, test_game_save_writes_name_tables());
|
||||
CATCH(errctx, test_game_save_roundtrip_with_a_spritesheet());
|
||||
CATCH(errctx, test_game_state_lock());
|
||||
CATCH(errctx, test_game_state_lock_budget());
|
||||
CATCH(errctx, test_game_updateFPS());
|
||||
CATCH(errctx, test_game_updateFPS_without_a_lowfps_handler());
|
||||
CATCH(errctx, test_game_update_visits_each_actor_once());
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
}
|
||||
42
tests/header_pool_override.c
Normal file
42
tests/header_pool_override.c
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @file header_pool_override.c
|
||||
* @brief Checks that the compile-time pool-size overrides in heap.h actually fire.
|
||||
*
|
||||
* `heap.h` wraps every `AKGL_MAX_HEAP_*` ceiling in an `#ifndef` so a game that
|
||||
* needs more actors can define its own before including it. That mechanism was
|
||||
* dead until 0.5.0: `actor.h`, `sprite.h` and `character.h` defined the same
|
||||
* four unconditionally, and `heap.h` includes all three *above* its own
|
||||
* `#ifndef` block, so whichever landed first won and the guard never fired. The
|
||||
* override was documented, and did nothing.
|
||||
*
|
||||
* The assertion is the compile. Nothing here links against the pools -- it
|
||||
* cannot, because the library was built with the default ceilings and this
|
||||
* translation unit deliberately disagrees with it. That disagreement is the
|
||||
* documented consequence of overriding a ceiling, not something this file is
|
||||
* working around: the arrays are sized at compile time, so the library and
|
||||
* everything linking it have to agree.
|
||||
*/
|
||||
|
||||
#define AKGL_MAX_HEAP_ACTOR 128
|
||||
#define AKGL_MAX_HEAP_STRING 512
|
||||
|
||||
#include <akgl/heap.h>
|
||||
|
||||
#if AKGL_MAX_HEAP_ACTOR != 128
|
||||
#error "heap.h overrode a caller's AKGL_MAX_HEAP_ACTOR; the #ifndef guard is dead again"
|
||||
#endif
|
||||
|
||||
#if AKGL_MAX_HEAP_STRING != 512
|
||||
#error "heap.h overrode a caller's AKGL_MAX_HEAP_STRING; the #ifndef guard is dead again"
|
||||
#endif
|
||||
|
||||
/* The derived ceilings have to follow the override rather than a stale literal. */
|
||||
#if AKGL_MAX_HEAP_SPRITE != (128 * 16)
|
||||
#error "AKGL_MAX_HEAP_SPRITE no longer derives from AKGL_MAX_HEAP_ACTOR"
|
||||
#endif
|
||||
|
||||
int akgl_header_selftest_pool_override(void);
|
||||
int akgl_header_selftest_pool_override(void)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
43
tests/headers.c
Normal file
43
tests/headers.c
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @file headers.c
|
||||
* @brief Checks that a public header can be the first thing a caller includes.
|
||||
*
|
||||
* The assertion here is the compile, not anything main() does. `akgl/controller.h`
|
||||
* declares its binding handlers as taking an `akgl_Actor *` and once did not
|
||||
* declare that type, so every translation unit that reached for it before
|
||||
* `akgl/actor.h` failed with "unknown type name 'akgl_Actor'". `src/controller.c`
|
||||
* never noticed, because it includes `akgl/game.h` first.
|
||||
*
|
||||
* One header per file, deliberately. A second `#include` after the first proves
|
||||
* nothing about the second: by then the first has already dragged its
|
||||
* dependencies in. So every other public header gets its own generated
|
||||
* translation unit -- `CMakeLists.txt` writes one per entry in
|
||||
* `AKGL_PUBLIC_HEADERS` and links them all into this program. That list also
|
||||
* drives `install()`, so a header that ships is a header that is checked.
|
||||
*
|
||||
* This file stays hand-written because it asserts something the generated ones
|
||||
* cannot: that `akgl_Actor` is *complete* here rather than merely declared.
|
||||
*/
|
||||
|
||||
#include <akgl/controller.h>
|
||||
|
||||
#include <string.h>
|
||||
|
||||
int main(void)
|
||||
{
|
||||
akgl_Control control;
|
||||
|
||||
// Enough to require the akgl_Actor declaration to be complete rather than
|
||||
// merely present: the handler signature names it, and a control map holds
|
||||
// one by value.
|
||||
memset(&control, 0x00, sizeof(akgl_Control));
|
||||
control.key = SDLK_LEFT;
|
||||
control.handler_on = &akgl_actor_cmhf_left_on;
|
||||
if ( control.handler_on == NULL ) {
|
||||
return 1;
|
||||
}
|
||||
if ( sizeof(akgl_controlmaps) != (sizeof(akgl_ControlMap) * AKGL_MAX_CONTROL_MAPS) ) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
379
tests/heap.c
Normal file
379
tests/heap.c
Normal file
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* @file heap.c
|
||||
* @brief Unit tests for the fixed-size object pools and their refcounting.
|
||||
*
|
||||
* The pools are process-wide arrays, so every test that fills one calls
|
||||
* akgl_heap_init() first and leaves the heap empty behind it.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <string.h>
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/character.h>
|
||||
#include <akgl/sprite.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/staticstring.h>
|
||||
|
||||
#include "testutil.h"
|
||||
|
||||
/**
|
||||
* @brief Reset every pool and the registries that reference their objects.
|
||||
*
|
||||
* The registries hold raw pointers into the pools, so clearing a pool without
|
||||
* clearing the registry would leave dangling entries for the next test.
|
||||
*/
|
||||
static akerr_ErrorContext *reset_all_heaps(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
ATTEMPT {
|
||||
CATCH(e, akgl_registry_init_actor());
|
||||
CATCH(e, akgl_registry_init_sprite());
|
||||
CATCH(e, akgl_registry_init_spritesheet());
|
||||
CATCH(e, akgl_registry_init_character());
|
||||
CATCH(e, akgl_heap_init());
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_heap_init_clears_every_pool(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
bool clean = true;
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
// Dirty every pool, then require that init scrubs all of them.
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
|
||||
akgl_heap_actors[i].refcount = 5;
|
||||
}
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_SPRITE; i++ ) {
|
||||
akgl_heap_sprites[i].refcount = 5;
|
||||
}
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_SPRITESHEET; i++ ) {
|
||||
akgl_heap_spritesheets[i].refcount = 5;
|
||||
}
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_CHARACTER; i++ ) {
|
||||
akgl_heap_characters[i].refcount = 5;
|
||||
}
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_STRING; i++ ) {
|
||||
akgl_heap_strings[i].refcount = 5;
|
||||
}
|
||||
|
||||
CATCH(e, akgl_heap_init());
|
||||
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
|
||||
TEST_ASSERT_FLAG(clean, akgl_heap_actors[i].refcount == 0);
|
||||
}
|
||||
TEST_ASSERT(e, clean, "akgl_heap_init left a nonzero refcount in the actor pool");
|
||||
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_SPRITE; i++ ) {
|
||||
TEST_ASSERT_FLAG(clean, akgl_heap_sprites[i].refcount == 0);
|
||||
}
|
||||
TEST_ASSERT(e, clean, "akgl_heap_init left a nonzero refcount in the sprite pool");
|
||||
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_SPRITESHEET; i++ ) {
|
||||
TEST_ASSERT_FLAG(clean, akgl_heap_spritesheets[i].refcount == 0);
|
||||
}
|
||||
TEST_ASSERT(e, clean, "akgl_heap_init left a nonzero refcount in the spritesheet pool");
|
||||
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_CHARACTER; i++ ) {
|
||||
TEST_ASSERT_FLAG(clean, akgl_heap_characters[i].refcount == 0);
|
||||
}
|
||||
TEST_ASSERT(e, clean, "akgl_heap_init left a nonzero refcount in the character pool");
|
||||
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_STRING; i++ ) {
|
||||
TEST_ASSERT_FLAG(clean, akgl_heap_strings[i].refcount == 0);
|
||||
}
|
||||
TEST_ASSERT(e, clean, "akgl_heap_init left a nonzero refcount in the string pool");
|
||||
|
||||
// akgl_heap_init_actor clears only the actor pool.
|
||||
akgl_heap_actors[0].refcount = 9;
|
||||
akgl_heap_sprites[0].refcount = 9;
|
||||
CATCH(e, akgl_heap_init_actor());
|
||||
TEST_ASSERT(e, akgl_heap_actors[0].refcount == 0,
|
||||
"akgl_heap_init_actor did not clear the actor pool");
|
||||
TEST_ASSERT(e, akgl_heap_sprites[0].refcount == 9,
|
||||
"akgl_heap_init_actor cleared the sprite pool as well");
|
||||
akgl_heap_sprites[0].refcount = 0;
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_heap_next_string_refcounting(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_String *first = NULL;
|
||||
akgl_String *second = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, reset_all_heaps());
|
||||
|
||||
// akgl_heap_next_string is the only acquire function that claims the slot
|
||||
// it hands out; the others leave refcount at zero for the caller to set.
|
||||
CATCH(e, akgl_heap_next_string(&first));
|
||||
TEST_ASSERT(e, first->refcount == 1,
|
||||
"akgl_heap_next_string returned a slot with refcount %d, expected 1",
|
||||
first->refcount);
|
||||
|
||||
CATCH(e, akgl_heap_next_string(&second));
|
||||
TEST_ASSERT(e, second != first,
|
||||
"akgl_heap_next_string handed out the same slot twice");
|
||||
|
||||
CATCH(e, akgl_heap_release_string(first));
|
||||
CATCH(e, akgl_heap_release_string(second));
|
||||
TEST_ASSERT(e, first->refcount == 0,
|
||||
"releasing a string left refcount at %d", first->refcount);
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_heap_exhaustion(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_Actor *actor = NULL;
|
||||
akgl_Sprite *sprite = NULL;
|
||||
akgl_SpriteSheet *sheet = NULL;
|
||||
akgl_Character *basechar = NULL;
|
||||
akgl_String *str = NULL;
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
// Actors: the acquire function does not claim the slot, so the test does.
|
||||
CATCH(e, reset_all_heaps());
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
|
||||
akgl_heap_actors[i].refcount = 1;
|
||||
}
|
||||
TEST_EXPECT_STATUS(e, AKGL_ERR_HEAP, akgl_heap_next_actor(&actor),
|
||||
"akgl_heap_next_actor with every slot claimed");
|
||||
|
||||
CATCH(e, reset_all_heaps());
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_SPRITE; i++ ) {
|
||||
akgl_heap_sprites[i].refcount = 1;
|
||||
}
|
||||
TEST_EXPECT_STATUS(e, AKGL_ERR_HEAP, akgl_heap_next_sprite(&sprite),
|
||||
"akgl_heap_next_sprite with every slot claimed");
|
||||
|
||||
CATCH(e, reset_all_heaps());
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_SPRITESHEET; i++ ) {
|
||||
akgl_heap_spritesheets[i].refcount = 1;
|
||||
}
|
||||
TEST_EXPECT_STATUS(e, AKGL_ERR_HEAP, akgl_heap_next_spritesheet(&sheet),
|
||||
"akgl_heap_next_spritesheet with every slot claimed");
|
||||
|
||||
CATCH(e, reset_all_heaps());
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_CHARACTER; i++ ) {
|
||||
akgl_heap_characters[i].refcount = 1;
|
||||
}
|
||||
TEST_EXPECT_STATUS(e, AKGL_ERR_HEAP, akgl_heap_next_character(&basechar),
|
||||
"akgl_heap_next_character with every slot claimed");
|
||||
|
||||
// Strings claim their own slots, so draining the pool needs no help.
|
||||
CATCH(e, reset_all_heaps());
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_STRING; i++ ) {
|
||||
CATCH(e, akgl_heap_next_string(&str));
|
||||
}
|
||||
TEST_EXPECT_STATUS(e, AKGL_ERR_HEAP, akgl_heap_next_string(&str),
|
||||
"akgl_heap_next_string with the pool drained");
|
||||
|
||||
// A single release makes exactly one slot available again.
|
||||
CATCH(e, akgl_heap_release_string(&akgl_heap_strings[0]));
|
||||
TEST_EXPECT_OK(e, akgl_heap_next_string(&str),
|
||||
"akgl_heap_next_string after freeing one slot");
|
||||
TEST_ASSERT(e, str == &akgl_heap_strings[0],
|
||||
"the reclaimed string was not the slot that was released");
|
||||
|
||||
CATCH(e, reset_all_heaps());
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_heap_release_refcounting(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_Sprite *sprite = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, reset_all_heaps());
|
||||
CATCH(e, akgl_heap_next_sprite(&sprite));
|
||||
|
||||
// A shared object survives until the last reference goes away.
|
||||
sprite->refcount = 2;
|
||||
strncpy((char *)&sprite->name, "shared", AKGL_SPRITE_MAX_NAME_LENGTH - 1);
|
||||
CATCH(e, akgl_heap_release_sprite(sprite));
|
||||
TEST_ASSERT(e, sprite->refcount == 1,
|
||||
"releasing a twice-referenced sprite left refcount %d, expected 1",
|
||||
sprite->refcount);
|
||||
TEST_ASSERT(e, sprite->name[0] == 's',
|
||||
"releasing a still-referenced sprite cleared its data");
|
||||
|
||||
CATCH(e, akgl_heap_release_sprite(sprite));
|
||||
TEST_ASSERT(e, sprite->refcount == 0,
|
||||
"the final release left refcount %d, expected 0", sprite->refcount);
|
||||
TEST_ASSERT(e, sprite->name[0] == 0x00,
|
||||
"the final release did not clear the sprite");
|
||||
|
||||
// Releasing an already-free object is clamped, not wrapped below zero.
|
||||
CATCH(e, akgl_heap_release_sprite(sprite));
|
||||
TEST_ASSERT(e, sprite->refcount == 0,
|
||||
"over-releasing drove refcount to %d, expected a clamp at 0",
|
||||
sprite->refcount);
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_heap_release_actor_children(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_Actor *parent = NULL;
|
||||
akgl_Actor *child = NULL;
|
||||
char namebuf[AKGL_ACTOR_MAX_NAME_LENGTH];
|
||||
bool released = true;
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, reset_all_heaps());
|
||||
CATCH(e, akgl_heap_next_actor(&parent));
|
||||
CATCH(e, akgl_actor_initialize(parent, "parent"));
|
||||
|
||||
// Fill every child slot, then release the parent once.
|
||||
for ( i = 0; i < AKGL_ACTOR_MAX_CHILDREN; i++ ) {
|
||||
snprintf((char *)&namebuf, AKGL_ACTOR_MAX_NAME_LENGTH, "child%d", i);
|
||||
CATCH(e, akgl_heap_next_actor(&child));
|
||||
CATCH(e, akgl_actor_initialize(child, (char *)&namebuf));
|
||||
CATCH(e, akgl_actor_add_child(parent, child));
|
||||
}
|
||||
|
||||
CATCH(e, akgl_heap_release_actor(parent));
|
||||
TEST_ASSERT(e, parent->refcount == 0,
|
||||
"releasing the parent left refcount %d", parent->refcount);
|
||||
|
||||
// Each child was initialized to 1 and incremented to 2 by add_child, so
|
||||
// the recursive release should have brought every one of them back to 1.
|
||||
for ( i = 1; i <= AKGL_ACTOR_MAX_CHILDREN; i++ ) {
|
||||
TEST_ASSERT_FLAG(released, akgl_heap_actors[i].refcount == 1);
|
||||
}
|
||||
TEST_ASSERT(e, released,
|
||||
"releasing a parent did not decrement every child exactly once");
|
||||
|
||||
CATCH(e, reset_all_heaps());
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_heap_release_clears_registry(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_Actor *actor = NULL;
|
||||
akgl_Character *basechar = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, reset_all_heaps());
|
||||
|
||||
CATCH(e, akgl_heap_next_actor(&actor));
|
||||
CATCH(e, akgl_actor_initialize(actor, "registered"));
|
||||
TEST_ASSERT(e, SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "registered", NULL) != NULL,
|
||||
"akgl_actor_initialize did not register the actor");
|
||||
CATCH(e, akgl_heap_release_actor(actor));
|
||||
TEST_ASSERT(e, SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "registered", NULL) == NULL,
|
||||
"releasing an actor left a dangling registry entry");
|
||||
|
||||
CATCH(e, akgl_heap_next_character(&basechar));
|
||||
CATCH(e, akgl_character_initialize(basechar, "regchar"));
|
||||
TEST_ASSERT(e, SDL_GetPointerProperty(AKGL_REGISTRY_CHARACTER, "regchar", NULL) != NULL,
|
||||
"akgl_character_initialize did not register the character");
|
||||
CATCH(e, akgl_heap_release_character(basechar));
|
||||
TEST_ASSERT(e, SDL_GetPointerProperty(AKGL_REGISTRY_CHARACTER, "regchar", NULL) == NULL,
|
||||
"releasing a character left a dangling registry entry");
|
||||
|
||||
CATCH(e, reset_all_heaps());
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_heap_release_nullpointers(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
|
||||
ATTEMPT {
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_heap_release_actor(NULL),
|
||||
"akgl_heap_release_actor(NULL)");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_heap_release_sprite(NULL),
|
||||
"akgl_heap_release_sprite(NULL)");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_heap_release_spritesheet(NULL),
|
||||
"akgl_heap_release_spritesheet(NULL)");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_heap_release_character(NULL),
|
||||
"akgl_heap_release_character(NULL)");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_heap_release_string(NULL),
|
||||
"akgl_heap_release_string(NULL)");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_heap_release_spritesheet_texture(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_SpriteSheet *sheet = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, reset_all_heaps());
|
||||
CATCH(e, akgl_heap_next_spritesheet(&sheet));
|
||||
|
||||
// A spritesheet with no texture must still release cleanly; the texture
|
||||
// branch is exercised by the sprite suite, which has a live renderer.
|
||||
sheet->refcount = 1;
|
||||
sheet->texture = NULL;
|
||||
CATCH(e, akgl_heap_release_spritesheet(sheet));
|
||||
TEST_ASSERT(e, sheet->refcount == 0,
|
||||
"releasing a textureless spritesheet left refcount %d", sheet->refcount);
|
||||
TEST_ASSERT(e, sheet->texture == NULL,
|
||||
"releasing a spritesheet left a non-NULL texture pointer");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
CATCH(errctx, akgl_heap_init());
|
||||
CATCH(errctx, akgl_registry_init());
|
||||
|
||||
CATCH(errctx, test_heap_init_clears_every_pool());
|
||||
CATCH(errctx, test_heap_next_string_refcounting());
|
||||
CATCH(errctx, test_heap_exhaustion());
|
||||
CATCH(errctx, test_heap_release_refcounting());
|
||||
CATCH(errctx, test_heap_release_actor_children());
|
||||
CATCH(errctx, test_heap_release_clears_registry());
|
||||
CATCH(errctx, test_heap_release_nullpointers());
|
||||
CATCH(errctx, test_heap_release_spritesheet_texture());
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
}
|
||||
549
tests/json_helpers.c
Normal file
549
tests/json_helpers.c
Normal file
@@ -0,0 +1,549 @@
|
||||
/**
|
||||
* @file json_helpers.c
|
||||
* @brief Unit tests for the typed JSON accessors.
|
||||
*
|
||||
* These exercise the accessors directly rather than through sprite, character,
|
||||
* or tilemap loading, so the error paths are reachable without building a
|
||||
* malformed asset for every case.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <jansson.h>
|
||||
#include <string.h>
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/json_helpers.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/staticstring.h>
|
||||
|
||||
#include "testutil.h"
|
||||
|
||||
/** @brief Fixture document shared by every test in this file. */
|
||||
static json_t *fixture = NULL;
|
||||
|
||||
/** @brief Load tests/assets/snippets/test_json_helpers.json into @ref fixture. */
|
||||
static akerr_ErrorContext *load_fixture(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
json_error_t jsonerr;
|
||||
akgl_String *pathstr = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, akgl_heap_next_string(&pathstr));
|
||||
snprintf(
|
||||
(char *)&pathstr->data,
|
||||
AKGL_MAX_STRING_LENGTH,
|
||||
"%s%s",
|
||||
SDL_GetBasePath(),
|
||||
"assets/snippets/test_json_helpers.json");
|
||||
fixture = json_load_file((char *)&pathstr->data, 0, &jsonerr);
|
||||
FAIL_ZERO_BREAK(e, fixture, AKERR_IO,
|
||||
"Unable to load the JSON fixture: %s", (char *)jsonerr.text);
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_string(pathstr));
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Every accessor must refuse a NULL key or destination, not dereference it.
|
||||
*
|
||||
* Only the two string accessors did. The other eight validated the container
|
||||
* and then wrote through @p dest unconditionally, so which arguments a caller
|
||||
* could safely get wrong depended on which typed accessor they happened to
|
||||
* call -- and the ones that crashed were the ones used most.
|
||||
*/
|
||||
/**
|
||||
* @brief An exhausted string pool must report AKGL_ERR_HEAP, not crash.
|
||||
*
|
||||
* Both string accessors ended their ATTEMPT block with `FINISH(errctx, false)`,
|
||||
* which swallows the failure instead of passing it up, and then ran
|
||||
* `strncpy(&(*dest)->data, ...)` through the pointer akgl_heap_next_string
|
||||
* never set. So the one condition the pool is designed to report -- it is full,
|
||||
* usually because something is not releasing -- arrived as a segfault inside
|
||||
* strncpy.
|
||||
*
|
||||
* This is what TODO.md Performance item 29 looked like from the outside: not
|
||||
* "the tilemap loader leaks five strings a load", but a crash somewhere else
|
||||
* entirely, fifty levels later.
|
||||
*/
|
||||
akerr_ErrorContext *test_json_string_accessor_reports_pool_exhaustion(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_String *claimed[AKGL_MAX_HEAP_STRING];
|
||||
akgl_String *dest = NULL;
|
||||
json_t *strings = NULL;
|
||||
int held = 0;
|
||||
int i = 0;
|
||||
|
||||
memset(&claimed, 0x00, sizeof(claimed));
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, akgl_get_json_array_value(fixture, "strings", &strings));
|
||||
|
||||
// Take every slot the pool has. Whatever else is holding one already
|
||||
// simply means this stops sooner.
|
||||
while ( held < AKGL_MAX_HEAP_STRING ) {
|
||||
akerr_ErrorContext *claim = akgl_heap_next_string(&claimed[held]);
|
||||
if ( claim != NULL ) {
|
||||
claim->handled = true;
|
||||
claim = akerr_release_error(claim);
|
||||
claimed[held] = NULL;
|
||||
break;
|
||||
}
|
||||
held += 1;
|
||||
}
|
||||
TEST_ASSERT(e, held > 0, "could not claim any pool strings");
|
||||
TEST_ASSERT(e, test_string_pool_used() == AKGL_MAX_HEAP_STRING,
|
||||
"the string pool is not full: %d of %d claimed",
|
||||
test_string_pool_used(), AKGL_MAX_HEAP_STRING);
|
||||
|
||||
// dest is NULL, so both accessors have to claim -- and cannot.
|
||||
dest = NULL;
|
||||
TEST_EXPECT_STATUS(e, AKGL_ERR_HEAP,
|
||||
akgl_get_json_string_value(fixture, "name", &dest),
|
||||
"reading a string with the pool exhausted");
|
||||
TEST_ASSERT(e, dest == NULL,
|
||||
"a refused string accessor wrote to its destination");
|
||||
|
||||
dest = NULL;
|
||||
TEST_EXPECT_STATUS(e, AKGL_ERR_HEAP,
|
||||
akgl_get_json_array_index_string(strings, 0, &dest),
|
||||
"reading an array string with the pool exhausted");
|
||||
TEST_ASSERT(e, dest == NULL,
|
||||
"a refused array string accessor wrote to its destination");
|
||||
} CLEANUP {
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_STRING; i++ ) {
|
||||
if ( claimed[i] != NULL ) {
|
||||
IGNORE(akgl_heap_release_string(claimed[i]));
|
||||
}
|
||||
}
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_json_accessor_null_arguments(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
int intval = 0;
|
||||
float floatval = 0;
|
||||
double dblval = 0;
|
||||
bool boolval = false;
|
||||
json_t *jsonval = NULL;
|
||||
akgl_String *strval = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_object_value(fixture, NULL, &jsonval),
|
||||
"object accessor with a NULL key");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_object_value(fixture, "nested", NULL),
|
||||
"object accessor with a NULL destination");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_boolean_value(fixture, NULL, &boolval),
|
||||
"boolean accessor with a NULL key");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_boolean_value(fixture, "enabled", NULL),
|
||||
"boolean accessor with a NULL destination");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_integer_value(fixture, NULL, &intval),
|
||||
"integer accessor with a NULL key");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_integer_value(fixture, "count", NULL),
|
||||
"integer accessor with a NULL destination");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_number_value(fixture, NULL, &floatval),
|
||||
"number accessor with a NULL key");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_number_value(fixture, "ratio", NULL),
|
||||
"number accessor with a NULL destination");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_double_value(fixture, NULL, &dblval),
|
||||
"double accessor with a NULL key");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_double_value(fixture, "ratio", NULL),
|
||||
"double accessor with a NULL destination");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_array_value(fixture, NULL, &jsonval),
|
||||
"array accessor with a NULL key");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_array_value(fixture, "integers", NULL),
|
||||
"array accessor with a NULL destination");
|
||||
|
||||
// The index accessors take no key, but they take a destination.
|
||||
CATCH(e, akgl_get_json_array_value(fixture, "integers", &jsonval));
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_array_index_integer(jsonval, 0, NULL),
|
||||
"array index integer accessor with a NULL destination");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_array_index_object(jsonval, 0, NULL),
|
||||
"array index object accessor with a NULL destination");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_array_index_string(jsonval, 0, NULL),
|
||||
"array index string accessor with a NULL destination");
|
||||
|
||||
// Nothing above should have claimed a pool string on the way to
|
||||
// refusing, which is the other half of "refuse rather than proceed".
|
||||
TEST_ASSERT(e, strval == NULL, "a refused accessor wrote to its destination anyway");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_json_scalar_accessors(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
int intval = 0;
|
||||
float floatval = 0;
|
||||
bool boolval = false;
|
||||
json_t *objval = NULL;
|
||||
json_t *arrayval = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
TEST_EXPECT_OK(e, akgl_get_json_integer_value(fixture, "count", &intval), "read count");
|
||||
TEST_ASSERT(e, intval == 42, "count read as %d, expected 42", intval);
|
||||
|
||||
TEST_EXPECT_OK(e, akgl_get_json_integer_value(fixture, "negative", &intval), "read negative");
|
||||
TEST_ASSERT(e, intval == -17, "negative read as %d, expected -17", intval);
|
||||
|
||||
TEST_EXPECT_OK(e, akgl_get_json_number_value(fixture, "ratio", &floatval), "read ratio");
|
||||
TEST_ASSERT_FEQ(e, floatval, 2.5f, "ratio read as %f, expected 2.5", floatval);
|
||||
|
||||
// A JSON integer is a number, so the float accessor accepts it too.
|
||||
TEST_EXPECT_OK(e, akgl_get_json_number_value(fixture, "count", &floatval), "read count as a number");
|
||||
TEST_ASSERT_FEQ(e, floatval, 42.0f, "count read as number %f, expected 42", floatval);
|
||||
|
||||
TEST_EXPECT_OK(e, akgl_get_json_boolean_value(fixture, "enabled", &boolval), "read enabled");
|
||||
TEST_ASSERT(e, boolval == true, "enabled read as false");
|
||||
TEST_EXPECT_OK(e, akgl_get_json_boolean_value(fixture, "disabled", &boolval), "read disabled");
|
||||
TEST_ASSERT(e, boolval == false, "disabled read as true");
|
||||
|
||||
TEST_EXPECT_OK(e, akgl_get_json_object_value(fixture, "nested", &objval), "read nested");
|
||||
TEST_ASSERT(e, objval != NULL, "nested object came back NULL");
|
||||
TEST_EXPECT_OK(e, akgl_get_json_integer_value(objval, "innercount", &intval), "read nested innercount");
|
||||
TEST_ASSERT(e, intval == 7, "nested innercount read as %d, expected 7", intval);
|
||||
|
||||
TEST_EXPECT_OK(e, akgl_get_json_array_value(fixture, "integers", &arrayval), "read integers array");
|
||||
TEST_ASSERT(e, json_array_size(arrayval) == 3,
|
||||
"integers array had %d entries, expected 3", (int)json_array_size(arrayval));
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_json_double_value(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
double dblval = 0;
|
||||
|
||||
ATTEMPT {
|
||||
TEST_EXPECT_OK(e, akgl_get_json_double_value(fixture, "ratio", &dblval), "read ratio as a double");
|
||||
TEST_ASSERT(e, dblval > 2.4999 && dblval < 2.5001,
|
||||
"ratio read as double %f, expected 2.5", dblval);
|
||||
|
||||
// Integers satisfy json_is_number, so the double accessor takes them.
|
||||
TEST_EXPECT_OK(e, akgl_get_json_double_value(fixture, "count", &dblval), "read count as a double");
|
||||
TEST_ASSERT(e, dblval > 41.999 && dblval < 42.001,
|
||||
"count read as double %f, expected 42", dblval);
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_get_json_double_value(fixture, "absent", &dblval),
|
||||
"double accessor on a missing key");
|
||||
TEST_EXPECT_STATUS(e, AKERR_TYPE, akgl_get_json_double_value(fixture, "name", &dblval),
|
||||
"double accessor on a string value");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_double_value(NULL, "ratio", &dblval),
|
||||
"double accessor on a NULL object");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_json_string_accessor(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_String *allocated = NULL;
|
||||
akgl_String *reused = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
// A NULL destination makes the accessor claim a heap string.
|
||||
TEST_EXPECT_OK(e, akgl_get_json_string_value(fixture, "name", &allocated),
|
||||
"read name into a NULL destination");
|
||||
TEST_ASSERT(e, allocated != NULL, "the accessor did not allocate a destination string");
|
||||
TEST_ASSERT(e, strcmp((char *)&allocated->data, "json helper fixture") == 0,
|
||||
"name read as \"%s\"", (char *)&allocated->data);
|
||||
|
||||
// A caller-supplied destination is written in place, not replaced.
|
||||
CATCH(e, akgl_heap_next_string(&reused));
|
||||
CATCH(e, akgl_string_initialize(reused, "placeholder"));
|
||||
{
|
||||
akgl_String *before = reused;
|
||||
TEST_EXPECT_OK(e, akgl_get_json_string_value(fixture, "name", &reused),
|
||||
"read name into a preallocated destination");
|
||||
TEST_ASSERT(e, reused == before,
|
||||
"the accessor replaced a caller-supplied destination pointer");
|
||||
}
|
||||
TEST_ASSERT(e, strcmp((char *)&reused->data, "json helper fixture") == 0,
|
||||
"in-place read produced \"%s\"", (char *)&reused->data);
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_get_json_string_value(fixture, "absent", &reused),
|
||||
"string accessor on a missing key");
|
||||
TEST_EXPECT_STATUS(e, AKERR_TYPE, akgl_get_json_string_value(fixture, "count", &reused),
|
||||
"string accessor on an integer value");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_string_value(NULL, "name", &reused),
|
||||
"string accessor on a NULL object");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_string_value(fixture, NULL, &reused),
|
||||
"string accessor with a NULL key");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_string_value(fixture, "name", NULL),
|
||||
"string accessor with a NULL destination");
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_string(allocated));
|
||||
IGNORE(akgl_heap_release_string(reused));
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_json_array_index_accessors(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
json_t *integers = NULL;
|
||||
json_t *strings = NULL;
|
||||
json_t *objects = NULL;
|
||||
json_t *mixed = NULL;
|
||||
json_t *element = NULL;
|
||||
akgl_String *strval = NULL;
|
||||
int intval = 0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, akgl_get_json_array_value(fixture, "integers", &integers));
|
||||
CATCH(e, akgl_get_json_array_value(fixture, "strings", &strings));
|
||||
CATCH(e, akgl_get_json_array_value(fixture, "objects", &objects));
|
||||
CATCH(e, akgl_get_json_array_value(fixture, "mixed", &mixed));
|
||||
|
||||
TEST_EXPECT_OK(e, akgl_get_json_array_index_integer(integers, 0, &intval), "integers[0]");
|
||||
TEST_ASSERT(e, intval == 10, "integers[0] read as %d, expected 10", intval);
|
||||
TEST_EXPECT_OK(e, akgl_get_json_array_index_integer(integers, 2, &intval), "integers[2]");
|
||||
TEST_ASSERT(e, intval == 30, "integers[2] read as %d, expected 30", intval);
|
||||
|
||||
TEST_EXPECT_OK(e, akgl_get_json_array_index_string(strings, 1, &strval), "strings[1]");
|
||||
TEST_ASSERT(e, strcmp((char *)&strval->data, "beta") == 0,
|
||||
"strings[1] read as \"%s\", expected \"beta\"", (char *)&strval->data);
|
||||
|
||||
TEST_EXPECT_OK(e, akgl_get_json_array_index_object(objects, 1, &element), "objects[1]");
|
||||
TEST_EXPECT_OK(e, akgl_get_json_integer_value(element, "id", &intval), "objects[1].id");
|
||||
TEST_ASSERT(e, intval == 2, "objects[1].id read as %d, expected 2", intval);
|
||||
|
||||
// One past the end, and far past the end, are both out of bounds.
|
||||
TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_get_json_array_index_integer(integers, 3, &intval),
|
||||
"integers[3] is one past the end");
|
||||
TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_get_json_array_index_integer(integers, 99, &intval),
|
||||
"integers[99] is far past the end");
|
||||
TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_get_json_array_index_object(objects, 5, &element),
|
||||
"objects[5] is past the end");
|
||||
TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_get_json_array_index_string(strings, 5, &strval),
|
||||
"strings[5] is past the end");
|
||||
|
||||
// The mixed array holds one of each type, so every accessor can be shown
|
||||
// to reject the entries that are not its own.
|
||||
TEST_EXPECT_STATUS(e, AKERR_TYPE, akgl_get_json_array_index_integer(mixed, 1, &intval),
|
||||
"integer accessor on a string element");
|
||||
TEST_EXPECT_STATUS(e, AKERR_TYPE, akgl_get_json_array_index_string(mixed, 0, &strval),
|
||||
"string accessor on an integer element");
|
||||
TEST_EXPECT_STATUS(e, AKERR_TYPE, akgl_get_json_array_index_object(mixed, 0, &element),
|
||||
"object accessor on an integer element");
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_array_index_integer(NULL, 0, &intval),
|
||||
"integer index accessor on a NULL array");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_array_index_object(NULL, 0, &element),
|
||||
"object index accessor on a NULL array");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_array_index_string(NULL, 0, &strval),
|
||||
"string index accessor on a NULL array");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_array_index_string(strings, 0, NULL),
|
||||
"string index accessor with a NULL destination");
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_string(strval));
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_json_type_and_key_errors(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
int intval = 0;
|
||||
float floatval = 0;
|
||||
bool boolval = false;
|
||||
json_t *objval = NULL;
|
||||
json_t *arrayval = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
// Missing keys.
|
||||
TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_get_json_integer_value(fixture, "absent", &intval),
|
||||
"integer accessor on a missing key");
|
||||
TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_get_json_number_value(fixture, "absent", &floatval),
|
||||
"number accessor on a missing key");
|
||||
TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_get_json_boolean_value(fixture, "absent", &boolval),
|
||||
"boolean accessor on a missing key");
|
||||
TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_get_json_object_value(fixture, "absent", &objval),
|
||||
"object accessor on a missing key");
|
||||
TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_get_json_array_value(fixture, "absent", &arrayval),
|
||||
"array accessor on a missing key");
|
||||
|
||||
// Wrong types.
|
||||
TEST_EXPECT_STATUS(e, AKERR_TYPE, akgl_get_json_integer_value(fixture, "name", &intval),
|
||||
"integer accessor on a string");
|
||||
TEST_EXPECT_STATUS(e, AKERR_TYPE, akgl_get_json_integer_value(fixture, "ratio", &intval),
|
||||
"integer accessor on a real");
|
||||
TEST_EXPECT_STATUS(e, AKERR_TYPE, akgl_get_json_number_value(fixture, "name", &floatval),
|
||||
"number accessor on a string");
|
||||
TEST_EXPECT_STATUS(e, AKERR_TYPE, akgl_get_json_boolean_value(fixture, "count", &boolval),
|
||||
"boolean accessor on an integer");
|
||||
TEST_EXPECT_STATUS(e, AKERR_TYPE, akgl_get_json_object_value(fixture, "integers", &objval),
|
||||
"object accessor on an array");
|
||||
TEST_EXPECT_STATUS(e, AKERR_TYPE, akgl_get_json_array_value(fixture, "nested", &arrayval),
|
||||
"array accessor on an object");
|
||||
|
||||
// NULL containers.
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_integer_value(NULL, "count", &intval),
|
||||
"integer accessor on a NULL object");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_number_value(NULL, "ratio", &floatval),
|
||||
"number accessor on a NULL object");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_boolean_value(NULL, "enabled", &boolval),
|
||||
"boolean accessor on a NULL object");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_object_value(NULL, "nested", &objval),
|
||||
"object accessor on a NULL object");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_get_json_array_value(NULL, "integers", &arrayval),
|
||||
"array accessor on a NULL object");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_json_with_default(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
int dest = 0;
|
||||
int defval = 99;
|
||||
akerr_ErrorContext *keyerr = NULL;
|
||||
akerr_ErrorContext *typeerr = NULL;
|
||||
akerr_ErrorContext *defaulted = NULL;
|
||||
json_t *shortarray = NULL;
|
||||
json_t *junkobj = NULL;
|
||||
int junk = 0;
|
||||
|
||||
ATTEMPT {
|
||||
// A NULL error means the read succeeded, so the default is not applied.
|
||||
dest = 1;
|
||||
TEST_EXPECT_OK(e, akgl_get_json_with_default(NULL, (void *)&defval, (void *)&dest, sizeof(int)),
|
||||
"with_default on a NULL error");
|
||||
TEST_ASSERT(e, dest == 1, "with_default overwrote a successful read (dest is now %d)", dest);
|
||||
|
||||
// An AKERR_KEY failure substitutes the default.
|
||||
dest = 1;
|
||||
keyerr = akgl_get_json_integer_value(fixture, "absent", &junk);
|
||||
TEST_ASSERT(e, keyerr != NULL, "the missing-key read unexpectedly succeeded");
|
||||
TEST_EXPECT_OK(e, akgl_get_json_with_default(keyerr, (void *)&defval, (void *)&dest, sizeof(int)),
|
||||
"with_default on an AKERR_KEY error");
|
||||
TEST_ASSERT(e, dest == 99, "with_default did not apply the default (dest is %d)", dest);
|
||||
keyerr = NULL;
|
||||
|
||||
// An unrelated failure is not swallowed, so the caller still sees it.
|
||||
dest = 1;
|
||||
typeerr = akgl_get_json_integer_value(fixture, "name", &junk);
|
||||
TEST_ASSERT(e, typeerr != NULL, "the wrong-type read unexpectedly succeeded");
|
||||
TEST_EXPECT_STATUS(e, AKERR_TYPE,
|
||||
akgl_get_json_with_default(typeerr, (void *)&defval, (void *)&dest, sizeof(int)),
|
||||
"with_default must propagate an unrelated error");
|
||||
TEST_ASSERT(e, dest == 1, "with_default applied the default for an unrelated error");
|
||||
typeerr = NULL;
|
||||
|
||||
// A short array. This is the one that did not work: the three array
|
||||
// index accessors report AKERR_OUTOFBOUNDS, which with_default never
|
||||
// handled, so "this element is optional" worked for an object member
|
||||
// and silently did not for an array element. Nothing in tree passed an
|
||||
// array accessor's error here, which is why it went unnoticed.
|
||||
dest = 1;
|
||||
CATCH(e, akgl_get_json_array_value(fixture, "integers", &shortarray));
|
||||
keyerr = akgl_get_json_array_index_integer(shortarray, 99, &junk);
|
||||
TEST_ASSERT(e, keyerr != NULL, "reading past the end of an array unexpectedly succeeded");
|
||||
TEST_ASSERT(e, keyerr->status == AKERR_OUTOFBOUNDS,
|
||||
"a short array reported %d, expected AKERR_OUTOFBOUNDS", keyerr->status);
|
||||
// Not TEST_EXPECT_OK here. That macro releases whatever context the
|
||||
// statement returns, and akgl_get_json_with_default returns *the context
|
||||
// it was given* when it does not handle it -- so on the failing path the
|
||||
// CLEANUP block below would release the same context a second time, and
|
||||
// a double-released context corrupts the failure rather than reporting
|
||||
// it. Ownership passes to with_default either way: it releases through
|
||||
// FINISH when it handles the status, and hands the context back when it
|
||||
// does not.
|
||||
defaulted = akgl_get_json_with_default(keyerr, (void *)&defval, (void *)&dest, sizeof(int));
|
||||
keyerr = NULL;
|
||||
if ( defaulted != NULL ) {
|
||||
defaulted->handled = true;
|
||||
defaulted = akerr_release_error(defaulted);
|
||||
FAIL_BREAK(e, AKGL_ERR_BEHAVIOR,
|
||||
"with_default propagated AKERR_OUTOFBOUNDS instead of applying the default");
|
||||
}
|
||||
TEST_ASSERT(e, dest == 99,
|
||||
"with_default did not apply the default for a short array (dest is %d)", dest);
|
||||
|
||||
// The same through the object index accessor, so the fix is pinned to
|
||||
// the status rather than to one call site.
|
||||
dest = 1;
|
||||
keyerr = akgl_get_json_array_index_object(shortarray, 99, &junkobj);
|
||||
TEST_ASSERT(e, keyerr != NULL, "reading an object past the end unexpectedly succeeded");
|
||||
defaulted = akgl_get_json_with_default(keyerr, (void *)&defval, (void *)&dest, sizeof(int));
|
||||
keyerr = NULL;
|
||||
if ( defaulted != NULL ) {
|
||||
defaulted->handled = true;
|
||||
defaulted = akerr_release_error(defaulted);
|
||||
FAIL_BREAK(e, AKGL_ERR_BEHAVIOR,
|
||||
"with_default propagated a short object array instead of applying the default");
|
||||
}
|
||||
TEST_ASSERT(e, dest == 99, "with_default did not apply the default (dest is %d)", dest);
|
||||
|
||||
// NULL arguments alongside a real error are a contract violation.
|
||||
keyerr = akgl_get_json_integer_value(fixture, "absent", &junk);
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER,
|
||||
akgl_get_json_with_default(keyerr, NULL, (void *)&dest, sizeof(int)),
|
||||
"with_default with a NULL default value");
|
||||
keyerr = NULL;
|
||||
keyerr = akgl_get_json_integer_value(fixture, "absent", &junk);
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER,
|
||||
akgl_get_json_with_default(keyerr, (void *)&defval, NULL, sizeof(int)),
|
||||
"with_default with a NULL destination");
|
||||
keyerr = NULL;
|
||||
} CLEANUP {
|
||||
if ( keyerr != NULL ) {
|
||||
keyerr->handled = true;
|
||||
keyerr = akerr_release_error(keyerr);
|
||||
}
|
||||
if ( typeerr != NULL ) {
|
||||
typeerr->handled = true;
|
||||
typeerr = akerr_release_error(typeerr);
|
||||
}
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
CATCH(errctx, akgl_heap_init());
|
||||
CATCH(errctx, akgl_registry_init());
|
||||
CATCH(errctx, load_fixture());
|
||||
|
||||
CATCH(errctx, test_json_accessor_null_arguments());
|
||||
CATCH(errctx, test_json_scalar_accessors());
|
||||
CATCH(errctx, test_json_double_value());
|
||||
CATCH(errctx, test_json_string_accessor());
|
||||
CATCH(errctx, test_json_array_index_accessors());
|
||||
CATCH(errctx, test_json_type_and_key_errors());
|
||||
CATCH(errctx, test_json_with_default());
|
||||
CATCH(errctx, test_json_string_accessor_reports_pool_exhaustion());
|
||||
} CLEANUP {
|
||||
if ( fixture != NULL ) {
|
||||
json_decref(fixture);
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
}
|
||||
928
tests/perf.c
Normal file
928
tests/perf.c
Normal file
@@ -0,0 +1,928 @@
|
||||
/**
|
||||
* @file perf.c
|
||||
* @brief Stress and timing benchmarks for every subsystem that does not need a renderer.
|
||||
*
|
||||
* These are not correctness tests. Each one drives a hot path -- the pool scans,
|
||||
* the registry, the physics sweep, the per-actor update, the geometry helpers,
|
||||
* the JSON accessors -- hard enough that its per-operation cost is measurable,
|
||||
* and reports what that cost is. The companion suite in `tests/perf_render.c`
|
||||
* covers the paths that need a renderer.
|
||||
*
|
||||
* The pools are the reason this matters. Every acquire is a linear scan of a
|
||||
* fixed array looking for a zero reference count, so cost grows with how full
|
||||
* the pool is, not with how much is being asked for. Several benchmarks below
|
||||
* are therefore run twice: once against an empty pool, which is the number a
|
||||
* casual reading of the code predicts, and once against a nearly full one,
|
||||
* which is the number a game in its fifth minute actually gets.
|
||||
*
|
||||
* Everything here runs headless under the dummy drivers, and every benchmark
|
||||
* rebuilds the state it needs rather than inheriting whatever the previous one
|
||||
* left behind.
|
||||
*
|
||||
* @note SDL's log output is redirected to a sink that discards it. The library
|
||||
* logs on paths this suite hammers -- akgl_actor_initialize logs every
|
||||
* spawn -- and timing a write to a terminal or a CTest capture file
|
||||
* measures the machine's I/O, not libakgl. What the *formatting* costs is
|
||||
* measured deliberately, by the pair of actor-spawn benchmarks.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <string.h>
|
||||
#include <jansson.h>
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/character.h>
|
||||
#include <akgl/sprite.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/staticstring.h>
|
||||
#include <akgl/util.h>
|
||||
#include <akgl/json_helpers.h>
|
||||
#include <akgl/tilemap.h>
|
||||
|
||||
#include "benchutil.h"
|
||||
#include "testutil.h"
|
||||
|
||||
/** @brief How many actors the frame-shaped benchmarks put on the heap. */
|
||||
#define BENCH_ACTOR_COUNT AKGL_MAX_HEAP_ACTOR
|
||||
/** @brief The JSON document the accessor benchmarks read. */
|
||||
#define BENCH_JSON_FIXTURE "assets/snippets/test_json_helpers.json"
|
||||
/** @brief A path that exists relative to the CTest working directory, for akgl_path_relative. */
|
||||
#define BENCH_PATH_FIXTURE "assets/testcharacter.json"
|
||||
|
||||
/**
|
||||
* @brief Print the library's fixed memory footprint.
|
||||
*
|
||||
* Not a benchmark, but it belongs in the same report: libakgl does not call
|
||||
* `malloc`, so every one of these arrays exists from process start whether the
|
||||
* game uses one slot or all of them, and several of the timings above are
|
||||
* explained entirely by their size. The string pool is a megabyte of `char`
|
||||
* arrays, and an akgl_Tilemap is dominated by 16 layers of 512x512 `int` cells
|
||||
* and 16 tilesets of 65536 offset pairs -- which is why zeroing one costs what
|
||||
* it costs, and why the header warns against putting one on the stack.
|
||||
*/
|
||||
static void bench_report_footprint(void)
|
||||
{
|
||||
size_t total = 0;
|
||||
|
||||
total = sizeof(akgl_heap_actors) + sizeof(akgl_heap_sprites) + sizeof(akgl_heap_spritesheets) +
|
||||
sizeof(akgl_heap_characters) + sizeof(akgl_heap_strings);
|
||||
|
||||
printf("\n");
|
||||
printf("static footprint, fixed at compile time:\n");
|
||||
printf(" %-24s %5d x %7zu = %10zu bytes\n",
|
||||
"akgl_heap_actors", AKGL_MAX_HEAP_ACTOR, sizeof(akgl_Actor), sizeof(akgl_heap_actors));
|
||||
printf(" %-24s %5d x %7zu = %10zu bytes\n",
|
||||
"akgl_heap_sprites", AKGL_MAX_HEAP_SPRITE, sizeof(akgl_Sprite), sizeof(akgl_heap_sprites));
|
||||
printf(" %-24s %5d x %7zu = %10zu bytes\n",
|
||||
"akgl_heap_spritesheets", AKGL_MAX_HEAP_SPRITESHEET, sizeof(akgl_SpriteSheet), sizeof(akgl_heap_spritesheets));
|
||||
printf(" %-24s %5d x %7zu = %10zu bytes\n",
|
||||
"akgl_heap_characters", AKGL_MAX_HEAP_CHARACTER, sizeof(akgl_Character), sizeof(akgl_heap_characters));
|
||||
printf(" %-24s %5d x %7zu = %10zu bytes\n",
|
||||
"akgl_heap_strings", AKGL_MAX_HEAP_STRING, sizeof(akgl_String), sizeof(akgl_heap_strings));
|
||||
printf(" %-24s %5s %7s %10zu bytes\n", "pools, total", "", "", total);
|
||||
printf(" %-24s %5d x %7s = %10zu bytes\n",
|
||||
"akgl_Tilemap", 1, "", sizeof(akgl_Tilemap));
|
||||
printf(" %-24s %5d x %7zu = %10zu bytes\n",
|
||||
" of which layers", AKGL_TILEMAP_MAX_LAYERS, sizeof(akgl_TilemapLayer),
|
||||
(sizeof(akgl_TilemapLayer) * AKGL_TILEMAP_MAX_LAYERS));
|
||||
printf(" %-24s %5d x %7zu = %10zu bytes\n",
|
||||
" of which tilesets", AKGL_TILEMAP_MAX_TILESETS, sizeof(akgl_Tileset),
|
||||
(sizeof(akgl_Tileset) * AKGL_TILEMAP_MAX_TILESETS));
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Swallow SDL's log output.
|
||||
*
|
||||
* Installed for the whole run. See the note in the file comment: the library
|
||||
* logs on paths this suite calls hundreds of thousands of times, and the cost of
|
||||
* writing that out is not a property of libakgl.
|
||||
*/
|
||||
static void bench_discard_log(void *userdata, int category, SDL_LogPriority priority, const char *message)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Claim a spritesheet slot without loading an image into it.
|
||||
*
|
||||
* akgl_spritesheet_initialize uploads a texture and so needs a renderer, which
|
||||
* this suite deliberately does not have. Nothing here draws, and the only thing
|
||||
* that reads the sheet is akgl_sprite_initialize storing the pointer, so an
|
||||
* empty claimed slot is enough.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_make_sheet(akgl_SpriteSheet **dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
PASS(errctx, akgl_heap_next_spritesheet(dest));
|
||||
memset(*dest, 0x00, sizeof(akgl_SpriteSheet));
|
||||
(*dest)->refcount += 1;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Build a sprite on a freshly claimed sheet and publish it in the sprite registry.
|
||||
*
|
||||
* @param dest Receives the sprite. Required.
|
||||
* @param name Registry key. Copied at a fixed #AKGL_SPRITE_MAX_NAME_LENGTH
|
||||
* bytes by akgl_sprite_initialize, so it is staged through a buffer
|
||||
* of exactly that size first.
|
||||
* @param speed Nanoseconds one frame is held. 0 makes every akgl_actor_update
|
||||
* advance the animation, which is the expensive path.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_make_sprite(akgl_Sprite **dest, char *name, uint32_t speed)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_SpriteSheet *sheet = NULL;
|
||||
char namebuf[AKGL_SPRITE_MAX_NAME_LENGTH];
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name");
|
||||
|
||||
memset(&namebuf, 0x00, sizeof(namebuf));
|
||||
strncpy((char *)&namebuf, name, AKGL_SPRITE_MAX_NAME_LENGTH - 1);
|
||||
|
||||
PASS(errctx, bench_make_sheet(&sheet));
|
||||
PASS(errctx, akgl_heap_next_sprite(dest));
|
||||
PASS(errctx, akgl_sprite_initialize(*dest, (char *)&namebuf, sheet));
|
||||
(*dest)->frames = 3;
|
||||
(*dest)->frameids[0] = 0;
|
||||
(*dest)->frameids[1] = 1;
|
||||
(*dest)->frameids[2] = 2;
|
||||
(*dest)->width = 48;
|
||||
(*dest)->height = 48;
|
||||
(*dest)->speed = speed;
|
||||
(*dest)->loop = true;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Build a character with one sprite mapped to the two states these benchmarks use.
|
||||
*
|
||||
* State 0 is what akgl_actor_initialize leaves an actor in and what
|
||||
* akgl_actor_automatic_face leaves it in while it is standing still;
|
||||
* `MOVING_RIGHT | FACE_RIGHT` is what bench_fill_actor_pool puts it in and what
|
||||
* the face logic settles on from there. Mapping both keeps the update path off
|
||||
* the missing-sprite branch, which is measured deliberately elsewhere rather
|
||||
* than by accident here.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_make_character(akgl_Character **dest, char *name, uint32_t spritespeed)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_Sprite *sprite = NULL;
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name");
|
||||
|
||||
PASS(errctx, akgl_heap_next_character(dest));
|
||||
PASS(errctx, akgl_character_initialize(*dest, name));
|
||||
(*dest)->sx = 120.0;
|
||||
(*dest)->sy = 120.0;
|
||||
(*dest)->sz = 0.0;
|
||||
(*dest)->ax = 40.0;
|
||||
(*dest)->ay = 40.0;
|
||||
(*dest)->speedtime = 16;
|
||||
|
||||
PASS(errctx, bench_make_sprite(&sprite, name, spritespeed));
|
||||
PASS(errctx, (*dest)->sprite_add(*dest, sprite, 0));
|
||||
PASS(errctx,
|
||||
(*dest)->sprite_add(
|
||||
*dest,
|
||||
sprite,
|
||||
(AKGL_ACTOR_STATE_MOVING_RIGHT | AKGL_ACTOR_STATE_FACE_RIGHT))
|
||||
);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Populate the actor pool with @p count live actors bound to @p basechar.
|
||||
*
|
||||
* The heap and the actor registry are rebuilt first, so this is the state every
|
||||
* frame-shaped benchmark starts from rather than something it inherits.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_fill_actor_pool(akgl_Character *basechar, int count)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_Actor *actor = NULL;
|
||||
char name[AKGL_ACTOR_MAX_NAME_LENGTH];
|
||||
int i = 0;
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, basechar, AKERR_NULLPOINTER, "basechar");
|
||||
|
||||
PASS(errctx, akgl_heap_init_actor());
|
||||
PASS(errctx, akgl_registry_init_actor());
|
||||
|
||||
for ( i = 0; i < count; i++ ) {
|
||||
memset(&name, 0x00, sizeof(name));
|
||||
snprintf((char *)&name, AKGL_ACTOR_MAX_NAME_LENGTH, "benchactor%d", i);
|
||||
PASS(errctx, akgl_heap_next_actor(&actor));
|
||||
PASS(errctx, akgl_actor_initialize(actor, (char *)&name));
|
||||
PASS(errctx, akgl_actor_set_character(actor, basechar->name));
|
||||
actor->visible = true;
|
||||
actor->layer = 0;
|
||||
actor->x = (float32_t)(i * 13);
|
||||
actor->y = (float32_t)(i * 7);
|
||||
actor->sx = 120.0;
|
||||
actor->sy = 120.0;
|
||||
actor->ax = 40.0;
|
||||
actor->ay = 40.0;
|
||||
AKGL_BITMASK_ADD(actor->state, AKGL_ACTOR_STATE_MOVING_RIGHT);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time a claim from an empty actor pool against one from a nearly full pool.
|
||||
*
|
||||
* The pool scan stops at the first slot with a zero reference count, so an empty
|
||||
* pool answers from index 0 and a pool with one slot left answers from index
|
||||
* #AKGL_MAX_HEAP_ACTOR - 1. The ratio between these two numbers *is* the cost of
|
||||
* the linear-scan allocator.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_heap_actor_claim(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akerr_ErrorContext *inner = NULL;
|
||||
akgl_Actor *actor = NULL;
|
||||
int count = bench_iterations(500000);
|
||||
int i = 0;
|
||||
int rep = 0;
|
||||
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
PASS(errctx, akgl_heap_init_actor());
|
||||
bench_start("heap_next_actor, empty pool", "call", 100.0);
|
||||
BENCH_LOOP(inner, i, count, akgl_heap_next_actor(&actor));
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
|
||||
for ( i = 0; i < (AKGL_MAX_HEAP_ACTOR - 1); i++ ) {
|
||||
akgl_heap_actors[i].refcount = 1;
|
||||
}
|
||||
bench_start("heap_next_actor, one slot left", "call", 400.0);
|
||||
BENCH_LOOP(inner, i, count, akgl_heap_next_actor(&actor));
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
PASS(errctx, akgl_heap_init_actor());
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time the string pool, which is the one that hurts.
|
||||
*
|
||||
* Every entry is #AKGL_MAX_STRING_LENGTH bytes, so 256 of them is a megabyte and
|
||||
* the scan for a free slot touches one reference count every 4 KiB -- a cache
|
||||
* miss per candidate. Release then wipes the whole 4 KiB whether the string held
|
||||
* a path or a single character. Three numbers come out of this: the claim on an
|
||||
* empty pool, the claim with one slot left, and the release on its own.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_heap_string(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akerr_ErrorContext *inner = NULL;
|
||||
akgl_String *str = NULL;
|
||||
int count = bench_iterations(100000);
|
||||
int i = 0;
|
||||
int rep = 0;
|
||||
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
PASS(errctx, akgl_heap_init());
|
||||
|
||||
// Claim and release together: the scratch-buffer idiom the library uses
|
||||
// everywhere, and the only string benchmark whose cost a caller can
|
||||
// actually observe from outside.
|
||||
bench_start("heap string claim + release cycle", "cycle", 500.0);
|
||||
for ( i = 0; i < count; i++ ) {
|
||||
inner = akgl_heap_next_string(&str);
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
inner = akgl_heap_release_string(str);
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
|
||||
// The claim on its own. The reference the claim takes is dropped by hand
|
||||
// rather than through release, so the 4 KiB wipe stays out of this one.
|
||||
bench_start("heap_next_string, empty pool", "call", 100.0);
|
||||
for ( i = 0; i < count; i++ ) {
|
||||
inner = akgl_heap_next_string(&str);
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
str->refcount = 0;
|
||||
}
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
|
||||
// The same claim with 255 of the 256 slots taken: a megabyte walked, one
|
||||
// reference count read per 4 KiB page.
|
||||
for ( i = 0; i < (AKGL_MAX_HEAP_STRING - 1); i++ ) {
|
||||
akgl_heap_strings[i].refcount = 1;
|
||||
}
|
||||
bench_start("heap_next_string, one slot left", "call", 2500.0);
|
||||
for ( i = 0; i < count; i++ ) {
|
||||
inner = akgl_heap_next_string(&str);
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
str->refcount = 0;
|
||||
}
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
PASS(errctx, akgl_heap_init());
|
||||
|
||||
// Release on its own: one 4 KiB memset per call, whatever the string held.
|
||||
PASS(errctx, akgl_heap_next_string(&str));
|
||||
bench_start("heap_release_string, 4 KiB wipe", "call", 500.0);
|
||||
for ( i = 0; i < count; i++ ) {
|
||||
str->refcount = 1;
|
||||
inner = akgl_heap_release_string(str);
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time a full reset of every pool.
|
||||
*
|
||||
* akgl_heap_init zeroes all five arrays -- better than a megabyte, dominated by
|
||||
* the string pool. A game pays this once at startup and again on every call to
|
||||
* akgl_game_init; a level transition that calls it per level pays it per level.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_heap_init(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akerr_ErrorContext *inner = NULL;
|
||||
int count = bench_iterations(2000);
|
||||
int i = 0;
|
||||
int rep = 0;
|
||||
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
bench_start("heap_init, all five pools", "call", 450000.0);
|
||||
BENCH_LOOP(inner, i, count, akgl_heap_init());
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time an actor spawn, with and without the log line the library writes.
|
||||
*
|
||||
* A spawn is a pool claim, a `memset` of the whole actor, a registry insert, and
|
||||
* an `SDL_Log`. The second run raises the log priority so SDL returns before it
|
||||
* formats anything; the gap between the two numbers is what the log line costs
|
||||
* every caller, including the ones that never read it.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_actor_spawn(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akerr_ErrorContext *inner = NULL;
|
||||
akgl_Actor *actor = NULL;
|
||||
char name[AKGL_ACTOR_MAX_NAME_LENGTH];
|
||||
int count = bench_iterations(20000);
|
||||
int i = 0;
|
||||
int rep = 0;
|
||||
|
||||
memset(&name, 0x00, sizeof(name));
|
||||
strncpy((char *)&name, "benchspawn", AKGL_ACTOR_MAX_NAME_LENGTH - 1);
|
||||
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
PASS(errctx, akgl_heap_init_actor());
|
||||
PASS(errctx, akgl_registry_init_actor());
|
||||
|
||||
bench_start("actor spawn + release, library logging on", "actor", 2100.0);
|
||||
for ( i = 0; i < count; i++ ) {
|
||||
inner = akgl_heap_next_actor(&actor);
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
inner = akgl_actor_initialize(actor, (char *)&name);
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
inner = akgl_heap_release_actor(actor);
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
|
||||
SDL_SetLogPriorities(SDL_LOG_PRIORITY_CRITICAL);
|
||||
bench_start("actor spawn + release, logging suppressed", "actor", 1700.0);
|
||||
for ( i = 0; i < count; i++ ) {
|
||||
inner = akgl_heap_next_actor(&actor);
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
inner = akgl_actor_initialize(actor, (char *)&name);
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
inner = akgl_heap_release_actor(actor);
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
bench_stop(count);
|
||||
SDL_SetLogPriorities(SDL_LOG_PRIORITY_INFO);
|
||||
PASS(errctx, inner);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time a registry lookup with the actor registry full.
|
||||
*
|
||||
* akgl_actor_set_character is the library's own name-to-pointer lookup: an SDL
|
||||
* property fetch against a registry holding #AKGL_MAX_HEAP_ACTOR entries, plus
|
||||
* the four field copies that follow it. Every actor built from a tilemap object
|
||||
* layer pays it once.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_registry_lookup(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akerr_ErrorContext *inner = NULL;
|
||||
akgl_Character *basechar = NULL;
|
||||
akgl_Actor *actor = NULL;
|
||||
int count = bench_iterations(200000);
|
||||
int i = 0;
|
||||
int rep = 0;
|
||||
|
||||
PASS(errctx, akgl_heap_init());
|
||||
PASS(errctx, akgl_registry_init());
|
||||
PASS(errctx, bench_make_character(&basechar, "benchlookupchar", 0));
|
||||
PASS(errctx, bench_fill_actor_pool(basechar, BENCH_ACTOR_COUNT));
|
||||
actor = &akgl_heap_actors[0];
|
||||
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
bench_start("actor_set_character, registry lookup", "call", 400.0);
|
||||
BENCH_LOOP(inner, i, count, akgl_actor_set_character(actor, basechar->name));
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time the property store a game reads its configuration out of.
|
||||
*
|
||||
* akgl_get_property copies a fixed #AKGL_MAX_STRING_LENGTH bytes out of the
|
||||
* property store regardless of how long the value is, so reading `"0.0"` moves
|
||||
* 4 KiB. akgl_physics_init_arcade makes six of these calls.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_properties(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akerr_ErrorContext *inner = NULL;
|
||||
akgl_String *value = NULL;
|
||||
int count = bench_iterations(200000);
|
||||
int i = 0;
|
||||
int rep = 0;
|
||||
|
||||
PASS(errctx, akgl_registry_init_properties());
|
||||
PASS(errctx, akgl_set_property("bench.property", "a short value"));
|
||||
PASS(errctx, akgl_heap_next_string(&value));
|
||||
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
bench_start("set_property", "call", 1000.0);
|
||||
BENCH_LOOP(inner, i, count, akgl_set_property("bench.property", "a short value"));
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
|
||||
bench_start("get_property, 4 KiB copy", "call", 900.0);
|
||||
BENCH_LOOP(inner, i, count, akgl_get_property("bench.property", &value, "unset"));
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
}
|
||||
PASS(errctx, akgl_heap_release_string(value));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time the state-to-sprite lookup that runs twice per actor per frame.
|
||||
*
|
||||
* akgl_character_sprite_get renders the state bitmask to decimal with `SDL_itoa`
|
||||
* and looks the result up as a property string. akgl_actor_update calls it, and
|
||||
* so does akgl_actor_render, so a 64-actor frame makes 128 of these calls before
|
||||
* anything is drawn.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_character_sprite_get(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akerr_ErrorContext *inner = NULL;
|
||||
akgl_Character *basechar = NULL;
|
||||
akgl_Sprite *sprite = NULL;
|
||||
int count = bench_iterations(200000);
|
||||
int i = 0;
|
||||
int rep = 0;
|
||||
|
||||
PASS(errctx, akgl_heap_init());
|
||||
PASS(errctx, akgl_registry_init());
|
||||
PASS(errctx, bench_make_character(&basechar, "benchspritechar", 0));
|
||||
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
bench_start("character_sprite_get, state to sprite", "call", 400.0);
|
||||
BENCH_LOOP(inner, i, count, basechar->sprite_get(basechar, 0, &sprite));
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time one actor's logic update, with the animation advancing every call.
|
||||
*
|
||||
* The sprite's frame time is 0, so every update takes the branch that changes
|
||||
* frame -- the expensive path, and the one a fast animation actually takes. An
|
||||
* update is a face recalculation, a sprite lookup, a clock read, and the frame
|
||||
* arithmetic.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_actor_update(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akerr_ErrorContext *inner = NULL;
|
||||
akgl_Character *basechar = NULL;
|
||||
akgl_Actor *actor = NULL;
|
||||
int count = bench_iterations(200000);
|
||||
int i = 0;
|
||||
int rep = 0;
|
||||
|
||||
PASS(errctx, akgl_heap_init());
|
||||
PASS(errctx, akgl_registry_init());
|
||||
PASS(errctx, bench_make_character(&basechar, "benchupdatechar", 0));
|
||||
PASS(errctx, bench_fill_actor_pool(basechar, 1));
|
||||
actor = &akgl_heap_actors[0];
|
||||
AKGL_BITMASK_CLEAR(actor->state);
|
||||
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
bench_start("actor_update, animation advancing", "actor", 700.0);
|
||||
BENCH_LOOP(inner, i, count, actor->updatefunc(actor));
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
|
||||
// An actor in a state its character has no sprite for is not an error
|
||||
// the library refuses -- akgl_actor_update handles AKERR_KEY and carries
|
||||
// on, and akgl_actor_render logs it and draws nothing. It is a normal
|
||||
// condition on a partly authored character, and it is worth knowing what
|
||||
// one costs, because raising, formatting, and handling a context is far
|
||||
// more work than the update it replaces.
|
||||
AKGL_BITMASK_ADD(actor->state, AKGL_ACTOR_STATE_MOVING_UP);
|
||||
bench_start("actor_update, no sprite for state", "actor", 6200.0);
|
||||
BENCH_LOOP(inner, i, count, actor->updatefunc(actor));
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
AKGL_BITMASK_CLEAR(actor->state);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time the physics sweep over a full actor pool, and over an empty one.
|
||||
*
|
||||
* akgl_physics_simulate walks all #AKGL_MAX_HEAP_ACTOR slots whether or not
|
||||
* anything is in them, so the empty-pool number is the floor every frame pays.
|
||||
* The full-pool number is one frame of movement for 64 actors: movement logic,
|
||||
* gravity, drag, clamp, and integrate, per actor.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_physics_simulate(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akerr_ErrorContext *inner = NULL;
|
||||
akgl_Character *basechar = NULL;
|
||||
akgl_PhysicsBackend backend;
|
||||
int count = bench_iterations(20000);
|
||||
int i = 0;
|
||||
int rep = 0;
|
||||
|
||||
PASS(errctx, akgl_heap_init());
|
||||
PASS(errctx, akgl_registry_init());
|
||||
PASS(errctx, akgl_registry_init_properties());
|
||||
PASS(errctx, bench_make_character(&basechar, "benchphysicschar", 0));
|
||||
|
||||
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
||||
PASS(errctx, akgl_physics_init_arcade(&backend));
|
||||
backend.gravity_y = 9.8;
|
||||
backend.drag_x = 0.1;
|
||||
backend.drag_y = 0.1;
|
||||
|
||||
PASS(errctx, bench_fill_actor_pool(basechar, BENCH_ACTOR_COUNT));
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
bench_start("physics_simulate, 64 live actors", "frame", 12500.0);
|
||||
BENCH_LOOP(inner, i, count, backend.simulate(&backend, NULL));
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
}
|
||||
|
||||
PASS(errctx, akgl_heap_init_actor());
|
||||
PASS(errctx, akgl_registry_init_actor());
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
bench_start("physics_simulate, empty pool", "frame", 650.0);
|
||||
BENCH_LOOP(inner, i, count, backend.simulate(&backend, NULL));
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time the logic half of a frame: update every actor, then simulate.
|
||||
*
|
||||
* This is what a host's main loop does before it draws anything, and it is the
|
||||
* number to subtract from a 16.6 ms frame to find out what is left for drawing.
|
||||
* Note that it is *not* what akgl_game_update does: that one runs the update
|
||||
* loop once per tilemap layer, which is measured in the render suite.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_logic_frame(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akerr_ErrorContext *inner = NULL;
|
||||
akgl_Character *basechar = NULL;
|
||||
akgl_Actor *actor = NULL;
|
||||
akgl_PhysicsBackend backend;
|
||||
int count = bench_iterations(5000);
|
||||
int i = 0;
|
||||
int j = 0;
|
||||
int rep = 0;
|
||||
|
||||
PASS(errctx, akgl_heap_init());
|
||||
PASS(errctx, akgl_registry_init());
|
||||
PASS(errctx, akgl_registry_init_properties());
|
||||
PASS(errctx, bench_make_character(&basechar, "benchframechar", 0));
|
||||
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
||||
PASS(errctx, akgl_physics_init_arcade(&backend));
|
||||
PASS(errctx, bench_fill_actor_pool(basechar, BENCH_ACTOR_COUNT));
|
||||
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
bench_start("logic frame, 64 actors updated + simulated", "frame", 60000.0);
|
||||
for ( i = 0; i < count; i++ ) {
|
||||
for ( j = 0; j < AKGL_MAX_HEAP_ACTOR; j++ ) {
|
||||
actor = &akgl_heap_actors[j];
|
||||
if ( actor->refcount == 0 ) {
|
||||
continue;
|
||||
}
|
||||
inner = actor->updatefunc(actor);
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
inner = backend.simulate(&backend, NULL);
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time the geometry helpers, including the all-pairs sweep a caller has to write.
|
||||
*
|
||||
* akgl_collide_rectangles tests eight corners and returns at the first hit, so
|
||||
* an overlap is cheap and a miss is the full eight. The third benchmark is the
|
||||
* broad phase the library does not provide: 64 actors is 2016 pairs, and a
|
||||
* caller doing collision at all does that every frame.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_geometry(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akerr_ErrorContext *inner = NULL;
|
||||
akgl_RectanglePoints points;
|
||||
SDL_FRect rects[BENCH_ACTOR_COUNT];
|
||||
SDL_FRect overlapping = { .x = 8.0, .y = 8.0, .w = 32.0, .h = 32.0 };
|
||||
SDL_FRect disjoint = { .x = 900.0, .y = 900.0, .w = 32.0, .h = 32.0 };
|
||||
SDL_FRect subject = { .x = 0.0, .y = 0.0, .w = 32.0, .h = 32.0 };
|
||||
bool collide = false;
|
||||
int count = bench_iterations(500000);
|
||||
int sweeps = bench_iterations(2000);
|
||||
int pairs = 0;
|
||||
int i = 0;
|
||||
int j = 0;
|
||||
int k = 0;
|
||||
int rep = 0;
|
||||
|
||||
for ( i = 0; i < BENCH_ACTOR_COUNT; i++ ) {
|
||||
rects[i].x = (float32_t)(i * 24);
|
||||
rects[i].y = (float32_t)(i * 18);
|
||||
rects[i].w = 32.0;
|
||||
rects[i].h = 32.0;
|
||||
}
|
||||
pairs = (BENCH_ACTOR_COUNT * (BENCH_ACTOR_COUNT - 1)) / 2;
|
||||
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
bench_start("rectangle_points", "call", 100.0);
|
||||
BENCH_LOOP(inner, i, count, akgl_rectangle_points(&points, &subject));
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
|
||||
bench_start("collide_rectangles, overlapping", "call", 300.0);
|
||||
BENCH_LOOP(inner, i, count, akgl_collide_rectangles(&subject, &overlapping, &collide));
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
|
||||
bench_start("collide_rectangles, disjoint", "call", 600.0);
|
||||
BENCH_LOOP(inner, i, count, akgl_collide_rectangles(&subject, &disjoint, &collide));
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
|
||||
bench_start("all-pairs collision sweep, 64 actors", "sweep", 1200000.0);
|
||||
for ( k = 0; k < sweeps; k++ ) {
|
||||
for ( i = 0; i < BENCH_ACTOR_COUNT; i++ ) {
|
||||
for ( j = i + 1; j < BENCH_ACTOR_COUNT; j++ ) {
|
||||
inner = akgl_collide_rectangles(&rects[i], &rects[j], &collide);
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
bench_stop(sweeps);
|
||||
PASS(errctx, inner);
|
||||
}
|
||||
printf("all-pairs collision sweep covers %d pairs per sweep\n", pairs);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time the pooled-string operations.
|
||||
*
|
||||
* Both of these move #AKGL_MAX_STRING_LENGTH bytes by default -- a copy with a
|
||||
* count of 0 means "all of it" -- so the cost is the same for a filename and for
|
||||
* a single character.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_strings(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akerr_ErrorContext *inner = NULL;
|
||||
akgl_String *src = NULL;
|
||||
akgl_String *dst = NULL;
|
||||
int count = bench_iterations(200000);
|
||||
int i = 0;
|
||||
int rep = 0;
|
||||
|
||||
PASS(errctx, akgl_heap_init());
|
||||
PASS(errctx, akgl_heap_next_string(&src));
|
||||
PASS(errctx, akgl_heap_next_string(&dst));
|
||||
PASS(errctx, akgl_string_initialize(src, "assets/testcharacter.json"));
|
||||
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
bench_start("string_initialize", "call", 350.0);
|
||||
BENCH_LOOP(inner, i, count, akgl_string_initialize(dst, "assets/testcharacter.json"));
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
|
||||
bench_start("string_copy, full length", "call", 350.0);
|
||||
BENCH_LOOP(inner, i, count, akgl_string_copy(src, dst, 0));
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
}
|
||||
PASS(errctx, akgl_heap_release_string(src));
|
||||
PASS(errctx, akgl_heap_release_string(dst));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time parsing a document and reading values back out of it.
|
||||
*
|
||||
* The parse is what an asset load pays per file. The accessors are what the
|
||||
* tilemap and character loaders pay per field, and every one of them that
|
||||
* returns a string claims and fills a 4 KiB pooled string.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_json(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akerr_ErrorContext *inner = NULL;
|
||||
akgl_String *path = NULL;
|
||||
akgl_String *value = NULL;
|
||||
json_t *doc = NULL;
|
||||
json_error_t jsonerr;
|
||||
int number = 0;
|
||||
int loads = bench_iterations(2000);
|
||||
int count = bench_iterations(200000);
|
||||
int i = 0;
|
||||
int rep = 0;
|
||||
|
||||
PASS(errctx, akgl_heap_init());
|
||||
PASS(errctx, akgl_heap_next_string(&path));
|
||||
PASS(errctx, akgl_heap_next_string(&value));
|
||||
snprintf((char *)&path->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), BENCH_JSON_FIXTURE);
|
||||
|
||||
doc = json_load_file((char *)&path->data, 0, &jsonerr);
|
||||
FAIL_ZERO_RETURN(errctx, doc, AKERR_IO, "Unable to load %s: %s", (char *)&path->data, jsonerr.text);
|
||||
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
bench_start("json_load_file, small document", "load", 120000.0);
|
||||
for ( i = 0; i < loads; i++ ) {
|
||||
json_t *scratch = json_load_file((char *)&path->data, 0, &jsonerr);
|
||||
if ( scratch == NULL ) {
|
||||
break;
|
||||
}
|
||||
json_decref(scratch);
|
||||
}
|
||||
bench_stop(loads);
|
||||
|
||||
bench_start("get_json_string_value", "call", 450.0);
|
||||
BENCH_LOOP(inner, i, count, akgl_get_json_string_value(doc, "name", &value));
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
|
||||
bench_start("get_json_integer_value", "call", 150.0);
|
||||
BENCH_LOOP(inner, i, count, akgl_get_json_integer_value(doc, "count", &number));
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
}
|
||||
json_decref(doc);
|
||||
PASS(errctx, akgl_heap_release_string(path));
|
||||
PASS(errctx, akgl_heap_release_string(value));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time path resolution, which every asset reference goes through.
|
||||
*
|
||||
* akgl_path_relative calls `realpath(3)`, so this is a syscall and a walk of the
|
||||
* filesystem, per reference, at load time. A tilemap naming twenty tilesets and
|
||||
* sprites pays it twenty times.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_path_relative(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akerr_ErrorContext *inner = NULL;
|
||||
akgl_String *dst = NULL;
|
||||
int count = bench_iterations(20000);
|
||||
int i = 0;
|
||||
int rep = 0;
|
||||
|
||||
PASS(errctx, akgl_heap_init());
|
||||
PASS(errctx, akgl_heap_next_string(&dst));
|
||||
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
bench_start("path_relative, realpath on an existing file", "call", 36000.0);
|
||||
BENCH_LOOP(inner, i, count, akgl_path_relative(".", BENCH_PATH_FIXTURE, dst));
|
||||
bench_stop(count);
|
||||
PASS(errctx, inner);
|
||||
}
|
||||
PASS(errctx, akgl_heap_release_string(dst));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
SDL_SetLogOutputFunction(bench_discard_log, NULL);
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
CATCH(errctx, akgl_heap_init());
|
||||
CATCH(errctx, akgl_registry_init());
|
||||
CATCH(errctx, akgl_registry_init_properties());
|
||||
|
||||
CATCH(errctx, bench_heap_actor_claim());
|
||||
CATCH(errctx, bench_heap_string());
|
||||
CATCH(errctx, bench_heap_init());
|
||||
CATCH(errctx, bench_actor_spawn());
|
||||
CATCH(errctx, bench_registry_lookup());
|
||||
CATCH(errctx, bench_properties());
|
||||
CATCH(errctx, bench_character_sprite_get());
|
||||
CATCH(errctx, bench_actor_update());
|
||||
CATCH(errctx, bench_physics_simulate());
|
||||
CATCH(errctx, bench_logic_frame());
|
||||
CATCH(errctx, bench_geometry());
|
||||
CATCH(errctx, bench_strings());
|
||||
CATCH(errctx, bench_json());
|
||||
CATCH(errctx, bench_path_relative());
|
||||
|
||||
bench_report_footprint();
|
||||
BENCH_REPORT_BREAK(errctx);
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
}
|
||||
838
tests/perf_render.c
Normal file
838
tests/perf_render.c
Normal file
@@ -0,0 +1,838 @@
|
||||
/**
|
||||
* @file perf_render.c
|
||||
* @brief Stress and timing benchmarks for the paths that need a renderer.
|
||||
*
|
||||
* The companion to `tests/perf.c`, which covers everything that does not draw.
|
||||
* Everything here runs against a software renderer under the dummy video driver
|
||||
* at #BENCH_SCREEN_W x #BENCH_SCREEN_H -- no display, no window shown, no GPU.
|
||||
*
|
||||
* A software renderer is the right instrument for this even though no shipped
|
||||
* game uses one. What these benchmarks are measuring is the work libakgl does
|
||||
* *per drawn thing* -- how many blits a screenful of tiles turns into, how many
|
||||
* lookups an actor costs before its texture moves, what a line of HUD text
|
||||
* allocates and throws away every frame. A software renderer keeps that work in
|
||||
* the same process where it can be timed, and its per-blit cost is at least
|
||||
* honest about how much pixel traffic was asked for. Read these numbers as
|
||||
* *counts of work*, not as frame rates a real game would see.
|
||||
*
|
||||
* The tilemap benchmarks build their map by hand rather than loading the 2x2
|
||||
* fixture. A 2x2 map measures nothing: the point of the exercise is a full
|
||||
* screen of tiles -- #BENCH_MAP_W x #BENCH_MAP_H cells at #BENCH_TILE_SIZE
|
||||
* pixels, of which the camera covers 40 x 30 -- and the second variant repeats
|
||||
* it with eight tilesets, because akgl_tilemap_draw scans every tileset for
|
||||
* every tile and never stops at the one that matched.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_image/SDL_image.h>
|
||||
#include <SDL3_ttf/SDL_ttf.h>
|
||||
#include <string.h>
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/character.h>
|
||||
#include <akgl/sprite.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/renderer.h>
|
||||
#include <akgl/draw.h>
|
||||
#include <akgl/text.h>
|
||||
#include <akgl/tilemap.h>
|
||||
#include <akgl/staticstring.h>
|
||||
|
||||
#include "benchutil.h"
|
||||
#include "testutil.h"
|
||||
|
||||
/** @brief Width of the offscreen render target every benchmark draws into. */
|
||||
#define BENCH_SCREEN_W 640
|
||||
/** @brief Height of that target. */
|
||||
#define BENCH_SCREEN_H 480
|
||||
/** @brief Edge of one cell in the synthetic map, in pixels. */
|
||||
#define BENCH_TILE_SIZE 16
|
||||
/** @brief Width of the synthetic map, in tiles. Bigger than the camera on purpose. */
|
||||
#define BENCH_MAP_W 128
|
||||
/** @brief Height of the synthetic map, in tiles. */
|
||||
#define BENCH_MAP_H 128
|
||||
/** @brief Tilesets in the many-tileset variant of the draw benchmark. */
|
||||
#define BENCH_MAP_TILESETS 8
|
||||
/** @brief Actors the scene-wide benchmarks put on the heap. */
|
||||
#define BENCH_ACTOR_COUNT AKGL_MAX_HEAP_ACTOR
|
||||
/** @brief The tileset image the synthetic map cuts its tiles from: 768x576, 48x36 tiles. */
|
||||
#define BENCH_TILESET_IMAGE "assets/World_A1.png"
|
||||
/** @brief The font the text benchmarks rasterize with. */
|
||||
#define BENCH_FONT_PATH "assets/akgl_test_mono.ttf"
|
||||
/** @brief Point size for that font. */
|
||||
#define BENCH_FONT_SIZE 16
|
||||
/** @brief A representative line of HUD text: a score readout. */
|
||||
#define BENCH_TEXT "SCORE 000123456"
|
||||
|
||||
/** @brief The font opened once in main and used by the text benchmarks. */
|
||||
static TTF_Font *benchfont = NULL;
|
||||
/** @brief The tileset texture the synthetic map borrows. Destroyed in main. */
|
||||
static SDL_Texture *benchtiles = NULL;
|
||||
|
||||
/**
|
||||
* @brief Stop the clock, but only after the renderer has done what it was asked.
|
||||
*
|
||||
* SDL batches. A `draw_*` call queues a command and returns, and the queue is
|
||||
* not executed until something forces it: a present, a readback, or the
|
||||
* destruction of a texture it refers to. Without a flush inside the timed
|
||||
* region, every drawing benchmark here measures the cost of *queueing* work and
|
||||
* none of the cost of doing it -- and the bill arrives at teardown, where
|
||||
* SDL_DestroyTexture on the tileset spent nearly two minutes executing blits
|
||||
* that six earlier benchmarks had queued and been given credit for not doing.
|
||||
*
|
||||
* So the flush goes inside the measurement: what these benchmarks report is N
|
||||
* calls plus the drawing those N calls asked for, which is the number a caller
|
||||
* actually pays over a frame.
|
||||
*/
|
||||
#define BENCH_FLUSH_STOP(e, ops) \
|
||||
{ \
|
||||
bool __bench_flushed = SDL_FlushRenderer(akgl_renderer->sdl_renderer); \
|
||||
bench_stop(ops); \
|
||||
FAIL_ZERO_RETURN(e, __bench_flushed, AKGL_ERR_SDL, "SDL_FlushRenderer: %s", SDL_GetError()); \
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Swallow SDL's log output. See the same function in tests/perf.c.
|
||||
*/
|
||||
static void bench_discard_log(void *userdata, int category, SDL_LogPriority priority, const char *message)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Build a map by hand: one tile layer, @p tilesets tilesets, every cell filled.
|
||||
*
|
||||
* The cells all draw from the *last* tileset, which is the worst case for
|
||||
* akgl_tilemap_draw's inner scan and the reason the many-tileset variant exists.
|
||||
* Global ids are laid out so that tileset k owns [1 + k*tilecount, ...], which
|
||||
* keeps each cell matching exactly one tileset -- the draw loop has no `break`,
|
||||
* so overlapping ranges would blit the same cell more than once and measure
|
||||
* something that cannot happen with a map Tiled produced.
|
||||
*
|
||||
* @param map The tilemap to fill in. Required. Zeroed first.
|
||||
* @param tilesets How many tilesets to declare, all sharing one texture.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_build_map(akgl_Tilemap *map, int tilesets)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
int columns = 0;
|
||||
int rows = 0;
|
||||
int tilecount = 0;
|
||||
int lastgid = 0;
|
||||
int i = 0;
|
||||
int cell = 0;
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, map, AKERR_NULLPOINTER, "map");
|
||||
FAIL_ZERO_RETURN(errctx, benchtiles, AKERR_NULLPOINTER, "benchtiles");
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
((tilesets < 1) || (tilesets > AKGL_TILEMAP_MAX_TILESETS)),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"%d tilesets is outside 1..%d",
|
||||
tilesets,
|
||||
AKGL_TILEMAP_MAX_TILESETS);
|
||||
|
||||
memset(map, 0x00, sizeof(akgl_Tilemap));
|
||||
columns = benchtiles->w / BENCH_TILE_SIZE;
|
||||
rows = benchtiles->h / BENCH_TILE_SIZE;
|
||||
tilecount = columns * rows;
|
||||
|
||||
map->tilewidth = BENCH_TILE_SIZE;
|
||||
map->tileheight = BENCH_TILE_SIZE;
|
||||
map->width = BENCH_MAP_W;
|
||||
map->height = BENCH_MAP_H;
|
||||
map->numlayers = 1;
|
||||
map->numtilesets = tilesets;
|
||||
|
||||
for ( i = 0; i < tilesets; i++ ) {
|
||||
map->tilesets[i].firstgid = 1 + (i * tilecount);
|
||||
map->tilesets[i].tilecount = tilecount;
|
||||
map->tilesets[i].columns = columns;
|
||||
map->tilesets[i].tilewidth = BENCH_TILE_SIZE;
|
||||
map->tilesets[i].tileheight = BENCH_TILE_SIZE;
|
||||
map->tilesets[i].imagewidth = benchtiles->w;
|
||||
map->tilesets[i].imageheight = benchtiles->h;
|
||||
map->tilesets[i].texture = benchtiles;
|
||||
snprintf((char *)&map->tilesets[i].name, AKGL_TILEMAP_MAX_TILESET_NAME_SIZE, "benchtileset%d", i);
|
||||
PASS(errctx, akgl_tilemap_compute_tileset_offsets(map, i));
|
||||
}
|
||||
|
||||
lastgid = map->tilesets[tilesets - 1].firstgid;
|
||||
map->layers[0].type = AKGL_TILEMAP_LAYER_TYPE_TILES;
|
||||
map->layers[0].width = BENCH_MAP_W;
|
||||
map->layers[0].height = BENCH_MAP_H;
|
||||
map->layers[0].visible = true;
|
||||
map->layers[0].opacity = 1.0;
|
||||
for ( i = 0; i < (BENCH_MAP_W * BENCH_MAP_H); i++ ) {
|
||||
cell = i % tilecount;
|
||||
map->layers[0].data[i] = lastgid + cell;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Load the test sprite and character, then fill the actor pool with actors wearing it.
|
||||
*
|
||||
* These are the real assets, loaded through the real loaders, so the actors have
|
||||
* a texture-backed sprite and akgl_actor_render takes its full path rather than
|
||||
* bailing out at the missing-sprite branch. Facing is pinned by turning
|
||||
* `movement_controls_face` off: the automatic face logic clears every facing bit
|
||||
* for an actor that is not moving, and the fixture character maps its sprite to
|
||||
* `ALIVE | FACE_LEFT`.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_populate_scene(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_Actor *actor = NULL;
|
||||
char name[AKGL_ACTOR_MAX_NAME_LENGTH];
|
||||
int i = 0;
|
||||
|
||||
PASS(errctx, akgl_heap_init());
|
||||
PASS(errctx, akgl_registry_init());
|
||||
PASS(errctx, akgl_sprite_load_json("assets/testsprite.json"));
|
||||
PASS(errctx, akgl_sprite_load_json("assets/testsprite2.json"));
|
||||
PASS(errctx, akgl_character_load_json("assets/testcharacter.json"));
|
||||
|
||||
for ( i = 0; i < BENCH_ACTOR_COUNT; i++ ) {
|
||||
memset(&name, 0x00, sizeof(name));
|
||||
snprintf((char *)&name, AKGL_ACTOR_MAX_NAME_LENGTH, "benchactor%d", i);
|
||||
PASS(errctx, akgl_heap_next_actor(&actor));
|
||||
PASS(errctx, akgl_actor_initialize(actor, (char *)&name));
|
||||
PASS(errctx, akgl_actor_set_character(actor, "testcharacter"));
|
||||
actor->visible = true;
|
||||
actor->layer = 0;
|
||||
actor->movement_controls_face = false;
|
||||
actor->x = (float32_t)((i * 37) % BENCH_SCREEN_W);
|
||||
actor->y = (float32_t)((i * 23) % BENCH_SCREEN_H);
|
||||
actor->curSpriteFrameId = 0;
|
||||
AKGL_BITMASK_ADD(actor->state, (AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_LEFT));
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time the empty frame: clear the target and present it.
|
||||
*
|
||||
* Everything else a frame does is on top of this. It is also the only benchmark
|
||||
* here whose cost is entirely SDL's rather than libakgl's, which makes it the
|
||||
* baseline the rest are worth reading against.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_frame(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akerr_ErrorContext *inner = NULL;
|
||||
int count = bench_iterations(5000);
|
||||
int i = 0;
|
||||
int rep = 0;
|
||||
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
bench_start("frame_start + frame_end, 640x480", "frame", 230000.0);
|
||||
for ( i = 0; i < count; i++ ) {
|
||||
inner = akgl_renderer->frame_start(akgl_renderer);
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
inner = akgl_renderer->frame_end(akgl_renderer);
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
BENCH_FLUSH_STOP(errctx, count);
|
||||
PASS(errctx, inner);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time the immediate-mode primitives.
|
||||
*
|
||||
* Each of these saves the renderer's draw colour, sets its own, draws, and puts
|
||||
* the colour back, so the fixed cost per call is three SDL round trips whatever
|
||||
* the shape. The shapes are sized like something a game would actually draw: a
|
||||
* full-diagonal line, a dialogue-box-sized rectangle, a 64 pixel radius circle.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_primitives(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akerr_ErrorContext *inner = NULL;
|
||||
SDL_Color red = { 0xff, 0x00, 0x00, 0xff };
|
||||
SDL_FRect rect = { .x = 32.0, .y = 32.0, .w = 200.0, .h = 150.0 };
|
||||
int count = bench_iterations(100000);
|
||||
int filled = bench_iterations(2000);
|
||||
int circles = bench_iterations(20000);
|
||||
int i = 0;
|
||||
int rep = 0;
|
||||
|
||||
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
bench_start("draw_point", "call", 1400.0);
|
||||
BENCH_LOOP(inner, i, count, akgl_draw_point(akgl_renderer, 320.0, 240.0, red));
|
||||
BENCH_FLUSH_STOP(errctx, count);
|
||||
PASS(errctx, inner);
|
||||
|
||||
bench_start("draw_line, screen diagonal", "call", 6200.0);
|
||||
BENCH_LOOP(inner, i, count, akgl_draw_line(akgl_renderer, 0.0, 0.0, 639.0, 479.0, red));
|
||||
BENCH_FLUSH_STOP(errctx, count);
|
||||
PASS(errctx, inner);
|
||||
|
||||
bench_start("draw_rect, 200x150 outline", "call", 5000.0);
|
||||
BENCH_LOOP(inner, i, count, akgl_draw_rect(akgl_renderer, &rect, red));
|
||||
BENCH_FLUSH_STOP(errctx, count);
|
||||
PASS(errctx, inner);
|
||||
|
||||
bench_start("draw_filled_rect, 200x150", "call", 440000.0);
|
||||
BENCH_LOOP(inner, i, filled, akgl_draw_filled_rect(akgl_renderer, &rect, red));
|
||||
BENCH_FLUSH_STOP(errctx, filled);
|
||||
PASS(errctx, inner);
|
||||
|
||||
bench_start("draw_circle, radius 64", "call", 25000.0);
|
||||
BENCH_LOOP(inner, i, circles, akgl_draw_circle(akgl_renderer, 320.0, 240.0, 64.0, red));
|
||||
BENCH_FLUSH_STOP(errctx, circles);
|
||||
PASS(errctx, inner);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time the framebuffer round trips: save a region, put it back, flood fill.
|
||||
*
|
||||
* These are the expensive ones by construction. A copy reads pixels back off the
|
||||
* render target; a paste uploads a texture, draws it, and destroys it again; and
|
||||
* a flood fill reads back the *entire* target, converts the whole surface to
|
||||
* RGBA32, walks it, uploads a patch, and destroys everything. That is a
|
||||
* megabyte and a half of traffic for one fill at 640x480, whatever the size of
|
||||
* the region that actually changed.
|
||||
*
|
||||
* The fill alternates colours because a fill whose seed pixel already carries
|
||||
* the requested colour returns immediately, and would otherwise measure the
|
||||
* early exit rather than the fill.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_framebuffer(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akerr_ErrorContext *inner = NULL;
|
||||
SDL_Surface *saved = NULL;
|
||||
SDL_Rect region = { .x = 64, .y = 64, .w = 64, .h = 64 };
|
||||
SDL_Color red = { 0xff, 0x00, 0x00, 0xff };
|
||||
SDL_Color blue = { 0x00, 0x00, 0xff, 0xff };
|
||||
int count = bench_iterations(5000);
|
||||
int fills = bench_iterations(200);
|
||||
int i = 0;
|
||||
int rep = 0;
|
||||
|
||||
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
||||
PASS(errctx, akgl_draw_copy_region(akgl_renderer, ®ion, &saved));
|
||||
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
bench_start("draw_copy_region, 64x64 readback", "call", 10000.0);
|
||||
for ( i = 0; i < count; i++ ) {
|
||||
SDL_Surface *scratch = NULL;
|
||||
inner = akgl_draw_copy_region(akgl_renderer, ®ion, &scratch);
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
SDL_DestroySurface(scratch);
|
||||
}
|
||||
BENCH_FLUSH_STOP(errctx, count);
|
||||
PASS(errctx, inner);
|
||||
|
||||
bench_start("draw_paste_region, 64x64 upload", "call", 55000.0);
|
||||
BENCH_LOOP(inner, i, count, akgl_draw_paste_region(akgl_renderer, saved, 64.0, 64.0));
|
||||
BENCH_FLUSH_STOP(errctx, count);
|
||||
PASS(errctx, inner);
|
||||
|
||||
bench_start("draw_flood_fill, full 640x480 target", "call", 20000000.0);
|
||||
for ( i = 0; i < fills; i++ ) {
|
||||
inner = akgl_draw_flood_fill(akgl_renderer, 320, 240, ( (i % 2) == 0 ) ? red : blue);
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
BENCH_FLUSH_STOP(errctx, fills);
|
||||
PASS(errctx, inner);
|
||||
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
||||
}
|
||||
SDL_DestroySurface(saved);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time measuring and drawing one line of text.
|
||||
*
|
||||
* akgl_text_rendertextat rasterizes the string, uploads it as a texture, blits
|
||||
* it, and destroys the texture again -- every call, with no cache anywhere. A
|
||||
* HUD with six readouts pays this six times a frame for text that changes once a
|
||||
* second.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_text(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akerr_ErrorContext *inner = NULL;
|
||||
SDL_Color white = { 0xff, 0xff, 0xff, 0xff };
|
||||
int w = 0;
|
||||
int h = 0;
|
||||
int count = bench_iterations(20000);
|
||||
int lines = bench_iterations(5000);
|
||||
int i = 0;
|
||||
int rep = 0;
|
||||
|
||||
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
bench_start("text_measure, 15 characters", "call", 400.0);
|
||||
BENCH_LOOP(inner, i, count, akgl_text_measure(benchfont, BENCH_TEXT, &w, &h));
|
||||
BENCH_FLUSH_STOP(errctx, count);
|
||||
PASS(errctx, inner);
|
||||
|
||||
bench_start("text_rendertextat, 15 characters", "call", 130000.0);
|
||||
BENCH_LOOP(inner, i, lines, akgl_text_rendertextat(benchfont, BENCH_TEXT, white, 0, 8, 8));
|
||||
BENCH_FLUSH_STOP(errctx, lines);
|
||||
PASS(errctx, inner);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time loading assets off disk: a sprite, a character, and a whole map.
|
||||
*
|
||||
* This is startup and level-transition cost, not frame cost, and it is measured
|
||||
* because it is the part of a game the player waits through. The sprite load
|
||||
* after the first reuses the already registered spritesheet, so what is measured
|
||||
* is the JSON path rather than the image decode.
|
||||
*
|
||||
* @note Each character load leaks the SDL property set holding its
|
||||
* state-to-sprite map -- akgl_heap_release_character does not walk it.
|
||||
* That is a known defect rather than something this benchmark introduces;
|
||||
* it is repeated here often enough to be worth saying out loud.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_asset_load(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akerr_ErrorContext *inner = NULL;
|
||||
akgl_Sprite *sprite = NULL;
|
||||
akgl_Character *basechar = NULL;
|
||||
int count = bench_iterations(2000);
|
||||
int maps = bench_iterations(50);
|
||||
int i = 0;
|
||||
int j = 0;
|
||||
int rep = 0;
|
||||
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
PASS(errctx, bench_populate_scene());
|
||||
|
||||
bench_start("sprite_load_json, sheet already loaded", "load", 180000.0);
|
||||
for ( i = 0; i < count; i++ ) {
|
||||
inner = akgl_sprite_load_json("assets/testsprite.json");
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
sprite = SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite", NULL);
|
||||
if ( sprite == NULL ) {
|
||||
break;
|
||||
}
|
||||
inner = akgl_heap_release_sprite(sprite);
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
BENCH_FLUSH_STOP(errctx, count);
|
||||
PASS(errctx, inner);
|
||||
|
||||
PASS(errctx, bench_populate_scene());
|
||||
bench_start("character_load_json, two sprite mappings", "load", 150000.0);
|
||||
for ( i = 0; i < count; i++ ) {
|
||||
inner = akgl_character_load_json("assets/testcharacter.json");
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
basechar = SDL_GetPointerProperty(AKGL_REGISTRY_CHARACTER, "testcharacter", NULL);
|
||||
if ( basechar == NULL ) {
|
||||
break;
|
||||
}
|
||||
inner = akgl_heap_release_character(basechar);
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
BENCH_FLUSH_STOP(errctx, count);
|
||||
PASS(errctx, inner);
|
||||
|
||||
PASS(errctx, bench_populate_scene());
|
||||
bench_start("tilemap_load + release, fixture map", "load", 120000000.0);
|
||||
for ( i = 0; i < maps; i++ ) {
|
||||
// The map's object layer spawns actors into the pool, and neither
|
||||
// akgl_tilemap_release nor anything else gives them back, so a loop
|
||||
// that only loaded and released would exhaust the actor pool on its
|
||||
// 32nd iteration. Clearing the pool is part of what a host does
|
||||
// between levels anyway, and it is a pair of memsets against a map
|
||||
// load that decodes a 768x576 PNG.
|
||||
inner = akgl_heap_init_actor();
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
inner = akgl_registry_init_actor();
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
// And the string pool has to be reclaimed by hand, because a map
|
||||
// load leaks five pooled strings that release does not give back.
|
||||
// The 52nd load would find the pool empty, and what happens then is
|
||||
// not an AKGL_ERR_HEAP: akgl_get_json_string_value finishes with
|
||||
// pass-up false, swallows the failed claim, and dereferences the
|
||||
// pointer it never set. Both defects are filed in TODO.md; this loop
|
||||
// works around the first so it never reaches the second.
|
||||
for ( j = 0; j < AKGL_MAX_HEAP_STRING; j++ ) {
|
||||
akgl_heap_strings[j].refcount = 0;
|
||||
}
|
||||
inner = akgl_tilemap_load("assets/testmap.tmj", akgl_gamemap);
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
inner = akgl_tilemap_release(akgl_gamemap);
|
||||
if ( inner != NULL ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
BENCH_FLUSH_STOP(errctx, maps);
|
||||
PASS(errctx, inner);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time zeroing an akgl_Tilemap, which is the first thing a map load does.
|
||||
*
|
||||
* akgl_tilemap_load begins with `memset(dest, 0, sizeof(akgl_Tilemap))`, and an
|
||||
* akgl_Tilemap is 16 layers of 512x512 cells plus 16 tilesets of 65536 offset
|
||||
* pairs. The struct is that size whether the map is 2x2 or 512x512, so this is a
|
||||
* fixed toll on every level transition, paid before a single byte of the map
|
||||
* file has been read. Measuring it separately is the only way to tell how much
|
||||
* of the load benchmark above is the map and how much is the container.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_map_zero(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
int count = bench_iterations(200);
|
||||
int i = 0;
|
||||
int rep = 0;
|
||||
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
bench_start("zeroing one akgl_Tilemap", "call", 16000000.0);
|
||||
for ( i = 0; i < count; i++ ) {
|
||||
memset(akgl_gamemap, 0x00, sizeof(akgl_Tilemap));
|
||||
}
|
||||
BENCH_FLUSH_STOP(errctx, count);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time building a tileset's offset table.
|
||||
*
|
||||
* akgl_tilemap_compute_tileset_offsets fills one entry per tile in the image --
|
||||
* 1728 of them for the fixture tileset -- and a map pays it once per tileset at
|
||||
* load time. It is also the function whose table costs 512 KB per tileset,
|
||||
* which is the reason an akgl_Tilemap is as large as it is.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_tileset_offsets(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akerr_ErrorContext *inner = NULL;
|
||||
int count = bench_iterations(20000);
|
||||
int i = 0;
|
||||
int rep = 0;
|
||||
|
||||
PASS(errctx, bench_build_map(akgl_gamemap, 1));
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
bench_start("tilemap_compute_tileset_offsets, 1728 tiles", "call", 23000.0);
|
||||
BENCH_LOOP(inner, i, count, akgl_tilemap_compute_tileset_offsets(akgl_gamemap, 0));
|
||||
BENCH_FLUSH_STOP(errctx, count);
|
||||
PASS(errctx, inner);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time drawing a screenful of tiles, with one tileset and with eight.
|
||||
*
|
||||
* The camera covers 40 x 30 cells, so each of these is 1200 blits plus whatever
|
||||
* the tileset scan costs on top. The gap between the two numbers is that scan:
|
||||
* akgl_tilemap_draw walks every tileset for every tile and does not stop at the
|
||||
* one that matched, so the cost is linear in tileset count even though at most
|
||||
* one can ever answer.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_tilemap_draw(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akerr_ErrorContext *inner = NULL;
|
||||
int count = bench_iterations(30);
|
||||
int i = 0;
|
||||
int rep = 0;
|
||||
|
||||
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
||||
PASS(errctx, bench_build_map(akgl_gamemap, 1));
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
bench_start("tilemap_draw, 40x30 tiles, 1 tileset", "frame", 170000000.0);
|
||||
BENCH_LOOP(inner, i, count, akgl_tilemap_draw(akgl_gamemap, akgl_camera, 0));
|
||||
BENCH_FLUSH_STOP(errctx, count);
|
||||
PASS(errctx, inner);
|
||||
}
|
||||
|
||||
PASS(errctx, bench_build_map(akgl_gamemap, BENCH_MAP_TILESETS));
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
bench_start("tilemap_draw, 40x30 tiles, 8 tilesets", "frame", 170000000.0);
|
||||
BENCH_LOOP(inner, i, count, akgl_tilemap_draw(akgl_gamemap, akgl_camera, 0));
|
||||
BENCH_FLUSH_STOP(errctx, count);
|
||||
PASS(errctx, inner);
|
||||
}
|
||||
printf("tilemap_draw covers %d x %d = %d tiles per frame\n",
|
||||
(BENCH_SCREEN_W / BENCH_TILE_SIZE),
|
||||
(BENCH_SCREEN_H / BENCH_TILE_SIZE),
|
||||
((BENCH_SCREEN_W / BENCH_TILE_SIZE) * (BENCH_SCREEN_H / BENCH_TILE_SIZE)));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief The control: the same 1200 blits, issued straight to SDL.
|
||||
*
|
||||
* Without this, every drawing number in this file is unreadable -- there is no
|
||||
* way to tell how much of a 16 ms tilemap frame is libakgl deciding what to draw
|
||||
* and how much is the software rasterizer moving pixels. This does the same
|
||||
* amount of pixel work with none of the library in the way: same texture, same
|
||||
* source rectangles, same destinations, no bounds logic, no tileset scan, no
|
||||
* error contexts. Subtract it from the tilemap number and what is left is what
|
||||
* libakgl costs.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_raw_blit_control(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_FRect src = { .x = 0.0, .y = 0.0, .w = BENCH_TILE_SIZE, .h = BENCH_TILE_SIZE };
|
||||
SDL_FRect dest = { .x = 0.0, .y = 0.0, .w = BENCH_TILE_SIZE, .h = BENCH_TILE_SIZE };
|
||||
int across = (BENCH_SCREEN_W / BENCH_TILE_SIZE);
|
||||
int down = (BENCH_SCREEN_H / BENCH_TILE_SIZE);
|
||||
int columns = 0;
|
||||
int tilecount = 0;
|
||||
int tile = 0;
|
||||
int count = bench_iterations(30);
|
||||
int i = 0;
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
int rep = 0;
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, benchtiles, AKERR_NULLPOINTER, "benchtiles");
|
||||
columns = benchtiles->w / BENCH_TILE_SIZE;
|
||||
tilecount = columns * (benchtiles->h / BENCH_TILE_SIZE);
|
||||
|
||||
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
// One source tile, blitted 1200 times. The floor: 1200 SDL calls and
|
||||
// 1200 x 256 pixels of copying, with the source rectangle staying in
|
||||
// cache the whole way.
|
||||
bench_start("raw SDL blits, one source tile (control)", "frame", 5000000.0);
|
||||
for ( i = 0; i < count; i++ ) {
|
||||
src.x = 0.0;
|
||||
src.y = 0.0;
|
||||
for ( y = 0; y < down; y++ ) {
|
||||
for ( x = 0; x < across; x++ ) {
|
||||
dest.x = (float32_t)(x * BENCH_TILE_SIZE);
|
||||
dest.y = (float32_t)(y * BENCH_TILE_SIZE);
|
||||
SDL_RenderTexture(akgl_renderer->sdl_renderer, benchtiles, &src, &dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
BENCH_FLUSH_STOP(errctx, count);
|
||||
|
||||
// The same 1200 blits reading the same scattered source tiles the map
|
||||
// asks for: cell (x, y) of a BENCH_MAP_W-wide map, so consecutive screen
|
||||
// rows read parts of the sheet BENCH_MAP_W tiles apart rather than
|
||||
// walking it in order. This is the honest control for
|
||||
// akgl_tilemap_draw -- same call count, same pixels, same access
|
||||
// pattern, none of libakgl in the path -- and the gap between the two is
|
||||
// what the library's own per-tile work costs.
|
||||
bench_start("raw SDL blits, map order (control)", "frame", 170000000.0);
|
||||
for ( i = 0; i < count; i++ ) {
|
||||
for ( y = 0; y < down; y++ ) {
|
||||
for ( x = 0; x < across; x++ ) {
|
||||
tile = (x + (y * BENCH_MAP_W)) % tilecount;
|
||||
src.x = (float32_t)((tile % columns) * BENCH_TILE_SIZE);
|
||||
src.y = (float32_t)((tile / columns) * BENCH_TILE_SIZE);
|
||||
dest.x = (float32_t)(x * BENCH_TILE_SIZE);
|
||||
dest.y = (float32_t)(y * BENCH_TILE_SIZE);
|
||||
SDL_RenderTexture(akgl_renderer->sdl_renderer, benchtiles, &src, &dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
BENCH_FLUSH_STOP(errctx, count);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time one actor's render, and then the whole scene through the backend.
|
||||
*
|
||||
* A render is two state-to-sprite lookups -- one for the sprite, one inside the
|
||||
* visibility test -- some frame arithmetic, and one blit. The scene benchmark
|
||||
* puts that together with the tilemap: one layer of tiles plus 64 actors, which
|
||||
* is what akgl_render_2d_draw_world does for a modest 2D game.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_scene(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akerr_ErrorContext *inner = NULL;
|
||||
akgl_Actor *actor = NULL;
|
||||
int count = bench_iterations(20000);
|
||||
int frames = bench_iterations(30);
|
||||
int i = 0;
|
||||
int rep = 0;
|
||||
|
||||
PASS(errctx, bench_populate_scene());
|
||||
PASS(errctx, bench_build_map(akgl_gamemap, 1));
|
||||
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
||||
actor = &akgl_heap_actors[0];
|
||||
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
bench_start("actor_render, on camera", "actor", 31000.0);
|
||||
BENCH_LOOP(inner, i, count, actor->renderfunc(actor));
|
||||
BENCH_FLUSH_STOP(errctx, count);
|
||||
PASS(errctx, inner);
|
||||
|
||||
bench_start("draw_world, 1200 tiles + 64 actors", "frame", 170000000.0);
|
||||
BENCH_LOOP(inner, i, frames, akgl_renderer->draw_world(akgl_renderer, NULL));
|
||||
BENCH_FLUSH_STOP(errctx, frames);
|
||||
PASS(errctx, inner);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Time a whole frame the way akgl_game_update runs one.
|
||||
*
|
||||
* This is the number a host actually gets, and it is not the sum of the parts.
|
||||
*
|
||||
* It used to be measurably worse than that: the update sweep was nested inside
|
||||
* a loop over #AKGL_TILEMAP_MAX_LAYERS that never filtered by layer, so every
|
||||
* live actor updated sixteen times a frame, and this benchmark sat about 92 us
|
||||
* above the scene benchmark below it. That gap *was* the multiplier. Since
|
||||
* 0.5.0 the two are equal within run-to-run noise, which is the shape of the
|
||||
* fix rather than a number worth quoting on its own.
|
||||
*/
|
||||
static akerr_ErrorContext *bench_game_update(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akerr_ErrorContext *inner = NULL;
|
||||
int count = bench_iterations(30);
|
||||
int i = 0;
|
||||
int rep = 0;
|
||||
|
||||
PASS(errctx, bench_populate_scene());
|
||||
PASS(errctx, bench_build_map(akgl_gamemap, 1));
|
||||
PASS(errctx, akgl_physics_init_null(akgl_physics));
|
||||
|
||||
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
|
||||
bench_start("game_update, full frame", "frame", 170000000.0);
|
||||
BENCH_LOOP(inner, i, count, akgl_game_update(NULL));
|
||||
BENCH_FLUSH_STOP(errctx, count);
|
||||
PASS(errctx, inner);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
|
||||
SDL_SetLogOutputFunction(bench_discard_log, NULL);
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
akgl_physics = &akgl_default_physics;
|
||||
akgl_camera = &akgl_default_camera;
|
||||
akgl_gamemap = &akgl_default_gamemap;
|
||||
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_Init(SDL_INIT_VIDEO),
|
||||
AKGL_ERR_SDL,
|
||||
"Couldn't initialize SDL: %s",
|
||||
SDL_GetError());
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
TTF_Init(),
|
||||
AKGL_ERR_SDL,
|
||||
"Couldn't initialize the font engine: %s",
|
||||
SDL_GetError());
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_CreateWindowAndRenderer(
|
||||
"net/aklabs/libakgl/test_perf_render",
|
||||
BENCH_SCREEN_W,
|
||||
BENCH_SCREEN_H,
|
||||
0,
|
||||
&akgl_window,
|
||||
&akgl_renderer->sdl_renderer),
|
||||
AKGL_ERR_SDL,
|
||||
"Couldn't create window/renderer: %s",
|
||||
SDL_GetError());
|
||||
CATCH(errctx, akgl_render_2d_bind(akgl_renderer));
|
||||
|
||||
akgl_camera->x = 0.0;
|
||||
akgl_camera->y = 0.0;
|
||||
akgl_camera->w = BENCH_SCREEN_W;
|
||||
akgl_camera->h = BENCH_SCREEN_H;
|
||||
|
||||
// akgl_game_update takes this before it does anything else. Normally
|
||||
// akgl_game_init creates it; this suite brings the library up piece by
|
||||
// piece so it can stay headless, so it creates the mutex itself.
|
||||
akgl_game.statelock = SDL_CreateMutex();
|
||||
FAIL_ZERO_BREAK(errctx, akgl_game.statelock, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
|
||||
// akgl_game.lowfpsfunc is deliberately left NULL. It used to have to be
|
||||
// installed by hand here, because akgl_game_update_fps called it
|
||||
// unguarded on every frame under 30 fps -- which includes the first,
|
||||
// before there is a frame rate to compare against -- and a host that
|
||||
// binds its own renderer the way renderer.h documents never went through
|
||||
// akgl_game_init to get one. It installs the default itself now, and
|
||||
// leaving it NULL here is what keeps that true.
|
||||
|
||||
benchfont = TTF_OpenFont(BENCH_FONT_PATH, BENCH_FONT_SIZE);
|
||||
FAIL_ZERO_BREAK(errctx, benchfont, AKGL_ERR_SDL, "Couldn't open %s: %s", BENCH_FONT_PATH, SDL_GetError());
|
||||
benchtiles = IMG_LoadTexture(akgl_renderer->sdl_renderer, BENCH_TILESET_IMAGE);
|
||||
FAIL_ZERO_BREAK(errctx, benchtiles, AKGL_ERR_SDL, "Couldn't load %s: %s", BENCH_TILESET_IMAGE, SDL_GetError());
|
||||
|
||||
CATCH(errctx, akgl_heap_init());
|
||||
CATCH(errctx, akgl_registry_init());
|
||||
CATCH(errctx, akgl_registry_init_properties());
|
||||
|
||||
CATCH(errctx, bench_frame());
|
||||
CATCH(errctx, bench_primitives());
|
||||
CATCH(errctx, bench_framebuffer());
|
||||
CATCH(errctx, bench_text());
|
||||
CATCH(errctx, bench_asset_load());
|
||||
CATCH(errctx, bench_map_zero());
|
||||
CATCH(errctx, bench_tileset_offsets());
|
||||
CATCH(errctx, bench_tilemap_draw());
|
||||
CATCH(errctx, bench_raw_blit_control());
|
||||
CATCH(errctx, bench_scene());
|
||||
CATCH(errctx, bench_game_update());
|
||||
|
||||
BENCH_REPORT_BREAK(errctx);
|
||||
} CLEANUP {
|
||||
if ( benchtiles != NULL ) {
|
||||
SDL_DestroyTexture(benchtiles);
|
||||
}
|
||||
if ( benchfont != NULL ) {
|
||||
TTF_CloseFont(benchfont);
|
||||
}
|
||||
TTF_Quit();
|
||||
SDL_Quit();
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
}
|
||||
809
tests/physics.c
Normal file
809
tests/physics.c
Normal file
@@ -0,0 +1,809 @@
|
||||
/**
|
||||
* @file physics.c
|
||||
* @brief Unit tests for the physics backends and the simulation loop.
|
||||
*
|
||||
* None of these tests need a renderer or a window. The simulation loop walks
|
||||
* akgl_heap_actors directly, so each test rebuilds the actor heap rather than relying
|
||||
* on state left behind by an earlier one.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <string.h>
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/character.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/staticstring.h>
|
||||
|
||||
#include "testutil.h"
|
||||
|
||||
/** @brief Records whether the stub movement logic ran, and with what dt. */
|
||||
static int stub_movement_calls = 0;
|
||||
static float32_t stub_movement_last_dt = 0;
|
||||
|
||||
/** @brief Movement logic stub that records its invocation and does nothing else. */
|
||||
static akerr_ErrorContext *stub_movement_noop(akgl_Actor *actor, float32_t dt)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
stub_movement_calls += 1;
|
||||
stub_movement_last_dt = dt;
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
/** @brief Movement logic stub that raises the interrupt the simulator must swallow. */
|
||||
static akerr_ErrorContext *stub_movement_interrupt(akgl_Actor *actor, float32_t dt)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
stub_movement_calls += 1;
|
||||
FAIL_RETURN(e, AKGL_ERR_LOGICINTERRUPT, "deliberate interrupt from test stub");
|
||||
}
|
||||
|
||||
/** @brief Movement logic stub that raises an error the simulator must propagate. */
|
||||
static akerr_ErrorContext *stub_movement_error(akgl_Actor *actor, float32_t dt)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
stub_movement_calls += 1;
|
||||
FAIL_RETURN(e, AKERR_VALUE, "deliberate error from test stub");
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Build a character and an actor bound to it, ready for simulation.
|
||||
*
|
||||
* The actor is placed on the heap with a nonzero refcount and a movement logic
|
||||
* stub, which is the minimum the simulation loop requires to process it.
|
||||
*/
|
||||
static akerr_ErrorContext *make_sim_actor(akgl_Actor **actor, akgl_Character **basechar, char *name)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
ATTEMPT {
|
||||
CATCH(e, akgl_heap_next_character(basechar));
|
||||
CATCH(e, akgl_character_initialize(*basechar, name));
|
||||
CATCH(e, akgl_heap_next_actor(actor));
|
||||
CATCH(e, akgl_actor_initialize(*actor, name));
|
||||
(*actor)->basechar = *basechar;
|
||||
(*actor)->movementlogicfunc = &stub_movement_noop;
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
/** @brief Reset the actor heap so each simulation test starts from a clean slate. */
|
||||
static akerr_ErrorContext *reset_sim_heap(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
ATTEMPT {
|
||||
CATCH(e, akgl_registry_init_actor());
|
||||
CATCH(e, akgl_registry_init_character());
|
||||
CATCH(e, akgl_heap_init());
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
stub_movement_calls = 0;
|
||||
stub_movement_last_dt = 0;
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_physics_init_null(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_PhysicsBackend backend;
|
||||
|
||||
ATTEMPT {
|
||||
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
||||
TEST_EXPECT_OK(e, akgl_physics_init_null(&backend), "akgl_physics_init_null");
|
||||
|
||||
TEST_ASSERT(e, backend.gravity == &akgl_physics_null_gravity,
|
||||
"init_null did not install the null gravity function");
|
||||
TEST_ASSERT(e, backend.collide == &akgl_physics_null_collide,
|
||||
"init_null did not install the null collide function");
|
||||
TEST_ASSERT(e, backend.move == &akgl_physics_null_move,
|
||||
"init_null did not install the null move function");
|
||||
TEST_ASSERT(e, backend.simulate == &akgl_physics_simulate,
|
||||
"init_null did not install the shared simulate function");
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_init_null(NULL),
|
||||
"akgl_physics_init_null(NULL)");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_physics_null_backend_is_inert(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_PhysicsBackend backend;
|
||||
akgl_Actor actor;
|
||||
akgl_Actor reference;
|
||||
|
||||
ATTEMPT {
|
||||
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
TEST_EXPECT_OK(e, akgl_physics_init_null(&backend), "akgl_physics_init_null");
|
||||
|
||||
actor.x = 3.0f; actor.y = 5.0f; actor.z = 7.0f;
|
||||
actor.vx = 11.0f; actor.vy = 13.0f; actor.vz = 17.0f;
|
||||
actor.ex = 19.0f; actor.ey = 23.0f; actor.ez = 29.0f;
|
||||
memcpy(&reference, &actor, sizeof(akgl_Actor));
|
||||
|
||||
TEST_EXPECT_OK(e, backend.gravity(&backend, &actor, 1.0f), "null gravity");
|
||||
TEST_EXPECT_OK(e, backend.move(&backend, &actor, 1.0f), "null move");
|
||||
TEST_EXPECT_OK(e, backend.collide(&backend, &actor, &reference), "null collide");
|
||||
|
||||
TEST_ASSERT(e, memcmp(&actor, &reference, sizeof(akgl_Actor)) == 0,
|
||||
"the null backend modified the actor");
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_gravity(NULL, &actor, 1.0f),
|
||||
"null gravity with NULL self");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_move(NULL, &actor, 1.0f),
|
||||
"null move with NULL self");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_collide(NULL, &actor, &reference),
|
||||
"null collide with NULL self");
|
||||
|
||||
// The actor arguments too. The arcade backend has always checked these;
|
||||
// the null one checked only `self`, so which pointers a caller could get
|
||||
// away with passing depended on which backend it happened to have.
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_gravity(&backend, NULL, 1.0f),
|
||||
"null gravity with a NULL actor");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_move(&backend, NULL, 1.0f),
|
||||
"null move with a NULL actor");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_collide(&backend, NULL, &reference),
|
||||
"null collide with a NULL first actor");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_null_collide(&backend, &actor, NULL),
|
||||
"null collide with a NULL second actor");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_physics_arcade_gravity(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_PhysicsBackend backend;
|
||||
akgl_Actor actor;
|
||||
|
||||
ATTEMPT {
|
||||
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
||||
|
||||
// X gravity subtracts, because the X origin is screen-left.
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
backend.gravity_x = 10.0;
|
||||
TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 0.5f), "arcade gravity x");
|
||||
TEST_ASSERT_FEQ(e, actor.ex, -5.0f, "gravity_x 10 over dt 0.5 gave ex %f, expected -5", actor.ex);
|
||||
TEST_ASSERT_FEQ(e, actor.ey, 0.0f, "gravity_x leaked into ey (%f)", actor.ey);
|
||||
TEST_ASSERT_FEQ(e, actor.ez, 0.0f, "gravity_x leaked into ez (%f)", actor.ez);
|
||||
|
||||
// Y gravity adds, because the Y origin is down-screen.
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
backend.gravity_x = 0;
|
||||
backend.gravity_y = 10.0;
|
||||
TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 0.5f), "arcade gravity y");
|
||||
TEST_ASSERT_FEQ(e, actor.ey, 5.0f, "gravity_y 10 over dt 0.5 gave ey %f, expected 5", actor.ey);
|
||||
TEST_ASSERT_FEQ(e, actor.ex, 0.0f, "gravity_y leaked into ex (%f)", actor.ex);
|
||||
|
||||
// Z gravity subtracts, because the Z origin is behind the camera.
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
backend.gravity_y = 0;
|
||||
backend.gravity_z = 4.0;
|
||||
TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 0.25f), "arcade gravity z");
|
||||
TEST_ASSERT_FEQ(e, actor.ez, -1.0f, "gravity_z 4 over dt 0.25 gave ez %f, expected -1", actor.ez);
|
||||
|
||||
// Negative gravity reverses each axis.
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
backend.gravity_x = -10.0;
|
||||
backend.gravity_y = -10.0;
|
||||
backend.gravity_z = -10.0;
|
||||
TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 1.0f), "arcade gravity negative");
|
||||
TEST_ASSERT_FEQ(e, actor.ex, 10.0f, "negative gravity_x gave ex %f, expected 10", actor.ex);
|
||||
TEST_ASSERT_FEQ(e, actor.ey, -10.0f, "negative gravity_y gave ey %f, expected -10", actor.ey);
|
||||
TEST_ASSERT_FEQ(e, actor.ez, 10.0f, "negative gravity_z gave ez %f, expected 10", actor.ez);
|
||||
|
||||
// A zero dt applies no force even when gravity is set.
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
backend.gravity_x = 10.0;
|
||||
backend.gravity_y = 10.0;
|
||||
backend.gravity_z = 10.0;
|
||||
TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 0.0f), "arcade gravity dt 0");
|
||||
TEST_ASSERT_FEQ(e, actor.ex, 0.0f, "dt 0 still moved ex (%f)", actor.ex);
|
||||
TEST_ASSERT_FEQ(e, actor.ey, 0.0f, "dt 0 still moved ey (%f)", actor.ey);
|
||||
TEST_ASSERT_FEQ(e, actor.ez, 0.0f, "dt 0 still moved ez (%f)", actor.ez);
|
||||
|
||||
// All-zero gravity leaves every axis alone.
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
||||
actor.ex = 1.0f; actor.ey = 2.0f; actor.ez = 3.0f;
|
||||
TEST_EXPECT_OK(e, akgl_physics_arcade_gravity(&backend, &actor, 1.0f), "arcade gravity all zero");
|
||||
TEST_ASSERT_FEQ(e, actor.ex, 1.0f, "zero gravity_x changed ex (%f)", actor.ex);
|
||||
TEST_ASSERT_FEQ(e, actor.ey, 2.0f, "zero gravity_y changed ey (%f)", actor.ey);
|
||||
TEST_ASSERT_FEQ(e, actor.ez, 3.0f, "zero gravity_z changed ez (%f)", actor.ez);
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_gravity(NULL, &actor, 1.0f),
|
||||
"arcade gravity with NULL self");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_gravity(&backend, NULL, 1.0f),
|
||||
"arcade gravity with NULL actor");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_physics_arcade_move(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_PhysicsBackend backend;
|
||||
akgl_Actor actor;
|
||||
|
||||
ATTEMPT {
|
||||
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
||||
memset(&actor, 0x00, sizeof(akgl_Actor));
|
||||
|
||||
actor.x = 100.0f; actor.y = 200.0f; actor.z = 300.0f;
|
||||
actor.vx = 10.0f; actor.vy = -20.0f; actor.vz = 30.0f;
|
||||
TEST_EXPECT_OK(e, akgl_physics_arcade_move(&backend, &actor, 0.5f), "arcade move");
|
||||
TEST_ASSERT_FEQ(e, actor.x, 105.0f, "x moved to %f, expected 105", actor.x);
|
||||
TEST_ASSERT_FEQ(e, actor.y, 190.0f, "y moved to %f, expected 190", actor.y);
|
||||
TEST_ASSERT_FEQ(e, actor.z, 315.0f, "z moved to %f, expected 315", actor.z);
|
||||
|
||||
// dt of zero is a no-op regardless of velocity.
|
||||
TEST_EXPECT_OK(e, akgl_physics_arcade_move(&backend, &actor, 0.0f), "arcade move dt 0");
|
||||
TEST_ASSERT_FEQ(e, actor.x, 105.0f, "dt 0 moved x to %f", actor.x);
|
||||
TEST_ASSERT_FEQ(e, actor.y, 190.0f, "dt 0 moved y to %f", actor.y);
|
||||
TEST_ASSERT_FEQ(e, actor.z, 315.0f, "dt 0 moved z to %f", actor.z);
|
||||
|
||||
// Zero velocity is a no-op regardless of dt.
|
||||
actor.vx = 0; actor.vy = 0; actor.vz = 0;
|
||||
TEST_EXPECT_OK(e, akgl_physics_arcade_move(&backend, &actor, 10.0f), "arcade move zero velocity");
|
||||
TEST_ASSERT_FEQ(e, actor.x, 105.0f, "zero velocity moved x to %f", actor.x);
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_move(NULL, &actor, 1.0f),
|
||||
"arcade move with NULL self");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_move(&backend, NULL, 1.0f),
|
||||
"arcade move with NULL actor");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_physics_arcade_collide_unimplemented(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_PhysicsBackend backend;
|
||||
akgl_Actor a1;
|
||||
akgl_Actor a2;
|
||||
|
||||
ATTEMPT {
|
||||
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
||||
memset(&a1, 0x00, sizeof(akgl_Actor));
|
||||
memset(&a2, 0x00, sizeof(akgl_Actor));
|
||||
|
||||
// Pins the stub so that implementing collision has to update this test.
|
||||
TEST_EXPECT_STATUS(e, AKERR_API, akgl_physics_arcade_collide(&backend, &a1, &a2),
|
||||
"arcade collide is still a stub");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_arcade_collide(NULL, &a1, &a2),
|
||||
"arcade collide with NULL self");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_physics_factory(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_PhysicsBackend backend;
|
||||
akgl_String typestr;
|
||||
|
||||
ATTEMPT {
|
||||
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
||||
|
||||
CATCH(e, akgl_string_initialize(&typestr, "null"));
|
||||
TEST_EXPECT_OK(e, akgl_physics_factory(&backend, &typestr), "factory(\"null\")");
|
||||
TEST_ASSERT(e, backend.move == &akgl_physics_null_move,
|
||||
"factory(\"null\") did not install the null backend");
|
||||
|
||||
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
||||
CATCH(e, akgl_string_initialize(&typestr, "arcade"));
|
||||
TEST_EXPECT_OK(e, akgl_physics_factory(&backend, &typestr), "factory(\"arcade\")");
|
||||
TEST_ASSERT(e, backend.move == &akgl_physics_arcade_move,
|
||||
"factory(\"arcade\") did not install the arcade backend");
|
||||
|
||||
CATCH(e, akgl_string_initialize(&typestr, "newtonian"));
|
||||
TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_physics_factory(&backend, &typestr),
|
||||
"factory with an unknown backend name");
|
||||
|
||||
// An empty name must not match either prefix.
|
||||
CATCH(e, akgl_string_initialize(&typestr, ""));
|
||||
TEST_EXPECT_STATUS(e, AKERR_KEY, akgl_physics_factory(&backend, &typestr),
|
||||
"factory with an empty backend name");
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_factory(NULL, &typestr),
|
||||
"factory with NULL self");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_factory(&backend, NULL),
|
||||
"factory with NULL type");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_physics_init_arcade_properties(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_PhysicsBackend backend;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, akgl_registry_init_properties());
|
||||
|
||||
// With nothing configured, every environmental constant defaults to zero.
|
||||
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
||||
TEST_EXPECT_OK(e, akgl_physics_init_arcade(&backend), "init_arcade with no properties set");
|
||||
TEST_ASSERT_FEQ(e, backend.gravity_y, 0.0f,
|
||||
"unset physics.gravity.y defaulted to %f, expected 0", backend.gravity_y);
|
||||
TEST_ASSERT_FEQ(e, backend.drag_x, 0.0f,
|
||||
"unset physics.drag.x defaulted to %f, expected 0", backend.drag_x);
|
||||
TEST_ASSERT(e, backend.gravity == &akgl_physics_arcade_gravity,
|
||||
"init_arcade did not install the arcade gravity function");
|
||||
TEST_ASSERT(e, backend.simulate == &akgl_physics_simulate,
|
||||
"init_arcade did not install the shared simulate function");
|
||||
|
||||
// Configured values are read out of the property registry.
|
||||
CATCH(e, akgl_set_property("physics.gravity.x", "1.5"));
|
||||
CATCH(e, akgl_set_property("physics.gravity.y", "9.8"));
|
||||
CATCH(e, akgl_set_property("physics.gravity.z", "2.25"));
|
||||
CATCH(e, akgl_set_property("physics.drag.x", "0.5"));
|
||||
CATCH(e, akgl_set_property("physics.drag.y", "0.25"));
|
||||
CATCH(e, akgl_set_property("physics.drag.z", "0.125"));
|
||||
|
||||
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
||||
TEST_EXPECT_OK(e, akgl_physics_init_arcade(&backend), "init_arcade with properties set");
|
||||
TEST_ASSERT_FEQ(e, backend.gravity_x, 1.5f, "physics.gravity.x read as %f", backend.gravity_x);
|
||||
TEST_ASSERT_FEQ(e, backend.gravity_y, 9.8f, "physics.gravity.y read as %f", backend.gravity_y);
|
||||
TEST_ASSERT_FEQ(e, backend.gravity_z, 2.25f, "physics.gravity.z read as %f", backend.gravity_z);
|
||||
TEST_ASSERT_FEQ(e, backend.drag_x, 0.5f, "physics.drag.x read as %f", backend.drag_x);
|
||||
TEST_ASSERT_FEQ(e, backend.drag_y, 0.25f, "physics.drag.y read as %f", backend.drag_y);
|
||||
TEST_ASSERT_FEQ(e, backend.drag_z, 0.125f, "physics.drag.z read as %f", backend.drag_z);
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_init_arcade(NULL),
|
||||
"init_arcade with NULL self");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_physics_simulate_skips_inactive(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_PhysicsBackend backend;
|
||||
akgl_Actor *actor = NULL;
|
||||
akgl_Character *basechar = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, reset_sim_heap());
|
||||
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
||||
CATCH(e, akgl_physics_init_null(&backend));
|
||||
|
||||
// A released actor (refcount 0) is skipped entirely.
|
||||
CATCH(e, make_sim_actor(&actor, &basechar, "inactive"));
|
||||
actor->refcount = 0;
|
||||
stub_movement_calls = 0;
|
||||
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with a released actor");
|
||||
TEST_ASSERT(e, stub_movement_calls == 0,
|
||||
"simulate ran movement logic for an actor with refcount 0 (%d calls)",
|
||||
stub_movement_calls);
|
||||
|
||||
// An actor with no base character is skipped.
|
||||
CATCH(e, reset_sim_heap());
|
||||
CATCH(e, make_sim_actor(&actor, &basechar, "nochar"));
|
||||
actor->basechar = NULL;
|
||||
stub_movement_calls = 0;
|
||||
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with a character-less actor");
|
||||
TEST_ASSERT(e, stub_movement_calls == 0,
|
||||
"simulate ran movement logic for an actor with no base character (%d calls)",
|
||||
stub_movement_calls);
|
||||
|
||||
// A fully wired actor is processed.
|
||||
CATCH(e, reset_sim_heap());
|
||||
CATCH(e, make_sim_actor(&actor, &basechar, "active"));
|
||||
stub_movement_calls = 0;
|
||||
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with an active actor");
|
||||
TEST_ASSERT(e, stub_movement_calls == 1,
|
||||
"simulate ran movement logic %d times for one active actor, expected 1",
|
||||
stub_movement_calls);
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_physics_simulate_children_follow_parents(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_PhysicsBackend backend;
|
||||
akgl_Actor *parent = NULL;
|
||||
akgl_Actor *child = NULL;
|
||||
akgl_Character *basechar = NULL;
|
||||
akgl_Character *childchar = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, reset_sim_heap());
|
||||
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
||||
CATCH(e, akgl_physics_init_null(&backend));
|
||||
|
||||
CATCH(e, make_sim_actor(&parent, &basechar, "parent"));
|
||||
CATCH(e, make_sim_actor(&child, &childchar, "child"));
|
||||
|
||||
parent->x = 100.0f; parent->y = 200.0f; parent->z = 300.0f;
|
||||
// For a child, vx/vy/vz are read as a fixed offset from the parent.
|
||||
child->vx = 5.0f; child->vy = -5.0f; child->vz = 2.0f;
|
||||
child->x = -999.0f; child->y = -999.0f; child->z = -999.0f;
|
||||
CATCH(e, akgl_actor_add_child(parent, child));
|
||||
|
||||
stub_movement_calls = 0;
|
||||
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with a parented child");
|
||||
|
||||
TEST_ASSERT_FEQ(e, child->x, 105.0f, "child x resolved to %f, expected parent 100 + offset 5", child->x);
|
||||
TEST_ASSERT_FEQ(e, child->y, 195.0f, "child y resolved to %f, expected parent 200 - offset 5", child->y);
|
||||
TEST_ASSERT_FEQ(e, child->z, 302.0f, "child z resolved to %f, expected parent 300 + offset 2", child->z);
|
||||
|
||||
// Only the parent goes through the movement logic; the child is positional only.
|
||||
TEST_ASSERT(e, stub_movement_calls == 1,
|
||||
"simulate ran movement logic %d times, expected 1 (parent only)",
|
||||
stub_movement_calls);
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_physics_simulate_layer_mask(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_PhysicsBackend backend;
|
||||
akgl_Actor *layer0 = NULL;
|
||||
akgl_Actor *layer3 = NULL;
|
||||
akgl_Character *char0 = NULL;
|
||||
akgl_Character *char3 = NULL;
|
||||
akgl_Iterator opflags;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, reset_sim_heap());
|
||||
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
||||
CATCH(e, akgl_physics_init_null(&backend));
|
||||
|
||||
CATCH(e, make_sim_actor(&layer0, &char0, "onlayer0"));
|
||||
CATCH(e, make_sim_actor(&layer3, &char3, "onlayer3"));
|
||||
layer0->layer = 0;
|
||||
layer3->layer = 3;
|
||||
|
||||
// Masking to layer 3 processes only the layer 3 actor.
|
||||
opflags.flags = AKGL_ITERATOR_OP_LAYERMASK;
|
||||
opflags.layerid = 3;
|
||||
stub_movement_calls = 0;
|
||||
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, &opflags), "simulate masked to layer 3");
|
||||
TEST_ASSERT(e, stub_movement_calls == 1,
|
||||
"layer mask 3 processed %d actors, expected 1", stub_movement_calls);
|
||||
|
||||
// Masking to a layer with no actors processes nothing.
|
||||
opflags.layerid = 7;
|
||||
stub_movement_calls = 0;
|
||||
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, &opflags), "simulate masked to an empty layer");
|
||||
TEST_ASSERT(e, stub_movement_calls == 0,
|
||||
"empty layer mask processed %d actors, expected 0", stub_movement_calls);
|
||||
|
||||
// Without the mask flag, layerid is ignored and both actors are processed.
|
||||
opflags.flags = 0;
|
||||
opflags.layerid = 7;
|
||||
stub_movement_calls = 0;
|
||||
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, &opflags), "simulate with the layer mask off");
|
||||
TEST_ASSERT(e, stub_movement_calls == 2,
|
||||
"unmasked simulate processed %d actors, expected 2", stub_movement_calls);
|
||||
|
||||
// A NULL iterator selects the documented defaults, which mask nothing.
|
||||
stub_movement_calls = 0;
|
||||
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with NULL opflags");
|
||||
TEST_ASSERT(e, stub_movement_calls == 2,
|
||||
"NULL opflags processed %d actors, expected 2", stub_movement_calls);
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_physics_simulate_thrust_and_clamp(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_PhysicsBackend backend;
|
||||
akgl_Actor *actor = NULL;
|
||||
akgl_Character *basechar = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, reset_sim_heap());
|
||||
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
||||
CATCH(e, akgl_physics_init_null(&backend));
|
||||
CATCH(e, make_sim_actor(&actor, &basechar, "thruster"));
|
||||
|
||||
// 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 = 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);
|
||||
|
||||
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);
|
||||
|
||||
// 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 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;
|
||||
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with in-range thrust");
|
||||
TEST_ASSERT_FEQ(e, actor->tx, 10.0f, "in-range tx changed to %f", actor->tx);
|
||||
TEST_ASSERT_FEQ(e, actor->ty, -10.0f, "in-range ty changed to %f", actor->ty);
|
||||
TEST_ASSERT_FEQ(e, actor->tz, 5.0f, "in-range tz changed to %f", actor->tz);
|
||||
|
||||
// Velocity is the sum of environmental force and thrust.
|
||||
actor->ex = 3.0f; actor->ey = 4.0f; actor->ez = 5.0f;
|
||||
actor->tx = 1.0f; actor->ty = 2.0f; actor->tz = 3.0f;
|
||||
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate velocity composition");
|
||||
TEST_ASSERT_FEQ(e, actor->vx, 4.0f, "vx composed to %f, expected ex 3 + tx 1", actor->vx);
|
||||
TEST_ASSERT_FEQ(e, actor->vy, 6.0f, "vy composed to %f, expected ey 4 + ty 2", actor->vy);
|
||||
TEST_ASSERT_FEQ(e, actor->vz, 8.0f, "vz composed to %f, expected ez 5 + tz 3", actor->vz);
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_physics_simulate_drag(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_PhysicsBackend backend;
|
||||
akgl_Actor *actor = NULL;
|
||||
akgl_Character *basechar = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, reset_sim_heap());
|
||||
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
||||
CATCH(e, akgl_physics_init_null(&backend));
|
||||
CATCH(e, make_sim_actor(&actor, &basechar, "dragged"));
|
||||
|
||||
// Zero drag leaves environmental velocity untouched.
|
||||
actor->ex = 100.0f; actor->ey = 100.0f; actor->ez = 100.0f;
|
||||
actor->sx = 1000.0f; actor->sy = 1000.0f; actor->sz = 1000.0f;
|
||||
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with zero drag");
|
||||
TEST_ASSERT_FEQ(e, actor->ex, 100.0f, "zero drag changed ex to %f", actor->ex);
|
||||
TEST_ASSERT_FEQ(e, actor->ey, 100.0f, "zero drag changed ey to %f", actor->ey);
|
||||
TEST_ASSERT_FEQ(e, actor->ez, 100.0f, "zero drag changed ez to %f", actor->ez);
|
||||
|
||||
// Nonzero drag bleeds off environmental velocity in proportion to dt.
|
||||
// The simulator derives dt from the wall clock, so assert the direction
|
||||
// and bounds of the change rather than an exact figure.
|
||||
backend.drag_x = 0.5;
|
||||
backend.drag_y = 0.5;
|
||||
backend.drag_z = 0.5;
|
||||
backend.gravity_time = SDL_GetTicksNS();
|
||||
SDL_Delay(20);
|
||||
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate with drag applied");
|
||||
TEST_ASSERT(e, actor->ex < 100.0f && actor->ex > 0.0f,
|
||||
"drag left ex at %f, expected a value between 0 and 100", actor->ex);
|
||||
TEST_ASSERT(e, actor->ey < 100.0f && actor->ey > 0.0f,
|
||||
"drag left ey at %f, expected a value between 0 and 100", actor->ey);
|
||||
TEST_ASSERT(e, actor->ez < 100.0f && actor->ez > 0.0f,
|
||||
"drag left ez at %f, expected a value between 0 and 100", actor->ez);
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_physics_simulate_movement_states(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_PhysicsBackend backend;
|
||||
akgl_Actor *actor = NULL;
|
||||
akgl_Character *basechar = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, reset_sim_heap());
|
||||
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
||||
CATCH(e, akgl_physics_init_null(&backend));
|
||||
CATCH(e, make_sim_actor(&actor, &basechar, "mover"));
|
||||
|
||||
actor->sx = 1000.0f; actor->sy = 1000.0f; actor->sz = 1000.0f;
|
||||
actor->ax = 10.0f; actor->ay = 20.0f;
|
||||
|
||||
// An idle actor accumulates no thrust on either axis.
|
||||
actor->state = 0;
|
||||
actor->tx = 0; actor->ty = 0;
|
||||
backend.gravity_time = SDL_GetTicksNS();
|
||||
SDL_Delay(10);
|
||||
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate an idle actor");
|
||||
TEST_ASSERT_FEQ(e, actor->tx, 0.0f, "idle actor accumulated tx %f", actor->tx);
|
||||
TEST_ASSERT_FEQ(e, actor->ty, 0.0f, "idle actor accumulated ty %f", actor->ty);
|
||||
|
||||
// Moving horizontally accumulates thrust on X only.
|
||||
actor->state = AKGL_ACTOR_STATE_MOVING_LEFT;
|
||||
actor->tx = 0; actor->ty = 0;
|
||||
backend.gravity_time = SDL_GetTicksNS();
|
||||
SDL_Delay(10);
|
||||
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate a horizontally moving actor");
|
||||
TEST_ASSERT(e, actor->tx > 0.0f, "MOVING_LEFT accumulated tx %f, expected a positive value", actor->tx);
|
||||
TEST_ASSERT_FEQ(e, actor->ty, 0.0f, "MOVING_LEFT leaked thrust into ty (%f)", actor->ty);
|
||||
|
||||
// Moving vertically accumulates thrust on Y only.
|
||||
actor->state = AKGL_ACTOR_STATE_MOVING_DOWN;
|
||||
actor->tx = 0; actor->ty = 0;
|
||||
backend.gravity_time = SDL_GetTicksNS();
|
||||
SDL_Delay(10);
|
||||
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate a vertically moving actor");
|
||||
TEST_ASSERT(e, actor->ty > 0.0f, "MOVING_DOWN accumulated ty %f, expected a positive value", actor->ty);
|
||||
TEST_ASSERT_FEQ(e, actor->tx, 0.0f, "MOVING_DOWN leaked thrust into tx (%f)", actor->tx);
|
||||
|
||||
// The right and up states drive the same accumulators.
|
||||
actor->state = (AKGL_ACTOR_STATE_MOVING_RIGHT | AKGL_ACTOR_STATE_MOVING_UP);
|
||||
actor->tx = 0; actor->ty = 0;
|
||||
backend.gravity_time = SDL_GetTicksNS();
|
||||
SDL_Delay(10);
|
||||
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL), "simulate a diagonally moving actor");
|
||||
TEST_ASSERT(e, actor->tx > 0.0f, "MOVING_RIGHT accumulated tx %f, expected a positive value", actor->tx);
|
||||
TEST_ASSERT(e, actor->ty > 0.0f, "MOVING_UP accumulated ty %f, expected a positive value", actor->ty);
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_physics_simulate_logic_interrupt(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_PhysicsBackend backend;
|
||||
akgl_Actor *first = NULL;
|
||||
akgl_Actor *second = NULL;
|
||||
akgl_Character *char1 = NULL;
|
||||
akgl_Character *char2 = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, reset_sim_heap());
|
||||
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
||||
CATCH(e, akgl_physics_init_null(&backend));
|
||||
|
||||
CATCH(e, make_sim_actor(&first, &char1, "interrupter"));
|
||||
CATCH(e, make_sim_actor(&second, &char2, "follower"));
|
||||
first->movementlogicfunc = &stub_movement_interrupt;
|
||||
|
||||
// AKGL_ERR_LOGICINTERRUPT means "skip me this frame", not "abort the frame",
|
||||
// so the second actor must still be reached.
|
||||
stub_movement_calls = 0;
|
||||
TEST_EXPECT_OK(e, akgl_physics_simulate(&backend, NULL),
|
||||
"simulate must swallow AKGL_ERR_LOGICINTERRUPT");
|
||||
TEST_ASSERT(e, stub_movement_calls == 2,
|
||||
"an interrupting actor stopped the loop after %d of 2 actors",
|
||||
stub_movement_calls);
|
||||
|
||||
// An unrelated error is not swallowed.
|
||||
CATCH(e, reset_sim_heap());
|
||||
CATCH(e, make_sim_actor(&first, &char1, "failer"));
|
||||
first->movementlogicfunc = &stub_movement_error;
|
||||
TEST_EXPECT_STATUS(e, AKERR_VALUE, akgl_physics_simulate(&backend, NULL),
|
||||
"simulate must propagate a non-interrupt movement error");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_physics_simulate_nullpointers(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
akgl_PhysicsBackend backend;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(e, reset_sim_heap());
|
||||
|
||||
// A backend with no move function cannot simulate.
|
||||
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_simulate(&backend, NULL),
|
||||
"simulate with no move function installed");
|
||||
|
||||
// A NULL backend must be reported, not dereferenced.
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_physics_simulate(NULL, NULL),
|
||||
"simulate with NULL self");
|
||||
} CLEANUP {
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
CATCH(errctx, akgl_heap_init());
|
||||
CATCH(errctx, akgl_registry_init());
|
||||
CATCH(errctx, akgl_registry_init_properties());
|
||||
|
||||
CATCH(errctx, test_physics_init_null());
|
||||
CATCH(errctx, test_physics_null_backend_is_inert());
|
||||
CATCH(errctx, test_physics_arcade_gravity());
|
||||
CATCH(errctx, test_physics_arcade_move());
|
||||
CATCH(errctx, test_physics_arcade_collide_unimplemented());
|
||||
CATCH(errctx, test_physics_factory());
|
||||
CATCH(errctx, test_physics_init_arcade_properties());
|
||||
CATCH(errctx, test_physics_simulate_skips_inactive());
|
||||
CATCH(errctx, test_physics_simulate_children_follow_parents());
|
||||
CATCH(errctx, test_physics_simulate_layer_mask());
|
||||
CATCH(errctx, test_physics_simulate_thrust_and_clamp());
|
||||
CATCH(errctx, test_physics_simulate_drag());
|
||||
CATCH(errctx, test_physics_simulate_movement_states());
|
||||
CATCH(errctx, test_physics_simulate_logic_interrupt());
|
||||
CATCH(errctx, test_physics_simulate_nullpointers());
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
}
|
||||
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;
|
||||
}
|
||||
216
tests/registry.c
216
tests/registry.c
@@ -1,7 +1,14 @@
|
||||
#include <SDL3/SDL.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <akerror.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/staticstring.h>
|
||||
|
||||
#include "testutil.h"
|
||||
|
||||
typedef akerr_ErrorContext *(*RegistryFuncPtr)(void);
|
||||
|
||||
@@ -17,7 +24,7 @@ akerr_ErrorContext *test_akgl_registry_init(RegistryFuncPtr funcptr)
|
||||
SDL_calloc_func calloc_func;
|
||||
SDL_realloc_func realloc_func;
|
||||
SDL_free_func free_func;
|
||||
|
||||
|
||||
SDL_GetMemoryFunctions(
|
||||
&malloc_func,
|
||||
&calloc_func,
|
||||
@@ -43,7 +50,7 @@ akerr_ErrorContext *test_akgl_registry_init(RegistryFuncPtr funcptr)
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
FAIL_RETURN(errctx, AKERR_BEHAVIOR, "SDL memory allocator fails but registry reports successful property creation");
|
||||
FAIL_RETURN(errctx, AKGL_ERR_BEHAVIOR, "SDL memory allocator fails but registry reports successful property creation");
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_akgl_registry_init_creation_failures(void)
|
||||
@@ -54,7 +61,7 @@ akerr_ErrorContext *test_akgl_registry_init_creation_failures(void)
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE(errctx, AKERR_NULLPOINTER) {
|
||||
printf("Sucess\n");
|
||||
printf("Sucess\n");
|
||||
} FINISH(errctx, true);
|
||||
|
||||
ATTEMPT {
|
||||
@@ -62,7 +69,7 @@ akerr_ErrorContext *test_akgl_registry_init_creation_failures(void)
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE(errctx, AKERR_NULLPOINTER) {
|
||||
printf("Sucess\n");
|
||||
printf("Sucess\n");
|
||||
} FINISH(errctx, true);
|
||||
|
||||
ATTEMPT {
|
||||
@@ -70,7 +77,7 @@ akerr_ErrorContext *test_akgl_registry_init_creation_failures(void)
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE(errctx, AKERR_NULLPOINTER) {
|
||||
printf("Sucess\n");
|
||||
printf("Sucess\n");
|
||||
} FINISH(errctx, true);
|
||||
|
||||
ATTEMPT {
|
||||
@@ -78,7 +85,200 @@ akerr_ErrorContext *test_akgl_registry_init_creation_failures(void)
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE(errctx, AKERR_NULLPOINTER) {
|
||||
printf("Sucess\n");
|
||||
printf("Sucess\n");
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief akgl_get_property must copy the value, and not a byte more.
|
||||
*
|
||||
* It used to copy a fixed AKGL_MAX_STRING_LENGTH bytes out of whatever SDL
|
||||
* returned, and what SDL returns is its own strdup of the value -- four bytes
|
||||
* for "0.0". That read up to 4 KiB past the end of somebody else's allocation on
|
||||
* every call, which valgrind reported twelve times over in tests/physics.c
|
||||
* alone.
|
||||
*
|
||||
* The destination is filled with a sentinel first, so the assertion is not
|
||||
* "the value arrived" -- that would pass either way -- but "the bytes past the
|
||||
* terminator were left alone", which is only true if the copy was bounded by
|
||||
* the source.
|
||||
*/
|
||||
akerr_ErrorContext *test_akgl_get_property_copies_only_the_value(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_String *value = NULL;
|
||||
char oversized[AKGL_MAX_STRING_LENGTH + 8];
|
||||
size_t valuelen = 0;
|
||||
size_t i = 0;
|
||||
bool untouched = true;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_init());
|
||||
CATCH(errctx, akgl_registry_init_properties());
|
||||
CATCH(errctx, akgl_heap_next_string(&value));
|
||||
|
||||
memset(&value->data, 0x5a, AKGL_MAX_STRING_LENGTH);
|
||||
CATCH(errctx, akgl_set_property("registry.short", "0.0"));
|
||||
CATCH(errctx, akgl_get_property("registry.short", &value, "unset"));
|
||||
|
||||
valuelen = strlen("0.0");
|
||||
TEST_ASSERT(errctx, strcmp(value->data, "0.0") == 0,
|
||||
"akgl_get_property returned \"%s\", expected \"0.0\"", value->data);
|
||||
for ( i = (valuelen + 1); i < AKGL_MAX_STRING_LENGTH; i++ ) {
|
||||
TEST_ASSERT_FLAG(untouched, (value->data[i] == 0x5a));
|
||||
}
|
||||
TEST_ASSERT(errctx, untouched,
|
||||
"akgl_get_property wrote past the end of a %zu byte value", valuelen);
|
||||
|
||||
// The default takes the same path as a stored value.
|
||||
CATCH(errctx, akgl_get_property("registry.absent", &value, "fallback"));
|
||||
TEST_ASSERT(errctx, strcmp(value->data, "fallback") == 0,
|
||||
"akgl_get_property returned \"%s\" for an unset property, expected the default",
|
||||
value->data);
|
||||
|
||||
// A value too long for an akgl_String is refused rather than truncated
|
||||
// into an unterminated buffer.
|
||||
memset(&oversized, 'x', sizeof(oversized));
|
||||
oversized[AKGL_MAX_STRING_LENGTH + 7] = '\0';
|
||||
CATCH(errctx, akgl_set_property("registry.oversized", (char *)&oversized));
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_get_property("registry.oversized", &value, "unset"),
|
||||
"reading a property longer than an akgl_String");
|
||||
|
||||
// And the documented refusal survives: unset, with no default.
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_get_property("registry.absent", &value, NULL),
|
||||
"reading an unset property with no default");
|
||||
} CLEANUP {
|
||||
if ( value != NULL ) {
|
||||
IGNORE(akgl_heap_release_string(value));
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Every actor-state bit must be nameable, and every name must map to its own bit.
|
||||
*
|
||||
* akgl_registry_init_actor_state_strings walks AKGL_ACTOR_STATE_STRING_NAMES
|
||||
* and registers entry `i` under the value `1 << i`. Character JSON resolves
|
||||
* state names through that registry, so an entry that disagrees with the
|
||||
* `#define` in actor.h is a state no character can ever bind a sprite to. Bits
|
||||
* 11 and 12 were `UNDEFINED_11` and `UNDEFINED_12` here while actor.h called
|
||||
* them MOVING_IN and MOVING_OUT, so both were unreachable by name.
|
||||
*
|
||||
* The loop also pins the array's length: it reads exactly
|
||||
* AKGL_ACTOR_MAX_STATES entries, which is what the header declares and what
|
||||
* the definition is now sized by. It was declared one longer than defined.
|
||||
*/
|
||||
akerr_ErrorContext *test_actor_state_string_names(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
int i = 0;
|
||||
int j = 0;
|
||||
Sint64 registered = 0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_registry_init_actor_state_strings());
|
||||
|
||||
for ( i = 0; i < AKGL_ACTOR_MAX_STATES; i++ ) {
|
||||
TEST_ASSERT(errctx, AKGL_ACTOR_STATE_STRING_NAMES[i] != NULL,
|
||||
"actor state bit %d has no name", i);
|
||||
|
||||
registered = SDL_GetNumberProperty(
|
||||
AKGL_REGISTRY_ACTOR_STATE_STRINGS,
|
||||
AKGL_ACTOR_STATE_STRING_NAMES[i],
|
||||
0);
|
||||
TEST_ASSERT(errctx, registered == (Sint64)(1 << i),
|
||||
"state name %s resolves to %lld, expected %d",
|
||||
AKGL_ACTOR_STATE_STRING_NAMES[i],
|
||||
(long long)registered,
|
||||
(1 << i));
|
||||
|
||||
// No two entries may share a name, or the later one silently
|
||||
// overwrites the earlier in the registry and one bit becomes
|
||||
// unreachable without anything looking wrong.
|
||||
for ( j = 0; j < i; j++ ) {
|
||||
TEST_ASSERT(errctx,
|
||||
strcmp(AKGL_ACTOR_STATE_STRING_NAMES[i],
|
||||
AKGL_ACTOR_STATE_STRING_NAMES[j]) != 0,
|
||||
"actor state bits %d and %d share the name %s",
|
||||
j, i, AKGL_ACTOR_STATE_STRING_NAMES[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// The two that were unreachable, named explicitly so a regression reads
|
||||
// as what it is rather than as an index.
|
||||
TEST_ASSERT(errctx,
|
||||
SDL_GetNumberProperty(AKGL_REGISTRY_ACTOR_STATE_STRINGS,
|
||||
"AKGL_ACTOR_STATE_MOVING_IN", 0)
|
||||
== (Sint64)AKGL_ACTOR_STATE_MOVING_IN,
|
||||
"AKGL_ACTOR_STATE_MOVING_IN is not resolvable by name");
|
||||
TEST_ASSERT(errctx,
|
||||
SDL_GetNumberProperty(AKGL_REGISTRY_ACTOR_STATE_STRINGS,
|
||||
"AKGL_ACTOR_STATE_MOVING_OUT", 0)
|
||||
== (Sint64)AKGL_ACTOR_STATE_MOVING_OUT,
|
||||
"AKGL_ACTOR_STATE_MOVING_OUT is not resolvable by name");
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Re-initializing a registry must replace the old set, not abandon it.
|
||||
*
|
||||
* Only akgl_registry_init_actor destroyed the set it was replacing; the other
|
||||
* seven leaked an SDL_PropertiesID on every call after the first. A game that
|
||||
* resets between levels calls these repeatedly.
|
||||
*
|
||||
* Also checks that akgl_registry_init() brings up the properties registry.
|
||||
* It did not until 0.5.0, so akgl_set_property was a silent no-op for any
|
||||
* caller that had not also gone through akgl_game_init.
|
||||
*/
|
||||
akerr_ErrorContext *test_akgl_registry_init_is_repeatable(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_String *readback = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_registry_init());
|
||||
TEST_ASSERT(errctx, AKGL_REGISTRY_PROPERTIES != 0,
|
||||
"akgl_registry_init did not create the properties registry");
|
||||
|
||||
// Whether the old set was *destroyed* is not observable from here --
|
||||
// SDL hands out no liveness query, and it reuses ids -- so that half is
|
||||
// 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.
|
||||
SDL_SetNumberProperty(AKGL_REGISTRY_SPRITE, "akgl_test_sentinel", 1);
|
||||
TEST_ASSERT(errctx,
|
||||
SDL_GetNumberProperty(AKGL_REGISTRY_SPRITE, "akgl_test_sentinel", 0) == 1,
|
||||
"could not write a sentinel into the sprite registry");
|
||||
CATCH(errctx, akgl_registry_init_sprite());
|
||||
TEST_ASSERT(errctx, AKGL_REGISTRY_SPRITE != 0,
|
||||
"re-initializing the sprite registry left it unset");
|
||||
TEST_ASSERT(errctx,
|
||||
SDL_GetNumberProperty(AKGL_REGISTRY_SPRITE, "akgl_test_sentinel", 0) == 0,
|
||||
"re-initializing the sprite registry kept the old contents");
|
||||
|
||||
// Configuration set through the registry has to survive and read back,
|
||||
// which is the behaviour the missing initializer was breaking.
|
||||
CATCH(errctx, akgl_registry_init());
|
||||
TEST_EXPECT_OK(errctx, akgl_set_property("test.property", "1234"),
|
||||
"setting a property after akgl_registry_init");
|
||||
TEST_EXPECT_OK(errctx, akgl_get_property("test.property", &readback, NULL),
|
||||
"reading a property back after akgl_registry_init");
|
||||
TEST_ASSERT(errctx, strcmp((char *)&readback->data, "1234") == 0,
|
||||
"property read back as '%s', expected '1234'",
|
||||
(char *)&readback->data);
|
||||
} CLEANUP {
|
||||
if ( readback != NULL ) {
|
||||
IGNORE(akgl_heap_release_string(readback));
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
@@ -87,7 +287,11 @@ int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
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());
|
||||
CATCH(errctx, test_akgl_registry_init_is_repeatable());
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
|
||||
286
tests/renderer.c
Normal file
286
tests/renderer.c
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* @file renderer.c
|
||||
* @brief Unit tests for the 2D render backend's vtable and its entry points.
|
||||
*
|
||||
* None of this needs a display. akgl_render_2d_bind() installs the backend's
|
||||
* methods and nothing else, so it can be checked against a backend that has no
|
||||
* SDL_Renderer at all; the entry points that do draw are checked against a
|
||||
* software renderer under the dummy video driver, the same way tests/draw.c
|
||||
* does it.
|
||||
*
|
||||
* akgl_render_2d_init() is not covered here: it creates a window from the
|
||||
* property registry and writes the `camera` global, which is what the offscreen
|
||||
* harness described in TODO.md exists to make testable.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <string.h>
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/renderer.h>
|
||||
#include <akgl/registry.h>
|
||||
|
||||
#include "testutil.h"
|
||||
|
||||
/** @brief Width and height of the offscreen target the drawing tests use. */
|
||||
#define TEST_TARGET_SIZE 32
|
||||
|
||||
/** @brief A backend bound to a live software renderer, built by main(). */
|
||||
static akgl_RenderBackend bound;
|
||||
|
||||
akerr_ErrorContext *test_render_bind2d(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_RenderBackend backend;
|
||||
|
||||
ATTEMPT {
|
||||
// The state a host is in when it owns its own window: a zeroed backend
|
||||
// with somebody else's SDL_Renderer in it. Binding must fill in the six
|
||||
// methods and leave that renderer alone -- creating a second window is
|
||||
// precisely what an embedded interpreter must not do.
|
||||
memset(&backend, 0x00, sizeof(akgl_RenderBackend));
|
||||
backend.sdl_renderer = (SDL_Renderer *)&bound;
|
||||
|
||||
TEST_EXPECT_OK(errctx, akgl_render_2d_bind(&backend), "binding the 2D backend");
|
||||
|
||||
TEST_ASSERT(errctx, backend.sdl_renderer == (SDL_Renderer *)&bound,
|
||||
"binding replaced the caller's SDL_Renderer");
|
||||
TEST_ASSERT(errctx, backend.shutdown == &akgl_render_2d_shutdown,
|
||||
"binding did not install shutdown");
|
||||
TEST_ASSERT(errctx, backend.frame_start == &akgl_render_2d_frame_start,
|
||||
"binding did not install frame_start");
|
||||
TEST_ASSERT(errctx, backend.frame_end == &akgl_render_2d_frame_end,
|
||||
"binding did not install frame_end");
|
||||
TEST_ASSERT(errctx, backend.draw_texture == &akgl_render_2d_draw_texture,
|
||||
"binding did not install draw_texture");
|
||||
TEST_ASSERT(errctx, backend.draw_mesh == &akgl_render_2d_draw_mesh,
|
||||
"binding did not install draw_mesh");
|
||||
TEST_ASSERT(errctx, backend.draw_world == &akgl_render_2d_draw_world,
|
||||
"binding did not install draw_world");
|
||||
|
||||
// Binding a backend that has no renderer yet is legal -- the two halves
|
||||
// are separable in both directions -- and leaves it NULL rather than
|
||||
// inventing one.
|
||||
memset(&backend, 0x00, sizeof(akgl_RenderBackend));
|
||||
TEST_EXPECT_OK(errctx, akgl_render_2d_bind(&backend),
|
||||
"binding a backend with no SDL renderer");
|
||||
TEST_ASSERT(errctx, backend.sdl_renderer == NULL,
|
||||
"binding invented an SDL_Renderer");
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_render_2d_bind(NULL),
|
||||
"binding a NULL backend");
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_render_backend_without_a_renderer(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_RenderBackend empty;
|
||||
|
||||
ATTEMPT {
|
||||
// Bound but never given an SDL_Renderer. Every entry point that draws
|
||||
// has to report that rather than dereference it.
|
||||
memset(&empty, 0x00, sizeof(akgl_RenderBackend));
|
||||
CATCH(errctx, akgl_render_2d_bind(&empty));
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, empty.frame_start(&empty),
|
||||
"starting a frame on a backend with no renderer");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, empty.frame_end(&empty),
|
||||
"ending a frame on a backend with no renderer");
|
||||
|
||||
// And a NULL backend outright. frame_start and frame_end used to read
|
||||
// self->sdl_renderer with no check on self at all, which is a segfault
|
||||
// rather than an error, while draw_texture beside them checked it.
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_render_2d_frame_start(NULL),
|
||||
"starting a frame on a NULL backend");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_render_2d_frame_end(NULL),
|
||||
"ending a frame on a NULL backend");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_render_2d_shutdown(NULL),
|
||||
"shutting down a NULL backend");
|
||||
|
||||
// Shutdown is the one that has nothing to release yet, so it succeeds.
|
||||
TEST_EXPECT_OK(errctx, empty.shutdown(&empty), "shutting down an unused backend");
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_render_frames_and_textures(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_Surface *surface = NULL;
|
||||
SDL_Texture *texture = NULL;
|
||||
SDL_FRect dest;
|
||||
SDL_FPoint center;
|
||||
|
||||
ATTEMPT {
|
||||
dest.x = 0.0f;
|
||||
dest.y = 0.0f;
|
||||
dest.w = 8.0f;
|
||||
dest.h = 8.0f;
|
||||
center.x = 4.0f;
|
||||
center.y = 4.0f;
|
||||
|
||||
surface = SDL_CreateSurface(8, 8, SDL_PIXELFORMAT_RGBA32);
|
||||
FAIL_ZERO_BREAK(errctx, surface, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
texture = SDL_CreateTextureFromSurface(bound.sdl_renderer, surface);
|
||||
FAIL_ZERO_BREAK(errctx, texture, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
|
||||
TEST_EXPECT_OK(errctx, bound.frame_start(&bound), "starting a frame");
|
||||
TEST_EXPECT_OK(errctx,
|
||||
bound.draw_texture(&bound, texture, NULL, &dest, 0, NULL, SDL_FLIP_NONE),
|
||||
"blitting a texture unrotated");
|
||||
// A non-zero angle takes the rotated path, which is the one that
|
||||
// consults the pivot and the flip mode.
|
||||
TEST_EXPECT_OK(errctx,
|
||||
bound.draw_texture(&bound, texture, NULL, &dest, 90.0, ¢er, SDL_FLIP_HORIZONTAL),
|
||||
"blitting a texture rotated");
|
||||
TEST_EXPECT_OK(errctx, bound.frame_end(&bound), "ending a frame");
|
||||
|
||||
// A rotation with no pivot is refused: SDL's "NULL means the centre of
|
||||
// dest" convention is not offered here, so a NULL is a caller mistake
|
||||
// rather than a default.
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
bound.draw_texture(&bound, texture, NULL, &dest, 90.0, NULL, SDL_FLIP_NONE),
|
||||
"blitting rotated with no pivot");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_render_2d_draw_texture(NULL, texture, NULL, &dest, 0, NULL, SDL_FLIP_NONE),
|
||||
"blitting through a NULL backend");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
bound.draw_texture(&bound, NULL, NULL, &dest, 0, NULL, SDL_FLIP_NONE),
|
||||
"blitting a NULL texture");
|
||||
} CLEANUP {
|
||||
if ( texture != NULL ) {
|
||||
SDL_DestroyTexture(texture);
|
||||
}
|
||||
if ( surface != NULL ) {
|
||||
SDL_DestroySurface(surface);
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_render_unimplemented_and_guards(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
// The 3D hook is not a stub that quietly does nothing. It refuses, so a
|
||||
// caller that reaches it finds out on the first frame.
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_API, bound.draw_mesh(&bound),
|
||||
"drawing a mesh through the 2D backend");
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_render_2d_draw_world(NULL, NULL),
|
||||
"drawing the world through a NULL backend");
|
||||
|
||||
// Shutting the 2D backend down releases nothing today, and says so.
|
||||
TEST_EXPECT_OK(errctx, bound.shutdown(&bound), "shutting the backend down");
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief akgl_render_2d_init must give back its pooled strings when a parse fails.
|
||||
*
|
||||
* It reads game.screenwidth and game.screenheight into two pool strings and
|
||||
* used to release them only after both had parsed, so a non-numeric value
|
||||
* returned past both and leaked two of the pool's 256 entries -- every time a
|
||||
* host started with a bad configuration and retried.
|
||||
*
|
||||
* The window creation after the parse is what makes the success path need a
|
||||
* display, so this only drives the failure path. That is the one that leaked.
|
||||
*/
|
||||
akerr_ErrorContext *test_render_2d_init_releases_strings_on_failure(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_RenderBackend backend;
|
||||
int baseline = 0;
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_registry_init_properties());
|
||||
memset(&backend, 0x00, sizeof(akgl_RenderBackend));
|
||||
|
||||
CATCH(errctx, akgl_set_property("game.screenwidth", "not-a-number"));
|
||||
CATCH(errctx, akgl_set_property("game.screenheight", "480"));
|
||||
|
||||
baseline = test_string_pool_used();
|
||||
for ( i = 0; i < (AKGL_MAX_HEAP_STRING * 2); i++ ) {
|
||||
TEST_EXPECT_ANY_ERROR(errctx, akgl_render_2d_init(&backend),
|
||||
"initializing a 2D renderer from an unparseable width");
|
||||
}
|
||||
TEST_ASSERT(errctx, test_string_pool_used() == baseline,
|
||||
"%d failed initializations left %d pool strings claimed, expected %d",
|
||||
(AKGL_MAX_HEAP_STRING * 2), test_string_pool_used(), baseline);
|
||||
|
||||
// The second property is the one read after the first parse, so fail on
|
||||
// it too and check the other order.
|
||||
CATCH(errctx, akgl_set_property("game.screenwidth", "640"));
|
||||
CATCH(errctx, akgl_set_property("game.screenheight", "also-not-a-number"));
|
||||
baseline = test_string_pool_used();
|
||||
for ( i = 0; i < (AKGL_MAX_HEAP_STRING * 2); i++ ) {
|
||||
TEST_EXPECT_ANY_ERROR(errctx, akgl_render_2d_init(&backend),
|
||||
"initializing a 2D renderer from an unparseable height");
|
||||
}
|
||||
TEST_ASSERT(errctx, test_string_pool_used() == baseline,
|
||||
"%d failed initializations left %d pool strings claimed, expected %d",
|
||||
(AKGL_MAX_HEAP_STRING * 2), test_string_pool_used(), baseline);
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
memset(&bound, 0x00, sizeof(akgl_RenderBackend));
|
||||
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_Init(SDL_INIT_VIDEO),
|
||||
AKGL_ERR_SDL,
|
||||
"Couldn't initialize SDL: %s",
|
||||
SDL_GetError());
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_CreateWindowAndRenderer(
|
||||
"net/aklabs/libakgl/test_renderer",
|
||||
TEST_TARGET_SIZE,
|
||||
TEST_TARGET_SIZE,
|
||||
0,
|
||||
&akgl_window,
|
||||
&bound.sdl_renderer),
|
||||
AKGL_ERR_SDL,
|
||||
"Couldn't create window/renderer: %s",
|
||||
SDL_GetError());
|
||||
// The host owns the window; libakgl only ever binds to it. This is the
|
||||
// arrangement akgl_render_2d_bind exists for.
|
||||
CATCH(errctx, akgl_render_2d_bind(&bound));
|
||||
|
||||
CATCH(errctx, test_render_bind2d());
|
||||
CATCH(errctx, test_render_backend_without_a_renderer());
|
||||
CATCH(errctx, test_render_frames_and_textures());
|
||||
CATCH(errctx, test_render_unimplemented_and_guards());
|
||||
CATCH(errctx, test_render_2d_init_releases_strings_on_failure());
|
||||
} CLEANUP {
|
||||
SDL_Quit();
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
}
|
||||
292
tests/sprite.c
292
tests/sprite.c
@@ -10,59 +10,61 @@
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/util.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/renderer.h>
|
||||
|
||||
#include "testutil.h"
|
||||
|
||||
SDL_Window *window;
|
||||
SDL_Renderer *renderer;
|
||||
|
||||
akerr_ErrorContext *test_akgl_spritesheet_initialize(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_SpriteSheet *sheet = NULL;
|
||||
SDL_Texture *image = NULL;
|
||||
akgl_String *tmpstr = NULL;
|
||||
// Does the image file get loaded?
|
||||
// Is the image file loaded correctly? (Surface comparison)
|
||||
// Is the spritesheet in the registry?
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_spritesheet(&sheet));
|
||||
CATCH(errctx, akgl_heap_next_string(&tmpstr));
|
||||
|
||||
snprintf((char *)&tmpstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets/spritesheet.png");
|
||||
CATCH(errctx, akgl_spritesheet_initialize(sheet, 48, 48, "assets/spritesheet.png"));
|
||||
FAIL_ZERO_BREAK(errctx, sheet->texture, AKERR_VALUE, "akgl_spritesheet_initialize failed to load the sprite texture");
|
||||
FAIL_NONZERO_BREAK(
|
||||
errctx,
|
||||
((sheet->texture->w != 576) || (sheet->texture->h != 384)),
|
||||
AKERR_VALUE,
|
||||
"Loaded texture was not the correct size");
|
||||
|
||||
snprintf((char *)&tmpstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets/spritesheet.png");
|
||||
image = IMG_LoadTexture(renderer, (char *)&tmpstr->data);
|
||||
FAIL_ZERO_BREAK(errctx, image, AKGL_ERR_SDL, "Failed to load comparison image");
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_SpriteSheet *sheet = NULL;
|
||||
SDL_Texture *image = NULL;
|
||||
akgl_String *tmpstr = NULL;
|
||||
// Does the image file get loaded?
|
||||
// Is the image file loaded correctly? (Surface comparison)
|
||||
// Is the spritesheet in the registry?
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_spritesheet(&sheet));
|
||||
CATCH(errctx, akgl_heap_next_string(&tmpstr));
|
||||
|
||||
CATCH(
|
||||
errctx,
|
||||
akgl_render_and_compare(
|
||||
sheet->texture,
|
||||
image,
|
||||
0, 0, 576, 384,
|
||||
"test_spritesheet_loaded_image.png")
|
||||
);
|
||||
snprintf((char *)&tmpstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets/spritesheet.png");
|
||||
CATCH(errctx, akgl_spritesheet_initialize(sheet, 48, 48, "assets/spritesheet.png"));
|
||||
FAIL_ZERO_BREAK(errctx, sheet->texture, AKERR_VALUE, "akgl_spritesheet_initialize failed to load the sprite texture");
|
||||
FAIL_NONZERO_BREAK(
|
||||
errctx,
|
||||
((sheet->texture->w != 576) || (sheet->texture->h != 384)),
|
||||
AKERR_VALUE,
|
||||
"Loaded texture was not the correct size");
|
||||
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_GetPointerProperty(AKGL_REGISTRY_SPRITESHEET, "assets/spritesheet.png", NULL),
|
||||
AKERR_KEY,
|
||||
"Spritesheet was not placed in the registry");
|
||||
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_string(tmpstr));
|
||||
IGNORE(akgl_heap_release_spritesheet(sheet));
|
||||
if ( image != NULL )
|
||||
SDL_DestroyTexture(image);
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
snprintf((char *)&tmpstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets/spritesheet.png");
|
||||
image = IMG_LoadTexture(akgl_renderer->sdl_renderer, (char *)&tmpstr->data);
|
||||
FAIL_ZERO_BREAK(errctx, image, AKGL_ERR_SDL, "Failed to load comparison image");
|
||||
|
||||
CATCH(
|
||||
errctx,
|
||||
akgl_render_and_compare(
|
||||
sheet->texture,
|
||||
image,
|
||||
0, 0, 576, 384,
|
||||
"test_spritesheet_loaded_image.png")
|
||||
);
|
||||
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_GetPointerProperty(AKGL_REGISTRY_SPRITESHEET, "assets/spritesheet.png", NULL),
|
||||
AKERR_KEY,
|
||||
"Spritesheet was not placed in the registry");
|
||||
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_string(tmpstr));
|
||||
IGNORE(akgl_heap_release_spritesheet(sheet));
|
||||
if ( image != NULL )
|
||||
SDL_DestroyTexture(image);
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_akgl_sprite_initialize(void)
|
||||
@@ -71,28 +73,28 @@ akerr_ErrorContext *test_akgl_sprite_initialize(void)
|
||||
akgl_SpriteSheet *testsheet = NULL;
|
||||
akgl_Sprite *testsprite = NULL;
|
||||
akgl_String *tmpstr = NULL;
|
||||
|
||||
|
||||
// Does the sprite get loaded?
|
||||
// Do all frames of the sprite get loaded?
|
||||
// Are all the frames of the sprite what we expect? (Surface comparison)
|
||||
// Is the sprite added to the registry?
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_spritesheet(&testsheet));
|
||||
CATCH(errctx, akgl_heap_next_sprite(&testsprite));
|
||||
CATCH(errctx, akgl_heap_next_string(&tmpstr));
|
||||
|
||||
snprintf((char *)&tmpstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets/spritesheet.png");
|
||||
CATCH(errctx, akgl_spritesheet_initialize(testsheet, 48, 48, "assets/spritesheet.png"));
|
||||
FAIL_ZERO_BREAK(errctx, testsheet, AKERR_VALUE, "akgl_spritesheet_initialize failed");
|
||||
CATCH(errctx, akgl_heap_next_spritesheet(&testsheet));
|
||||
CATCH(errctx, akgl_heap_next_sprite(&testsprite));
|
||||
CATCH(errctx, akgl_heap_next_string(&tmpstr));
|
||||
|
||||
snprintf((char *)&tmpstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets/spritesheet.png");
|
||||
CATCH(errctx, akgl_spritesheet_initialize(testsheet, 48, 48, "assets/spritesheet.png"));
|
||||
FAIL_ZERO_BREAK(errctx, testsheet, AKERR_VALUE, "akgl_spritesheet_initialize failed");
|
||||
|
||||
CATCH(errctx, akgl_sprite_initialize(testsprite, "test", testsheet));
|
||||
FAIL_NONZERO_BREAK(errctx, (testsprite->sheet != testsheet), AKERR_VALUE, "Initialized sprite uses wrong sheet");
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "test", NULL),
|
||||
AKERR_KEY,
|
||||
"Sprite was not placed in the registry");
|
||||
|
||||
CATCH(errctx, akgl_sprite_initialize(testsprite, "test", testsheet));
|
||||
FAIL_NONZERO_BREAK(errctx, (testsprite->sheet != testsheet), AKERR_VALUE, "Initialized sprite uses wrong sheet");
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "test", NULL),
|
||||
AKERR_KEY,
|
||||
"Sprite was not placed in the registry");
|
||||
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_sprite(testsprite));
|
||||
IGNORE(akgl_heap_release_string(tmpstr));
|
||||
@@ -108,65 +110,65 @@ akerr_ErrorContext *test_akgl_sprite_load_json(void)
|
||||
akgl_Sprite *testsprite2 = NULL;
|
||||
akgl_String *tmpstr = NULL;
|
||||
SDL_Texture *image = NULL;
|
||||
|
||||
|
||||
// Does the sprite get loaded?
|
||||
// Do all frames of the sprite get loaded?
|
||||
// Are all the frames of the sprite what we expect? (Surface comparison)
|
||||
// Is the sprite added to the registry?
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_string(&tmpstr));
|
||||
|
||||
CATCH(errctx, akgl_sprite_load_json("assets/testsprite.json"));
|
||||
testsprite = SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite", NULL);
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
testsprite,
|
||||
AKERR_KEY,
|
||||
"akgl_sprite_load_json succeeds but sprite is not placed in the registry");
|
||||
CATCH(errctx, akgl_heap_next_string(&tmpstr));
|
||||
|
||||
FAIL_ZERO_BREAK(errctx, (testsprite->width == 48), AKERR_VALUE, "width incorrect (48 : %d)", testsprite->width);
|
||||
FAIL_ZERO_BREAK(errctx, (testsprite->height == 48), AKERR_VALUE, "height incorrect (48 : %d)", testsprite->height);
|
||||
FAIL_ZERO_BREAK(errctx, (testsprite->speed == 100000000), AKERR_VALUE, "speed incorrect (100 : %d)", testsprite->speed);
|
||||
FAIL_ZERO_BREAK(errctx, (testsprite->loop == true), AKERR_VALUE, "loop incorrect (1 : %d)", testsprite->loop);
|
||||
FAIL_ZERO_BREAK(errctx, (testsprite->loopReverse == true), AKERR_VALUE, "loopReverse incorrect (1 : %d)", testsprite->loopReverse);
|
||||
FAIL_ZERO_BREAK(errctx, (testsprite->frames == 3), AKERR_VALUE, "frame count incorrect (3 : %d)", testsprite->frames);
|
||||
FAIL_ZERO_BREAK(errctx, (testsprite->frameids[0] == 12), AKERR_VALUE, "frameids[0] incorrect (12 : %d)", testsprite->frameids[0]);
|
||||
FAIL_ZERO_BREAK(errctx, (testsprite->frameids[1] == 13), AKERR_VALUE, "frameids[1] incorrect (13 : %d)", testsprite->frameids[1]);
|
||||
FAIL_ZERO_BREAK(errctx, (testsprite->frameids[2] == 14), AKERR_VALUE, "frameids[2] incorrect (14 : %d)", testsprite->frameids[2]);
|
||||
FAIL_NONZERO_BREAK(errctx, strcmp(testsprite->name, "testsprite"), AKERR_VALUE, "name incorrect (testsprite : %s)", (char *)testsprite->name);
|
||||
CATCH(errctx, akgl_sprite_load_json("assets/testsprite.json"));
|
||||
testsprite = SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite", NULL);
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
testsprite,
|
||||
AKERR_KEY,
|
||||
"akgl_sprite_load_json succeeds but sprite is not placed in the registry");
|
||||
|
||||
// Is it using the right spritesheet?
|
||||
|
||||
snprintf((char *)&tmpstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets/spritesheet.png");
|
||||
image = IMG_LoadTexture(renderer, (char *)&tmpstr->data);
|
||||
FAIL_ZERO_BREAK(errctx, image, AKGL_ERR_SDL, "Failed to load comparison image");
|
||||
FAIL_ZERO_BREAK(errctx, (testsprite->width == 48), AKERR_VALUE, "width incorrect (48 : %d)", testsprite->width);
|
||||
FAIL_ZERO_BREAK(errctx, (testsprite->height == 48), AKERR_VALUE, "height incorrect (48 : %d)", testsprite->height);
|
||||
FAIL_ZERO_BREAK(errctx, (testsprite->speed == 100000000), AKERR_VALUE, "speed incorrect (100 : %d)", testsprite->speed);
|
||||
FAIL_ZERO_BREAK(errctx, (testsprite->loop == true), AKERR_VALUE, "loop incorrect (1 : %d)", testsprite->loop);
|
||||
FAIL_ZERO_BREAK(errctx, (testsprite->loopReverse == true), AKERR_VALUE, "loopReverse incorrect (1 : %d)", testsprite->loopReverse);
|
||||
FAIL_ZERO_BREAK(errctx, (testsprite->frames == 3), AKERR_VALUE, "frame count incorrect (3 : %d)", testsprite->frames);
|
||||
FAIL_ZERO_BREAK(errctx, (testsprite->frameids[0] == 12), AKERR_VALUE, "frameids[0] incorrect (12 : %d)", testsprite->frameids[0]);
|
||||
FAIL_ZERO_BREAK(errctx, (testsprite->frameids[1] == 13), AKERR_VALUE, "frameids[1] incorrect (13 : %d)", testsprite->frameids[1]);
|
||||
FAIL_ZERO_BREAK(errctx, (testsprite->frameids[2] == 14), AKERR_VALUE, "frameids[2] incorrect (14 : %d)", testsprite->frameids[2]);
|
||||
FAIL_NONZERO_BREAK(errctx, strcmp(testsprite->name, "testsprite"), AKERR_VALUE, "name incorrect (testsprite : %s)", (char *)testsprite->name);
|
||||
|
||||
CATCH(
|
||||
errctx,
|
||||
akgl_render_and_compare(
|
||||
testsprite->sheet->texture,
|
||||
image,
|
||||
0, 0, 576, 384,
|
||||
"test_sprite_loaded_from_json_sheet.png"
|
||||
)
|
||||
);
|
||||
// Is it using the right spritesheet?
|
||||
|
||||
// If we load a second sprite using the same sheet name, do they use the same sheet in memory?
|
||||
snprintf((char *)&tmpstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets/testsprite2.json");
|
||||
CATCH(errctx, akgl_sprite_load_json("assets/testsprite2.json"));
|
||||
testsprite2 = SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite2", NULL);
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
testsprite,
|
||||
AKERR_KEY,
|
||||
"akgl_sprite_load_json succeeds but second sprite is not placed in the registry");
|
||||
snprintf((char *)&tmpstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets/spritesheet.png");
|
||||
image = IMG_LoadTexture(akgl_renderer->sdl_renderer, (char *)&tmpstr->data);
|
||||
FAIL_ZERO_BREAK(errctx, image, AKGL_ERR_SDL, "Failed to load comparison image");
|
||||
|
||||
CATCH(
|
||||
errctx,
|
||||
akgl_render_and_compare(
|
||||
testsprite->sheet->texture,
|
||||
image,
|
||||
0, 0, 576, 384,
|
||||
"test_sprite_loaded_from_json_sheet.png"
|
||||
)
|
||||
);
|
||||
|
||||
// If we load a second sprite using the same sheet name, do they use the same sheet in memory?
|
||||
snprintf((char *)&tmpstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets/testsprite2.json");
|
||||
CATCH(errctx, akgl_sprite_load_json("assets/testsprite2.json"));
|
||||
testsprite2 = SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite2", NULL);
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
testsprite,
|
||||
AKERR_KEY,
|
||||
"akgl_sprite_load_json succeeds but second sprite is not placed in the registry");
|
||||
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
(testsprite->sheet == testsprite2->sheet),
|
||||
AKERR_VALUE,
|
||||
"Previously loaded spritesheets are not reused");
|
||||
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
(testsprite->sheet == testsprite2->sheet),
|
||||
AKERR_VALUE,
|
||||
"Previously loaded spritesheets are not reused");
|
||||
|
||||
} CLEANUP {
|
||||
if ( testsprite != NULL ) {
|
||||
IGNORE(akgl_heap_release_sprite(testsprite));
|
||||
@@ -182,21 +184,76 @@ akerr_ErrorContext *test_akgl_sprite_load_json(void)
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief A sprite definition must not be able to write past `frameids`.
|
||||
*
|
||||
* `frameids` is AKGL_SPRITE_MAX_FRAMES bytes and the loader took its count
|
||||
* straight from the document, so a definition with seventeen frames wrote past
|
||||
* the array, past the rest of akgl_Sprite, and into the next pool slot. It also
|
||||
* wrote through a `uint32_t *` cast of a `uint8_t *`, four bytes at a time,
|
||||
* which is why the overrun reached four bytes past the array rather than one.
|
||||
*
|
||||
* The neighbouring slot is claimed and stamped first, so the test fails on the
|
||||
* corruption rather than on whatever the corruption happens to do later.
|
||||
*/
|
||||
akerr_ErrorContext *test_akgl_sprite_load_json_bounds_frames(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_Sprite *loaded = NULL;
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
// Exactly the maximum is legal and must still load.
|
||||
TEST_EXPECT_OK(errctx, akgl_sprite_load_json("assets/testsprite_maxframes.json"),
|
||||
"loading a sprite with exactly AKGL_SPRITE_MAX_FRAMES frames");
|
||||
loaded = SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite_maxframes", NULL);
|
||||
FAIL_ZERO_BREAK(errctx, loaded, AKERR_KEY, "the max-frames sprite is not in the registry");
|
||||
TEST_ASSERT(errctx, loaded->frames == AKGL_SPRITE_MAX_FRAMES,
|
||||
"max-frames sprite loaded %d frames, expected %d",
|
||||
loaded->frames, AKGL_SPRITE_MAX_FRAMES);
|
||||
for ( i = 0; i < AKGL_SPRITE_MAX_FRAMES; i++ ) {
|
||||
TEST_ASSERT(errctx, loaded->frameids[i] == (uint8_t)i,
|
||||
"max-frames sprite frame %d is %d, expected %d",
|
||||
i, loaded->frameids[i], i);
|
||||
}
|
||||
|
||||
// One more than the maximum is refused, and nothing is registered.
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_sprite_load_json("assets/testsprite_toomanyframes.json"),
|
||||
"loading a sprite with more frames than the array holds");
|
||||
TEST_ASSERT(errctx,
|
||||
SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite_toomanyframes", NULL) == NULL,
|
||||
"a sprite with too many frames was registered anyway");
|
||||
|
||||
// A frame number a uint8_t cannot hold is refused rather than truncated
|
||||
// to something that indexes a different tile.
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_sprite_load_json("assets/testsprite_widecount.json"),
|
||||
"loading a sprite whose frame number does not fit a uint8_t");
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
|
||||
ATTEMPT {
|
||||
|
||||
CATCH(errctx, akgl_error_init());
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
|
||||
SDL_SetAppMetadata("SDL3-GameTest", "0.1", "net.aklabs.sdl3-gametest");
|
||||
|
||||
|
||||
if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_JOYSTICK | SDL_INIT_AUDIO )) {
|
||||
FAIL_BREAK(errctx, AKGL_ERR_SDL, "Couldn't initialize SDL: %s", SDL_GetError());
|
||||
}
|
||||
|
||||
if (!SDL_CreateWindowAndRenderer("net/aklabs/libakgl/test_sprite", 640, 480, 0, &window, &renderer)) {
|
||||
|
||||
if (!SDL_CreateWindowAndRenderer("net/aklabs/libakgl/test_sprite", 640, 480, 0, &akgl_window, &akgl_renderer->sdl_renderer)) {
|
||||
FAIL_BREAK(errctx, AKGL_ERR_SDL, "Couldn't create window/renderer: %s", SDL_GetError());
|
||||
}
|
||||
akgl_renderer->draw_texture = &akgl_render_2d_draw_texture;
|
||||
|
||||
CATCH(errctx, akgl_heap_init());
|
||||
CATCH(errctx, akgl_registry_init_sprite());
|
||||
@@ -205,6 +262,7 @@ int main(void)
|
||||
CATCH(errctx, test_akgl_spritesheet_initialize());
|
||||
CATCH(errctx, test_akgl_sprite_initialize());
|
||||
CATCH(errctx, test_akgl_sprite_load_json());
|
||||
CATCH(errctx, test_akgl_sprite_load_json_bounds_frames());
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
#include <string.h>
|
||||
#include <akerror.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/staticstring.h>
|
||||
|
||||
#include "testutil.h"
|
||||
|
||||
void reset_string_heap(void);
|
||||
|
||||
akerr_ErrorContext *test_fresh_heap_gives_strings(void)
|
||||
{
|
||||
akgl_String *ptr = NULL;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
for ( int i = 0; i < AKGL_MAX_HEAP_STRING - 1; i++ ) {
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_string(&ptr));
|
||||
} CLEANUP {
|
||||
reset_string_heap();
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
}
|
||||
akgl_String *ptr = NULL;
|
||||
|
||||
return 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
for ( int i = 0; i < AKGL_MAX_HEAP_STRING - 1; i++ ) {
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_string(&ptr));
|
||||
} CLEANUP {
|
||||
reset_string_heap();
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_string_heap_error_when_no_strings_left(void)
|
||||
@@ -27,7 +30,7 @@ akerr_ErrorContext *test_string_heap_error_when_no_strings_left(void)
|
||||
akgl_String *ptr;
|
||||
PREPARE_ERROR(errctx);
|
||||
for ( int i = 0; i < AKGL_MAX_HEAP_STRING; i++ ) {
|
||||
HEAP_STRING[i].refcount = 1;
|
||||
akgl_heap_strings[i].refcount = 1;
|
||||
}
|
||||
for ( int i = 0; i < AKGL_MAX_HEAP_STRING - 1; i++ ) {
|
||||
ATTEMPT {
|
||||
@@ -44,8 +47,8 @@ akerr_ErrorContext *test_string_heap_error_when_no_strings_left(void)
|
||||
|
||||
akerr_ErrorContext *test_string_heap_honors_refcount(void)
|
||||
{
|
||||
akgl_String *firstptr = &HEAP_STRING[0];
|
||||
akgl_String *secondptr = &HEAP_STRING[1];
|
||||
akgl_String *firstptr = &akgl_heap_strings[0];
|
||||
akgl_String *secondptr = &akgl_heap_strings[1];
|
||||
akgl_String *testptr = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
ATTEMPT {
|
||||
@@ -54,7 +57,7 @@ akerr_ErrorContext *test_string_heap_honors_refcount(void)
|
||||
FAIL_RETURN(
|
||||
errctx,
|
||||
AKERR_VALUE,
|
||||
"Expected testptr to equal (HEAP_STRING[0] = %p) but got %p",
|
||||
"Expected testptr to equal (akgl_heap_strings[0] = %p) but got %p",
|
||||
firstptr,
|
||||
testptr
|
||||
);
|
||||
@@ -67,7 +70,7 @@ akerr_ErrorContext *test_string_heap_honors_refcount(void)
|
||||
FAIL_RETURN(
|
||||
errctx,
|
||||
AKERR_VALUE,
|
||||
"Expected testptr to equal (HEAP_STRING[1] = %p) but got %p",
|
||||
"Expected testptr to equal (akgl_heap_strings[1] = %p) but got %p",
|
||||
secondptr,
|
||||
testptr
|
||||
);
|
||||
@@ -94,7 +97,7 @@ akerr_ErrorContext *test_strcpy_to_all_strings_no_segfault(void)
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_akgl_string_initialize(void)
|
||||
{
|
||||
@@ -110,8 +113,8 @@ akerr_ErrorContext *test_akgl_string_initialize(void)
|
||||
CATCH(errctx, akgl_string_initialize(ptr, "Test value"));
|
||||
FAIL_NONZERO_BREAK(errctx, strcmp((char *)&ptr->data, "Test value"), AKERR_VALUE, "Expected 'Test value', got %s", (char *)&ptr->data);
|
||||
|
||||
CATCH(errctx, akgl_heap_release_string(NULL));
|
||||
FAIL_BREAK(errctx, AKERR_BEHAVIOR, "Failure to properly handle NULL pointer");
|
||||
CATCH(errctx, akgl_heap_release_string(NULL));
|
||||
FAIL_BREAK(errctx, AKGL_ERR_BEHAVIOR, "Failure to properly handle NULL pointer");
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
@@ -123,15 +126,121 @@ akerr_ErrorContext *test_akgl_string_initialize(void)
|
||||
void reset_string_heap(void)
|
||||
{
|
||||
for ( int i = 0; i < AKGL_MAX_HEAP_STRING; i++ ) {
|
||||
memset(&HEAP_STRING[i], 0x00, sizeof(akgl_String));
|
||||
memset(&akgl_heap_strings[i], 0x00, sizeof(akgl_String));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief akgl_string_initialize must not write past the buffer it is zeroing.
|
||||
*
|
||||
* The `NULL` init path zeroed `sizeof(akgl_String)` bytes starting at `data`.
|
||||
* `data` begins after the `int refcount` in front of it, so that ran four bytes
|
||||
* past the end of the object -- straight onto the *next* pool slot's refcount,
|
||||
* which is the field the allocator uses to decide whether a slot is free.
|
||||
*
|
||||
* Claiming two adjacent slots and initializing the first is enough to catch it:
|
||||
* the second's refcount goes to zero and the pool believes it is free while the
|
||||
* caller is still holding it.
|
||||
*/
|
||||
akerr_ErrorContext *test_akgl_string_initialize_stays_in_bounds(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_String *first = NULL;
|
||||
akgl_String *second = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_string(&first));
|
||||
CATCH(errctx, akgl_heap_next_string(&second));
|
||||
TEST_ASSERT(errctx, second == (first + 1),
|
||||
"the pool did not hand out adjacent slots; this test needs them");
|
||||
|
||||
// A sentinel the overrun would land on. akgl_heap_next_string has
|
||||
// already set it to 1; make it something an accidental write cannot
|
||||
// coincide with.
|
||||
second->refcount = 0x5A;
|
||||
|
||||
CATCH(errctx, akgl_string_initialize(first, NULL));
|
||||
|
||||
TEST_ASSERT(errctx, second->refcount == 0x5A,
|
||||
"initializing a string wrote past its buffer: the next slot's "
|
||||
"refcount is %d, expected %d",
|
||||
second->refcount, 0x5A);
|
||||
TEST_ASSERT(errctx, first->refcount == 1,
|
||||
"initializing a string left its own refcount at %d, expected 1",
|
||||
first->refcount);
|
||||
TEST_ASSERT(errctx, first->data[0] == '\0',
|
||||
"initializing a string with NULL did not zero its buffer");
|
||||
TEST_ASSERT(errctx, first->data[AKGL_MAX_STRING_LENGTH - 1] == '\0',
|
||||
"initializing a string with NULL did not zero its last byte");
|
||||
} CLEANUP {
|
||||
if ( second != NULL ) {
|
||||
second->refcount = 1;
|
||||
IGNORE(akgl_heap_release_string(second));
|
||||
}
|
||||
if ( first != NULL ) {
|
||||
IGNORE(akgl_heap_release_string(first));
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief akgl_string_copy must refuse a count that would leave both buffers.
|
||||
*
|
||||
* Both slots are exactly AKGL_MAX_STRING_LENGTH bytes, so a larger count read
|
||||
* past the end of one and wrote past the end of the other. The header used to
|
||||
* document that as behaviour.
|
||||
*/
|
||||
akerr_ErrorContext *test_akgl_string_copy_bounds_its_count(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_String *src = NULL;
|
||||
akgl_String *dest = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_string(&src));
|
||||
CATCH(errctx, akgl_heap_next_string(&dest));
|
||||
CATCH(errctx, akgl_string_initialize(src, "bounded"));
|
||||
CATCH(errctx, akgl_string_initialize(dest, NULL));
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_string_copy(src, dest, AKGL_MAX_STRING_LENGTH + 1),
|
||||
"copying one byte more than a pool string holds");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_string_copy(src, dest, -1),
|
||||
"copying a negative number of bytes");
|
||||
|
||||
// The boundary itself is legal: it is exactly the buffer.
|
||||
TEST_EXPECT_OK(errctx, akgl_string_copy(src, dest, AKGL_MAX_STRING_LENGTH),
|
||||
"copying exactly a pool string's length");
|
||||
TEST_ASSERT(errctx, strcmp((char *)&dest->data, "bounded") == 0,
|
||||
"a full-length copy did not transfer the contents");
|
||||
|
||||
// 0 still means "the whole buffer" rather than "nothing".
|
||||
CATCH(errctx, akgl_string_initialize(dest, NULL));
|
||||
TEST_EXPECT_OK(errctx, akgl_string_copy(src, dest, 0),
|
||||
"copying with a count of zero");
|
||||
TEST_ASSERT(errctx, strcmp((char *)&dest->data, "bounded") == 0,
|
||||
"a zero-count copy did not transfer the whole buffer");
|
||||
} CLEANUP {
|
||||
if ( dest != NULL ) {
|
||||
IGNORE(akgl_heap_release_string(dest));
|
||||
}
|
||||
if ( src != NULL ) {
|
||||
IGNORE(akgl_heap_release_string(src));
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
printf("test_fresh_heap_gives_string ....\n");
|
||||
test_fresh_heap_gives_strings();
|
||||
reset_string_heap();
|
||||
@@ -146,9 +255,15 @@ int main(void)
|
||||
reset_string_heap();
|
||||
printf("test_akgl_string_initialize....\n");
|
||||
test_akgl_string_initialize();
|
||||
reset_string_heap();
|
||||
printf("test_akgl_string_initialize_stays_in_bounds ...\n");
|
||||
CATCH(errctx, test_akgl_string_initialize_stays_in_bounds());
|
||||
reset_string_heap();
|
||||
printf("test_akgl_string_copy_bounds_its_count ...\n");
|
||||
CATCH(errctx, test_akgl_string_copy_bounds_its_count());
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
122
tests/testutil.h
Normal file
122
tests/testutil.h
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* @file testutil.h
|
||||
* @brief Shared assertion helpers and headless bootstrap for the libakgl test suites.
|
||||
*
|
||||
* The akerror ATTEMPT/CATCH/PROCESS/FINISH macros are verbose when what a test
|
||||
* wants to say is "this call must fail with exactly this status". These helpers
|
||||
* wrap that pattern.
|
||||
*
|
||||
* All of the TEST_* assertion macros expand to a `break` on failure, so they
|
||||
* must be used directly inside an ATTEMPT block. Inside a `for` or `while`
|
||||
* nested in an ATTEMPT they would break the inner loop instead; use
|
||||
* TEST_ASSERT_FLAG in that case and check the flag after the loop.
|
||||
*/
|
||||
|
||||
#ifndef _AKGL_TESTUTIL_H_
|
||||
#define _AKGL_TESTUTIL_H_
|
||||
|
||||
#include <math.h>
|
||||
#include <stdlib.h>
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akerror.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/heap.h>
|
||||
|
||||
/**
|
||||
* @brief How many pool string slots are currently claimed.
|
||||
*
|
||||
* The string pool is 256 entries and every loader borrows scratch strings from
|
||||
* it, so "does this function give back what it took" is a question a test can
|
||||
* answer directly by counting either side of a call. A leak that would take
|
||||
* fifty map loads to show up in production shows up here on the first one.
|
||||
*/
|
||||
static inline int test_string_pool_used(void)
|
||||
{
|
||||
int used = 0;
|
||||
int i = 0;
|
||||
|
||||
for ( i = 0; i < AKGL_MAX_HEAP_STRING; i++ ) {
|
||||
if ( akgl_heap_strings[i].refcount != 0 ) {
|
||||
used += 1;
|
||||
}
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
/** @brief Fail the enclosing ATTEMPT block unless @p cond holds. */
|
||||
#define TEST_ASSERT(e, cond, ...) \
|
||||
if ( ! (cond) ) { \
|
||||
FAIL_BREAK(e, AKGL_ERR_BEHAVIOR, __VA_ARGS__); \
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Run @p stmt and require that it reports exactly @p expected.
|
||||
*
|
||||
* Releases whatever context @p stmt returns, so a test can assert many failure
|
||||
* paths in a row without draining AKERR_ARRAY_ERROR. Pass 0 for @p expected to
|
||||
* require success.
|
||||
*/
|
||||
#define TEST_EXPECT_STATUS(e, expected, stmt, desc) \
|
||||
{ \
|
||||
akerr_ErrorContext *__tec = (stmt); \
|
||||
int __tst = ( __tec == NULL ) ? 0 : __tec->status; \
|
||||
if ( __tec != NULL ) { \
|
||||
__tec->handled = true; \
|
||||
__tec = akerr_release_error(__tec); \
|
||||
} \
|
||||
if ( __tst != (expected) ) { \
|
||||
FAIL_BREAK( \
|
||||
e, \
|
||||
AKGL_ERR_BEHAVIOR, \
|
||||
"%s: expected status %d (%s), got %d (%s)", \
|
||||
desc, \
|
||||
(int)(expected), \
|
||||
akerr_name_for_status((int)(expected), NULL), \
|
||||
__tst, \
|
||||
akerr_name_for_status(__tst, NULL)); \
|
||||
} \
|
||||
}
|
||||
|
||||
/** @brief Require that @p stmt succeeds, reporting @p desc if it does not. */
|
||||
#define TEST_EXPECT_OK(e, stmt, desc) \
|
||||
TEST_EXPECT_STATUS(e, 0, stmt, desc)
|
||||
|
||||
/**
|
||||
* @brief Require that @p stmt fails, without pinning which status it reports.
|
||||
*
|
||||
* For paths that are delegated to a dependency, where the exact status is that
|
||||
* dependency's business and asserting it would make the test brittle.
|
||||
*/
|
||||
#define TEST_EXPECT_ANY_ERROR(e, stmt, desc) \
|
||||
{ \
|
||||
akerr_ErrorContext *__tec = (stmt); \
|
||||
int __tst = ( __tec == NULL ) ? 0 : __tec->status; \
|
||||
if ( __tec != NULL ) { \
|
||||
__tec->handled = true; \
|
||||
__tec = akerr_release_error(__tec); \
|
||||
} \
|
||||
if ( __tst == 0 ) { \
|
||||
FAIL_BREAK(e, AKGL_ERR_BEHAVIOR, "%s: expected a failure, got success", desc); \
|
||||
} \
|
||||
}
|
||||
|
||||
/** @brief Require that two floats agree to within AKGL_TEST_EPSILON. */
|
||||
#define AKGL_TEST_EPSILON 0.0001f
|
||||
|
||||
#define TEST_ASSERT_FEQ(e, actual, expected, ...) \
|
||||
if ( fabsf((float)(actual) - (float)(expected)) > AKGL_TEST_EPSILON ) { \
|
||||
FAIL_BREAK(e, AKGL_ERR_BEHAVIOR, __VA_ARGS__); \
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Record a failure into a flag instead of breaking.
|
||||
*
|
||||
* For assertions inside a loop nested in an ATTEMPT block, where `break` would
|
||||
* only leave the loop.
|
||||
*/
|
||||
#define TEST_ASSERT_FLAG(flag, cond) \
|
||||
if ( ! (cond) ) { \
|
||||
(flag) = false; \
|
||||
}
|
||||
|
||||
#endif // _AKGL_TESTUTIL_H_
|
||||
449
tests/text.c
Normal file
449
tests/text.c
Normal file
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* @file text.c
|
||||
* @brief Unit tests for font loading and text measurement.
|
||||
*
|
||||
* Measurement needs a font but no renderer at all. Drawing needs one, but not a
|
||||
* display: a software renderer over a window under the dummy video driver takes
|
||||
* akgl_text_rendertextat() all the way from rasterizing to blitting, so this
|
||||
* suite covers it without waiting for the offscreen harness. What is still
|
||||
* missing is any assertion about the *pixels* -- that is the harness's job.
|
||||
*
|
||||
* The fixture font is monospaced on purpose: the width of an N-character string
|
||||
* is exactly N times the width of one character, so every assertion below is a
|
||||
* relationship between measurements rather than a hardcoded pixel count that
|
||||
* would break when FreeType changes its rounding.
|
||||
*/
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_ttf/SDL_ttf.h>
|
||||
#include <string.h>
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/renderer.h>
|
||||
#include <akgl/text.h>
|
||||
|
||||
#include "testutil.h"
|
||||
|
||||
/** @brief The monospaced ASCII subset described in assets/akgl_test_mono.LICENSE.txt. */
|
||||
#define TEST_FONT_PATH "assets/akgl_test_mono.ttf"
|
||||
/** @brief Point size every test in this file opens the fixture font at. */
|
||||
#define TEST_FONT_SIZE 16
|
||||
/** @brief Width and height of the offscreen target the drawing tests draw into. */
|
||||
#define TEST_TARGET_SIZE 128
|
||||
|
||||
/** @brief The fixture font, opened once by main() and shared by every test. */
|
||||
static TTF_Font *testfont = NULL;
|
||||
/** @brief A 2D backend bound to a software renderer, built by main(). */
|
||||
static akgl_RenderBackend testbackend;
|
||||
|
||||
akerr_ErrorContext *test_text_loadfont(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
TTF_Font *registered = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_registry_init_font());
|
||||
|
||||
TEST_EXPECT_OK(
|
||||
errctx,
|
||||
akgl_text_loadfont("testfont", TEST_FONT_PATH, TEST_FONT_SIZE),
|
||||
"loading the fixture font");
|
||||
|
||||
registered = SDL_GetPointerProperty(AKGL_REGISTRY_FONT, "testfont", NULL);
|
||||
TEST_ASSERT(errctx, registered != NULL,
|
||||
"akgl_text_loadfont did not place the font in AKGL_REGISTRY_FONT");
|
||||
TEST_ASSERT(errctx, TTF_GetFontHeight(registered) > 0,
|
||||
"the registered font reports height %d, expected a positive height",
|
||||
TTF_GetFontHeight(registered));
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_text_loadfont(NULL, TEST_FONT_PATH, TEST_FONT_SIZE),
|
||||
"loading a font under a NULL name");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_text_loadfont("nullpath", NULL, TEST_FONT_SIZE),
|
||||
"loading a font from a NULL path");
|
||||
TEST_EXPECT_STATUS(errctx, AKGL_ERR_SDL,
|
||||
akgl_text_loadfont("missing", "assets/no_such_font.ttf", TEST_FONT_SIZE),
|
||||
"loading a font that does not exist");
|
||||
|
||||
// Loading over a live name closes the font it displaces. Nothing here
|
||||
// can observe the close directly -- the proof is the memcheck run --
|
||||
// but the replacement itself is observable, and it is the path that
|
||||
// used to abandon ten kilobytes per call.
|
||||
TEST_EXPECT_OK(
|
||||
errctx,
|
||||
akgl_text_loadfont("testfont", TEST_FONT_PATH, TEST_FONT_SIZE),
|
||||
"loading a second font over a registered name");
|
||||
TEST_ASSERT(errctx,
|
||||
SDL_GetPointerProperty(AKGL_REGISTRY_FONT, "testfont", NULL) != registered,
|
||||
"reloading a font under a live name left the displaced font registered");
|
||||
|
||||
// And the font goes back. Unloading is what makes the suite's own
|
||||
// bookkeeping honest, so this is not only an API test.
|
||||
TEST_EXPECT_OK(errctx, akgl_text_unloadfont("testfont"), "unloading a registered font");
|
||||
TEST_ASSERT(errctx,
|
||||
SDL_GetPointerProperty(AKGL_REGISTRY_FONT, "testfont", NULL) == NULL,
|
||||
"akgl_text_unloadfont left the font in AKGL_REGISTRY_FONT");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_KEY, akgl_text_unloadfont("testfont"),
|
||||
"unloading a font that is already gone");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_KEY, akgl_text_unloadfont("never_loaded"),
|
||||
"unloading a name that was never registered");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_text_unloadfont(NULL),
|
||||
"unloading a NULL name");
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_text_measure(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
int w = 0;
|
||||
int h = 0;
|
||||
int onecharw = 0;
|
||||
int onecharh = 0;
|
||||
int emptyw = 0;
|
||||
int emptyh = 0;
|
||||
|
||||
ATTEMPT {
|
||||
// One cell. This is the measurement a character grid is built from.
|
||||
TEST_EXPECT_OK(errctx, akgl_text_measure(testfont, "A", &onecharw, &onecharh),
|
||||
"measuring a single character");
|
||||
TEST_ASSERT(errctx, onecharw > 0,
|
||||
"one character measured %d wide, expected a positive width", onecharw);
|
||||
TEST_ASSERT(errctx, onecharh == TTF_GetFontHeight(testfont),
|
||||
"one character measured %d high, expected the font height %d",
|
||||
onecharh, TTF_GetFontHeight(testfont));
|
||||
|
||||
// The font is monospaced, so four cells are exactly four times one.
|
||||
TEST_EXPECT_OK(errctx, akgl_text_measure(testfont, "AAAA", &w, &h),
|
||||
"measuring four characters");
|
||||
TEST_ASSERT(errctx, w == (onecharw * 4),
|
||||
"four characters measured %d wide, expected %d", w, onecharw * 4);
|
||||
TEST_ASSERT(errctx, h == onecharh,
|
||||
"four characters on one line measured %d high, expected %d", h, onecharh);
|
||||
|
||||
// ...and every character advances by the same amount, which is what
|
||||
// makes a fixed grid legitimate in the first place.
|
||||
TEST_EXPECT_OK(errctx, akgl_text_measure(testfont, "W", &w, &h), "measuring a wide glyph");
|
||||
TEST_ASSERT(errctx, w == onecharw,
|
||||
"'W' measured %d wide but 'A' measured %d in a monospaced font", w, onecharw);
|
||||
TEST_EXPECT_OK(errctx, akgl_text_measure(testfont, "i", &w, &h), "measuring a narrow glyph");
|
||||
TEST_ASSERT(errctx, w == onecharw,
|
||||
"'i' measured %d wide but 'A' measured %d in a monospaced font", w, onecharw);
|
||||
|
||||
// The empty string is zero wide and still one line high, so a cursor
|
||||
// sitting on an empty line has somewhere to be.
|
||||
TEST_EXPECT_OK(errctx, akgl_text_measure(testfont, "", &emptyw, &emptyh),
|
||||
"measuring the empty string");
|
||||
TEST_ASSERT(errctx, emptyw == 0,
|
||||
"the empty string measured %d wide, expected 0", emptyw);
|
||||
TEST_ASSERT(errctx, emptyh == onecharh,
|
||||
"the empty string measured %d high, expected the font height %d",
|
||||
emptyh, onecharh);
|
||||
|
||||
// Measuring does not disturb the destinations it was not asked about.
|
||||
w = -1;
|
||||
h = -1;
|
||||
TEST_EXPECT_OK(errctx, akgl_text_measure(testfont, "hello", &w, &h),
|
||||
"measuring a word");
|
||||
TEST_ASSERT(errctx, w == (onecharw * 5),
|
||||
"\"hello\" measured %d wide, expected %d", w, onecharw * 5);
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_text_measure(NULL, "A", &w, &h),
|
||||
"measuring with a NULL font");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_text_measure(testfont, NULL, &w, &h),
|
||||
"measuring a NULL string");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_text_measure(testfont, "A", NULL, &h),
|
||||
"measuring into a NULL width");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_text_measure(testfont, "A", &w, NULL),
|
||||
"measuring into a NULL height");
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_text_measure_wrapped(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
int w = 0;
|
||||
int h = 0;
|
||||
int onecharw = 0;
|
||||
int flatw = 0;
|
||||
int flath = 0;
|
||||
int lineskip = 0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_text_measure(testfont, "A", &onecharw, &flath));
|
||||
lineskip = TTF_GetFontLineSkip(testfont);
|
||||
TEST_ASSERT(errctx, lineskip > 0, "the font reports a line skip of %d", lineskip);
|
||||
|
||||
// A wrap length wide enough for the whole string measures the same as
|
||||
// the unwrapped call.
|
||||
CATCH(errctx, akgl_text_measure(testfont, "one two", &flatw, &flath));
|
||||
TEST_EXPECT_OK(errctx,
|
||||
akgl_text_measure_wrapped(testfont, "one two", flatw + onecharw, &w, &h),
|
||||
"measuring a string that fits inside the wrap length");
|
||||
TEST_ASSERT(errctx, w == flatw,
|
||||
"an unwrapped measurement gave %d wide, expected %d", w, flatw);
|
||||
TEST_ASSERT(errctx, h == flath,
|
||||
"an unwrapped measurement gave %d high, expected %d", h, flath);
|
||||
|
||||
// Narrow enough to force a break at the space: two lines, and nothing
|
||||
// wider than the wrap length.
|
||||
TEST_EXPECT_OK(errctx,
|
||||
akgl_text_measure_wrapped(testfont, "one two", onecharw * 4, &w, &h),
|
||||
"measuring a string that has to wrap");
|
||||
TEST_ASSERT(errctx, w <= (onecharw * 4),
|
||||
"a wrapped measurement gave %d wide, past the %d wrap length",
|
||||
w, onecharw * 4);
|
||||
TEST_ASSERT(errctx, h >= (lineskip * 2),
|
||||
"a wrapped measurement gave %d high, expected at least two lines (%d)",
|
||||
h, lineskip * 2);
|
||||
|
||||
// Zero wraps on newlines only, so an embedded newline still costs a line
|
||||
// and a long unbroken string does not.
|
||||
TEST_EXPECT_OK(errctx, akgl_text_measure_wrapped(testfont, "one\ntwo", 0, &w, &h),
|
||||
"measuring a string with an embedded newline");
|
||||
TEST_ASSERT(errctx, h >= (lineskip * 2),
|
||||
"an embedded newline measured %d high, expected at least two lines (%d)",
|
||||
h, lineskip * 2);
|
||||
TEST_ASSERT(errctx, w == (onecharw * 3),
|
||||
"the longer of two three-character lines measured %d wide, expected %d",
|
||||
w, onecharw * 3);
|
||||
|
||||
TEST_EXPECT_OK(errctx, akgl_text_measure_wrapped(testfont, "one two", 0, &w, &h),
|
||||
"measuring a string with no newline at wrap length zero");
|
||||
TEST_ASSERT(errctx, h == flath,
|
||||
"a string with no newline measured %d high at wrap length 0, expected %d",
|
||||
h, flath);
|
||||
|
||||
// A negative wrap length is refused rather than silently treated as
|
||||
// "never wrap", which is what SDL_ttf does with it.
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_text_measure_wrapped(testfont, "one two", -1, &w, &h),
|
||||
"measuring at a negative wrap length");
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_text_measure_wrapped(NULL, "A", 100, &w, &h),
|
||||
"measuring wrapped with a NULL font");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_text_measure_wrapped(testfont, NULL, 100, &w, &h),
|
||||
"measuring a NULL string wrapped");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_text_measure_wrapped(testfont, "A", 100, NULL, &h),
|
||||
"measuring wrapped into a NULL width");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_text_measure_wrapped(testfont, "A", 100, &w, NULL),
|
||||
"measuring wrapped into a NULL height");
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_text_render_backend_guards(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akgl_RenderBackend backend;
|
||||
SDL_Color white = { 0xff, 0xff, 0xff, 0xff };
|
||||
|
||||
ATTEMPT {
|
||||
// Nothing initialized the renderer at all, which is where the global
|
||||
// starts and where a host that has not called akgl_game_init leaves it.
|
||||
akgl_renderer = NULL;
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_text_rendertextat(testfont, "hello", white, 0, 0, 0),
|
||||
"drawing text with no renderer backend");
|
||||
|
||||
// A backend that exists but was never given an SDL_Renderer -- the
|
||||
// state a host is in between allocating one and initializing it.
|
||||
memset(&backend, 0x00, sizeof(akgl_RenderBackend));
|
||||
akgl_renderer = &backend;
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_text_rendertextat(testfont, "hello", white, 0, 0, 0),
|
||||
"drawing text through a backend with no SDL renderer");
|
||||
|
||||
// A live SDL_Renderer but no methods: a host that created its own
|
||||
// renderer and has not called akgl_render_2d_bind() yet. This is the one
|
||||
// that used to be a segfault rather than an error -- with a real
|
||||
// renderer behind it the call gets all the way to a NULL function
|
||||
// pointer, which is why the check has to be here and not left to SDL.
|
||||
backend.sdl_renderer = testbackend.sdl_renderer;
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_text_rendertextat(testfont, "hello", white, 0, 0, 0),
|
||||
"drawing text through a backend that was never bound");
|
||||
|
||||
akgl_renderer = &testbackend;
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_text_rendertextat(NULL, "hello", white, 0, 0, 0),
|
||||
"drawing text with a NULL font");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_text_rendertextat(testfont, NULL, white, 0, 0, 0),
|
||||
"drawing a NULL string");
|
||||
} CLEANUP {
|
||||
akgl_renderer = &testbackend;
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_text_rendertextat(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_Color white = { 0xff, 0xff, 0xff, 0xff };
|
||||
|
||||
ATTEMPT {
|
||||
// A bound backend over a software renderer is enough to draw through:
|
||||
// no display, no window shown, and the whole path from rasterizing to
|
||||
// blitting runs.
|
||||
akgl_renderer = &testbackend;
|
||||
TEST_EXPECT_OK(errctx, akgl_renderer->frame_start(akgl_renderer), "starting a frame");
|
||||
TEST_EXPECT_OK(errctx, akgl_text_rendertextat(testfont, "hello", white, 0, 4, 4),
|
||||
"drawing a line of text");
|
||||
// Wrapping takes the other rasterizing path.
|
||||
TEST_EXPECT_OK(errctx,
|
||||
akgl_text_rendertextat(testfont, "one two three", white, TEST_TARGET_SIZE / 2, 0, 0),
|
||||
"drawing wrapped text");
|
||||
// The empty string. SDL_ttf refuses it with "Text has zero width" from
|
||||
// both rasterizers, so this used to report AKERR_NULLPOINTER for what a
|
||||
// caller means as "draw nothing" -- while akgl_text_measure("") is
|
||||
// documented as legal. This case was written and deliberately left
|
||||
// unasserted until the two halves of the header agreed.
|
||||
TEST_EXPECT_OK(errctx, akgl_text_rendertextat(testfont, "", white, 0, 0, 0),
|
||||
"drawing an empty line of text");
|
||||
TEST_EXPECT_OK(errctx, akgl_text_rendertextat(testfont, "", white, TEST_TARGET_SIZE / 2, 0, 0),
|
||||
"drawing an empty line of wrapped text");
|
||||
|
||||
// Drawing nothing must still refuse the things drawing something
|
||||
// refuses, rather than short-circuiting past the checks.
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_text_rendertextat(NULL, "", white, 0, 0, 0),
|
||||
"drawing an empty line with no font");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_text_rendertextat(testfont, NULL, white, 0, 0, 0),
|
||||
"drawing a NULL string");
|
||||
|
||||
TEST_EXPECT_OK(errctx, akgl_renderer->frame_end(akgl_renderer), "ending the frame");
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief akgl_text_unloadallfonts closes every registered font and clears the registry.
|
||||
*
|
||||
* A font was reachable only by name, so a game that exited without unloading
|
||||
* each one by hand left them open -- and SDL_Quit destroying the property
|
||||
* registry took the last reference with it. Roughly 10 KB per font, most of it
|
||||
* FreeType's. Bounded by how many fonts a game loads, but there was nothing a
|
||||
* game could do about it.
|
||||
*/
|
||||
akerr_ErrorContext *test_text_unloadallfonts(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_registry_init_font());
|
||||
CATCH(errctx, akgl_text_loadfont("unloadall_a", TEST_FONT_PATH, 16));
|
||||
CATCH(errctx, akgl_text_loadfont("unloadall_b", TEST_FONT_PATH, 24));
|
||||
TEST_ASSERT(errctx,
|
||||
SDL_GetPointerProperty(AKGL_REGISTRY_FONT, "unloadall_a", NULL) != NULL,
|
||||
"the first font is not in the registry");
|
||||
TEST_ASSERT(errctx,
|
||||
SDL_GetPointerProperty(AKGL_REGISTRY_FONT, "unloadall_b", NULL) != NULL,
|
||||
"the second font is not in the registry");
|
||||
|
||||
TEST_EXPECT_OK(errctx, akgl_text_unloadallfonts(), "unloading every font");
|
||||
TEST_ASSERT(errctx, AKGL_REGISTRY_FONT == 0,
|
||||
"unloading every font left the registry behind");
|
||||
|
||||
// Shutdown paths run after partial startups, so a second call and a
|
||||
// call against no registry at all are both success.
|
||||
TEST_EXPECT_OK(errctx, akgl_text_unloadallfonts(), "unloading every font again");
|
||||
|
||||
// And the subsystem comes back with a fresh registry.
|
||||
CATCH(errctx, akgl_registry_init_font());
|
||||
TEST_EXPECT_OK(errctx, akgl_text_loadfont("unloadall_c", TEST_FONT_PATH, 16),
|
||||
"loading a font after unloading them all");
|
||||
TEST_EXPECT_OK(errctx, akgl_text_unloadallfonts(), "unloading that one too");
|
||||
} CLEANUP {
|
||||
if ( AKGL_REGISTRY_FONT == 0 ) {
|
||||
IGNORE(akgl_registry_init_font());
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
memset(&testbackend, 0x00, sizeof(akgl_RenderBackend));
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_Init(SDL_INIT_VIDEO),
|
||||
AKGL_ERR_SDL,
|
||||
"Couldn't initialize SDL: %s",
|
||||
SDL_GetError());
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
TTF_Init(),
|
||||
AKGL_ERR_SDL,
|
||||
"Couldn't initialize the font engine: %s",
|
||||
SDL_GetError());
|
||||
|
||||
testfont = TTF_OpenFont(TEST_FONT_PATH, TEST_FONT_SIZE);
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
testfont,
|
||||
AKGL_ERR_SDL,
|
||||
"Couldn't open %s: %s",
|
||||
TEST_FONT_PATH,
|
||||
SDL_GetError());
|
||||
|
||||
// The host owns the window and libakgl binds to it, which is the
|
||||
// arrangement akgl_render_2d_bind exists for and the one an embedded
|
||||
// interpreter drawing text is in.
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_CreateWindowAndRenderer(
|
||||
"net/aklabs/libakgl/test_text",
|
||||
TEST_TARGET_SIZE,
|
||||
TEST_TARGET_SIZE,
|
||||
0,
|
||||
&akgl_window,
|
||||
&testbackend.sdl_renderer),
|
||||
AKGL_ERR_SDL,
|
||||
"Couldn't create window/renderer: %s",
|
||||
SDL_GetError());
|
||||
CATCH(errctx, akgl_render_2d_bind(&testbackend));
|
||||
|
||||
CATCH(errctx, test_text_loadfont());
|
||||
CATCH(errctx, test_text_measure());
|
||||
CATCH(errctx, test_text_measure_wrapped());
|
||||
CATCH(errctx, test_text_render_backend_guards());
|
||||
CATCH(errctx, test_text_rendertextat());
|
||||
CATCH(errctx, test_text_unloadallfonts());
|
||||
} CLEANUP {
|
||||
if ( testfont != NULL ) {
|
||||
TTF_CloseFont(testfont);
|
||||
}
|
||||
TTF_Quit();
|
||||
SDL_Quit();
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
}
|
||||
527
tests/tilemap.c
527
tests/tilemap.c
@@ -9,8 +9,105 @@
|
||||
#include <akgl/tilemap.h>
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akstdlib.h>
|
||||
#include <akgl/json_helpers.h>
|
||||
|
||||
#include "testutil.h"
|
||||
|
||||
/**
|
||||
* @brief akgl_get_json_tilemap_property must give back its scratch strings on every path.
|
||||
*
|
||||
* It claims two pool strings per call -- one for the property name it is
|
||||
* comparing, one for the declared type -- and used to leave the block through a
|
||||
* `SUCCEED_RETURN` from inside `ATTEMPT` on the path where it *found* what it
|
||||
* was asked for. That returns past `CLEANUP`, so the ordinary, successful
|
||||
* lookup was the leaking one, at two of the pool's 256 entries a time. A map
|
||||
* load does this several times per layer.
|
||||
*
|
||||
* Three paths, run more times than the pool has entries: found, absent, and
|
||||
* present-but-wrong-type. Against the old code the found loop exhausts the pool
|
||||
* and comes back AKGL_ERR_HEAP long before it finishes.
|
||||
*/
|
||||
akerr_ErrorContext *test_tilemap_property_lookup_releases_strings(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
json_t *jsondoc = NULL;
|
||||
json_error_t jsonerr;
|
||||
akgl_String *pathstr = NULL;
|
||||
int baseline = 0;
|
||||
int propnum = 0;
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_string(&pathstr));
|
||||
snprintf(
|
||||
(char *)&pathstr->data,
|
||||
AKGL_MAX_STRING_LENGTH,
|
||||
"%s%s",
|
||||
SDL_GetBasePath(),
|
||||
"assets/snippets/test_tilemap_get_json_tilemap_property.json"
|
||||
);
|
||||
jsondoc = json_load_file((char *)&pathstr->data, 0, (json_error_t *)&jsonerr);
|
||||
FAIL_ZERO_BREAK(errctx, jsondoc, AKERR_NULLPOINTER, "Failure loading json fixture: %s", (char *)jsonerr.text);
|
||||
|
||||
baseline = test_string_pool_used();
|
||||
|
||||
// The success path. Twice the pool size, so a leak of even one entry
|
||||
// per call cannot finish.
|
||||
for ( i = 0; i < (AKGL_MAX_HEAP_STRING * 2); i++ ) {
|
||||
CATCH(errctx, akgl_get_json_properties_integer(jsondoc, "state", &propnum));
|
||||
}
|
||||
TEST_ASSERT(
|
||||
errctx,
|
||||
test_string_pool_used() == baseline,
|
||||
"%d successful property lookups left %d pool strings claimed, expected %d",
|
||||
(AKGL_MAX_HEAP_STRING * 2),
|
||||
test_string_pool_used(),
|
||||
baseline);
|
||||
|
||||
// The absent path, which reports AKERR_KEY from after FINISH.
|
||||
for ( i = 0; i < (AKGL_MAX_HEAP_STRING * 2); i++ ) {
|
||||
TEST_EXPECT_STATUS(
|
||||
errctx,
|
||||
AKERR_KEY,
|
||||
akgl_get_json_properties_integer(jsondoc, "no_such_property", &propnum),
|
||||
"looking up a property the object does not have");
|
||||
}
|
||||
TEST_ASSERT(
|
||||
errctx,
|
||||
test_string_pool_used() == baseline,
|
||||
"absent-property lookups left %d pool strings claimed, expected %d",
|
||||
test_string_pool_used(),
|
||||
baseline);
|
||||
|
||||
// The wrong-type path, which fails from inside the ATTEMPT block with
|
||||
// both scratch strings already claimed.
|
||||
for ( i = 0; i < (AKGL_MAX_HEAP_STRING * 2); i++ ) {
|
||||
TEST_EXPECT_STATUS(
|
||||
errctx,
|
||||
AKERR_TYPE,
|
||||
akgl_get_json_properties_integer(jsondoc, "character", &propnum),
|
||||
"reading a string property as an int");
|
||||
}
|
||||
TEST_ASSERT(
|
||||
errctx,
|
||||
test_string_pool_used() == baseline,
|
||||
"wrong-type lookups left %d pool strings claimed, expected %d",
|
||||
test_string_pool_used(),
|
||||
baseline);
|
||||
} CLEANUP {
|
||||
if ( jsondoc != NULL ) {
|
||||
json_decref(jsondoc);
|
||||
jsondoc = NULL;
|
||||
}
|
||||
if ( pathstr != NULL ) {
|
||||
IGNORE(akgl_heap_release_string(pathstr));
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_tilemap_akgl_get_json_tilemap_property(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
@@ -18,15 +115,17 @@ akerr_ErrorContext *test_tilemap_akgl_get_json_tilemap_property(void)
|
||||
json_error_t jsonerr;
|
||||
akgl_String *tmpstr = NULL;
|
||||
int propnum;
|
||||
|
||||
|
||||
ATTEMPT {
|
||||
akgl_gamemap = &akgl_default_gamemap;
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
CATCH(errctx, akgl_heap_next_string(&tmpstr));
|
||||
snprintf(
|
||||
(char *)&tmpstr->data,
|
||||
AKGL_MAX_STRING_LENGTH,
|
||||
"%s%s",
|
||||
SDL_GetBasePath(),
|
||||
"assets/snippets/test_tilemap_akgl_get_json_tilemap_property.json"
|
||||
"assets/snippets/test_tilemap_get_json_tilemap_property.json"
|
||||
);
|
||||
jsondoc = json_load_file((char *)&tmpstr->data, 0, (json_error_t *)&jsonerr);
|
||||
FAIL_ZERO_BREAK(errctx, jsondoc, AKERR_NULLPOINTER, "Failure loading json fixture: %s", (char *)jsonerr.text);
|
||||
@@ -85,32 +184,34 @@ akerr_ErrorContext *test_akgl_tilemap_compute_tileset_offsets(void)
|
||||
10 // Tile 2 Y
|
||||
};
|
||||
int *comparison_ptrs[8] = {
|
||||
&gamemap.tilesets[0].tile_offsets[0][0], // Tile 0 X
|
||||
&gamemap.tilesets[0].tile_offsets[0][1], // Tile 0 Y
|
||||
&gamemap.tilesets[0].tile_offsets[1][0], // Tile 1 X
|
||||
&gamemap.tilesets[0].tile_offsets[1][1], // Tile 0 Y
|
||||
&gamemap.tilesets[0].tile_offsets[2][0], // Tile 2 X
|
||||
&gamemap.tilesets[0].tile_offsets[2][1], // Tile 2 Y
|
||||
&gamemap.tilesets[0].tile_offsets[3][0], // Tile 3 X
|
||||
&gamemap.tilesets[0].tile_offsets[3][0], // Tile 3 Y
|
||||
&akgl_gamemap->tilesets[0].tile_offsets[0][0], // Tile 0 X
|
||||
&akgl_gamemap->tilesets[0].tile_offsets[0][1], // Tile 0 Y
|
||||
&akgl_gamemap->tilesets[0].tile_offsets[1][0], // Tile 1 X
|
||||
&akgl_gamemap->tilesets[0].tile_offsets[1][1], // Tile 0 Y
|
||||
&akgl_gamemap->tilesets[0].tile_offsets[2][0], // Tile 2 X
|
||||
&akgl_gamemap->tilesets[0].tile_offsets[2][1], // Tile 2 Y
|
||||
&akgl_gamemap->tilesets[0].tile_offsets[3][0], // Tile 3 X
|
||||
&akgl_gamemap->tilesets[0].tile_offsets[3][1], // Tile 3 Y
|
||||
};
|
||||
int i = 0;
|
||||
|
||||
memset((void *)&gamemap, 0x00, sizeof(akgl_Tilemap));
|
||||
gamemap.tilesets[0].tilecount = 4;
|
||||
gamemap.tilesets[0].columns = 2;
|
||||
gamemap.tilesets[0].firstgid = 1;
|
||||
gamemap.tilesets[0].imageheight = 20;
|
||||
gamemap.tilesets[0].imagewidth = 20;
|
||||
gamemap.tilesets[0].tilecount = 4;
|
||||
gamemap.tilesets[0].tileheight = 10;
|
||||
gamemap.tilesets[0].tilewidth = 10;
|
||||
gamemap.tilesets[0].spacing = 0;
|
||||
gamemap.tilesets[0].margin = 0;
|
||||
|
||||
memset((void *)akgl_gamemap, 0x00, sizeof(akgl_Tilemap));
|
||||
akgl_gamemap->tilesets[0].tilecount = 4;
|
||||
akgl_gamemap->tilesets[0].columns = 2;
|
||||
akgl_gamemap->tilesets[0].firstgid = 1;
|
||||
akgl_gamemap->tilesets[0].imageheight = 20;
|
||||
akgl_gamemap->tilesets[0].imagewidth = 20;
|
||||
akgl_gamemap->tilesets[0].tilecount = 4;
|
||||
akgl_gamemap->tilesets[0].tileheight = 10;
|
||||
akgl_gamemap->tilesets[0].tilewidth = 10;
|
||||
akgl_gamemap->tilesets[0].spacing = 0;
|
||||
akgl_gamemap->tilesets[0].margin = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_tilemap_compute_tileset_offsets(&gamemap, 0));
|
||||
akgl_gamemap = &akgl_default_gamemap;
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
CATCH(errctx, akgl_tilemap_compute_tileset_offsets(akgl_gamemap, 0));
|
||||
// Tile 0 X offset
|
||||
for ( i = 0; i < 8; i++ ) {
|
||||
FAIL_NONZERO_BREAK(
|
||||
@@ -129,6 +230,190 @@ akerr_ErrorContext *test_akgl_tilemap_compute_tileset_offsets(void)
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief The loader must refuse a map that would overrun its fixed tables.
|
||||
*
|
||||
* Two loops indexed straight from the document. `curlayer->objects[j]` had no
|
||||
* check against AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER, and `dest->tilesets[i]`
|
||||
* none against AKGL_TILEMAP_MAX_TILESETS. The object one is the reachable one:
|
||||
* 128 objects is not a large object layer, and akgl_TilemapObject is big, so
|
||||
* the 129th wrote well past the end of the layer.
|
||||
*
|
||||
* akgl_tilemap_load_layers already bounded its own loop and raised
|
||||
* AKERR_OUTOFBOUNDS, so the shape to copy was in the same file.
|
||||
*/
|
||||
/**
|
||||
* @brief Releasing a map twice must not free a texture twice.
|
||||
*
|
||||
* The layers loop tested `dest->layers[i].texture` and then destroyed
|
||||
* `dest->tilesets[i].texture` -- already destroyed by the loop above it. So
|
||||
* every tileset texture was freed twice on a single release, and no image
|
||||
* layer's texture was freed at all. Neither pointer was cleared either, so a
|
||||
* second release was a use-after-free on top of that.
|
||||
*/
|
||||
/**
|
||||
* @brief A load/release cycle must give back every pooled string it took.
|
||||
*
|
||||
* Measured before the fix by counting non-zero HEAP_STRING refcounts across
|
||||
* cycles: five per cycle, exactly, and akgl_tilemap_release gave none of them
|
||||
* back. The pool is 256 entries, so the 52nd map load in a process found it
|
||||
* empty -- and until the accessors were fixed, "empty" arrived as a segfault
|
||||
* rather than as AKGL_ERR_HEAP.
|
||||
*
|
||||
* Blast radius is every level transition, and a game that reloads a level on
|
||||
* death hits it sooner.
|
||||
*/
|
||||
akerr_ErrorContext *test_akgl_tilemap_load_releases_strings(void)
|
||||
{
|
||||
akgl_String *pathstr = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
int baseline = 0;
|
||||
int afterfirst = 0;
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
akgl_gamemap = &akgl_default_gamemap;
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
CATCH(errctx, akgl_heap_next_string(&pathstr));
|
||||
snprintf((char *)&pathstr->data, AKGL_MAX_STRING_LENGTH, "%s%s",
|
||||
SDL_GetBasePath(), "assets/testmap.tmj");
|
||||
|
||||
baseline = test_string_pool_used();
|
||||
|
||||
memset((void *)akgl_gamemap, 0x00, sizeof(akgl_Tilemap));
|
||||
CATCH(errctx, akgl_tilemap_load((char *)&pathstr->data, akgl_gamemap));
|
||||
CATCH(errctx, akgl_tilemap_release(akgl_gamemap));
|
||||
afterfirst = test_string_pool_used();
|
||||
TEST_ASSERT(errctx, afterfirst == baseline,
|
||||
"one load/release cycle left %d pool strings claimed, expected %d",
|
||||
afterfirst, baseline);
|
||||
|
||||
// Then enough cycles that a leak of even one string per load would run
|
||||
// the pool dry, so this cannot pass by rounding.
|
||||
for ( i = 0; i < 64; i++ ) {
|
||||
memset((void *)akgl_gamemap, 0x00, sizeof(akgl_Tilemap));
|
||||
CATCH(errctx, akgl_tilemap_load((char *)&pathstr->data, akgl_gamemap));
|
||||
CATCH(errctx, akgl_tilemap_release(akgl_gamemap));
|
||||
}
|
||||
TEST_ASSERT(errctx, test_string_pool_used() == baseline,
|
||||
"64 load/release cycles left %d pool strings claimed, expected %d",
|
||||
test_string_pool_used(), baseline);
|
||||
} CLEANUP {
|
||||
if ( pathstr != NULL ) {
|
||||
IGNORE(akgl_heap_release_string(pathstr));
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_akgl_tilemap_release_is_idempotent(void)
|
||||
{
|
||||
akgl_String *pathstr = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
int i = 0;
|
||||
|
||||
ATTEMPT {
|
||||
akgl_gamemap = &akgl_default_gamemap;
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
memset((void *)akgl_gamemap, 0x00, sizeof(akgl_Tilemap));
|
||||
|
||||
CATCH(errctx, akgl_heap_next_string(&pathstr));
|
||||
snprintf((char *)&pathstr->data, AKGL_MAX_STRING_LENGTH, "%s%s",
|
||||
SDL_GetBasePath(), "assets/testmap.tmj");
|
||||
CATCH(errctx, akgl_tilemap_load((char *)&pathstr->data, akgl_gamemap));
|
||||
TEST_ASSERT(errctx, akgl_gamemap->numtilesets > 0,
|
||||
"the fixture map loaded no tilesets, so this test proves nothing");
|
||||
TEST_ASSERT(errctx, akgl_gamemap->tilesets[0].texture != NULL,
|
||||
"the fixture map's first tileset has no texture");
|
||||
|
||||
TEST_EXPECT_OK(errctx, akgl_tilemap_release(akgl_gamemap), "releasing a loaded map");
|
||||
for ( i = 0; i < AKGL_TILEMAP_MAX_TILESETS; i++ ) {
|
||||
TEST_ASSERT(errctx, akgl_gamemap->tilesets[i].texture == NULL,
|
||||
"tileset %d's texture pointer survived release", i);
|
||||
}
|
||||
for ( i = 0; i < AKGL_TILEMAP_MAX_LAYERS; i++ ) {
|
||||
TEST_ASSERT(errctx, akgl_gamemap->layers[i].texture == NULL,
|
||||
"layer %d's texture pointer survived release", i);
|
||||
}
|
||||
|
||||
// The one that used to be a use-after-free.
|
||||
TEST_EXPECT_OK(errctx, akgl_tilemap_release(akgl_gamemap), "releasing the same map again");
|
||||
TEST_EXPECT_OK(errctx, akgl_tilemap_release(akgl_gamemap), "releasing it a third time");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_tilemap_release(NULL),
|
||||
"releasing a NULL map");
|
||||
} CLEANUP {
|
||||
if ( pathstr != NULL ) {
|
||||
IGNORE(akgl_heap_release_string(pathstr));
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_akgl_tilemap_load_bounds_fixed_tables(void)
|
||||
{
|
||||
akgl_String *pathstr = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
json_t *doc = NULL;
|
||||
json_error_t errdata;
|
||||
|
||||
ATTEMPT {
|
||||
akgl_gamemap = &akgl_default_gamemap;
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
CATCH(errctx, akgl_heap_next_string(&pathstr));
|
||||
|
||||
// Exactly the maximum must still load.
|
||||
memset((void *)akgl_gamemap, 0x00, sizeof(akgl_Tilemap));
|
||||
snprintf((char *)&pathstr->data, AKGL_MAX_STRING_LENGTH, "%s%s",
|
||||
SDL_GetBasePath(), "assets/snippets/test_tilemap_max_objects.json");
|
||||
doc = json_load_file((char *)&pathstr->data, 0, &errdata);
|
||||
FAIL_ZERO_BREAK(errctx, doc, AKERR_NULLPOINTER, "max-objects fixture: %s", (char *)&errdata.text);
|
||||
snprintf((char *)&pathstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets");
|
||||
TEST_EXPECT_OK(errctx,
|
||||
akgl_tilemap_load_layer_objects(akgl_gamemap, doc, 0, pathstr),
|
||||
"loading an object layer with exactly the maximum objects");
|
||||
json_decref(doc);
|
||||
doc = NULL;
|
||||
|
||||
// One more must be refused rather than written.
|
||||
memset((void *)akgl_gamemap, 0x00, sizeof(akgl_Tilemap));
|
||||
snprintf((char *)&pathstr->data, AKGL_MAX_STRING_LENGTH, "%s%s",
|
||||
SDL_GetBasePath(), "assets/snippets/test_tilemap_too_many_objects.json");
|
||||
doc = json_load_file((char *)&pathstr->data, 0, &errdata);
|
||||
FAIL_ZERO_BREAK(errctx, doc, AKERR_NULLPOINTER, "too-many-objects fixture: %s", (char *)&errdata.text);
|
||||
snprintf((char *)&pathstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_tilemap_load_layer_objects(akgl_gamemap, doc, 0, pathstr),
|
||||
"loading an object layer with one object too many");
|
||||
json_decref(doc);
|
||||
doc = NULL;
|
||||
|
||||
// And the tileset table.
|
||||
memset((void *)akgl_gamemap, 0x00, sizeof(akgl_Tilemap));
|
||||
snprintf((char *)&pathstr->data, AKGL_MAX_STRING_LENGTH, "%s%s",
|
||||
SDL_GetBasePath(), "assets/snippets/test_tilemap_too_many_tilesets.json");
|
||||
doc = json_load_file((char *)&pathstr->data, 0, &errdata);
|
||||
FAIL_ZERO_BREAK(errctx, doc, AKERR_NULLPOINTER, "too-many-tilesets fixture: %s", (char *)&errdata.text);
|
||||
snprintf((char *)&pathstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_tilemap_load_tilesets(akgl_gamemap, doc, pathstr),
|
||||
"loading a map with one tileset too many");
|
||||
TEST_ASSERT(errctx, akgl_gamemap->numtilesets <= AKGL_TILEMAP_MAX_TILESETS,
|
||||
"numtilesets reached %d, past the %d the table holds",
|
||||
akgl_gamemap->numtilesets, AKGL_TILEMAP_MAX_TILESETS);
|
||||
} CLEANUP {
|
||||
if ( doc != NULL ) {
|
||||
json_decref(doc);
|
||||
}
|
||||
if ( pathstr != NULL ) {
|
||||
IGNORE(akgl_heap_release_string(pathstr));
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_akgl_tilemap_load_layer_objects(void)
|
||||
{
|
||||
akgl_String *pathstr;
|
||||
@@ -139,15 +424,18 @@ akerr_ErrorContext *test_akgl_tilemap_load_layer_objects(void)
|
||||
json_error_t errdata;
|
||||
akgl_Actor *testactor;
|
||||
|
||||
memset((void *)&gamemap, 0x00, sizeof(akgl_Tilemap));
|
||||
memset((void *)akgl_gamemap, 0x00, sizeof(akgl_Tilemap));
|
||||
ATTEMPT {
|
||||
akgl_gamemap = &akgl_default_gamemap;
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
CATCH(errctx, akgl_heap_next_string(&pathstr));
|
||||
snprintf((char *)&pathstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets/testmap.tmj");
|
||||
doc = json_load_file((char *)&pathstr->data, 0, &errdata);
|
||||
snprintf((char *)&pathstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets");
|
||||
FAIL_ZERO_BREAK(errctx, doc, AKERR_NULLPOINTER, "Failed to load testmap: %s", (char *)&errdata.text);
|
||||
CATCH(errctx, akgl_get_json_array_value(doc, "layers", &layers));
|
||||
CATCH(errctx, akgl_get_json_array_index_object(layers, 1, &objectlayer));
|
||||
CATCH(errctx, akgl_tilemap_load_layer_objects(&gamemap, objectlayer, 1));
|
||||
CATCH(errctx, akgl_tilemap_load_layer_objects(akgl_gamemap, objectlayer, 1, pathstr));
|
||||
|
||||
testactor = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "testactor", NULL);
|
||||
FAIL_ZERO_BREAK(
|
||||
@@ -186,23 +474,26 @@ akerr_ErrorContext *test_akgl_tilemap_load_layer_tile(void)
|
||||
json_t *tiledata = NULL;
|
||||
json_error_t errdata;
|
||||
|
||||
memset((void *)&gamemap, 0x00, sizeof(akgl_Tilemap));
|
||||
|
||||
memset((void *)akgl_gamemap, 0x00, sizeof(akgl_Tilemap));
|
||||
|
||||
ATTEMPT {
|
||||
akgl_gamemap = &akgl_default_gamemap;
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
CATCH(errctx, akgl_heap_next_string(&pathstr));
|
||||
snprintf((char *)&pathstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets/testmap.tmj");
|
||||
doc = json_load_file((char *)&pathstr->data, 0, &errdata);
|
||||
snprintf((char *)&pathstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets");
|
||||
FAIL_ZERO_BREAK(errctx, doc, AKERR_NULLPOINTER, "Failed to load testmap: %s", (char *)&errdata.text);
|
||||
CATCH(errctx, akgl_get_json_array_value(doc, "layers", &layers));
|
||||
CATCH(errctx, akgl_get_json_array_index_object(layers, 0, &tilelayer));
|
||||
CATCH(errctx, akgl_get_json_array_value(tilelayer, "data", &tiledata));
|
||||
CATCH(errctx, akgl_tilemap_load_layer_tile(&gamemap, tilelayer, 0));
|
||||
if ( (gamemap.layers[0].data[0] != 1) ||
|
||||
(gamemap.layers[0].data[1] != 2) ||
|
||||
(gamemap.layers[0].data[2] != 3) ||
|
||||
(gamemap.layers[0].data[3] != 4) ) {
|
||||
CATCH(errctx, akgl_tilemap_load_layer_tile(akgl_gamemap, tilelayer, 0, pathstr));
|
||||
if ( (akgl_gamemap->layers[0].data[0] != 1) ||
|
||||
(akgl_gamemap->layers[0].data[1] != 2) ||
|
||||
(akgl_gamemap->layers[0].data[2] != 3) ||
|
||||
(akgl_gamemap->layers[0].data[3] != 4) ) {
|
||||
FAIL_BREAK(errctx, AKERR_VALUE, "Test tilemap layer 0 tiles loaded with incorrect values (check gdb)");
|
||||
}
|
||||
}
|
||||
} CLEANUP {
|
||||
if ( pathstr != NULL ) {
|
||||
IGNORE(akgl_heap_release_string(pathstr));
|
||||
@@ -215,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;
|
||||
@@ -223,62 +560,65 @@ akerr_ErrorContext *test_akgl_tilemap_load_layers(void)
|
||||
json_error_t errdata;
|
||||
int i = 0;
|
||||
|
||||
memset((void *)&gamemap, 0x00, sizeof(akgl_Tilemap));
|
||||
|
||||
memset((void *)akgl_gamemap, 0x00, sizeof(akgl_Tilemap));
|
||||
|
||||
ATTEMPT {
|
||||
akgl_gamemap = &akgl_default_gamemap;
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
CATCH(errctx, akgl_heap_next_string(&pathstr));
|
||||
snprintf((char *)&pathstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets/testmap.tmj");
|
||||
doc = json_load_file((char *)&pathstr->data, 0, &errdata);
|
||||
snprintf((char *)&pathstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets");
|
||||
FAIL_ZERO_BREAK(errctx, doc, AKERR_NULLPOINTER, "Failed to load testmap: %s", (char *)&errdata.text);
|
||||
CATCH(errctx, akgl_tilemap_load_layers(&gamemap, doc));
|
||||
CATCH(errctx, akgl_tilemap_load_layers(akgl_gamemap, doc, pathstr));
|
||||
FAIL_NONZERO_BREAK(
|
||||
errctx,
|
||||
(gamemap.numlayers != 3),
|
||||
(akgl_gamemap->numlayers != 3),
|
||||
AKERR_VALUE,
|
||||
"Map layer count incorrect"
|
||||
);
|
||||
for ( i = 0; i < gamemap.numlayers; i++ ) {
|
||||
if ( (gamemap.layers[i].opacity != 1) ||
|
||||
(gamemap.layers[i].visible != true) ||
|
||||
(gamemap.layers[i].id != (i + 1)) ||
|
||||
(gamemap.layers[i].x != 0) ||
|
||||
(gamemap.layers[i].y != 0) ) {
|
||||
for ( i = 0; i < akgl_gamemap->numlayers; i++ ) {
|
||||
if ( (akgl_gamemap->layers[i].opacity != 1) ||
|
||||
(akgl_gamemap->layers[i].visible != true) ||
|
||||
(akgl_gamemap->layers[i].id != (i + 1)) ||
|
||||
(akgl_gamemap->layers[i].x != 0) ||
|
||||
(akgl_gamemap->layers[i].y != 0) ) {
|
||||
FAIL(errctx, AKERR_VALUE, "Map layer data loaded incorrectly (see gdb)");
|
||||
goto _test_akgl_tilemap_load_layers_cleanup;
|
||||
}
|
||||
}
|
||||
// Layer 2 should have 1 object loaded
|
||||
if ( (gamemap.layers[1].objects[0].actorptr != SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "testactor", NULL)) ||
|
||||
(gamemap.layers[1].objects[1].name[0] != '\0' ) ||
|
||||
(gamemap.layers[1].objects[1].id != 0) ) {
|
||||
if ( (akgl_gamemap->layers[1].objects[0].actorptr != SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "testactor", NULL)) ||
|
||||
(akgl_gamemap->layers[1].objects[1].name[0] != '\0' ) ||
|
||||
(akgl_gamemap->layers[1].objects[1].id != 0) ) {
|
||||
FAIL_BREAK(errctx, AKERR_VALUE, "Map layer 2 should have 1 loaded object (testactor) and nothing else (see gdb)");
|
||||
}
|
||||
// Layer 1 and 3 should have no objects
|
||||
for ( i = 0; i < AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER ; i++ ) {
|
||||
if ( gamemap.layers[0].objects[i].id != 0 ) {
|
||||
for ( i = 0; i < AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER ; i++ ) {
|
||||
if ( akgl_gamemap->layers[0].objects[i].id != 0 ) {
|
||||
FAIL(errctx, AKERR_VALUE, "Map layers 1 and 3 should have no objects loaded but found objects");
|
||||
goto _test_akgl_tilemap_load_layers_cleanup;
|
||||
}
|
||||
}
|
||||
for ( i = 0; i < AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER ; i++ ) {
|
||||
if ( gamemap.layers[2].objects[i].id != 0 ) {
|
||||
for ( i = 0; i < AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER ; i++ ) {
|
||||
if ( akgl_gamemap->layers[2].objects[i].id != 0 ) {
|
||||
FAIL(errctx, AKERR_VALUE, "Map layers 1 and 3 should have no objects loaded but found objects");
|
||||
goto _test_akgl_tilemap_load_layers_cleanup;
|
||||
}
|
||||
}
|
||||
// Layers 1 and 3 should have tile data
|
||||
if ( (gamemap.layers[0].data[0] != 1) ||
|
||||
(gamemap.layers[0].data[1] != 2) ||
|
||||
(gamemap.layers[0].data[2] != 3) ||
|
||||
(gamemap.layers[0].data[3] != 4) ||
|
||||
(gamemap.layers[2].data[0] != 0) ||
|
||||
(gamemap.layers[2].data[1] != 5) ||
|
||||
(gamemap.layers[2].data[2] != 0) ||
|
||||
(gamemap.layers[2].data[3] != 6)
|
||||
if ( (akgl_gamemap->layers[0].data[0] != 1) ||
|
||||
(akgl_gamemap->layers[0].data[1] != 2) ||
|
||||
(akgl_gamemap->layers[0].data[2] != 3) ||
|
||||
(akgl_gamemap->layers[0].data[3] != 4) ||
|
||||
(akgl_gamemap->layers[2].data[0] != 0) ||
|
||||
(akgl_gamemap->layers[2].data[1] != 5) ||
|
||||
(akgl_gamemap->layers[2].data[2] != 0) ||
|
||||
(akgl_gamemap->layers[2].data[3] != 6)
|
||||
) {
|
||||
FAIL_BREAK(errctx, AKERR_VALUE, "Map layers 1 and 3 should have tile data but it is incorrect");
|
||||
}
|
||||
_test_akgl_tilemap_load_layers_cleanup:
|
||||
_test_akgl_tilemap_load_layers_cleanup:
|
||||
} CLEANUP {
|
||||
if ( pathstr != NULL ) {
|
||||
IGNORE(akgl_heap_release_string(pathstr));
|
||||
@@ -299,40 +639,43 @@ akerr_ErrorContext *test_akgl_tilemap_load_tilesets(void)
|
||||
json_error_t errdata;
|
||||
SDL_Texture *image = NULL;
|
||||
|
||||
memset((void *)&gamemap, 0x00, sizeof(akgl_Tilemap));
|
||||
|
||||
memset((void *)akgl_gamemap, 0x00, sizeof(akgl_Tilemap));
|
||||
|
||||
ATTEMPT {
|
||||
akgl_gamemap = &akgl_default_gamemap;
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
CATCH(errctx, akgl_heap_next_string(&pathstr));
|
||||
snprintf((char *)&pathstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets/testmap.tmj");
|
||||
doc = json_load_file((char *)&pathstr->data, 0, &errdata);
|
||||
snprintf((char *)&pathstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets");
|
||||
FAIL_ZERO_BREAK(errctx, doc, AKERR_NULLPOINTER, "Failed to load testmap: %s", (char *)&errdata.text);
|
||||
CATCH(errctx, akgl_tilemap_load_tilesets(&gamemap, doc));
|
||||
FAIL_NONZERO_BREAK(errctx, (gamemap.numtilesets != 1), AKERR_VALUE, "Incorrect number of tilesets loaded for map");
|
||||
if ( (gamemap.tilesets[0].columns != 48 ) ||
|
||||
(gamemap.tilesets[0].firstgid != 1) ||
|
||||
(gamemap.tilesets[0].imageheight != 576) ||
|
||||
(gamemap.tilesets[0].imagewidth != 768) ||
|
||||
(gamemap.tilesets[0].margin != 0) ||
|
||||
(gamemap.tilesets[0].spacing != 0) ||
|
||||
(gamemap.tilesets[0].tilecount != 1728) ||
|
||||
(gamemap.tilesets[0].tileheight != 16) ||
|
||||
(gamemap.tilesets[0].tilewidth != 16) ) {
|
||||
CATCH(errctx, akgl_tilemap_load_tilesets(akgl_gamemap, doc, pathstr));
|
||||
FAIL_NONZERO_BREAK(errctx, (akgl_gamemap->numtilesets != 1), AKERR_VALUE, "Incorrect number of tilesets loaded for map");
|
||||
if ( (akgl_gamemap->tilesets[0].columns != 48 ) ||
|
||||
(akgl_gamemap->tilesets[0].firstgid != 1) ||
|
||||
(akgl_gamemap->tilesets[0].imageheight != 576) ||
|
||||
(akgl_gamemap->tilesets[0].imagewidth != 768) ||
|
||||
(akgl_gamemap->tilesets[0].margin != 0) ||
|
||||
(akgl_gamemap->tilesets[0].spacing != 0) ||
|
||||
(akgl_gamemap->tilesets[0].tilecount != 1728) ||
|
||||
(akgl_gamemap->tilesets[0].tileheight != 16) ||
|
||||
(akgl_gamemap->tilesets[0].tilewidth != 16) ) {
|
||||
FAIL_BREAK(errctx, AKERR_VALUE, "Tileset loaded with incorrect values");
|
||||
}
|
||||
FAIL_NONZERO_BREAK(
|
||||
errctx,
|
||||
strcmp((char *)&gamemap.tilesets[0].name, "World_A1"),
|
||||
strcmp((char *)&akgl_gamemap->tilesets[0].name, "World_A1"),
|
||||
AKERR_VALUE,
|
||||
"Tileset loaded with incorrect name");
|
||||
|
||||
snprintf((char *)&pathstr->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), "assets/World_A1.png");
|
||||
image = IMG_LoadTexture(renderer, (char *)&pathstr->data);
|
||||
image = IMG_LoadTexture(akgl_renderer->sdl_renderer, (char *)&pathstr->data);
|
||||
FAIL_ZERO_BREAK(errctx, image, AKGL_ERR_SDL, "Failed to load comparison image");
|
||||
|
||||
CATCH(
|
||||
errctx,
|
||||
akgl_render_and_compare(
|
||||
gamemap.tilesets[0].texture,
|
||||
akgl_gamemap->tilesets[0].texture,
|
||||
image,
|
||||
0, 0, 768, 576,
|
||||
"test_akgl_tilemap_loaded_tileset.png")
|
||||
@@ -354,10 +697,12 @@ akerr_ErrorContext *test_akgl_tilemap_load(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
memset((void *)&gamemap, 0x00, sizeof(akgl_Tilemap));
|
||||
memset((void *)akgl_gamemap, 0x00, sizeof(akgl_Tilemap));
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_tilemap_load("assets/testmap.tmj", &gamemap));
|
||||
akgl_gamemap = &akgl_default_gamemap;
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
CATCH(errctx, akgl_tilemap_load("assets/testmap.tmj", akgl_gamemap));
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
@@ -367,8 +712,10 @@ akerr_ErrorContext *test_akgl_tilemap_load(void)
|
||||
akerr_ErrorContext *test_akgl_tilemap_draw(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
|
||||
ATTEMPT {
|
||||
akgl_gamemap = &akgl_default_gamemap;
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
@@ -379,8 +726,10 @@ akerr_ErrorContext *test_akgl_tilemap_draw(void)
|
||||
akerr_ErrorContext *test_akgl_tilemap_draw_tileset(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
|
||||
ATTEMPT {
|
||||
akgl_gamemap = &akgl_default_gamemap;
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
@@ -390,17 +739,21 @@ akerr_ErrorContext *test_akgl_tilemap_draw_tileset(void)
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
akgl_gamemap = &akgl_default_gamemap;
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
SDL_SetAppMetadata("SDL3-GameTest", "0.1", "net.aklabs.sdl3-gametest");
|
||||
|
||||
|
||||
if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_JOYSTICK | SDL_INIT_AUDIO )) {
|
||||
FAIL_BREAK(errctx, AKGL_ERR_SDL, "Couldn't initialize SDL: %s", SDL_GetError());
|
||||
}
|
||||
|
||||
if (!SDL_CreateWindowAndRenderer("net/aklabs/libakgl/test_sprite", 768, 576, 0, &window, &renderer)) {
|
||||
|
||||
if (!SDL_CreateWindowAndRenderer("net/aklabs/libakgl/test_sprite", 768, 576, 0, &akgl_window, &akgl_renderer->sdl_renderer)) {
|
||||
FAIL_BREAK(errctx, AKGL_ERR_SDL, "Couldn't create window/renderer: %s", SDL_GetError());
|
||||
}
|
||||
akgl_renderer->draw_texture = &akgl_render_2d_draw_texture;
|
||||
|
||||
CATCH(errctx, akgl_registry_init());
|
||||
CATCH(errctx, akgl_heap_init());
|
||||
@@ -409,17 +762,21 @@ int main(void)
|
||||
CATCH(errctx, akgl_character_load_json("assets/testcharacter.json"));
|
||||
|
||||
CATCH(errctx, test_tilemap_akgl_get_json_tilemap_property());
|
||||
CATCH(errctx, test_tilemap_property_lookup_releases_strings());
|
||||
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());
|
||||
CATCH(errctx, test_akgl_tilemap_release_is_idempotent());
|
||||
CATCH(errctx, test_akgl_tilemap_load_releases_strings());
|
||||
//CATCH(errctx, test_akgl_tilemap_load());
|
||||
//CATCH(errctx, test_akgl_tilemap_draw_tileset());
|
||||
//CATCH(errctx, test_akgl_tilemap_draw());
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user