/** * 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/dmf/assetmeshloader.h" #include "display/mesh/meshvertex.h" #include "thread/thread.h" #include "util/memory.h" #include #include // ============================================================ // DMF binary fixtures // DMF layout: // [0-2] magic "DMF" // [3] pad // [4-7] version (uint32 LE) // [8-11] vertCount (uint32 LE) // [12..] vertCount * meshvertex_t {uv[2], pos[3]} (float LE each) // // meshvertex_t is already {float uv[2]; float pos[3];} in wire order, and // this test host is little-endian, so a plain memcpy of real meshvertex_t // values reproduces the exact on-disk format -- no manual byte encoding. // ============================================================ typedef struct { uint8_t magic[3]; uint8_t pad; uint32_t version; uint32_t vertCount; } dmfheader_t; static size_t buildMeshFixture( uint8_t *out, const uint8_t magic[3], uint32_t version, uint32_t vertCount, const meshvertex_t *verts ) { dmfheader_t header; header.magic[0] = magic[0]; header.magic[1] = magic[1]; header.magic[2] = magic[2]; header.pad = 0; header.version = version; header.vertCount = vertCount; memcpy(out, &header, sizeof(header)); if(vertCount > 0) { memcpy(out + sizeof(header), verts, vertCount * sizeof(meshvertex_t)); } return sizeof(header) + (size_t)vertCount * sizeof(meshvertex_t); } static const uint8_t MAGIC_DMF[3] = { 'D', 'M', 'F' }; static const uint8_t MAGIC_BAD[3] = { 'X', 'Y', 'Z' }; static const meshvertex_t TWO_VERTS[2] = { { .uv = { 0.25f, 0.75f }, .pos = { 1.0f, 2.0f, 3.0f } }, { .uv = { 0.5f, 0.5f }, .pos = { -1.0f, 0.0f, 4.0f } }, }; // ============================================================ // Async thread helper // ============================================================ typedef struct { assetloading_t *loading; bool_t ok; } mesh_async_run_t; static void mesh_async_thread_cb(thread_t *thread) { mesh_async_run_t *run = (mesh_async_run_t *)thread->data; errorret_t ret = assetMeshLoaderAsync(run->loading); run->ok = errorIsOk(ret); if(errorIsNotOk(ret)) errorCatch(ret); } static bool_t run_mesh_async(assetloading_t *loading) { mesh_async_run_t run = { .loading = loading, .ok = false }; thread_t thread; threadInit(&thread, mesh_async_thread_cb); thread.data = &run; threadStart(&thread); threadStop(&thread); return run.ok; } // ============================================================ // In-memory ZIP // ============================================================ static zip_t *g_zip = NULL; static int mesh_zip_add( zip_t *za, const char_t *name, const void *data, size_t len ) { zip_source_t *s = zip_source_buffer(za, data, len, 0); return (int)zip_file_add(za, name, s, ZIP_FL_OVERWRITE); } 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_buffer defers reading until zip_close(), so each fixture // needs its own backing buffer -- reusing one scratch buffer across // multiple calls would make every entry read back whatever was written // into it last. static uint8_t bufEmpty[512], bufBadMagic[512], bufBadVersion[512], bufVerts[512]; size_t len; len = buildMeshFixture(bufEmpty, MAGIC_DMF, ASSET_MESH_FILE_VERSION, 0, NULL); if(mesh_zip_add(za, "empty.mesh", bufEmpty, len) < 0) { zip_close(za); return -1; } len = buildMeshFixture(bufBadMagic, MAGIC_BAD, ASSET_MESH_FILE_VERSION, 0, NULL); if(mesh_zip_add(za, "badmagic.mesh", bufBadMagic, len) < 0) { zip_close(za); return -1; } len = buildMeshFixture(bufBadVersion, MAGIC_DMF, ASSET_MESH_FILE_VERSION + 1, 0, NULL); if(mesh_zip_add(za, "badversion.mesh", bufBadVersion, len) < 0) { zip_close(za); return -1; } len = buildMeshFixture(bufVerts, MAGIC_DMF, ASSET_MESH_FILE_VERSION, 2, TWO_VERTS); if(mesh_zip_add(za, "verts.mesh", bufVerts, len) < 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_MESH, NULL); threadMutexInit(&ctx->loading.mutex); memoryZero(&ctx->loading.loading, sizeof(ctx->loading.loading)); ctx->loading.type = ASSET_LOADER_TYPE_MESH; ctx->loading.entry = &ctx->entry; ctx->entry.state = ASSET_ENTRY_STATE_PENDING_SYNC; } // Drives sync(INITIAL) -> async(READ_FILE) -> sync(CREATE_MESH). Only safe // to call for a fixture with vertCount == 0: any vertCount > 0 would reach // the GPU-touching mesh upload in the sync phase, which this headless test // binary has no GL context for. static errorret_t loader_ctx_run_empty(loader_ctx_t *ctx) { errorret_t ret = assetMeshLoaderSync(&ctx->loading); if(errorIsNotOk(ret)) return ret; if(!run_mesh_async(&ctx->loading)) { ctx->entry.state = ASSET_ENTRY_STATE_ERROR; errorThrow("Async mesh load failed"); } return assetMeshLoaderSync(&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_mesh_zero_vertices_full_roundtrip_skips_gpu(void **state) { loader_ctx_t ctx; loader_ctx_init(&ctx, "empty.mesh"); errorret_t ret = loader_ctx_run_empty(&ctx); assert_true(errorIsOk(ret)); assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_LOADED); assetmeshoutput_t *out = &ctx.entry.data.mesh; assert_false(out->meshInitialized); assert_null(out->vertices); loader_ctx_dispose(&ctx); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_mesh_bad_magic_errors(void **state) { loader_ctx_t ctx; loader_ctx_init(&ctx, "badmagic.mesh"); errorret_t ret = assetMeshLoaderSync(&ctx.loading); assert_true(errorIsOk(ret)); assert_false(run_mesh_async(&ctx.loading)); assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_ERROR); loader_ctx_dispose(&ctx); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_mesh_bad_version_errors(void **state) { loader_ctx_t ctx; loader_ctx_init(&ctx, "badversion.mesh"); errorret_t ret = assetMeshLoaderSync(&ctx.loading); assert_true(errorIsOk(ret)); assert_false(run_mesh_async(&ctx.loading)); assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_ERROR); loader_ctx_dispose(&ctx); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_mesh_missing_file_errors(void **state) { loader_ctx_t ctx; loader_ctx_init(&ctx, "nonexistent.mesh"); errorret_t ret = assetMeshLoaderSync(&ctx.loading); assert_true(errorIsOk(ret)); assert_false(run_mesh_async(&ctx.loading)); assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_ERROR); loader_ctx_dispose(&ctx); assert_int_equal(memoryGetAllocatedCount(), 0); } static void test_mesh_async_parses_vertices_and_endian(void **state) { // Only drives the async (file-read/parse) phase -- the sync phase for a // non-zero vertex count would upload to the GPU, which this headless // test binary can't do. loader_ctx_t ctx; loader_ctx_init(&ctx, "verts.mesh"); errorret_t ret = assetMeshLoaderSync(&ctx.loading); // arms READ_FILE state assert_true(errorIsOk(ret)); assert_true(run_mesh_async(&ctx.loading)); assert_int_equal((int)ctx.loading.loading.mesh.vertCount, 2); assert_int_equal(ctx.entry.state, ASSET_ENTRY_STATE_PENDING_SYNC); assert_int_equal( ctx.loading.loading.mesh.state, ASSET_MESH_LOADING_STATE_CREATE_MESH ); meshvertex_t *parsed = (meshvertex_t *)ctx.loading.loading.mesh.data; assert_non_null(parsed); assert_float_equal(parsed[0].uv[0], 0.25f, 0.0001f); assert_float_equal(parsed[0].uv[1], 0.75f, 0.0001f); assert_float_equal(parsed[0].pos[0], 1.0f, 0.0001f); assert_float_equal(parsed[0].pos[1], 2.0f, 0.0001f); assert_float_equal(parsed[0].pos[2], 3.0f, 0.0001f); assert_float_equal(parsed[1].uv[0], 0.5f, 0.0001f); assert_float_equal(parsed[1].pos[2], 4.0f, 0.0001f); // Never reached the sync/GPU phase, so the parsed buffer is still owned // by the loading slot (entry->data.mesh.vertices is still NULL) -- free // it manually before disposing, since assetMeshDispose only knows about // the entry-owned copy. memoryFree(ctx.loading.loading.mesh.data); ctx.loading.loading.mesh.data = NULL; loader_ctx_dispose(&ctx); assert_int_equal(memoryGetAllocatedCount(), 0); } // ============================================================ // main // ============================================================ int main(void) { assertInit(); const struct CMUnitTest tests[] = { cmocka_unit_test_setup_teardown(test_mesh_zero_vertices_full_roundtrip_skips_gpu, zip_setup, zip_teardown), cmocka_unit_test_setup_teardown(test_mesh_bad_magic_errors, zip_setup, zip_teardown), cmocka_unit_test_setup_teardown(test_mesh_bad_version_errors, zip_setup, zip_teardown), cmocka_unit_test_setup_teardown(test_mesh_missing_file_errors, zip_setup, zip_teardown), cmocka_unit_test_setup_teardown(test_mesh_async_parses_vertices_and_endian, zip_setup, zip_teardown), }; return cmocka_run_group_tests(tests, NULL, NULL); }