From e56a8a71ac13a4960ccdc8d2f5e065bb3933a825 Mon Sep 17 00:00:00 2001 From: Dustinspace217 Date: Sat, 17 Oct 2026 01:34:34 -0700 Subject: [PATCH] Give EFF streaming animations their own user bitmap The EFF stream path drew from the bitmap of its first frame file and wrote later frames straight into that bitmap's GPU texture, so anything that drops the GPU copy (the Vulkan backend's flush at mission load, a texture detail change, the OpenGL eviction proposed in #7878) left the animation showing frame zero until it next advanced, and a paused stream stayed on the wrong frame. ANI and APNG streams already draw from a user bitmap over a buffer they own, which the renderer rebuilds from the current pixels and never culls by texture detail. EFF streams now do the same: the frame files are read into the stream's buffer and uploaded from there. The copy converts what the loader's depth needs: a 24 bpp PNG frame gains an opaque alpha for a 32 bpp stream, and color folds to luminance for an 8 bpp HUD-color stream, as the OpenGL update path did before. Frames the loader hands back compressed, at another size, or at a depth with no conversion are skipped with a log line. A stream restarted on the same generic_anim (talking heads replay without unloading) keeps its buffer and bitmap when they still fit, and a frame zero that fails to lock fails the stream cleanly. --- code/graphics/generic.cpp | 154 ++++- code/graphics/generic.h | 1 + .../src/graphics/test_generic_anim_stream.cpp | 582 ++++++++++++++++++ test/src/source_groups.cmake | 4 + test/test_data/data/effects/streambad.eff | 3 + .../test_data/data/effects/streambad_0000.png | Bin 0 -> 48 bytes .../test_data/data/effects/streambad_0001.png | Bin 0 -> 79 bytes .../test_data/data/effects/streambadlater.eff | 3 + .../data/effects/streambadlater_0000.png | Bin 0 -> 79 bytes .../data/effects/streambadlater_0001.png | Bin 0 -> 48 bytes test/test_data/data/effects/streamdxt.eff | 3 + .../test_data/data/effects/streamdxt_0000.dds | Bin 0 -> 136 bytes .../test_data/data/effects/streamdxt_0001.dds | Bin 0 -> 136 bytes test/test_data/data/effects/streamhud.eff | 3 + .../test_data/data/effects/streamhud_0000.png | Bin 0 -> 78 bytes .../test_data/data/effects/streamhud_0001.png | Bin 0 -> 78 bytes test/test_data/data/effects/streammissing.eff | 3 + test/test_data/data/effects/streammix.eff | 3 + .../test_data/data/effects/streammix_0000.png | Bin 0 -> 78 bytes .../test_data/data/effects/streammix_0001.dds | Bin 0 -> 136 bytes test/test_data/data/effects/streamone.eff | 3 + .../test_data/data/effects/streamone_0000.png | Bin 0 -> 78 bytes test/test_data/data/effects/streamrgb.eff | 3 + .../test_data/data/effects/streamrgb_0000.png | Bin 0 -> 73 bytes .../test_data/data/effects/streamrgb_0001.png | Bin 0 -> 77 bytes test/test_data/data/effects/streamsize.eff | 3 + .../data/effects/streamsize_0000.png | Bin 0 -> 79 bytes .../data/effects/streamsize_0001.png | Bin 0 -> 78 bytes test/test_data/data/effects/streamtest.eff | 3 + .../data/effects/streamtest_0000.png | Bin 0 -> 79 bytes .../data/effects/streamtest_0001.png | Bin 0 -> 79 bytes .../data/effects/streamtest_0002.png | Bin 0 -> 79 bytes 32 files changed, 762 insertions(+), 9 deletions(-) create mode 100644 test/src/graphics/test_generic_anim_stream.cpp create mode 100644 test/test_data/data/effects/streambad.eff create mode 100644 test/test_data/data/effects/streambad_0000.png create mode 100644 test/test_data/data/effects/streambad_0001.png create mode 100644 test/test_data/data/effects/streambadlater.eff create mode 100644 test/test_data/data/effects/streambadlater_0000.png create mode 100644 test/test_data/data/effects/streambadlater_0001.png create mode 100644 test/test_data/data/effects/streamdxt.eff create mode 100644 test/test_data/data/effects/streamdxt_0000.dds create mode 100644 test/test_data/data/effects/streamdxt_0001.dds create mode 100644 test/test_data/data/effects/streamhud.eff create mode 100644 test/test_data/data/effects/streamhud_0000.png create mode 100644 test/test_data/data/effects/streamhud_0001.png create mode 100644 test/test_data/data/effects/streammissing.eff create mode 100644 test/test_data/data/effects/streammix.eff create mode 100644 test/test_data/data/effects/streammix_0000.png create mode 100644 test/test_data/data/effects/streammix_0001.dds create mode 100644 test/test_data/data/effects/streamone.eff create mode 100644 test/test_data/data/effects/streamone_0000.png create mode 100644 test/test_data/data/effects/streamrgb.eff create mode 100644 test/test_data/data/effects/streamrgb_0000.png create mode 100644 test/test_data/data/effects/streamrgb_0001.png create mode 100644 test/test_data/data/effects/streamsize.eff create mode 100644 test/test_data/data/effects/streamsize_0000.png create mode 100644 test/test_data/data/effects/streamsize_0001.png create mode 100644 test/test_data/data/effects/streamtest.eff create mode 100644 test/test_data/data/effects/streamtest_0000.png create mode 100644 test/test_data/data/effects/streamtest_0001.png create mode 100644 test/test_data/data/effects/streamtest_0002.png diff --git a/code/graphics/generic.cpp b/code/graphics/generic.cpp index aae13a8a0d0..a942574c876 100644 --- a/code/graphics/generic.cpp +++ b/code/graphics/generic.cpp @@ -154,24 +154,87 @@ int generic_anim_load(generic_anim *ga) return 0; } +// Ends a failed EFF stream: releases the pair and next-frame load the stream owns and leaves the struct as an unloaded +// one (num_frames 0, type NONE, as the APNG branch does), since generic_anim_unload would otherwise release handle -1. +static void eff_stream_drop(generic_anim* ga, bool owned) +{ + if (owned) { + if (bm_is_valid(ga->eff.next_frame)) { + bm_release(ga->eff.next_frame); + } + if (bm_is_valid(ga->bitmap_id)) { + bm_release(ga->bitmap_id); + } + vm_free(ga->buffer); + } + ga->buffer = nullptr; + ga->bitmap_id = -1; + ga->eff.next_frame = -1; + ga->num_frames = 0; + ga->type = BM_TYPE_NONE; +} + +// Loaders keep a frame at its file depth whatever was asked for, so a 24 bpp frame gains alpha for a 32 bpp stream and +// color folds to luminance for an 8 bpp HUD-color stream (the OpenGL update path's formula). False means nothing was +// copied: no lock, a compressed payload (a DDS decoded on read is recorded as uncompressed), another size or depth. +static bool eff_copy_frame(const generic_anim* ga, int handle, const bitmap* bmp, int bpp) +{ + if (bmp == nullptr || bmp->data == 0 || bm_is_compressed(handle) || bmp->w != ga->width || bmp->h != ga->height) { + return false; + } + auto src = reinterpret_cast(bmp->data); + size_t pixels = static_cast(ga->width) * ga->height; + int src_bytes = bmp->bpp / 8; + if (bmp->bpp == bpp) { + memcpy(ga->buffer, src, pixels * src_bytes); + return true; + } + if (bpp == 32 && bmp->bpp == 24) { + for (size_t px = 0; px < pixels; px++) { + ga->buffer[px * 4] = src[px * 3]; + ga->buffer[px * 4 + 1] = src[px * 3 + 1]; + ga->buffer[px * 4 + 2] = src[px * 3 + 2]; + ga->buffer[px * 4 + 3] = 255; + } + return true; + } + if (bpp == 8 && (bmp->bpp == 24 || bmp->bpp == 32)) { + for (size_t px = 0; px < pixels; px++) { + int luminance = src[px * src_bytes] + src[px * src_bytes + 1] + src[px * src_bytes + 2]; + auto value = static_cast(luminance / 3); + if (src_bytes == 4) { + value = static_cast((luminance / 3) * (src[px * 4 + 3] / 255.0f)); + } + ga->buffer[px] = value; + } + return true; + } + return false; +} + int generic_anim_stream(generic_anim *ga, const bool cache) { CFILE *img_cfp = NULL; int anim_fps = 0; int bpp; + // what this struct streamed last: only a buffer the EFF path allocated is the EFF path's to reuse or free + BM_TYPE previous_type = ga->type; ga->type = BM_TYPE_NONE; auto res = cf_find_file_location_ext(ga->filename, BM_ANI_NUM_TYPES, bm_ani_ext_list, CF_TYPE_ANY); - // could not be found, or is invalid for some reason - if ( !res.found ) + // could not be found, or is invalid for some reason; a failed attempt must not change what the struct still owns + if ( !res.found ) { + ga->type = previous_type; return -1; + } //make sure we can open it img_cfp = cfopen_special(res, "rb", CF_TYPE_ANY); if (img_cfp == NULL) { + ga->type = previous_type; return -1; } @@ -249,17 +312,76 @@ int generic_anim_stream(generic_anim *ga, const bool cache) // Make sure the string is null terminated frame_name[MAX_FILENAME_LEN - 1] = '\0'; } - ga->bitmap_id = bm_load(frame_name); - if(ga->bitmap_id < 0) { + // the talking-head code streams the same generic_anim again without unloading it, so what the previous EFF run + // owns is reused when it still fits and released when it does not + bool owned = (previous_type == BM_TYPE_EFF) && (ga->buffer != nullptr); + if (!owned) { + // the union field still holds another member's bytes on a fresh struct; nothing is loaded yet + ga->eff.next_frame = -1; + } + // a private slot: bm_load would hand back another stream's next-frame slot when that one sits on frame 0 + // (bm_reload names the slot after the frame it holds), and releasing it below would freeze that stream + int first_frame = bm_load_duplicate(frame_name); + if(first_frame < 0) { mprintf(("Cannot find first frame for eff streaming. eff Filename: %s\n", ga->filename)); + eff_stream_drop(ga, owned); + return -1; + } + // the stream draws from a user bitmap over its own buffer, like the ANI and APNG streams, so the current frame + // survives any renderer-side texture flush + int frame_w = 0, frame_h = 0; + bm_get_info(first_frame, &frame_w, &frame_h); + size_t frame_bytes = static_cast(frame_w) * frame_h * (bpp / 8); + bool reuse = owned && bm_is_valid(ga->bitmap_id) && (ga->width == frame_w) && (ga->height == frame_h) + && (ga->eff.bpp == bpp); + if (!reuse) { + if (owned) { + if (bm_is_valid(ga->bitmap_id)) { + bm_release(ga->bitmap_id); + } + vm_free(ga->buffer); + } + ga->width = frame_w; + ga->height = frame_h; + ga->eff.bpp = bpp; + ga->buffer = static_cast(vm_malloc(frame_bytes)); + ga->bitmap_id = bm_create(bpp, frame_w, frame_h, ga->buffer, (bpp==8)?BMP_AABITMAP:0); + if (ga->bitmap_id < 0) { + mprintf(("Cannot create the stream bitmap for eff streaming. eff Filename: %s\n", ga->filename)); + bm_release(first_frame); + eff_stream_drop(ga, true); + return -1; + } + } + bitmap* first_bmp = bm_lock(first_frame, bpp, (bpp==8)?BMP_AABITMAP:BMP_TEX_NONCOMP, true); + if (first_bmp == nullptr) { + // the failed lock has unloaded the private slot already; the release frees it + mprintf(("Cannot read first frame for eff streaming. eff Filename: %s\n", ga->filename)); + bm_release(first_frame); + eff_stream_drop(ga, true); return -1; } + if (!eff_copy_frame(ga, first_frame, first_bmp, bpp)) { + mprintf(("eff stream %s: first frame could not be copied, the stream starts blank\n", ga->filename)); + memset(ga->buffer, 0, frame_bytes); + } + bm_unlock(first_frame); + bm_release(first_frame); + if (reuse) { + // the kept bitmap still holds the previous run's last frame on the GPU + gr_update_texture(ga->bitmap_id, bpp, ga->buffer, ga->width, ga->height); + } if (snprintf(frame_name, MAX_FILENAME_LEN, "%s_0001", ga->filename) >= MAX_FILENAME_LEN) { // Make sure the string is null terminated frame_name[MAX_FILENAME_LEN - 1] = '\0'; } - ga->eff.next_frame = bm_load(frame_name); - bm_get_info(ga->bitmap_id, &ga->width, &ga->height); + if (owned && bm_is_valid(ga->eff.next_frame)) { + // the restart loads the next-frame file again below; drop the previous run's load first + bm_release(ga->eff.next_frame); + } + // private for the same reason as frame 0: bm_reload renames this slot every advance, and a second stream of the + // file would otherwise share it and lose it when the first stream unloads + ga->eff.next_frame = bm_load_duplicate(frame_name); ga->previous_frame = 0; } @@ -331,6 +453,10 @@ void generic_anim_unload(generic_anim *ga) bm_release(ga->eff.next_frame); if(ga->bitmap_id >= 0) bm_release(ga->bitmap_id); + if (ga->buffer != nullptr) { + vm_free(ga->buffer); + ga->buffer = nullptr; + } } if(ga->type == BM_TYPE_PNG) { if(ga->bitmap_id >= 0) @@ -376,9 +502,19 @@ void generic_render_eff_stream(generic_anim *ga) if(bm_reload(ga->eff.next_frame, frame_name) == ga->eff.next_frame) { bitmap* next_frame_bmp = bm_lock(ga->eff.next_frame, bpp, (bpp==8)?BMP_AABITMAP:BMP_TEX_NONCOMP, true); - if(next_frame_bmp->data) - gr_update_texture(ga->bitmap_id, bpp, (ubyte*)next_frame_bmp->data, ga->width, ga->height); - bm_unlock(ga->eff.next_frame); + if (eff_copy_frame(ga, ga->eff.next_frame, next_frame_bmp, bpp)) { + gr_update_texture(ga->bitmap_id, bpp, ga->buffer, ga->width, ga->height); + } else { + bool unread = (next_frame_bmp == nullptr); + nprintf(("Warning", + "eff stream %s: frame %d %s (%dx%d at %d bpp%s, stream is %dx%d at %d bpp); frame kept\n", + ga->filename, ga->current_frame, unread ? "could not be read" : "could not be copied", + unread ? 0 : next_frame_bmp->w, unread ? 0 : next_frame_bmp->h, unread ? 0 : next_frame_bmp->bpp, + bm_is_compressed(ga->eff.next_frame) ? " compressed" : "", ga->width, ga->height, bpp)); + } + if (next_frame_bmp != nullptr) { + bm_unlock(ga->eff.next_frame); + } bm_unload(ga->eff.next_frame, 0, true); if (ga->current_frame == ga->num_frames-1) { diff --git a/code/graphics/generic.h b/code/graphics/generic.h index dd3ca01c1b2..118333cdec6 100644 --- a/code/graphics/generic.h +++ b/code/graphics/generic.h @@ -37,6 +37,7 @@ typedef struct generic_anim { } ani; struct { int next_frame; + int bpp; // depth of buffer and bitmap_id, so a restart can tell whether they still fit } eff; struct { apng::apng_ani* anim; diff --git a/test/src/graphics/test_generic_anim_stream.cpp b/test/src/graphics/test_generic_anim_stream.cpp new file mode 100644 index 00000000000..4cf6abcb3ff --- /dev/null +++ b/test/src/graphics/test_generic_anim_stream.cpp @@ -0,0 +1,582 @@ +#include + +#include +#include +#include +#include +#include + +#include + +namespace { + +class GenericAnimStreamTest : public test::FSTestFixture { + public: + GenericAnimStreamTest() : test::FSTestFixture(INIT_CFILE | INIT_GRAPHICS | INIT_MOD_TABLE) {} + + protected: + void SetUp() override { + test::FSTestFixture::SetUp(); + + // the fixture runs as a standalone server, where bm_load substitutes placeholder images + saved_standalone = Is_standalone; + Is_standalone = 0; + + saved_capable = gr_screen.gf_is_capable; + } + + void TearDown() override { + gr_screen.gf_is_capable = saved_capable; + Is_standalone = saved_standalone; + + test::FSTestFixture::TearDown(); + } + + // the pixels of one frame file, read through the same loader and flags the stream uses; loaded as a private slot so + // the helper can never adopt a live stream's renamed next-frame slot + static SCP_vector frame_pixels(const char* name) { + SCP_vector out; + int handle = bm_load_duplicate(name); + EXPECT_GE(handle, 0); + if (handle < 0) { + return out; + } + + bitmap* bmp = bm_lock(handle, 32, BMP_TEX_NONCOMP, true); + if (bmp != nullptr && bmp->data != 0) { + auto* data = reinterpret_cast(bmp->data); + out.assign(data, data + bmp->w * bmp->h * 4); + } + bm_unlock(handle); + bm_release(handle); + return out; + } + + private: + int saved_standalone = 0; + std::function saved_capable; +}; + +TEST_F(GenericAnimStreamTest, eff_stream_owns_a_user_bitmap_holding_frame_zero) { + generic_anim ga; + generic_anim_init(&ga, "streamtest"); + ASSERT_EQ(0, generic_anim_stream(&ga)); + + EXPECT_EQ(BM_TYPE_EFF, ga.type); + EXPECT_EQ(3, ga.num_frames); + ASSERT_GE(ga.bitmap_id, 0); + EXPECT_EQ(BM_TYPE_USER, bm_get_type(ga.bitmap_id)); + ASSERT_NE(nullptr, ga.buffer); + + auto frame0 = frame_pixels("streamtest_0000"); + ASSERT_EQ(static_cast(ga.width * ga.height * 4), frame0.size()); + EXPECT_EQ(0, memcmp(ga.buffer, frame0.data(), frame0.size())); + + generic_anim_unload(&ga); +} + +TEST_F(GenericAnimStreamTest, eff_stream_buffer_follows_the_current_frame) { + generic_anim ga; + generic_anim_init(&ga, "streamtest"); + ASSERT_EQ(0, generic_anim_stream(&ga)); + ASSERT_NE(nullptr, ga.buffer); + + // three frames over 0.3 s: 0.15 s of play lands on frame 1 + generic_anim_render(&ga, 0.15f, 0, 0); + EXPECT_EQ(1, ga.current_frame); + + auto frame1 = frame_pixels("streamtest_0001"); + EXPECT_TRUE(bm_is_valid(ga.eff.next_frame)) << "the comparison read must not take the stream's slot"; + ASSERT_EQ(static_cast(ga.width * ga.height * 4), frame1.size()); + EXPECT_EQ(0, memcmp(ga.buffer, frame1.data(), frame1.size())); + + generic_anim_unload(&ga); +} + +TEST_F(GenericAnimStreamTest, eff_stream_unload_releases_bitmap_and_buffer) { + generic_anim ga; + generic_anim_init(&ga, "streamtest"); + ASSERT_EQ(0, generic_anim_stream(&ga)); + int handle = ga.bitmap_id; + ASSERT_TRUE(bm_is_valid(handle)); + + generic_anim_unload(&ga); + + EXPECT_EQ(nullptr, ga.buffer); + EXPECT_EQ(-1, ga.bitmap_id); + EXPECT_FALSE(bm_is_valid(handle)); +} + +TEST_F(GenericAnimStreamTest, eff_stream_skips_compressed_frames_and_keeps_the_buffer) { + // with S3TC reported as supported the loader keeps DXT frames compressed instead of converting them to 32 bit + gr_screen.gf_is_capable = [](gr_capability capability) { return capability == gr_capability::CAPABILITY_S3TC; }; + + generic_anim ga; + generic_anim_init(&ga, "streamdxt"); + ASSERT_EQ(0, generic_anim_stream(&ga)); + ASSERT_NE(nullptr, ga.buffer); + EXPECT_EQ(BM_TYPE_USER, bm_get_type(ga.bitmap_id)); + + // the loader hands DXT frames back still compressed, so neither frame can be copied into the 32-bit buffer + size_t bytes = static_cast(ga.width) * ga.height * 4; + SCP_vector zeros(bytes, 0); + EXPECT_EQ(0, memcmp(ga.buffer, zeros.data(), bytes)); + + generic_anim_render(&ga, 0.15f, 0, 0); + EXPECT_EQ(1, ga.current_frame); + EXPECT_EQ(0, memcmp(ga.buffer, zeros.data(), bytes)); + + generic_anim_unload(&ga); +} + +TEST_F(GenericAnimStreamTest, eff_stream_skips_a_compressed_frame_and_keeps_the_last_good_one) { + gr_screen.gf_is_capable = [](gr_capability capability) { return capability == gr_capability::CAPABILITY_S3TC; }; + + // frame 0 is a PNG, frame 1 a DXT1 file: the frame files are found by name, so a stream can mix formats + generic_anim ga; + generic_anim_init(&ga, "streammix"); + ASSERT_EQ(0, generic_anim_stream(&ga)); + ASSERT_NE(nullptr, ga.buffer); + + auto frame0 = frame_pixels("streammix_0000"); + ASSERT_EQ(static_cast(ga.width * ga.height * 4), frame0.size()); + EXPECT_EQ(0, memcmp(ga.buffer, frame0.data(), frame0.size())); + + generic_anim_render(&ga, 0.15f, 0, 0); + EXPECT_EQ(1, ga.current_frame); + EXPECT_EQ(0, memcmp(ga.buffer, frame0.data(), frame0.size())); + + generic_anim_unload(&ga); +} + +// locks a frame file at 32 bpp and returns its bytes as the loader delivered them; bpp_out receives the loader's depth, +// which for PNG is the file's channel count times eight regardless of the depth asked for +SCP_vector frame_raw(const char* name, int* bpp_out) { + int handle = bm_load_duplicate(name); + EXPECT_TRUE(handle >= 0) << name; + if (handle < 0) { + return {}; + } + bitmap* bmp = bm_lock(handle, 32, BMP_TEX_NONCOMP, true); + EXPECT_NE(nullptr, bmp); + if (bmp == nullptr) { + return {}; + } + *bpp_out = bmp->bpp; + auto bytes = reinterpret_cast(bmp->data); + SCP_vector out(bytes, bytes + static_cast(bmp->w) * bmp->h * (bmp->bpp / 8)); + bm_unlock(handle); + bm_release(handle); + return out; +} + +// an RGB PNG without alpha comes back from the loader at 24 bpp; the stream's 32 bpp buffer gets it with alpha 255 +TEST_F(GenericAnimStreamTest, eff_stream_expands_rgb_frames_to_32_bit) { + generic_anim ga; + generic_anim_init(&ga, "streamrgb"); + ASSERT_EQ(0, generic_anim_stream(&ga)); + ASSERT_NE(nullptr, ga.buffer); + + for (int frame = 0; frame < 2; frame++) { + if (frame == 1) { + generic_anim_render(&ga, 0.15f, 0, 0); + ASSERT_EQ(1, ga.current_frame); + } + int bpp = 0; + auto raw = frame_raw(frame == 0 ? "streamrgb_0000" : "streamrgb_0001", &bpp); + ASSERT_EQ(24, bpp); + SCP_vector expected; + for (size_t px = 0; px < raw.size() / 3; px++) { + expected.push_back(raw[px * 3]); + expected.push_back(raw[px * 3 + 1]); + expected.push_back(raw[px * 3 + 2]); + expected.push_back(255); + } + ASSERT_EQ(static_cast(ga.width * ga.height * 4), expected.size()); + EXPECT_EQ(0, memcmp(ga.buffer, expected.data(), expected.size())) << "frame " << frame; + } + + generic_anim_unload(&ga); +} + +// a HUD-color stream draws through an 8 bpp AABITMAP; the frame's color is folded to luminance the way the OpenGL +// update path did for the old file bitmap (mean of the three color channels, scaled by alpha) +TEST_F(GenericAnimStreamTest, eff_stream_hud_color_stream_holds_8_bit_luminance) { + generic_anim ga; + generic_anim_init(&ga, "streamhud"); + ga.use_hud_color = true; + ASSERT_EQ(0, generic_anim_stream(&ga)); + ASSERT_NE(nullptr, ga.buffer); + ASSERT_TRUE(bm_is_valid(ga.bitmap_id)); + EXPECT_EQ(BM_TYPE_USER, bm_get_type(ga.bitmap_id)); + + int bpp = 0; + auto raw = frame_raw("streamhud_0000", &bpp); + ASSERT_EQ(32, bpp); + SCP_vector expected; + for (size_t px = 0; px < raw.size() / 4; px++) { + int luminance = raw[px * 4] + raw[px * 4 + 1] + raw[px * 4 + 2]; + expected.push_back(static_cast((luminance / 3) * (raw[px * 4 + 3] / 255.0f))); + } + ASSERT_EQ(static_cast(ga.width * ga.height), expected.size()); + // solid red at alpha 128: (255 / 3) * (128 / 255) truncates to 42 + EXPECT_EQ(42, expected[0]); + EXPECT_EQ(0, memcmp(ga.buffer, expected.data(), expected.size())); + + // the advance path runs the same conversion: frame 1 is white at alpha 128, (765 / 3) * (128 / 255) truncates + // to 128 + generic_anim_render(&ga, 0.15f, 0, 0); + ASSERT_EQ(1, ga.current_frame); + EXPECT_EQ(128, ga.buffer[0]); + for (size_t px = 0; px < expected.size(); px++) { + EXPECT_EQ(128, ga.buffer[px]) << "pixel " << px; + } + + generic_anim_unload(&ga); +} + +// the talking-head code streams the same generic_anim again without unloading it when the head file has not changed +TEST_F(GenericAnimStreamTest, eff_stream_restream_reuses_its_bitmap_and_buffer) { + generic_anim ga; + generic_anim_init(&ga, "streamtest"); + ASSERT_EQ(0, generic_anim_stream(&ga)); + int first_bitmap = ga.bitmap_id; + ASSERT_TRUE(first_bitmap >= 0); + ASSERT_NE(nullptr, ga.buffer); + // a released slot can be handed out again under the same handle, so the entry's signature (unique per creation) + // is what shows the bitmap was kept rather than remade + int first_signature = bm_get_entry(first_bitmap)->signature; + + generic_anim_render(&ga, 0.15f, 0, 0); + ASSERT_EQ(1, ga.current_frame); + + ASSERT_EQ(0, generic_anim_stream(&ga)); + EXPECT_EQ(first_bitmap, ga.bitmap_id); + EXPECT_EQ(first_signature, bm_get_entry(ga.bitmap_id)->signature); + auto frame0 = frame_pixels("streamtest_0000"); + EXPECT_EQ(0, memcmp(ga.buffer, frame0.data(), frame0.size())); + + generic_anim_unload(&ga); + EXPECT_EQ(nullptr, ga.buffer); + EXPECT_FALSE(bm_is_valid(first_bitmap)); +} + +// streambad_0000.png has a valid header and truncated image data, so it loads but cannot be locked +TEST_F(GenericAnimStreamTest, eff_stream_fails_cleanly_when_frame_zero_is_corrupt) { + generic_anim ga; + generic_anim_init(&ga, "streambad"); + EXPECT_EQ(-1, generic_anim_stream(&ga)); + EXPECT_EQ(nullptr, ga.buffer); + EXPECT_TRUE(ga.bitmap_id < 0); + + // the frame file's load count is balanced: a fresh load gets a handle that can be released normally + int handle = bm_load("streambad_0000"); + ASSERT_TRUE(handle >= 0); + EXPECT_TRUE(bm_release(handle)); + EXPECT_FALSE(bm_is_valid(handle)); +} + +// streamsize_0001.png is 4x4 where frame 0 is 8x8: a frame of another size is not copied into the 8x8 buffer. The +// smaller direction is used on purpose: bm_reload keeps the slot's first-file size, so a BIGGER later frame overflows +// inside the loader before this code runs (scp-fs2open #7883, a bmpman guard), which no check here can prevent +TEST_F(GenericAnimStreamTest, eff_stream_skips_a_frame_of_another_size) { + generic_anim ga; + generic_anim_init(&ga, "streamsize"); + ASSERT_EQ(0, generic_anim_stream(&ga)); + ASSERT_EQ(8, ga.width); + ASSERT_EQ(8, ga.height); + + auto frame0 = frame_pixels("streamsize_0000"); + generic_anim_render(&ga, 0.15f, 0, 0); + EXPECT_EQ(1, ga.current_frame); + EXPECT_EQ(0, memcmp(ga.buffer, frame0.data(), frame0.size())); + + generic_anim_unload(&ga); +} + +// without S3TC the loader decodes DXT frames to 32 bpp on read; those are plain pixels and are copied like any other +TEST_F(GenericAnimStreamTest, eff_stream_copies_dxt_frames_the_loader_has_decoded) { + gr_screen.gf_is_capable = [](gr_capability) { return false; }; + + generic_anim ga; + generic_anim_init(&ga, "streamdxt"); + ASSERT_EQ(0, generic_anim_stream(&ga)); + ASSERT_NE(nullptr, ga.buffer); + + for (int frame = 0; frame < 2; frame++) { + if (frame == 1) { + generic_anim_render(&ga, 0.15f, 0, 0); + ASSERT_EQ(1, ga.current_frame); + } + int bpp = 0; + auto decoded = frame_raw(frame == 0 ? "streamdxt_0000" : "streamdxt_0001", &bpp); + ASSERT_EQ(32, bpp); + ASSERT_EQ(static_cast(ga.width * ga.height * 4), decoded.size()); + EXPECT_NE(0, decoded[0] + decoded[1] + decoded[2]) << "a decoded red block has color"; + EXPECT_EQ(0, memcmp(ga.buffer, decoded.data(), decoded.size())) << "frame " << frame; + } + + generic_anim_unload(&ga); +} + +// streambadlater_0001.png is truncated: the lock fails, the stream keeps frame 0 and does not touch a null bitmap +TEST_F(GenericAnimStreamTest, eff_stream_keeps_frame_zero_when_a_later_frame_is_corrupt) { + generic_anim ga; + generic_anim_init(&ga, "streambadlater"); + ASSERT_EQ(0, generic_anim_stream(&ga)); + + auto frame0 = frame_pixels("streambadlater_0000"); + generic_anim_render(&ga, 0.15f, 0, 0); + EXPECT_EQ(1, ga.current_frame); + EXPECT_EQ(0, memcmp(ga.buffer, frame0.data(), frame0.size())); + + generic_anim_unload(&ga); +} + +// a re-stream loads the next-frame file again; the previous load is released first, so the count does not climb +TEST_F(GenericAnimStreamTest, eff_stream_restream_balances_the_next_frame_load) { + generic_anim ga; + generic_anim_init(&ga, "streamtest"); + ASSERT_EQ(0, generic_anim_stream(&ga)); + ASSERT_TRUE(ga.eff.next_frame >= 0); + EXPECT_EQ(1, bm_get_entry(ga.eff.next_frame)->load_count); + + ASSERT_EQ(0, generic_anim_stream(&ga)); + ASSERT_TRUE(ga.eff.next_frame >= 0); + EXPECT_EQ(1, bm_get_entry(ga.eff.next_frame)->load_count); + + generic_anim_unload(&ga); +} + +// a struct whose last stream was another format (type and buffer left by the ANI path) is not the EFF path's to free; +// the old buffer is left alone and a fresh pair is made +TEST_F(GenericAnimStreamTest, eff_stream_does_not_free_a_buffer_another_stream_type_owns) { + static ubyte foreign_buffer[16] = {7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7}; + generic_anim ga; + generic_anim_init(&ga, "streamtest"); + ga.type = BM_TYPE_ANI; + ga.buffer = foreign_buffer; + ga.width = 4; + ga.height = 4; + + ASSERT_EQ(0, generic_anim_stream(&ga)); + EXPECT_NE(foreign_buffer, ga.buffer); + EXPECT_EQ(7, foreign_buffer[0]); + + generic_anim_unload(&ga); +} + +// a restart at another depth replaces the buffer and bitmap; the next-frame load from the first run is still released +TEST_F(GenericAnimStreamTest, eff_stream_restart_at_another_depth_balances_the_next_frame_load) { + generic_anim ga; + generic_anim_init(&ga, "streamtest"); + ASSERT_EQ(0, generic_anim_stream(&ga)); + ASSERT_EQ(1, bm_get_entry(ga.eff.next_frame)->load_count); + + ga.use_hud_color = true; + ASSERT_EQ(0, generic_anim_stream(&ga)); + // the replaced bitmap may come back in the same slot, so the handle value is not checked; the depth is + EXPECT_EQ(8, ga.eff.bpp); + EXPECT_EQ(1, bm_get_entry(ga.eff.next_frame)->load_count); + + generic_anim_unload(&ga); +} + +// bm_lock's failure path already drops the stream's load of frame 0; another owner's load of the same file must survive +TEST_F(GenericAnimStreamTest, eff_stream_failure_keeps_another_owners_load_of_frame_zero) { + int shared = bm_load("streambad_0000"); + ASSERT_TRUE(shared >= 0); + + generic_anim ga; + generic_anim_init(&ga, "streambad"); + EXPECT_EQ(-1, generic_anim_stream(&ga)); + + // the stream reads frame 0 through a private slot, so the other owner's slot and load are untouched + EXPECT_TRUE(bm_is_valid(shared)); + EXPECT_EQ(1, bm_get_entry(shared)->load_count); + EXPECT_TRUE(bm_release(shared)); + EXPECT_FALSE(bm_is_valid(shared)); +} + +// a failed stream must leave the struct in a state generic_anim_unload accepts (the Lua object unloads unconditionally) +TEST_F(GenericAnimStreamTest, eff_stream_failure_leaves_a_struct_unload_accepts) { + generic_anim ga; + generic_anim_init(&ga, "streambad"); + ASSERT_EQ(-1, generic_anim_stream(&ga)); + EXPECT_EQ(0, ga.num_frames); + generic_anim_unload(&ga); + + // streammissing.eff exists but has no streammissing_0000 image: the first-frame-not-found exit + generic_anim missing; + generic_anim_init(&missing, "streammissing"); + EXPECT_EQ(-1, generic_anim_stream(&missing)); + EXPECT_EQ(0, missing.num_frames); + EXPECT_EQ(nullptr, missing.buffer); + generic_anim_unload(&missing); + + // the same exit on a struct that owns a previous EFF run releases that run's pair + generic_anim owner; + generic_anim_init(&owner, "streamtest"); + ASSERT_EQ(0, generic_anim_stream(&owner)); + int owned_bitmap = owner.bitmap_id; + int owned_next = owner.eff.next_frame; + strcpy_s(owner.filename, "streammissing"); + EXPECT_EQ(-1, generic_anim_stream(&owner)); + EXPECT_FALSE(bm_is_valid(owned_bitmap)); + EXPECT_FALSE(bm_is_valid(owned_next)); + EXPECT_EQ(nullptr, owner.buffer); + generic_anim_unload(&owner); +} + +// bm_reload renames a stream's next-frame slot to the frame it holds, and bm_load finds slots by name: a second +// stream of the same file must not take, copy and release the first stream's slot when that one sits on frame 0 +TEST_F(GenericAnimStreamTest, eff_stream_frame_zero_load_never_takes_another_streams_slot) { + generic_anim first; + generic_anim_init(&first, "streamtest"); + ASSERT_EQ(0, generic_anim_stream(&first)); + generic_anim_render(&first, 0.15f, 0, 0); + generic_anim_render(&first, 0.1f, 0, 0); + generic_anim_render(&first, 0.1f, 0, 0); + ASSERT_EQ(0, first.current_frame) << "three advances on a looping three-frame stream wrap to frame 0"; + int slot = first.eff.next_frame; + ASSERT_TRUE(bm_is_valid(slot)); + + generic_anim second; + generic_anim_init(&second, "streamtest"); + ASSERT_EQ(0, generic_anim_stream(&second)); + EXPECT_TRUE(bm_is_valid(slot)) << "the first stream's next-frame slot survives the second stream's start"; + + auto frame1 = frame_pixels("streamtest_0001"); + generic_anim_render(&first, 0.1f, 0, 0); + EXPECT_EQ(1, first.current_frame); + EXPECT_EQ(0, memcmp(first.buffer, frame1.data(), frame1.size())) << "the first stream still advances"; + + generic_anim_unload(&second); + generic_anim_unload(&first); +} + +// a one-frame EFF has no next-frame file: the stream, a restart and the unload all tolerate the -1 handle +TEST_F(GenericAnimStreamTest, eff_stream_one_frame_eff_streams_restarts_and_unloads) { + generic_anim ga; + generic_anim_init(&ga, "streamone"); + ASSERT_EQ(0, generic_anim_stream(&ga)); + EXPECT_EQ(1, ga.num_frames); + EXPECT_TRUE(ga.eff.next_frame < 0); + auto frame0 = frame_pixels("streamone_0000"); + EXPECT_EQ(0, memcmp(ga.buffer, frame0.data(), frame0.size())); + + generic_anim_render(&ga, 0.5f, 0, 0); + EXPECT_EQ(0, ga.current_frame); + + ASSERT_EQ(0, generic_anim_stream(&ga)); + EXPECT_TRUE(ga.eff.next_frame < 0); + generic_anim_unload(&ga); + EXPECT_EQ(nullptr, ga.buffer); +} + +// a restart that fails before the EFF branch (file not found) must leave the ownership record intact, so the next +// successful restart still reuses or releases the first run's pair instead of orphaning it +TEST_F(GenericAnimStreamTest, eff_stream_failed_lookup_keeps_the_ownership_record) { + generic_anim ga; + generic_anim_init(&ga, "streamtest"); + ASSERT_EQ(0, generic_anim_stream(&ga)); + int first_bitmap = ga.bitmap_id; + int first_signature = bm_get_entry(first_bitmap)->signature; + + strcpy_s(ga.filename, "streamnone"); + ASSERT_EQ(-1, generic_anim_stream(&ga)); + EXPECT_TRUE(bm_is_valid(first_bitmap)) << "a failed lookup touches nothing"; + + strcpy_s(ga.filename, "streamtest"); + ASSERT_EQ(0, generic_anim_stream(&ga)); + EXPECT_EQ(first_signature, bm_get_entry(ga.bitmap_id)->signature); + EXPECT_EQ(1, bm_get_entry(ga.eff.next_frame)->load_count); + + generic_anim_unload(&ga); + EXPECT_FALSE(bm_is_valid(first_bitmap)); +} + +// on a fresh struct eff.next_frame is whatever the union held (0 after init, often a live bitmap); a failure exit must +// not release a handle this stream never loaded +TEST_F(GenericAnimStreamTest, eff_stream_failure_never_releases_a_handle_it_did_not_load) { + int bystander = bm_load("streamtest_0002"); + ASSERT_TRUE(bystander >= 0); + + generic_anim ga; + generic_anim_init(&ga, "streambad"); + ga.eff.next_frame = bystander; + EXPECT_EQ(-1, generic_anim_stream(&ga)); + + EXPECT_TRUE(bm_is_valid(bystander)); + EXPECT_EQ(1, bm_get_entry(bystander)->load_count); + EXPECT_TRUE(bm_release(bystander)); + generic_anim_unload(&ga); +} + +// a struct tagged by another stream type whose EFF attempt fails before frame 0 must not become "owned" and have its +// foreign buffer freed by the successful attempt that follows +TEST_F(GenericAnimStreamTest, eff_stream_failed_attempt_does_not_adopt_a_foreign_buffer) { + static ubyte foreign_buffer[16] = {9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9}; + generic_anim ga; + generic_anim_init(&ga, "streammissing"); + ga.type = BM_TYPE_ANI; + ga.buffer = foreign_buffer; + EXPECT_EQ(-1, generic_anim_stream(&ga)); + + strcpy_s(ga.filename, "streamtest"); + ASSERT_EQ(0, generic_anim_stream(&ga)); + EXPECT_NE(foreign_buffer, ga.buffer); + EXPECT_EQ(9, foreign_buffer[0]); + generic_anim_unload(&ga); +} + +// the renderer upload runs once per advance and once more only when a restart keeps a bitmap that already has a texture +TEST_F(GenericAnimStreamTest, eff_stream_uploads_once_per_advance_and_on_a_kept_bitmap) { + static int uploads = 0; + uploads = 0; + auto saved_update = gr_screen.gf_update_texture; + gr_screen.gf_update_texture = [](int, int, const ubyte*, int, int) { uploads++; }; + + generic_anim ga; + generic_anim_init(&ga, "streamtest"); + ASSERT_EQ(0, generic_anim_stream(&ga)); + EXPECT_EQ(0, uploads) << "a fresh bitmap has no texture to refresh"; + + generic_anim_render(&ga, 0.15f, 0, 0); + ASSERT_EQ(1, ga.current_frame); + EXPECT_EQ(1, uploads); + + ASSERT_EQ(0, generic_anim_stream(&ga)); + EXPECT_EQ(2, uploads) << "a kept bitmap is refreshed with frame 0"; + + generic_anim_unload(&ga); + gr_screen.gf_update_texture = saved_update; +} + +// two streams of one EFF must not share a next-frame slot: with a shared slot the first stream's unload frees it (the +// per-frame unload has already taken both loads to zero) and the second stream's next bm_reload fails, freezing it +TEST_F(GenericAnimStreamTest, eff_stream_next_frame_slot_is_private_to_each_stream) { + generic_anim first; + generic_anim_init(&first, "streamtest"); + ASSERT_EQ(0, generic_anim_stream(&first)); + generic_anim second; + generic_anim_init(&second, "streamtest"); + ASSERT_EQ(0, generic_anim_stream(&second)); + EXPECT_NE(first.eff.next_frame, second.eff.next_frame); + + generic_anim_render(&first, 0.15f, 0, 0); + generic_anim_render(&second, 0.15f, 0, 0); + ASSERT_EQ(1, first.current_frame); + ASSERT_EQ(1, second.current_frame); + + generic_anim_unload(&first); + EXPECT_TRUE(bm_is_valid(second.eff.next_frame)) << "the second stream's slot survives the first stream's unload"; + + auto frame2 = frame_pixels("streamtest_0002"); + generic_anim_render(&second, 0.1f, 0, 0); + EXPECT_EQ(2, second.current_frame); + EXPECT_EQ(0, memcmp(second.buffer, frame2.data(), frame2.size())) << "the second stream still advances"; + generic_anim_unload(&second); +} + +} // namespace diff --git a/test/src/source_groups.cmake b/test/src/source_groups.cmake index 1c268dda86e..42d5fbfa702 100644 --- a/test/src/source_groups.cmake +++ b/test/src/source_groups.cmake @@ -37,6 +37,10 @@ add_file_folder("Math" math/test_vecmat.cpp ) +add_file_folder("graphics" + graphics/test_generic_anim_stream.cpp +) + add_file_folder("menuui" menuui/test_intel_parse.cpp ) diff --git a/test/test_data/data/effects/streambad.eff b/test/test_data/data/effects/streambad.eff new file mode 100644 index 00000000000..f3e014bf066 --- /dev/null +++ b/test/test_data/data/effects/streambad.eff @@ -0,0 +1,3 @@ +$Type: PNG +$Frames: 2 +$FPS: 10 diff --git a/test/test_data/data/effects/streambad_0000.png b/test/test_data/data/effects/streambad_0000.png new file mode 100644 index 0000000000000000000000000000000000000000..3d31602251238f6309d66cbc1c195eec168f21b5 GIT binary patch literal 48 xcmeAS@N?(olHy`uVBq!ia0vp^93afW1|*O0@9PFqVxBIJAr*6y|C~SY0RX$W3qk+@ literal 0 HcmV?d00001 diff --git a/test/test_data/data/effects/streambad_0001.png b/test/test_data/data/effects/streambad_0001.png new file mode 100644 index 0000000000000000000000000000000000000000..2877add2c432d2344f701451d4aa7572aaf953ee GIT binary patch literal 79 zcmeAS@N?(olHy`uVBq!ia0vp^93afW1|*O0@9PFqVxBIJAr*6y|C~SYVLl^&!Xb6b b8W9Etp=c&Pc`wgcpb7?0S3j3^P6930A0KU|?Vu;9y_@(jd$M#N+@4peB%hmxvHUtojA8i2h*s0TKlODmDb{ literal 0 HcmV?d00001 diff --git a/test/test_data/data/effects/streamdxt_0001.dds b/test/test_data/data/effects/streamdxt_0001.dds new file mode 100644 index 0000000000000000000000000000000000000000..19466a1a08b707a827a1a8359164f9f166e87872 GIT binary patch literal 136 ucmZ>930A0KU|?Vu;9y_@(jd$M#N+@4peB%hmxvHUtoj8oMdcaf!GZuFNCP?m literal 0 HcmV?d00001 diff --git a/test/test_data/data/effects/streamhud.eff b/test/test_data/data/effects/streamhud.eff new file mode 100644 index 00000000000..f3e014bf066 --- /dev/null +++ b/test/test_data/data/effects/streamhud.eff @@ -0,0 +1,3 @@ +$Type: PNG +$Frames: 2 +$FPS: 10 diff --git a/test/test_data/data/effects/streamhud_0000.png b/test/test_data/data/effects/streamhud_0000.png new file mode 100644 index 0000000000000000000000000000000000000000..13742144505da814b9e5e772b876d3a50306a046 GIT binary patch literal 78 zcmeAS@N?(olHy`uVBq!ia0vp^EFjFm1|(O0oL2{=L_J*`Ln`JZ|2co)!hr^s84?#5 b5*Zm%1R2}iRl-w%3K%?H{an^LB{Ts5s8tj0 literal 0 HcmV?d00001 diff --git a/test/test_data/data/effects/streamhud_0001.png b/test/test_data/data/effects/streamhud_0001.png new file mode 100644 index 0000000000000000000000000000000000000000..24d0b16606273045fc93ee3e755b52c0af62e59f GIT binary patch literal 78 zcmeAS@N?(olHy`uVBq!ia0vp^EFjFm1|(O0oL2{=L_J*`Ln`JZ|M~yFo|#!FA<2Qc ag_Yr1A9L%Y`33Pn1q`09elF{r5}E*}lN7Z8 literal 0 HcmV?d00001 diff --git a/test/test_data/data/effects/streammissing.eff b/test/test_data/data/effects/streammissing.eff new file mode 100644 index 00000000000..f3e014bf066 --- /dev/null +++ b/test/test_data/data/effects/streammissing.eff @@ -0,0 +1,3 @@ +$Type: PNG +$Frames: 2 +$FPS: 10 diff --git a/test/test_data/data/effects/streammix.eff b/test/test_data/data/effects/streammix.eff new file mode 100644 index 00000000000..f3e014bf066 --- /dev/null +++ b/test/test_data/data/effects/streammix.eff @@ -0,0 +1,3 @@ +$Type: PNG +$Frames: 2 +$FPS: 10 diff --git a/test/test_data/data/effects/streammix_0000.png b/test/test_data/data/effects/streammix_0000.png new file mode 100644 index 0000000000000000000000000000000000000000..54af68a7035617067a8ef9c672d4ab077cc40730 GIT binary patch literal 78 zcmeAS@N?(olHy`uVBq!ia0vp^EFjFm1|(O0oL2{=L_J*`Ln`JZ|2co)!+b_g1H%Q3 bhnN_eFEX()O?V#xRKVcr>gTe~DWM4fs4Ns| literal 0 HcmV?d00001 diff --git a/test/test_data/data/effects/streammix_0001.dds b/test/test_data/data/effects/streammix_0001.dds new file mode 100644 index 0000000000000000000000000000000000000000..19466a1a08b707a827a1a8359164f9f166e87872 GIT binary patch literal 136 ucmZ>930A0KU|?Vu;9y_@(jd$M#N+@4peB%hmxvHUtoj8oMdcaf!GZuFNCP?m literal 0 HcmV?d00001 diff --git a/test/test_data/data/effects/streamone.eff b/test/test_data/data/effects/streamone.eff new file mode 100644 index 00000000000..dfdf9474651 --- /dev/null +++ b/test/test_data/data/effects/streamone.eff @@ -0,0 +1,3 @@ +$Type: PNG +$Frames: 1 +$FPS: 10 diff --git a/test/test_data/data/effects/streamone_0000.png b/test/test_data/data/effects/streamone_0000.png new file mode 100644 index 0000000000000000000000000000000000000000..e58df336000193cde94405df36e3820d517fe003 GIT binary patch literal 78 zcmeAS@N?(olHy`uVBq!ia0vp^EFjFm1|(O0oL2{=L_J*`Ln`JZr~EkoVLl_Lf#Cwi bLre@!mzdagR8C?CDq!$*^>bP0l+XkKoA?v@ literal 0 HcmV?d00001 diff --git a/test/test_data/data/effects/streamrgb.eff b/test/test_data/data/effects/streamrgb.eff new file mode 100644 index 00000000000..f3e014bf066 --- /dev/null +++ b/test/test_data/data/effects/streamrgb.eff @@ -0,0 +1,3 @@ +$Type: PNG +$Frames: 2 +$FPS: 10 diff --git a/test/test_data/data/effects/streamrgb_0000.png b/test/test_data/data/effects/streamrgb_0000.png new file mode 100644 index 0000000000000000000000000000000000000000..8aada8e2fe72ac5ead50e118194ae291df888e4a GIT binary patch literal 73 zcmeAS@N?(olHy`uVBq!ia0vp^EFjFm1SHiab7}%90Z$jlkcv6Uf6g~B`0%(CG8kWE VWVdRu+X0kg@O1TaS?83{1OU+t4~_r; literal 0 HcmV?d00001 diff --git a/test/test_data/data/effects/streamrgb_0001.png b/test/test_data/data/effects/streamrgb_0001.png new file mode 100644 index 0000000000000000000000000000000000000000..aa9bfc09047c9e44b84ae2401c8c0a24122c70e7 GIT binary patch literal 77 zcmeAS@N?(olHy`uVBq!ia0vp^EFjFm1SHiab7}%95l!LR+-!bDq!$*^>bP0l+XkKm-ZAy literal 0 HcmV?d00001 diff --git a/test/test_data/data/effects/streamtest.eff b/test/test_data/data/effects/streamtest.eff new file mode 100644 index 00000000000..6af35926ef0 --- /dev/null +++ b/test/test_data/data/effects/streamtest.eff @@ -0,0 +1,3 @@ +$Type: PNG +$Frames: 3 +$FPS: 10 diff --git a/test/test_data/data/effects/streamtest_0000.png b/test/test_data/data/effects/streamtest_0000.png new file mode 100644 index 0000000000000000000000000000000000000000..2877add2c432d2344f701451d4aa7572aaf953ee GIT binary patch literal 79 zcmeAS@N?(olHy`uVBq!ia0vp^93afW1|*O0@9PFqVxBIJAr*6y|C~SYVLl^&!Xb6b b8W9Etp=c&Pc`wgcpb7?0S3j3^P6;gGsz bjR*sSU