Compare commits
7 Commits
5f03475e0f
...
42b60f725d
| Author | SHA1 | Date | |
|---|---|---|---|
|
42b60f725d
|
|||
|
4208d9d471
|
|||
|
f56f88710f
|
|||
|
2a3ca48d8f
|
|||
|
dba0f8db89
|
|||
|
1ddc64010a
|
|||
|
17e6e04c79
|
@@ -109,6 +109,7 @@ 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
|
||||
@@ -154,9 +155,11 @@ add_executable(akgl_test_semver_unit deps/semver/semver_unit.c)
|
||||
# akgl_test_<name> and registered with CTest under <name>.
|
||||
set(AKGL_TEST_SUITES
|
||||
actor
|
||||
audio
|
||||
bitmasks
|
||||
character
|
||||
controller
|
||||
draw
|
||||
error
|
||||
game
|
||||
heap
|
||||
@@ -165,6 +168,7 @@ set(AKGL_TEST_SUITES
|
||||
registry
|
||||
sprite
|
||||
staticstring
|
||||
text
|
||||
tilemap
|
||||
util
|
||||
version
|
||||
@@ -331,6 +335,7 @@ 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/audio.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/")
|
||||
|
||||
175
TODO.md
175
TODO.md
@@ -352,6 +352,9 @@ overlap the existing **Defects** list are cross-referenced rather than repeated.
|
||||
`name` a second time; `filepath` is never checked and is passed straight to
|
||||
`TTF_OpenFont`. Copy-paste of line 18.
|
||||
|
||||
**Resolved** alongside the text measurement work; `tests/text.c` asserts a
|
||||
NULL filepath reports `AKERR_NULLPOINTER`.
|
||||
|
||||
40. **`akgl_path_relative` and `akgl_path_relative_from` disagree on the output
|
||||
parameter.** `akgl_path_relative` and `akgl_path_relative_root` take
|
||||
`akgl_String *dst`; `akgl_path_relative_from` takes `akgl_String **dst`
|
||||
@@ -376,35 +379,67 @@ ctest --test-dir build-coverage --output-on-failure
|
||||
|
||||
Reports land in `build-coverage/coverage/` (`index.html`, `coverage.xml`).
|
||||
|
||||
**Line coverage 72.2%, function coverage 78.6%** (1534/2125 lines), up from a
|
||||
39.6% / 44.3% baseline. `character` is the one intentionally failing suite;
|
||||
everything else passes.
|
||||
**Line coverage 77.2%, function coverage 83.1%** (2035/2637 lines), up from a
|
||||
39.6% / 44.3% baseline. All 19 suites pass. (An earlier revision of this file
|
||||
recorded `character` as an intentionally failing suite; it passes now, and
|
||||
nothing in `tests/character.c` or `src/character.c` has changed since — the fix
|
||||
came from elsewhere in the tree and this note was never updated.)
|
||||
|
||||
| File | Lines | Functions |
|
||||
|---|---|---|
|
||||
| `src/actor.c` | 205/258 (80%) | 16/18 |
|
||||
| `src/actor.c` | 205/258 (79%) | 16/18 |
|
||||
| `src/assets.c` | 0/21 (0%) | 0/1 |
|
||||
| `src/audio.c` | 189/207 (91%) | 19/20 |
|
||||
| `src/character.c` | 104/118 (88%) | 6/7 |
|
||||
| `src/controller.c` | 220/243 (90%) | 9/9 |
|
||||
| `src/draw.c` | 0/13 (0%) | 0/1 |
|
||||
| `src/game.c` | 124/230 (54%) | 10/15 |
|
||||
| `src/heap.c` | 116/116 (100%) | 12/12 |
|
||||
| `src/controller.c` | 247/270 (91%) | 12/12 |
|
||||
| `src/draw.c` | 253/267 (95%) | 11/12 |
|
||||
| `src/error.c` | 9/9 (100%) | 1/1 |
|
||||
| `src/game.c` | 124/231 (54%) | 10/15 |
|
||||
| `src/heap.c` | 111/111 (100%) | 12/12 |
|
||||
| `src/json_helpers.c` | 111/111 (100%) | 11/11 |
|
||||
| `src/physics.c` | 140/140 (100%) | 10/10 |
|
||||
| `src/registry.c` | 76/102 (74%) | 11/12 |
|
||||
| `src/registry.c` | 76/102 (75%) | 11/12 |
|
||||
| `src/renderer.c` | 7/70 (10%) | 1/7 |
|
||||
| `src/sprite.c` | 93/101 (92%) | 5/5 |
|
||||
| `src/staticstring.c` | 16/17 (94%) | 2/2 |
|
||||
| `src/text.c` | 0/28 (0%) | 0/2 |
|
||||
| `src/text.c` | 26/45 (58%) | 3/4 |
|
||||
| `src/tilemap.c` | 201/426 (47%) | 10/20 |
|
||||
| `src/util.c` | 121/131 (92%) | 7/8 |
|
||||
| `src/version.c` | 2/2 (100%) | 1/1 |
|
||||
|
||||
Branch coverage reads 18.6% and should not be used as a target. The akerror
|
||||
Branch coverage reads 21.2% and should not be used as a target. The akerror
|
||||
control-flow macros (`ATTEMPT`/`CATCH`/`PROCESS`/`FINISH`, `FAIL_*_RETURN`)
|
||||
expand into large branch trees per call site, most of them unreachable in normal
|
||||
operation — `src/game.c` reports over 1700 branches across 230 lines. Track line
|
||||
and function coverage; treat branch coverage as a relative signal within a file.
|
||||
|
||||
### Mutation testing
|
||||
|
||||
`scripts/mutation_test.py` was run over the three new files as a smoke check
|
||||
(`--max-mutants 8` to 10 each, so these are samples rather than exhaustive
|
||||
scores):
|
||||
|
||||
| File | Score | Surviving mutants |
|
||||
|---|---|---|
|
||||
| `src/draw.c` | 90% | Deleting the `FAIL_ZERO_BREAK` on `SDL_CreateTextureFromSurface` in `akgl_draw_flood_fill` |
|
||||
| `src/audio.c` | 75% | Deleting `SUCCEED_RETURN` from the static `check_voice`; deleting `spec.freq` before opening a device |
|
||||
| `src/text.c` | 50% | Three in `akgl_text_rendertextat`, which has no test yet, plus one `SUCCEED_RETURN` deletion |
|
||||
|
||||
The first pass over `src/audio.c` scored 50% and named two real gaps, both of
|
||||
which are now tested: nothing asserted that an *unconfigured* voice is audible
|
||||
(the whole reason the table defaults to a square wave at full level rather than
|
||||
a zeroed struct), and no envelope test used a non-zero attack *and* decay
|
||||
together, so the decay measuring from the wrong origin survived. `src/draw.c`
|
||||
scored 70% first and named one: the circle was only checked at its four axis
|
||||
points, which a mis-signed octant reflection survives, so it now checks that
|
||||
every plotted pixel has a mirror in the other three quadrants.
|
||||
|
||||
What is left is honestly untestable from here. Deleting a `SUCCEED_RETURN`
|
||||
leaves a non-void function falling off its end, which is undefined rather than
|
||||
observably wrong, and the surviving SDL branches are allocation failures the
|
||||
suite has no way to provoke. The `src/text.c` survivors go away with the
|
||||
offscreen harness.
|
||||
|
||||
### Suites
|
||||
|
||||
Every suite is registered through the `AKGL_TEST_SUITES` list in
|
||||
@@ -436,14 +471,24 @@ Done:
|
||||
- `tests/actor.c` — extended with the eight control-map handlers, automatic
|
||||
facing, movement logic, the animation frame state machine, `akgl_actor_update`,
|
||||
and character/sprite binding lookups. 80%.
|
||||
- `tests/audio.c` — every waveform, the ADSR envelope stage by stage, gate
|
||||
expiry and release, voice summing and clamping, the master level, and device
|
||||
open/shutdown under the dummy driver. 91%.
|
||||
- `tests/draw.c` — every primitive against a 64x64 software renderer with the
|
||||
pixels read back, including flood-fill containment, save/paste roundtrip, and
|
||||
that drawing restores the renderer's draw color. 95%.
|
||||
- `tests/text.c` — font loading into the registry and both measurement entry
|
||||
points against a monospaced fixture font. 58%; the rest of `src/text.c` is
|
||||
`akgl_text_rendertextat`, which needs the harness.
|
||||
|
||||
## Remaining work
|
||||
|
||||
### Needs the offscreen renderer harness
|
||||
|
||||
`src/renderer.c` (63 lines), `src/text.c` (28), `src/draw.c` (13),
|
||||
`src/assets.c` (21), `akgl_actor_render`/`actor_visible` in `src/actor.c` (53),
|
||||
and the drawing half of `src/tilemap.c` all need a live `renderer` global.
|
||||
`src/renderer.c` (63 lines), `akgl_text_rendertextat` in `src/text.c` (19),
|
||||
`akgl_draw_background` in `src/draw.c` (13), `src/assets.c` (21),
|
||||
`akgl_actor_render`/`actor_visible` in `src/actor.c` (53), and the drawing half
|
||||
of `src/tilemap.c` all need a live `renderer` global.
|
||||
|
||||
Build `tests/harness.c` / `tests/harness.h` with `akgl_test_init_headless()` and
|
||||
`akgl_test_shutdown_headless()`: set the dummy video and audio drivers,
|
||||
@@ -461,10 +506,15 @@ Then:
|
||||
rotated `draw_texture` path including `angle != 0` with a NULL center; the
|
||||
`draw_mesh` "not implemented" stub; and `draw_world` layer ordering. Note
|
||||
`defflags` at `src/renderer.c:113` is uninitialized until the `if` body runs.
|
||||
- **`tests/text.c`**, **`tests/draw.c`**, **`tests/assets.c`** — font loading
|
||||
into `AKGL_REGISTRY_FONT`, text rendering with wrap on and off, background
|
||||
drawing at zero/negative/oversized dimensions, and BGM loading into
|
||||
`AKGL_REGISTRY_MUSIC` under the dummy audio driver.
|
||||
- **`tests/assets.c`** — BGM loading into `AKGL_REGISTRY_MUSIC` under the dummy
|
||||
audio driver.
|
||||
- **`tests/text.c` extensions** — text rendering with wrap on and off.
|
||||
`tests/text.c` exists and covers font loading and measurement; only
|
||||
`akgl_text_rendertextat` is left.
|
||||
- **`tests/draw.c` extensions** — `akgl_draw_background` at zero, negative and
|
||||
oversized dimensions. `tests/draw.c` exists and covers every other primitive
|
||||
against a software renderer; `akgl_draw_background` is the one function in the
|
||||
file that still reads the global `renderer` rather than taking a backend.
|
||||
- **`tests/tilemap.c` extensions** — `akgl_tilemap_draw`, `_draw_tileset`, and
|
||||
`akgl_tilemap_load_layer_image`.
|
||||
|
||||
@@ -611,6 +661,12 @@ Each was found by a test written to assert correct behavior.
|
||||
The `build*/` entry in `.gitignore` hides these trees from `git status`,
|
||||
which makes the state easier to get into and no easier to notice.
|
||||
|
||||
A second, smaller version of the same thing: rebuilding an existing
|
||||
coverage tree after editing a test leaves `.gcda` files describing the old
|
||||
object layout, and `coverage_reset` -- whose whole job is to delete them --
|
||||
fails with `GCOV returncode was 5` before it gets the chance. `find
|
||||
build-coverage -name '*.gcda' -delete` clears it. Observed with gcovr 7.0.
|
||||
|
||||
Fix: pass the build tree to gcovr as an explicit search path instead of
|
||||
letting it default to `--root`, so only the tree under measurement is
|
||||
considered. Touches the two `add_test` blocks at `CMakeLists.txt:204-211`
|
||||
@@ -654,6 +710,18 @@ a consumer is the wanted outcome. Each entry says what the BASIC verb needs, wha
|
||||
This is the only one of the four that blocks work already designed and waiting. **`akbasic`
|
||||
cannot render any output through `libakgl` until it lands.**
|
||||
|
||||
**Resolved.** `akgl_text_measure(font, text, w, h)` and
|
||||
`akgl_text_measure_wrapped(font, text, wraplength, w, h)` are in
|
||||
`include/akgl/text.h`, over `TTF_GetStringSize` and `TTF_GetStringSizeWrapped`.
|
||||
Neither needs a renderer. A negative `wraplength` is refused with
|
||||
`AKERR_OUTOFBOUNDS` rather than passed through, because SDL_ttf reads it as a
|
||||
very large unsigned width and silently stops wrapping. `tests/text.c` covers
|
||||
both against `tests/assets/akgl_test_mono.ttf`, a 10 KB monospaced ASCII
|
||||
subset added for the purpose — being monospaced, it lets the suite assert
|
||||
`width("AAAA") == 4 * width("A")` instead of hardcoding glyph metrics that
|
||||
FreeType is free to round differently. Same change fixed item 39 below:
|
||||
`akgl_text_loadfont` checked `name` twice and never checked `filepath`.
|
||||
|
||||
2. **No immediate-mode drawing.** `include/akgl/draw.h` declares exactly one function,
|
||||
`akgl_draw_background(int w, int h)`, and `src/draw.c` is at 0% coverage. BASIC 7.0's
|
||||
graphics verbs are all immediate-mode plotting against the current screen: `DRAW` (line and
|
||||
@@ -668,6 +736,35 @@ a consumer is the wanted outcome. Each entry says what the BASIC verb needs, wha
|
||||
harness described under "Remaining work": render a known shape, read the target back, and
|
||||
compare against a reference surface with the existing `akgl_compare_sdl_surfaces`.
|
||||
|
||||
**Resolved.** `include/akgl/draw.h` now declares `akgl_draw_point`, `_line`,
|
||||
`_rect`, `_filled_rect`, `_circle`, `_flood_fill`, `_copy_region` and
|
||||
`_paste_region`, all taking the `akgl_RenderBackend *` the host initialized,
|
||||
in the shape of `akgl_render_2d_draw_texture`. Decisions worth knowing:
|
||||
|
||||
- **Color is an argument, not state.** There is no current-color global to
|
||||
get out of step with the caller's own. Each call saves and restores the
|
||||
renderer's draw color, so drawing a line does not change what the host's
|
||||
next `SDL_RenderClear` paints. `tests/draw.c` asserts that.
|
||||
- **The circle is a midpoint circle**, integer arithmetic with eight-way
|
||||
symmetry, plotted eight points per step through `SDL_RenderPoints`.
|
||||
- **The flood fill reads the target back, fills on the CPU, and blits only
|
||||
the bounding box of what changed.** It keeps a fixed
|
||||
`AKGL_DRAW_MAX_FLOOD_SPANS` (4096) stack of horizontal runs at file scope
|
||||
rather than recursing per pixel; running out reports `AKERR_OUTOFBOUNDS`
|
||||
and leaves the region partially filled, which is stated in the header. It
|
||||
is therefore not reentrant — neither is anything else that draws to a
|
||||
single `SDL_Renderer`.
|
||||
- `_copy_region` allocates when `*dest` is `NULL` and otherwise copies into
|
||||
the caller's surface, matching `akgl_get_json_string_value` and friends. A
|
||||
region that would be clipped by the target edge is refused rather than
|
||||
silently returning a smaller surface.
|
||||
|
||||
`tests/draw.c` draws into a 64x64 software renderer under the dummy video
|
||||
driver and reads pixels back, so it did not need the offscreen harness. That
|
||||
harness is still wanted for `src/renderer.c`, `src/text.c` and `src/assets.c`.
|
||||
`akgl_draw_background` is untouched and still outside the error protocol
|
||||
(item 35).
|
||||
|
||||
3. **No audio API at all.** `SDL3_mixer` is a vendored dependency and `registry.h` declares
|
||||
`AKGL_REGISTRY_MUSIC`, but there is no `src/audio.c`, no `include/akgl/audio.h`, and no
|
||||
`akgl_*` symbol that opens a mixer, loads a chunk, or plays a note. BASIC 7.0's sound verbs
|
||||
@@ -681,6 +778,35 @@ a consumer is the wanted outcome. Each entry says what the BASIC verb needs, wha
|
||||
of the four and the one most worth designing before writing. Tests can run under the dummy
|
||||
audio driver and assert state transitions rather than sound.
|
||||
|
||||
**Resolved.** `include/akgl/audio.h` and `src/audio.c` add a three-voice tone
|
||||
generator over `SDL_AudioStream`: `akgl_audio_init`, `_shutdown`, `_tone`,
|
||||
`_stop`, `_waveform`, `_envelope`, `_volume`, `_voice_active` and `_mix`.
|
||||
It is deliberately separate from the SDL3_mixer side of the library, which
|
||||
plays audio *assets*; nothing in `audio.c` reads a file. Decisions worth
|
||||
knowing:
|
||||
|
||||
- **The voice table works with no device open.** `akgl_audio_init` connects
|
||||
it to one; without that a host can still pull samples itself through
|
||||
`akgl_audio_mix`. That is not only for embedding — it is what makes the
|
||||
suite deterministic. A device pulls samples on SDL's audio thread whenever
|
||||
it likes, so a test that opened one and then asserted on voice state would
|
||||
be racing the callback. `tests/audio.c` mixes by hand and opens a device
|
||||
only in its last test.
|
||||
- **Phase is derived from the frame counter, not accumulated.** A float
|
||||
increment of `hz / 44100` is not exact, and adding it 44100 times a second
|
||||
walks a held note off pitch.
|
||||
- **A voice that was never configured is audible.** A zeroed voice has a
|
||||
sustain of 0.0, which is silence with no error to explain it, so the table
|
||||
defaults to a square wave held at full level.
|
||||
- **Three voices summing past full scale are clamped, not scaled**, so one
|
||||
voice plays at the level it was asked for rather than a third of it.
|
||||
- Everything that touches the table takes the stream lock when a device is
|
||||
open, since the callback reads it on another thread.
|
||||
|
||||
Still missing for a complete BASIC sound vocabulary: `FILTER` (SDL3 has no
|
||||
filter primitive; this would need writing), `TEMPO` and the `PLAY` note-string
|
||||
parser, both of which belong in the interpreter rather than here.
|
||||
|
||||
4. **No non-blocking keystroke read.** `include/akgl/controller.h` is built around SDL event
|
||||
handlers the host pumps (`akgl_controller_handle_event` and friends), which suits a game
|
||||
loop and does not suit `GET` and `GETKEY` -- those ask "is there a keystroke waiting, yes or
|
||||
@@ -693,6 +819,19 @@ a consumer is the wanted outcome. Each entry says what the BASIC verb needs, wha
|
||||
synthetic `SDL_EVENT_KEY_DOWN` events through the existing handler and drain them, plus the
|
||||
empty-buffer and overflow cases.
|
||||
|
||||
**Resolved.** `akgl_controller_poll_key(int *keycode, bool *available)` and
|
||||
`akgl_controller_flush_keys(void)` are in `include/akgl/controller.h`, over a
|
||||
fixed `AKGL_CONTROLLER_KEY_BUFFER` (32) ring in `src/controller.c`.
|
||||
`akgl_controller_handle_event` records every `SDL_EVENT_KEY_DOWN` *before* it
|
||||
scans the control maps, so a key bound to an actor still reaches a polling
|
||||
caller — the scan returns as soon as a binding claims the event, and doing it
|
||||
afterwards would have lost exactly the keys a game also acts on. An empty
|
||||
buffer is success with `available` false, not an error. A full buffer drops
|
||||
the newest key rather than overwriting the oldest, matching the Commodore
|
||||
keyboard buffer and keeping what was typed first. `tests/controller.c` covers
|
||||
drain order, the release-is-not-a-keystroke case, a key shared with a control
|
||||
map, flush, both NULL arguments, and overflow plus reuse afterwards.
|
||||
|
||||
## Carried over
|
||||
|
||||
1. **Make character-to-sprite state bindings release their references symmetrically.**
|
||||
|
||||
204
include/akgl/audio.h
Normal file
204
include/akgl/audio.h
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* @file audio.h
|
||||
* @brief Declares the public audio API.
|
||||
*
|
||||
* A small tone generator: a fixed set of voices, each with a waveform, a
|
||||
* frequency, 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 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;
|
||||
akgl_AudioWaveform waveform;
|
||||
float32_t hz;
|
||||
/**
|
||||
* @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;
|
||||
uint32_t attack_frames;
|
||||
uint32_t decay_frames;
|
||||
uint32_t release_frames;
|
||||
/** @brief Level the envelope decays to and holds, 0.0 to 1.0. */
|
||||
float32_t sustain;
|
||||
} 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.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKGL_ERR_SDL When the corresponding validation or operation fails.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* @param voice Zero-based voice index.
|
||||
* @param hz Frequency in hertz.
|
||||
* @param ms Gate length in milliseconds.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_OUTOFBOUNDS When the corresponding validation or operation fails.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_tone(int voice, float32_t hz, uint32_t ms);
|
||||
|
||||
/**
|
||||
* @brief Silence one voice immediately, skipping its release.
|
||||
* @param voice Zero-based voice index.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_OUTOFBOUNDS When the corresponding validation or operation fails.
|
||||
*/
|
||||
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.
|
||||
* @param waveform Oscillator shape to use.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_OUTOFBOUNDS When the corresponding validation or operation fails.
|
||||
*/
|
||||
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.
|
||||
* @param attack Milliseconds to rise from silence to full level.
|
||||
* @param decay Milliseconds to fall from full level to @p sustain.
|
||||
* @param sustain Held level while the gate is open, 0.0 to 1.0.
|
||||
* @param release Milliseconds to fall to silence once the gate closes.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_OUTOFBOUNDS When the corresponding validation or operation fails.
|
||||
*/
|
||||
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.
|
||||
* @param level Master level, 0.0 to 1.0.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_OUTOFBOUNDS When the corresponding validation or operation fails.
|
||||
*/
|
||||
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.
|
||||
* @param active Output destination set to `true` while the voice is sounding.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER When the corresponding validation or operation fails.
|
||||
* @throws AKERR_OUTOFBOUNDS When the corresponding validation or operation fails.
|
||||
*/
|
||||
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 Output destination populated with @p frames samples.
|
||||
* @param frames Number of samples to generate.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER When the corresponding validation or operation fails.
|
||||
* @throws AKERR_OUTOFBOUNDS When the corresponding validation or operation fails.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_audio_mix(float32_t *dest, int frames);
|
||||
|
||||
#endif // _AKGL_AUDIO_H_
|
||||
@@ -13,6 +13,16 @@
|
||||
#define AKGL_MAX_CONTROL_MAPS 8
|
||||
#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 Maps one SDL input to pressed and released callbacks. */
|
||||
typedef struct {
|
||||
uint32_t event_on;
|
||||
@@ -117,4 +127,39 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_default(int controlmapid, cha
|
||||
* @throws AKERR_NULLPOINTER When the corresponding validation or operation fails.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_open_gamepads(void);
|
||||
|
||||
/**
|
||||
* @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.
|
||||
*
|
||||
* @param keycode Output destination populated with the SDL keycode, or 0.
|
||||
* @param available Output destination set to `true` when a keystroke was taken.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER When the corresponding validation or operation fails.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_poll_key(int *keycode, 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.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_flush_keys(void);
|
||||
#endif // _CONTROLLER_H_
|
||||
|
||||
@@ -1,11 +1,41 @@
|
||||
/**
|
||||
* @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.
|
||||
*/
|
||||
|
||||
#ifndef _DRAW_H_
|
||||
#define _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 Draw background.
|
||||
* @param w Destination width.
|
||||
@@ -13,4 +43,129 @@
|
||||
*/
|
||||
void akgl_draw_background(int w, int h);
|
||||
|
||||
/**
|
||||
* @brief Plot a single pixel.
|
||||
* @param self Backend or object instance to operate on.
|
||||
* @param x Horizontal destination coordinate.
|
||||
* @param y Vertical destination coordinate.
|
||||
* @param color Color to draw with.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER When the corresponding validation or operation fails.
|
||||
* @throws AKGL_ERR_SDL When the corresponding validation or operation fails.
|
||||
*/
|
||||
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.
|
||||
* @param self Backend or object instance to operate on.
|
||||
* @param x1 Horizontal coordinate of the first endpoint.
|
||||
* @param y1 Vertical coordinate of the first endpoint.
|
||||
* @param x2 Horizontal coordinate of the second endpoint.
|
||||
* @param y2 Vertical coordinate of the second endpoint.
|
||||
* @param color Color to draw with.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER When the corresponding validation or operation fails.
|
||||
* @throws AKGL_ERR_SDL When the corresponding validation or operation fails.
|
||||
*/
|
||||
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 Backend or object instance to operate on.
|
||||
* @param rect Rectangle to outline.
|
||||
* @param color Color to draw with.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER When the corresponding validation or operation fails.
|
||||
* @throws AKGL_ERR_SDL When the corresponding validation or operation fails.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_rect(akgl_RenderBackend *self, SDL_FRect *rect, SDL_Color color);
|
||||
|
||||
/**
|
||||
* @brief Fill a rectangle.
|
||||
* @param self Backend or object instance to operate on.
|
||||
* @param rect Rectangle to fill.
|
||||
* @param color Color to draw with.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER When the corresponding validation or operation fails.
|
||||
* @throws AKGL_ERR_SDL When the corresponding validation or operation fails.
|
||||
*/
|
||||
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 Backend or object instance to operate on.
|
||||
* @param x Horizontal coordinate of the center.
|
||||
* @param y Vertical coordinate of the center.
|
||||
* @param radius Circle radius in pixels.
|
||||
* @param color Color to draw with.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER When the corresponding validation or operation fails.
|
||||
* @throws AKERR_OUTOFBOUNDS When the corresponding validation or operation fails.
|
||||
* @throws AKGL_ERR_SDL When the corresponding validation or operation fails.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* @param self Backend or object instance to operate on.
|
||||
* @param x Horizontal coordinate of the seed pixel.
|
||||
* @param y Vertical coordinate of the seed pixel.
|
||||
* @param color Color to fill with.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER When the corresponding validation or operation fails.
|
||||
* @throws AKERR_OUTOFBOUNDS When the corresponding validation or operation fails.
|
||||
* @throws AKGL_ERR_SDL When the corresponding validation or operation fails.
|
||||
*/
|
||||
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 Backend or object instance to operate on.
|
||||
* @param src Rectangle of the render target to read.
|
||||
* @param dest Output destination populated by the function.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER When the corresponding validation or operation fails.
|
||||
* @throws AKERR_OUTOFBOUNDS When the corresponding validation or operation fails.
|
||||
* @throws AKGL_ERR_SDL When the corresponding validation or operation 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 Backend or object instance to operate on.
|
||||
* @param src Surface to draw.
|
||||
* @param x Horizontal destination coordinate.
|
||||
* @param y Vertical destination coordinate.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER When the corresponding validation or operation fails.
|
||||
* @throws AKGL_ERR_SDL When the corresponding validation or operation fails.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_paste_region(akgl_RenderBackend *self, SDL_Surface *src, float32_t x, float32_t y);
|
||||
|
||||
#endif //_DRAW_H_
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
#ifndef _TEXT_H_
|
||||
#define _TEXT_H_
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_ttf/SDL_ttf.h>
|
||||
#include <akerror.h>
|
||||
|
||||
/**
|
||||
* @brief Text loadfont.
|
||||
@@ -31,5 +33,40 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_text_loadfont(char *name, char *filepath
|
||||
* @throws AKERR_NULLPOINTER When the corresponding validation or operation fails.
|
||||
*/
|
||||
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 used to render the text.
|
||||
* @param text UTF-8 text to measure.
|
||||
* @param w Output destination populated with the rendered width in pixels.
|
||||
* @param h Output destination populated with the rendered height in pixels.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER When the corresponding validation or operation fails.
|
||||
* @throws AKGL_ERR_SDL When the corresponding validation or operation fails.
|
||||
*/
|
||||
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 used to render the text.
|
||||
* @param text UTF-8 text to measure.
|
||||
* @param wraplength Maximum rendered line width; zero wraps on newlines only.
|
||||
* @param w Output destination populated with the rendered width in pixels.
|
||||
* @param h Output destination populated with the rendered height in pixels.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER When the corresponding validation or operation fails.
|
||||
* @throws AKERR_OUTOFBOUNDS When the corresponding validation or operation fails.
|
||||
* @throws AKGL_ERR_SDL When the corresponding validation or operation fails.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_text_measure_wrapped(TTF_Font *font, char *text, int wraplength, int *w, int *h);
|
||||
|
||||
#endif // _TEXT_H_
|
||||
|
||||
431
src/audio.c
Normal file
431
src/audio.c
Normal file
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
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 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.
|
||||
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 AKERR_NOIGNORE *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 AKERR_NOIGNORE *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 AKERR_NOIGNORE *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");
|
||||
|
||||
ensure_voices();
|
||||
lock_voices();
|
||||
akgl_audio_voices[voice].hz = hz;
|
||||
akgl_audio_voices[voice].phase = 0.0f;
|
||||
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();
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *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 AKERR_NOIGNORE *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 AKERR_NOIGNORE *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 AKERR_NOIGNORE *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 AKERR_NOIGNORE *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 AKERR_NOIGNORE *akgl_audio_mix(float32_t *dest, int frames)
|
||||
{
|
||||
akgl_AudioVoice *voice = NULL;
|
||||
float32_t sum = 0.0f;
|
||||
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;
|
||||
}
|
||||
// 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);
|
||||
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);
|
||||
}
|
||||
@@ -12,6 +12,39 @@
|
||||
|
||||
akgl_ControlMap GAME_ControlMaps[AKGL_MAX_CONTROL_MAPS];
|
||||
|
||||
/*
|
||||
* Keystrokes waiting for akgl_controller_poll_key(). 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 SDL_Keycode keybuffer[AKGL_CONTROLLER_KEY_BUFFER];
|
||||
static int keybuffer_head = 0;
|
||||
static int keybuffer_count = 0;
|
||||
|
||||
/**
|
||||
* @brief Record one keystroke, 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.
|
||||
*/
|
||||
static void keybuffer_push(SDL_Keycode key)
|
||||
{
|
||||
int tail = 0;
|
||||
|
||||
if ( keybuffer_count >= AKGL_CONTROLLER_KEY_BUFFER ) {
|
||||
return;
|
||||
}
|
||||
tail = (keybuffer_head + keybuffer_count) % AKGL_CONTROLLER_KEY_BUFFER;
|
||||
keybuffer[tail] = key;
|
||||
keybuffer_count += 1;
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_list_keyboards(void)
|
||||
{
|
||||
int count;
|
||||
@@ -65,6 +98,13 @@ 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(event->key.key);
|
||||
}
|
||||
|
||||
ATTEMPT {
|
||||
for ( i = 0 ; i < AKGL_MAX_CONTROL_MAPS; i++ ) {
|
||||
curmap = &GAME_ControlMaps[i];
|
||||
@@ -386,3 +426,32 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_default(int controlmapid, cha
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_poll_key(int *keycode, bool *available)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, keycode, AKERR_NULLPOINTER, "NULL keycode destination");
|
||||
FAIL_ZERO_RETURN(errctx, available, AKERR_NULLPOINTER, "NULL availability destination");
|
||||
|
||||
if ( keybuffer_count == 0 ) {
|
||||
// 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;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
*keycode = (int)keybuffer[keybuffer_head];
|
||||
*available = true;
|
||||
keybuffer_head = (keybuffer_head + 1) % AKGL_CONTROLLER_KEY_BUFFER;
|
||||
keybuffer_count -= 1;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_flush_keys(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
keybuffer_head = 0;
|
||||
keybuffer_count = 0;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
516
src/draw.c
516
src/draw.c
@@ -6,8 +6,28 @@
|
||||
#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>
|
||||
|
||||
/** @brief One horizontal run of pixels the flood fill has still to examine. */
|
||||
typedef struct {
|
||||
int x1;
|
||||
int x2;
|
||||
int y;
|
||||
} 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];
|
||||
|
||||
/* Draw a Gimpish background pattern to show transparency in the image */
|
||||
void akgl_draw_background(int w, int h)
|
||||
{
|
||||
@@ -33,3 +53,499 @@ void akgl_draw_background(int w, int h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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.
|
||||
*/
|
||||
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. */
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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.
|
||||
*/
|
||||
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 AKERR_NOIGNORE *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 AKERR_NOIGNORE *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 AKERR_NOIGNORE *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 AKERR_NOIGNORE *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 AKERR_NOIGNORE *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 AKERR_NOIGNORE *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 AKERR_NOIGNORE *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 AKERR_NOIGNORE *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);
|
||||
}
|
||||
|
||||
45
src/text.c
45
src/text.c
@@ -16,7 +16,7 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_text_loadfont(char *name, char *filepath
|
||||
|
||||
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());
|
||||
FAIL_ZERO_RETURN(
|
||||
@@ -64,3 +64,46 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_text_rendertextat(TTF_Font *font, char *
|
||||
SDL_DestroySurface(textsurf);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *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 AKERR_NOIGNORE *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);
|
||||
}
|
||||
|
||||
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.
491
tests/audio.c
Normal file
491
tests/audio.c
Normal file
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
|
||||
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_volume_and_mixing());
|
||||
CATCH(errctx, test_audio_device());
|
||||
} CLEANUP {
|
||||
SDL_Quit();
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
}
|
||||
@@ -524,6 +524,164 @@ akerr_ErrorContext *test_controller_device_enumeration(void)
|
||||
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);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
@@ -551,6 +709,8 @@ int main(void)
|
||||
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());
|
||||
} CLEANUP {
|
||||
SDL_Quit();
|
||||
} PROCESS(errctx) {
|
||||
|
||||
600
tests/draw.c
Normal file
600
tests/draw.c
Normal file
@@ -0,0 +1,600 @@
|
||||
/**
|
||||
* @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(renderer->sdl_renderer, 0x00, 0x00, 0x00, 0xff),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
SDL_RenderClear(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(renderer, 10.0f, 20.0f, testred),
|
||||
"plotting one pixel");
|
||||
|
||||
shot = SDL_RenderReadPixels(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(renderer, 5.0f, 4.0f, 5.0f, 12.0f, testred),
|
||||
"drawing a vertical line");
|
||||
|
||||
shot = SDL_RenderReadPixels(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(renderer, &box, testred), "outlining a rectangle");
|
||||
shot = SDL_RenderReadPixels(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(renderer, &box, testred), "filling a rectangle");
|
||||
shot = SDL_RenderReadPixels(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(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(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(renderer, 32.0f, 32.0f, 10.0f, testred),
|
||||
"drawing a circle");
|
||||
|
||||
shot = SDL_RenderReadPixels(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(renderer, 5.0f, 5.0f, 0.0f, testred),
|
||||
"drawing a circle of radius zero");
|
||||
shot = SDL_RenderReadPixels(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(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(renderer, &box, testred));
|
||||
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_flood_fill(renderer, 20, 20, testgreen),
|
||||
"flooding the inside of a box");
|
||||
|
||||
shot = SDL_RenderReadPixels(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(renderer, 20, 20, testgreen),
|
||||
"flooding a region that is already that color");
|
||||
shot = SDL_RenderReadPixels(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(renderer, 0, 0, testgreen),
|
||||
"flooding the area around a box");
|
||||
shot = SDL_RenderReadPixels(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(renderer, -1, 0, testred),
|
||||
"flooding from a seed left of the target");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_draw_flood_fill(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(renderer, &box, testred));
|
||||
|
||||
region.x = 0;
|
||||
region.y = 0;
|
||||
region.w = 8;
|
||||
region.h = 8;
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_copy_region(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(renderer, saved, 32.0f, 32.0f),
|
||||
"pasting a saved region");
|
||||
shot = SDL_RenderReadPixels(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(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(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(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(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(renderer, NULL, &reused),
|
||||
"saving a NULL region");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_copy_region(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(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(renderer->sdl_renderer, 0x11, 0x22, 0x33, 0x44),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
CATCH(errctx, akgl_draw_point(renderer, 1.0f, 1.0f, testred));
|
||||
CATCH(errctx, akgl_draw_line(renderer, 0.0f, 0.0f, 3.0f, 3.0f, testred));
|
||||
CATCH(errctx, akgl_draw_rect(renderer, &box, testred));
|
||||
CATCH(errctx, akgl_draw_filled_rect(renderer, &box, testred));
|
||||
CATCH(errctx, akgl_draw_circle(renderer, 20.0f, 20.0f, 4.0f, testred));
|
||||
|
||||
FAIL_ZERO_BREAK(
|
||||
errctx,
|
||||
SDL_GetRenderDrawColor(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);
|
||||
}
|
||||
|
||||
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());
|
||||
renderer = &_akgl_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,
|
||||
&window,
|
||||
&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());
|
||||
} CLEANUP {
|
||||
SDL_Quit();
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
}
|
||||
259
tests/text.c
Normal file
259
tests/text.c
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* @file text.c
|
||||
* @brief Unit tests for font loading and text measurement.
|
||||
*
|
||||
* Measurement needs a font but no renderer, so this suite runs without a window
|
||||
* and without the offscreen harness the drawing half of src/text.c is waiting
|
||||
* on. akgl_text_rendertextat() is therefore not covered here.
|
||||
*
|
||||
* 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/registry.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 The fixture font, opened once by main() and shared by every test. */
|
||||
static TTF_Font *testfont = NULL;
|
||||
|
||||
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");
|
||||
} 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);
|
||||
}
|
||||
|
||||
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(0),
|
||||
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());
|
||||
|
||||
CATCH(errctx, test_text_loadfont());
|
||||
CATCH(errctx, test_text_measure());
|
||||
CATCH(errctx, test_text_measure_wrapped());
|
||||
} CLEANUP {
|
||||
if ( testfont != NULL ) {
|
||||
TTF_CloseFont(testfont);
|
||||
}
|
||||
TTF_Quit();
|
||||
SDL_Quit();
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
}
|
||||
Reference in New Issue
Block a user