diff --git a/src/dusk/animation/animation.c b/src/dusk/animation/animation.c index 1dc7a38e..11cd23b7 100644 --- a/src/dusk/animation/animation.c +++ b/src/dusk/animation/animation.c @@ -16,7 +16,9 @@ void animationInit( ) { assertNotNull(anim, "Animation pointer cannot be null."); - keyframeSetInit(&anim->keyframes, tracks, trackCounts, trackCount); + keyframeSetInit( + &anim->keyframes, tracks, trackCounts, NULL, NULL, trackCount, NULL + ); anim->time = 0.0f; anim->speed = 1.0f; anim->loop = false; diff --git a/src/dusk/animation/keyframeset.c b/src/dusk/animation/keyframeset.c index 50abbd59..45dcd62b 100644 --- a/src/dusk/animation/keyframeset.c +++ b/src/dusk/animation/keyframeset.c @@ -10,7 +10,10 @@ void keyframeSetInit( keyframeset_t *set, keyframe_t **tracks, uint16_t *trackCounts, - uint16_t trackCount + keyframesetcallback_t *callbacks, + void **users, + uint16_t trackCount, + void *user ) { assertNotNull(set, "Keyframe set pointer cannot be null."); assertNotNull(tracks, "Tracks pointer cannot be null."); @@ -20,6 +23,9 @@ void keyframeSetInit( set->tracks = tracks; set->trackCounts = trackCounts; set->trackCount = trackCount; + set->callbacks = callbacks; + set->users = users; + set->user = user; } float_t keyframeSetGetValue( @@ -58,3 +64,13 @@ float_t keyframeSetGetDuration(keyframeset_t *set) { } return duration; } + +void keyframeSetFireCallback(keyframeset_t *set, const uint16_t trackIndex) { + assertNotNull(set, "Keyframe set pointer cannot be null."); + assertTrue(trackIndex < set->trackCount, "Track index out of bounds."); + + if(!set->callbacks || !set->callbacks[trackIndex]) return; + set->callbacks[trackIndex]( + set, trackIndex, set->users ? set->users[trackIndex] : NULL + ); +} diff --git a/src/dusk/animation/keyframeset.h b/src/dusk/animation/keyframeset.h index c9a19b38..f141af5e 100644 --- a/src/dusk/animation/keyframeset.h +++ b/src/dusk/animation/keyframeset.h @@ -6,35 +6,68 @@ #pragma once #include "keyframe.h" +typedef struct keyframeset_t keyframeset_t; + +/** + * Callback associated with one track of a keyframe set (see + * keyframeSetFireCallback()). + * + * @param set The keyframe set the track belongs to. + * @param trackIndex The track this callback is registered for. + * @param user The per-track user pointer passed to keyframeSetInit(). + */ +typedef void (*keyframesetcallback_t)( + keyframeset_t *set, + const uint16_t trackIndex, + void *user +); + /** * A group of N parallel keyframe tracks (e.g. one per animated channel -- * position.x/y/z, bone rotations, etc.) sharing a single timeline. Each * track is independent: its own keyframe array and count, evaluated at * whatever time is asked for. */ -typedef struct { +typedef struct keyframeset_t { /** Caller-owned array of trackCount keyframe_t arrays. */ keyframe_t **tracks; /** Caller-owned array of trackCount counts, one per entry in tracks. */ uint16_t *trackCounts; uint16_t trackCount; + + /** Caller-owned array of trackCount callbacks, one per track. Entries + * (or the whole array) may be NULL for tracks with no callback. */ + keyframesetcallback_t *callbacks; + /** Caller-owned array of trackCount user pointers, one per track, + * passed to that track's callbacks[] entry. May be NULL. */ + void **users; + /** Extra user data for the set as a whole, not tied to any one track. */ + void *user; } keyframeset_t; /** - * Initializes a keyframe set. Neither tracks, trackCounts, nor the - * keyframe_t arrays they point to are copied -- they must outlive this - * keyframeset_t. + * Initializes a keyframe set. None of the passed-in arrays (or the + * keyframe_t arrays tracks points to) are copied -- they must outlive + * this keyframeset_t. * * @param set The keyframe set to initialize. * @param tracks Array of trackCount keyframe_t arrays. * @param trackCounts Array of trackCount keyframe counts, matching tracks. + * @param callbacks Array of trackCount callbacks, one per track, or NULL + * if no track has one. + * @param users Array of trackCount user pointers, matching callbacks, or + * NULL. * @param trackCount The number of tracks. + * @param user Extra user data for the set as a whole; may be NULL. */ void keyframeSetInit( keyframeset_t *set, keyframe_t **tracks, uint16_t *trackCounts, - uint16_t trackCount + keyframesetcallback_t *callbacks, + void **users, + uint16_t trackCount, + void *user ); /** @@ -72,3 +105,12 @@ void keyframeSetGetValues( * @return The set's duration, in seconds. */ float_t keyframeSetGetDuration(keyframeset_t *set); + +/** + * Invokes a track's callback, if it (and its callbacks array) is set. + * No-op otherwise. + * + * @param set The keyframe set the track belongs to. + * @param trackIndex The track whose callback to invoke. + */ +void keyframeSetFireCallback(keyframeset_t *set, const uint16_t trackIndex); diff --git a/src/dusk/asset/loader/CMakeLists.txt b/src/dusk/asset/loader/CMakeLists.txt index 44bb11b1..71670a44 100644 --- a/src/dusk/asset/loader/CMakeLists.txt +++ b/src/dusk/asset/loader/CMakeLists.txt @@ -15,4 +15,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} add_subdirectory(display) add_subdirectory(locale) add_subdirectory(json) -add_subdirectory(dmf) \ No newline at end of file +add_subdirectory(dmf) +add_subdirectory(animation) \ No newline at end of file diff --git a/src/dusk/asset/loader/animation/CMakeLists.txt b/src/dusk/asset/loader/animation/CMakeLists.txt new file mode 100644 index 00000000..4b53ad03 --- /dev/null +++ b/src/dusk/asset/loader/animation/CMakeLists.txt @@ -0,0 +1,9 @@ +# Copyright (c) 2026 Dominic Masters +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +target_sources(${DUSK_LIBRARY_TARGET_NAME} + PUBLIC + assetanimationloader.c +) diff --git a/src/dusk/asset/loader/animation/assetanimationloader.c b/src/dusk/asset/loader/animation/assetanimationloader.c new file mode 100644 index 00000000..958c7958 --- /dev/null +++ b/src/dusk/asset/loader/animation/assetanimationloader.c @@ -0,0 +1,234 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "assetanimationloader.h" +#include "util/memory.h" +#include "util/string.h" +#include "assert/assert.h" +#include "asset/loader/assetloading.h" +#include "asset/loader/assetentry.h" + +errorret_t assetAnimationLoaderAsync(assetloading_t *loading) { + assertNotNull(loading, "Loading cannot be NULL"); + assertNotMainThread("Async loader should not be on main thread."); + + if( + loading->loading.animation.state != ASSET_ANIMATION_LOADING_STATE_READ_FILE + ) { + errorOk(); + } + + assertNull(loading->loading.animation.buffer, "Buffer already defined?"); + + assetfile_t *file = &loading->loading.animation.file; + assetLoaderErrorChain( + loading, assetFileInit(file, loading->entry->name, NULL, NULL) + ); + + if(file->size > ASSET_ANIMATION_FILE_SIZE_MAX) { + assetLoaderErrorThrow( + loading, "Animation JSON exceeds maximum allowed size" + ); + } + + uint8_t *buffer; + size_t size; + assetLoaderErrorChain(loading, assetFileReadEntire(file, &buffer, &size)); + assetLoaderErrorChain(loading, assetFileDispose(file)); + + loading->loading.animation.buffer = buffer; + loading->loading.animation.size = size; + loading->loading.animation.state = ASSET_ANIMATION_LOADING_STATE_PARSE; + loading->entry->state = ASSET_ENTRY_STATE_PENDING_SYNC; + errorOk(); +} + +errorret_t assetAnimationLoaderSync(assetloading_t *loading) { + assertNotNull(loading, "Loading cannot be NULL"); + assertTrue(loading->type == ASSET_LOADER_TYPE_ANIMATION, "Invalid type."); + assertIsMainThread("Must be called from the main thread."); + + switch(loading->loading.animation.state) { + case ASSET_ANIMATION_LOADING_STATE_INITIAL: + loading->loading.animation.state = + ASSET_ANIMATION_LOADING_STATE_READ_FILE; + loading->entry->state = ASSET_ENTRY_STATE_PENDING_ASYNC; + errorOk(); + break; + + case ASSET_ANIMATION_LOADING_STATE_PARSE: + break; + + default: + errorOk(); + } + + uint8_t *buffer = loading->loading.animation.buffer; + assertNotNull(buffer, "Animation buffer should have been loaded by now."); + + yyjson_doc *doc = yyjson_read( + (char *)buffer, + loading->loading.animation.size, + YYJSON_READ_ALLOW_COMMENTS | YYJSON_READ_ALLOW_TRAILING_COMMAS + ); + memoryFree(buffer); + loading->loading.animation.buffer = NULL; + + if(!doc) assetLoaderErrorThrow(loading, "Failed to parse animation JSON"); + + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *channelsVal = yyjson_obj_get(root, "channels"); + if(!channelsVal || !yyjson_is_arr(channelsVal)) { + yyjson_doc_free(doc); + assetLoaderErrorThrow(loading, "Animation JSON missing 'channels' array"); + } + + uint16_t channelCount = (uint16_t)yyjson_arr_size(channelsVal); + if(channelCount == 0) { + yyjson_doc_free(doc); + assetLoaderErrorThrow(loading, "Animation must have at least one channel"); + } + + keyframe_t **tracks = memoryAllocate(channelCount * sizeof(keyframe_t *)); + uint16_t *trackCounts = memoryAllocate(channelCount * sizeof(uint16_t)); + + size_t idx, max; + yyjson_val *channelJson; + uint16_t parsed = 0; + yyjson_arr_foreach(channelsVal, idx, max, channelJson) { + keyframe_t *keyframes; + uint16_t count; + errorret_t ret = + assetAnimationParseChannel(channelJson, &keyframes, &count); + if(errorIsNotOk(ret)) { + assetAnimationFreeChannels(tracks, parsed); + memoryFree(trackCounts); + yyjson_doc_free(doc); + loading->entry->state = ASSET_ENTRY_STATE_ERROR; + errorChain(ret); + } + + tracks[idx] = keyframes; + trackCounts[idx] = count; + parsed++; + } + + animationInit( + &loading->entry->data.animation, tracks, trackCounts, channelCount + ); + + yyjson_val *loopVal = yyjson_obj_get(root, "loop"); + if(loopVal) loading->entry->data.animation.loop = yyjson_get_bool(loopVal); + + yyjson_val *speedVal = yyjson_obj_get(root, "speed"); + if(speedVal) { + loading->entry->data.animation.speed = (float_t)yyjson_get_num(speedVal); + } + + yyjson_doc_free(doc); + + loading->entry->state = ASSET_ENTRY_STATE_LOADED; + errorOk(); +} + +errorret_t assetAnimationDispose(assetentry_t *entry) { + assertNotNull(entry, "Asset entry cannot be NULL"); + assertTrue(entry->type == ASSET_LOADER_TYPE_ANIMATION, "Invalid type."); + assertIsMainThread("Must be called from the main thread."); + + // A load that failed before animationInit() ran (e.g. a parse error) + // never populated these -- nothing to free in that case. + keyframeset_t *set = &entry->data.animation.keyframes; + if(set->tracks) { + assetAnimationFreeChannels(set->tracks, set->trackCount); + memoryFree(set->trackCounts); + set->tracks = NULL; + set->trackCounts = NULL; + set->trackCount = 0; + } + + errorOk(); +} + +errorret_t assetAnimationParseEasing( + const char_t *name, + easingtype_t *outEasing +) { + if(stringEquals(name, "LINEAR")) *outEasing = EASING_LINEAR; + else if(stringEquals(name, "IN_SINE")) *outEasing = EASING_IN_SINE; + else if(stringEquals(name, "OUT_SINE")) *outEasing = EASING_OUT_SINE; + else if(stringEquals(name, "IN_OUT_SINE")) *outEasing = EASING_IN_OUT_SINE; + else if(stringEquals(name, "IN_QUAD")) *outEasing = EASING_IN_QUAD; + else if(stringEquals(name, "OUT_QUAD")) *outEasing = EASING_OUT_QUAD; + else if(stringEquals(name, "IN_OUT_QUAD")) *outEasing = EASING_IN_OUT_QUAD; + else if(stringEquals(name, "IN_CUBIC")) *outEasing = EASING_IN_CUBIC; + else if(stringEquals(name, "OUT_CUBIC")) *outEasing = EASING_OUT_CUBIC; + else if(stringEquals(name, "IN_OUT_CUBIC")) *outEasing = EASING_IN_OUT_CUBIC; + else if(stringEquals(name, "IN_QUART")) *outEasing = EASING_IN_QUART; + else if(stringEquals(name, "OUT_QUART")) *outEasing = EASING_OUT_QUART; + else if(stringEquals(name, "IN_OUT_QUART")) *outEasing = EASING_IN_OUT_QUART; + else if(stringEquals(name, "IN_BACK")) *outEasing = EASING_IN_BACK; + else if(stringEquals(name, "OUT_BACK")) *outEasing = EASING_OUT_BACK; + else if(stringEquals(name, "IN_OUT_BACK")) *outEasing = EASING_IN_OUT_BACK; + else errorThrow("Unknown easing type '%s'", name); + + errorOk(); +} + +errorret_t assetAnimationParseChannel( + yyjson_val *channelJson, + keyframe_t **outKeyframes, + uint16_t *outCount +) { + if(!yyjson_is_arr(channelJson)) { + errorThrow("Animation channel must be an array"); + } + + size_t count = yyjson_arr_size(channelJson); + if(count == 0) { + errorThrow("Animation channel must have at least one keyframe"); + } + + keyframe_t *keyframes = memoryAllocate(count * sizeof(keyframe_t)); + + size_t idx, max; + yyjson_val *keyframeJson; + yyjson_arr_foreach(channelJson, idx, max, keyframeJson) { + yyjson_val *timeVal = yyjson_obj_get(keyframeJson, "time"); + yyjson_val *valueVal = yyjson_obj_get(keyframeJson, "value"); + if(!timeVal || !valueVal) { + memoryFree(keyframes); + errorThrow("Animation keyframe missing 'time' or 'value'"); + } + + keyframes[idx].time = (float_t)yyjson_get_num(timeVal); + keyframes[idx].value = (float_t)yyjson_get_num(valueVal); + + yyjson_val *easingVal = yyjson_obj_get(keyframeJson, "easing"); + if(!easingVal) { + keyframes[idx].easing = EASING_LINEAR; + continue; + } + + errorret_t ret = assetAnimationParseEasing( + yyjson_get_str(easingVal), &keyframes[idx].easing + ); + if(errorIsNotOk(ret)) { + memoryFree(keyframes); + errorChain(ret); + } + } + + *outKeyframes = keyframes; + *outCount = (uint16_t)count; + errorOk(); +} + +void assetAnimationFreeChannels(keyframe_t **tracks, const uint16_t count) { + for(uint16_t i = 0; i < count; i++) memoryFree(tracks[i]); + memoryFree(tracks); +} diff --git a/src/dusk/asset/loader/animation/assetanimationloader.h b/src/dusk/asset/loader/animation/assetanimationloader.h new file mode 100644 index 00000000..c9fa44ff --- /dev/null +++ b/src/dusk/asset/loader/animation/assetanimationloader.h @@ -0,0 +1,101 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "error/error.h" +#include "asset/assetfile.h" +#include "animation/animation.h" +#include "yyjson.h" + +#define ASSET_ANIMATION_FILE_SIZE_MAX 1024*256 + +typedef struct assetloading_s assetloading_t; +typedef struct assetentry_s assetentry_t; + +/** + * JSON animation file format: + * { + * "loop": false, + * "speed": 1.0, + * "channels": [ + * [ + * { "time": 0.0, "value": 0.0, "easing": "LINEAR" }, + * { "time": 1.0, "value": 10.0 } + * ] + * ] + * } + * + * "loop" and "speed" are optional, defaulting to false and 1.0 (see + * animationInit()). "channels" is required and must have at least one + * entry; each entry is itself a non-empty array of keyframes, ascending + * by "time", sharing one timeline with every other channel (see + * keyframeset_t). Each keyframe requires "time" and "value"; "easing" is + * optional and defaults to "LINEAR" (see easingtype_t for the full set of + * names, e.g. "IN_QUAD", "OUT_BACK", "IN_OUT_CUBIC"). + */ + +typedef enum { + ASSET_ANIMATION_LOADING_STATE_INITIAL, + ASSET_ANIMATION_LOADING_STATE_READ_FILE, + ASSET_ANIMATION_LOADING_STATE_PARSE, + ASSET_ANIMATION_LOADING_STATE_DONE +} assetanimationloadingstate_t; + +typedef struct { + assetfile_t file; + assetanimationloadingstate_t state; + uint8_t *buffer; + size_t size; +} assetanimationloaderloading_t; + +typedef animation_t assetanimationoutput_t; + +errorret_t assetAnimationLoaderAsync(assetloading_t *loading); +errorret_t assetAnimationLoaderSync(assetloading_t *loading); +errorret_t assetAnimationDispose(assetentry_t *entry); + +/** + * Internal. Maps a JSON easing name (e.g. "IN_OUT_QUAD") to its + * easingtype_t. + * + * @param name The easing name to parse. + * @param outEasing Destination for the parsed easing type. + * @return Error state; fails if name doesn't match any easingtype_t. + */ +errorret_t assetAnimationParseEasing( + const char_t *name, + easingtype_t *outEasing +); + +/** + * Internal. Parses one "channels" array entry into a heap-allocated + * keyframe_t array (memoryAllocate) -- freed later by + * assetAnimationFreeChannels() (on a parse failure) or + * assetAnimationDispose() (once loaded). + * + * @param channelJson The channel's JSON array of keyframe objects. + * @param outKeyframes Destination for the newly allocated keyframe array. + * @param outCount Destination for the number of keyframes parsed. + * @return Error state. + */ +errorret_t assetAnimationParseChannel( + yyjson_val *channelJson, + keyframe_t **outKeyframes, + uint16_t *outCount +); + +/** + * Internal. Frees the first count entries of tracks (each a + * memoryAllocate'd keyframe_t array from assetAnimationParseChannel()), + * then frees tracks itself. Used both to unwind a partially-parsed + * channel list on failure and to free a fully-loaded animation's + * channels in assetAnimationDispose(). + * + * @param tracks The channel array to free. + * @param count The number of entries in tracks to free. + */ +void assetAnimationFreeChannels(keyframe_t **tracks, const uint16_t count); diff --git a/src/dusk/asset/loader/assetloader.c b/src/dusk/asset/loader/assetloader.c index 6ce9494b..1a44e60c 100644 --- a/src/dusk/asset/loader/assetloader.c +++ b/src/dusk/asset/loader/assetloader.c @@ -45,4 +45,10 @@ assetloadercallbacks_t ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_COUNT] = { .loadAsync = assetJsonLoaderAsync, .dispose = assetJsonDispose }, + + [ASSET_LOADER_TYPE_ANIMATION] = { + .loadSync = assetAnimationLoaderSync, + .loadAsync = assetAnimationLoaderAsync, + .dispose = assetAnimationDispose + }, }; diff --git a/src/dusk/asset/loader/assetloader.h b/src/dusk/asset/loader/assetloader.h index 5edae5ff..75cc54d8 100644 --- a/src/dusk/asset/loader/assetloader.h +++ b/src/dusk/asset/loader/assetloader.h @@ -12,6 +12,7 @@ #include "asset/loader/display/assettilesetloader.h" #include "asset/loader/locale/assetlocaleloader.h" #include "asset/loader/json/assetjsonloader.h" +#include "asset/loader/animation/assetanimationloader.h" typedef enum { ASSET_LOADER_TYPE_NULL, @@ -22,6 +23,7 @@ typedef enum { ASSET_LOADER_TYPE_TILESET, ASSET_LOADER_TYPE_LOCALE, ASSET_LOADER_TYPE_JSON, + ASSET_LOADER_TYPE_ANIMATION, ASSET_LOADER_TYPE_COUNT } assetloadertype_t; @@ -33,6 +35,7 @@ typedef union { assettilesetloaderloading_t tileset; assetlocaleloaderloading_t locale; assetjsonloaderloading_t json; + assetanimationloaderloading_t animation; } assetloaderloading_t; typedef union { @@ -42,6 +45,7 @@ typedef union { assettilesetoutput_t tileset; assetlocaleoutput_t locale; assetjsonoutput_t json; + assetanimationoutput_t animation; } assetloaderoutput_t; typedef union { diff --git a/test/animation/test_keyframeset.c b/test/animation/test_keyframeset.c index 6963a4c5..13a86a5c 100644 --- a/test/animation/test_keyframeset.c +++ b/test/animation/test_keyframeset.c @@ -21,7 +21,7 @@ static void test_keyframeSetGetValuePerTrack(void **state) { uint16_t trackCounts[] = { 2, 2 }; keyframeset_t set; - keyframeSetInit(&set, tracks, trackCounts, 2); + keyframeSetInit(&set, tracks, trackCounts, NULL, NULL, 2, NULL); assert_float_equal(keyframeSetGetValue(&set, 0, 0.5f), 5.0f, 0.0001f); assert_float_equal(keyframeSetGetValue(&set, 1, 0.5f), 150.0f, 0.0001f); @@ -40,7 +40,7 @@ static void test_keyframeSetGetValuesFillsAllTracks(void **state) { uint16_t trackCounts[] = { 2, 2 }; keyframeset_t set; - keyframeSetInit(&set, tracks, trackCounts, 2); + keyframeSetInit(&set, tracks, trackCounts, NULL, NULL, 2, NULL); float_t values[2]; keyframeSetGetValues(&set, 0.5f, values); @@ -61,16 +61,66 @@ static void test_keyframeSetGetDurationIsLongestTrack(void **state) { uint16_t trackCounts[] = { 2, 2 }; keyframeset_t set; - keyframeSetInit(&set, tracks, trackCounts, 2); + keyframeSetInit(&set, tracks, trackCounts, NULL, NULL, 2, NULL); assert_float_equal(keyframeSetGetDuration(&set), 3.5f, 0.0001f); } +static uint32_t TEST_CALLBACK_FIRE_COUNT; +static uint16_t TEST_CALLBACK_LAST_TRACK; +static void *TEST_CALLBACK_LAST_USER; + +static void test_onTrackCallback( + keyframeset_t *set, + const uint16_t trackIndex, + void *user +) { + TEST_CALLBACK_FIRE_COUNT++; + TEST_CALLBACK_LAST_TRACK = trackIndex; + TEST_CALLBACK_LAST_USER = user; +} + +static void test_keyframeSetFireCallbackInvokesPerTrackCallback(void **state) { + TEST_CALLBACK_FIRE_COUNT = 0; + TEST_CALLBACK_LAST_TRACK = 0xFFFF; + TEST_CALLBACK_LAST_USER = NULL; + + keyframe_t xKeyframes[] = { + { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, + { .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR } + }; + keyframe_t yKeyframes[] = { + { .time = 0.0f, .value = 0.0f, .easing = EASING_LINEAR }, + { .time = 1.0f, .value = 10.0f, .easing = EASING_LINEAR } + }; + keyframe_t *tracks[] = { xKeyframes, yKeyframes }; + uint16_t trackCounts[] = { 2, 2 }; + + int32_t trackOneUser = 42; + keyframesetcallback_t callbacks[] = { NULL, test_onTrackCallback }; + void *users[] = { NULL, &trackOneUser }; + int32_t setUser = 7; + + keyframeset_t set; + keyframeSetInit(&set, tracks, trackCounts, callbacks, users, 2, &setUser); + + // Track 0 has no callback registered -- no-op. + keyframeSetFireCallback(&set, 0); + assert_int_equal(TEST_CALLBACK_FIRE_COUNT, 0); + + keyframeSetFireCallback(&set, 1); + assert_int_equal(TEST_CALLBACK_FIRE_COUNT, 1); + assert_int_equal(TEST_CALLBACK_LAST_TRACK, 1); + assert_ptr_equal(TEST_CALLBACK_LAST_USER, &trackOneUser); + assert_ptr_equal(set.user, &setUser); +} + int main(int argc, char **argv) { const struct CMUnitTest tests[] = { cmocka_unit_test(test_keyframeSetGetValuePerTrack), cmocka_unit_test(test_keyframeSetGetValuesFillsAllTracks), cmocka_unit_test(test_keyframeSetGetDurationIsLongestTrack), + cmocka_unit_test(test_keyframeSetFireCallbackInvokesPerTrackCallback), }; return cmocka_run_group_tests(tests, NULL, NULL); diff --git a/test/asset/CMakeLists.txt b/test/asset/CMakeLists.txt index f0f5c424..1c82d4ff 100644 --- a/test/asset/CMakeLists.txt +++ b/test/asset/CMakeLists.txt @@ -9,3 +9,4 @@ dusktest(test_assetlocale.c) dusktest(test_asset.c) dusktest(test_assetjsonloader.c) dusktest(test_assettilesetloader.c) +dusktest(test_assetanimationloader.c) diff --git a/test/asset/test_assetanimationloader.c b/test/asset/test_assetanimationloader.c new file mode 100644 index 00000000..26a39c45 --- /dev/null +++ b/test/asset/test_assetanimationloader.c @@ -0,0 +1,329 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "dusktest.h" +#include "asset/asset.h" +#include "asset/loader/assetloader.h" +#include "asset/loader/assetentry.h" +#include "asset/loader/animation/assetanimationloader.h" +#include "thread/thread.h" +#include "util/memory.h" +#include + +// ============================================================ +// Fixtures +// ============================================================ + +static const char_t *ANIMATION_VALID = + "{" + " \"loop\": true," + " \"speed\": 2.0," + " \"channels\": [" + " [" + " { \"time\": 0.0, \"value\": 0.0, \"easing\": \"LINEAR\" }," + " { \"time\": 1.0, \"value\": 10.0 }" + " ]," + " [" + " { \"time\": 0.0, \"value\": 100.0, \"easing\": \"IN_QUAD\" }," + " { \"time\": 2.0, \"value\": 200.0 }" + " ]" + " ]" + "}"; +static const char_t *ANIMATION_INVALID_JSON = + "{ this is definitely not valid json !!!"; +static const char_t *ANIMATION_MISSING_CHANNELS = "{ \"loop\": false }"; +static const char_t *ANIMATION_EMPTY_CHANNELS = "{ \"channels\": [] }"; +static const char_t *ANIMATION_BAD_EASING = + "{" + " \"channels\": [" + " [" + " { \"time\": 0.0, \"value\": 0.0, \"easing\": \"NOT_A_REAL_EASING\" }," + " { \"time\": 1.0, \"value\": 10.0 }" + " ]" + " ]" + "}"; + +// ============================================================ +// Async thread helper +// ============================================================ + +typedef struct { + assetloading_t *loading; + bool_t ok; +} animation_async_run_t; + +static void animation_async_thread_cb(thread_t *thread) { + animation_async_run_t *run = (animation_async_run_t *)thread->data; + errorret_t ret = assetAnimationLoaderAsync(run->loading); + run->ok = errorIsOk(ret); + if(errorIsNotOk(ret)) errorCatch(ret); +} + +static bool_t run_animation_async(assetloading_t *loading) { + animation_async_run_t run = { .loading = loading, .ok = false }; + thread_t thread; + threadInit(&thread, animation_async_thread_cb); + thread.data = &run; + threadStart(&thread); + threadStop(&thread); + return run.ok; +} + +// ============================================================ +// In-memory ZIP +// ============================================================ + +static zip_t *g_zip = NULL; + +static int zip_setup(void **state) { + zip_error_t err; + zip_error_init(&err); + + zip_source_t *write_src = zip_source_buffer_create(NULL, 0, 1, &err); + if(!write_src) return -1; + + zip_t *za = zip_open_from_source(write_src, ZIP_TRUNCATE, &err); + if(!za) { zip_source_free(write_src); return -1; } + + zip_source_t *s; + s = zip_source_buffer(za, ANIMATION_VALID, strlen(ANIMATION_VALID), 0); + if(zip_file_add(za, "valid.anim.json", s, ZIP_FL_OVERWRITE) < 0) { + zip_close(za); return -1; + } + s = zip_source_buffer( + za, ANIMATION_INVALID_JSON, strlen(ANIMATION_INVALID_JSON), 0 + ); + if(zip_file_add(za, "invalid.anim.json", s, ZIP_FL_OVERWRITE) < 0) { + zip_close(za); return -1; + } + s = zip_source_buffer( + za, ANIMATION_MISSING_CHANNELS, strlen(ANIMATION_MISSING_CHANNELS), 0 + ); + if(zip_file_add(za, "missingchannels.anim.json", s, ZIP_FL_OVERWRITE) < 0) { + zip_close(za); return -1; + } + s = zip_source_buffer( + za, ANIMATION_EMPTY_CHANNELS, strlen(ANIMATION_EMPTY_CHANNELS), 0 + ); + if(zip_file_add(za, "emptychannels.anim.json", s, ZIP_FL_OVERWRITE) < 0) { + zip_close(za); return -1; + } + s = zip_source_buffer( + za, ANIMATION_BAD_EASING, strlen(ANIMATION_BAD_EASING), 0 + ); + if(zip_file_add(za, "badeasing.anim.json", s, ZIP_FL_OVERWRITE) < 0) { + zip_close(za); return -1; + } + + zip_source_keep(write_src); + if(zip_close(za) != 0) { zip_source_free(write_src); return -1; } + + zip_stat_t zs; + memset(&zs, 0, sizeof(zs)); + if(zip_source_stat(write_src, &zs) != 0 || !(zs.valid & ZIP_STAT_SIZE)) { + zip_source_free(write_src); return -1; + } + + void *zipbuf = malloc((size_t)zs.size); + if(!zipbuf) { zip_source_free(write_src); return -1; } + + if(zip_source_open(write_src) != 0) { + free(zipbuf); zip_source_free(write_src); return -1; + } + zip_source_read(write_src, zipbuf, (zip_uint64_t)zs.size); + zip_source_close(write_src); + zip_source_free(write_src); + + zip_error_init(&err); + zip_source_t *read_src = zip_source_buffer_create( + zipbuf, (zip_uint64_t)zs.size, 1, &err + ); + if(!read_src) { free(zipbuf); return -1; } + + g_zip = zip_open_from_source(read_src, 0, &err); + if(!g_zip) { zip_source_free(read_src); return -1; } + + ASSET.zip = g_zip; + return 0; +} + +static int zip_teardown(void **state) { + if(g_zip) { zip_close(g_zip); g_zip = NULL; } + ASSET.zip = NULL; + return 0; +} + +// ============================================================ +// Loader pipeline helper +// ============================================================ + +typedef struct { + assetentry_t entry; + assetloading_t loading; +} loader_ctx_t; + +static void loader_ctx_init(loader_ctx_t *ctx, const char_t *name) { + assetEntryInit(&ctx->entry, name, ASSET_LOADER_TYPE_ANIMATION, NULL); + threadMutexInit(&ctx->loading.mutex); + memoryZero(&ctx->loading.loading, sizeof(ctx->loading.loading)); + ctx->loading.type = ASSET_LOADER_TYPE_ANIMATION; + ctx->loading.entry = &ctx->entry; + ctx->entry.state = ASSET_ENTRY_STATE_PENDING_SYNC; +} + +// Drives sync(INITIAL) -> async(READ) -> sync(PARSE). +static errorret_t loader_ctx_run(loader_ctx_t *ctx) { + errorret_t ret = assetAnimationLoaderSync(&ctx->loading); + if(errorIsNotOk(ret)) return ret; + + if(!run_animation_async(&ctx->loading)) { + ctx->entry.state = ASSET_ENTRY_STATE_ERROR; + errorThrow("Async animation load failed"); + } + + return assetAnimationLoaderSync(&ctx->loading); +} + +static void loader_ctx_dispose(loader_ctx_t *ctx) { + if(ctx->entry.type != ASSET_LOADER_TYPE_NULL) { + errorret_t ret = assetEntryDispose(&ctx->entry); + if(errorIsNotOk(ret)) errorCatch(ret); + } + threadMutexDispose(&ctx->loading.mutex); +} + +// ============================================================ +// Tests +// ============================================================ + +static void test_animation_valid_loads(void **state) { + loader_ctx_t ctx; + loader_ctx_init(&ctx, "valid.anim.json"); + + errorret_t ret = loader_ctx_run(&ctx); + assert_true(errorIsOk(ret)); + assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_LOADED); + + animation_t *anim = &ctx.entry.data.animation; + assert_true(anim->loop); + assert_float_equal(anim->speed, 2.0f, 0.0001f); + assert_int_equal(anim->keyframes.trackCount, 2); + + assert_float_equal( + keyframeSetGetValue(&anim->keyframes, 0, 0.5f), 5.0f, 0.0001f + ); + // Channel 1's first keyframe uses IN_QUAD -- a segment's easing comes + // from its starting keyframe, not its end. easingInQuad(0.5) = 0.25 -> + // lerp(100, 200, 0.25) = 125. + assert_float_equal( + keyframeSetGetValue(&anim->keyframes, 1, 1.0f), 125.0f, 0.0001f + ); + + loader_ctx_dispose(&ctx); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_animation_parse_error(void **state) { + loader_ctx_t ctx; + loader_ctx_init(&ctx, "invalid.anim.json"); + + errorret_t ret = assetAnimationLoaderSync(&ctx.loading); + assert_true(errorIsOk(ret)); + assert_true(run_animation_async(&ctx.loading)); + + ret = assetAnimationLoaderSync(&ctx.loading); + assert_true(errorIsNotOk(ret)); + errorCatch(ret); + assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_ERROR); + + loader_ctx_dispose(&ctx); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_animation_missing_channels(void **state) { + loader_ctx_t ctx; + loader_ctx_init(&ctx, "missingchannels.anim.json"); + + errorret_t ret = loader_ctx_run(&ctx); + assert_true(errorIsNotOk(ret)); + errorCatch(ret); + assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_ERROR); + + loader_ctx_dispose(&ctx); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_animation_empty_channels(void **state) { + loader_ctx_t ctx; + loader_ctx_init(&ctx, "emptychannels.anim.json"); + + errorret_t ret = loader_ctx_run(&ctx); + assert_true(errorIsNotOk(ret)); + errorCatch(ret); + assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_ERROR); + + loader_ctx_dispose(&ctx); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_animation_bad_easing(void **state) { + loader_ctx_t ctx; + loader_ctx_init(&ctx, "badeasing.anim.json"); + + errorret_t ret = loader_ctx_run(&ctx); + assert_true(errorIsNotOk(ret)); + errorCatch(ret); + assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_ERROR); + + // Confirms the mid-parse cleanup path frees the channel array it had + // already allocated before hitting the bad easing name. + loader_ctx_dispose(&ctx); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +static void test_animation_missing_file(void **state) { + loader_ctx_t ctx; + loader_ctx_init(&ctx, "nonexistent.anim.json"); + + errorret_t ret = assetAnimationLoaderSync(&ctx.loading); + assert_true(errorIsOk(ret)); + + assert_false(run_animation_async(&ctx.loading)); + assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_ERROR); + + loader_ctx_dispose(&ctx); + assert_int_equal(memoryGetAllocatedCount(), 0); +} + +// ============================================================ +// main +// ============================================================ + +int main(void) { + assertInit(); + const struct CMUnitTest tests[] = { + cmocka_unit_test_setup_teardown( + test_animation_valid_loads, zip_setup, zip_teardown + ), + cmocka_unit_test_setup_teardown( + test_animation_parse_error, zip_setup, zip_teardown + ), + cmocka_unit_test_setup_teardown( + test_animation_missing_channels, zip_setup, zip_teardown + ), + cmocka_unit_test_setup_teardown( + test_animation_empty_channels, zip_setup, zip_teardown + ), + cmocka_unit_test_setup_teardown( + test_animation_bad_easing, zip_setup, zip_teardown + ), + cmocka_unit_test_setup_teardown( + test_animation_missing_file, zip_setup, zip_teardown + ), + }; + return cmocka_run_group_tests(tests, NULL, NULL); +}