//SPDX-License-Identifier: LGPL-3.0-only //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import Crafter.Build; namespace fs = std::filesystem; using namespace Crafter; extern "C" Configuration CrafterBuildProject(std::span args) { std::vector depArgs(args.begin(), args.end()); // Crafter.Event/Math/Asset come from forgejo, or with --local from the // sibling checkouts next to this one (../Crafter.Event, ...), the way // Crafter.Asset resolves Crafter.Math. --local stays in depArgs so // Crafter.Asset picks its local Crafter.Math too: a consumer building // against local checkouts has to see the same sibling projects // everywhere, because Crafter.Build tells projects apart by path and // arguments and two Crafter.Math projects would put two copies of the // module on the compile path. const bool useLocal = std::ranges::find(args, std::string_view("--local")) != args.end(); auto sibling = [&](std::string_view name) -> Configuration* { if (useLocal) { return LocalProject({ .projectFile = std::format("../{}/project.cpp", name), .args = depArgs, }); } return GitProject({ .source = { .url = std::format("https://forgejo.catcrafts.net/Catcrafts/{}.git", name) }, .args = depArgs, }); }; Configuration* event = sibling("Crafter.Event"); Configuration* math = sibling("Crafter.Math"); Configuration* asset = sibling("Crafter.Asset"); Configuration cfg; cfg.path = "./"; cfg.name = "Crafter.Graphics"; cfg.outputName = "Crafter.Graphics"; cfg.type = ConfigurationType::LibraryStatic; auto opts = ApplyStandardArgs(cfg, args); cfg.dependencies = { event, math, asset }; // Window backend follows the target triple. V1 had separate lib-wayland / // lib-win32 configurations; V2 picks the right one automatically based on // where the build is going. Cross-compile (`--target=...`) flips the // backend along with everything else. The DOM backend is reached by any // wasm32-* target and produces a Vulkan-free build whose Window is wired // to a custom JS env (see additional/dom-env.js). bool dom = cfg.target.find("wasm") != std::string::npos; bool windows = !dom && (cfg.target.find("windows") != std::string::npos || cfg.target.find("mingw") != std::string::npos); if (dom) { cfg.defines.push_back({"CRAFTER_GRAPHICS_WINDOW_DOM", ""}); // No native window libs, no Vulkan loader, no Wayland/X11. The JS // bridge satisfies every dynamic symbol via wasm imports. Crafter.Build // strips -march/-mtune from the clang command line for any wasm32-* // triple, so cfg.march/mtune can stay at their defaults — keeping them // matches the VariantId of dependency PCMs. // // WasmAlloc / WasmFree live in Crafter.Graphics-Dom.cpp and back // dom-env.js's __writeUtf8 path (every keyboard / text-input event // routes through them). The TU defines no symbols main.cpp would // reference, so wasm-ld dead-strips it from libCrafter.Graphics.a // for examples that don't touch the `Dom::HtmlElement*` API (like // Sponza). `--export=` both forces the export AND pulls the // defining .o in — solving both halves of the dead-strip problem. cfg.linkFlags.push_back("-Wl,--export=WasmAlloc"); cfg.linkFlags.push_back("-Wl,--export=WasmFree"); } else if (windows) { cfg.defines.push_back({"CRAFTER_GRAPHICS_WINDOW_WIN32", ""}); cfg.linkFlags.push_back("-lkernel32"); cfg.linkFlags.push_back("-luser32"); cfg.linkFlags.push_back("-lgdi32"); // Windows.Gaming.Input (WGI) needs the WinRT activation runtime for // HSTRING and RoGetActivationFactory. Its gamepad code is C (see // Crafter.Graphics-GamepadWin32.h). cfg.linkFlags.push_back("-lruntimeobject"); cfg.cFiles.push_back("implementations/Crafter.Graphics-GamepadWin32"); } else { cfg.defines.push_back({"CRAFTER_GRAPHICS_WINDOW_WAYLAND", ""}); cfg.linkFlags.push_back("-lwayland-client"); cfg.linkFlags.push_back("-lxkbcommon"); // Gamepad: libudev for hot-plug + device enumeration; libevdev // for event parsing + axis calibration. cfg.linkFlags.push_back("-ludev"); // libevdev is vendored in lib/libevdev rather than linked from the // system: the Flatpak SDK crafter-build runs in ships no libevdev // headers, and a vendored copy cross-builds like everything else. It // is its own static library so it compiles once, not again in every // test configuration (they copy cfg.cFiles). One per call, since each // distinct argument list loads this project separately; it lives for // the whole build like a GitProject's configuration. Its paths are // absolute because Crafter.Build only resolves the returned // configuration's paths against this project's root, and this // project.cpp runs with that root as cwd. static std::deque libevdevConfigs; Configuration& libevdev = libevdevConfigs.emplace_back(); libevdev.path = fs::absolute(cfg.path); libevdev.name = "Crafter.Graphics-libevdev"; libevdev.outputName = "Crafter.Graphics-libevdev"; libevdev.type = ConfigurationType::LibraryStatic; libevdev.target = cfg.target; libevdev.march = cfg.march; libevdev.mtune = cfg.mtune; libevdev.debug = cfg.debug; libevdev.sysroot = cfg.sysroot; libevdev.projectArgs = cfg.projectArgs; libevdev.cFiles = { fs::absolute("lib/libevdev/libevdev/libevdev"), fs::absolute("lib/libevdev/libevdev/libevdev-names"), }; // Its generated event-names.h names every input code by macro, so it // compiles against the kernel headers that table was generated from // rather than the target's. libevdev.compileFlags.push_back(std::format("-I{}", fs::absolute("lib/libevdev/kernel-headers").string())); cfg.dependencies.push_back(&libevdev); // For Gamepad's #include . cfg.compileFlags.push_back(std::format("-I{}", fs::absolute("lib/libevdev").string())); cfg.cFiles.push_back("lib/xdg-shell-protocol"); cfg.cFiles.push_back("lib/wayland-xdg-decoration-unstable-v1-client-protocol"); cfg.cFiles.push_back("lib/fractional-scale-v1"); cfg.cFiles.push_back("lib/viewporter"); cfg.cFiles.push_back("lib/text-input-unstable-v3-protocol"); } // Vulkan is the only renderer on native targets. Software fallback is // provided externally via the Vulkan loader (e.g. llvmpipe / lavapipe) — // no separate code path. The DOM backend doesn't render in V1 (the // UIRenderer and every Vulkan-typed module are excluded below); the // WebGPU follow-up will gain its own headers/loader rather than reuse // the Vulkan ones. if (!dom) { ExternalDependency& vkHeaders = cfg.externalDependencies.emplace_back(); vkHeaders.name = "Vulkan-Headers"; vkHeaders.source.url = "https://github.com/KhronosGroup/Vulkan-Headers.git"; vkHeaders.builder = ExternalBuilder::None; vkHeaders.includeDirs = { "include" }; ExternalDependency& vkUtility = cfg.externalDependencies.emplace_back(); vkUtility.name = "Vulkan-Utility-Libraries"; vkUtility.source.url = "https://github.com/KhronosGroup/Vulkan-Utility-Libraries.git"; vkUtility.builder = ExternalBuilder::None; vkUtility.includeDirs = { "include" }; cfg.linkFlags.push_back(windows ? "-lvulkan-1" : "-lvulkan"); } if (opts.Has("--timing")) cfg.defines.push_back({"CRAFTER_TIMING", ""}); // One master interface list. Every partition exists on every target // — Crafter.Build's dependency scanner doesn't respect `#ifdef` on // `import :X` statements, so the partition file must be present even // when its body is gated out. Vulkan-typed partitions stub to empty // modules under CRAFTER_GRAPHICS_WINDOW_DOM; the Dom/DomEvents/Router // partitions stub to empty modules in the opposite direction. std::array ifaces = { "interfaces/Crafter.Graphics", "interfaces/Crafter.Graphics-Animation", "interfaces/Crafter.Graphics-Clipboard", "interfaces/Crafter.Graphics-ComputeShader", "interfaces/Crafter.Graphics-Decompress", "interfaces/Crafter.Graphics-DescriptorHeapVulkan", "interfaces/Crafter.Graphics-DescriptorHeapWebGPU", "interfaces/Crafter.Graphics-Device", "interfaces/Crafter.Graphics-Dom", "interfaces/Crafter.Graphics-DomEvents", "interfaces/Crafter.Graphics-Font", "interfaces/Crafter.Graphics-FontAtlas", "interfaces/Crafter.Graphics-ForwardDeclarations", "interfaces/Crafter.Graphics-Gamepad", "interfaces/Crafter.Graphics-GraphicsTypes", "interfaces/Crafter.Graphics-Image2D", "interfaces/Crafter.Graphics-ImageVulkan", "interfaces/Crafter.Graphics-Input", "interfaces/Crafter.Graphics-InputField", "interfaces/Crafter.Graphics-Keys", "interfaces/Crafter.Graphics-Mesh", "interfaces/Crafter.Graphics-PipelineRTVulkan", "interfaces/Crafter.Graphics-PipelineRTWebGPU", "interfaces/Crafter.Graphics-PlainComputeShader", "interfaces/Crafter.Graphics-RenderingElement3D", "interfaces/Crafter.Graphics-RenderPass", "interfaces/Crafter.Graphics-Router", "interfaces/Crafter.Graphics-RT", "interfaces/Crafter.Graphics-RTPass", "interfaces/Crafter.Graphics-SamplerVulkan", "interfaces/Crafter.Graphics-ShaderBindingTableVulkan", "interfaces/Crafter.Graphics-ShaderBindingTableWebGPU", "interfaces/Crafter.Graphics-ShaderVulkan", "interfaces/Crafter.Graphics-Types", "interfaces/Crafter.Graphics-UI", "interfaces/Crafter.Graphics-UIComponents", "interfaces/Crafter.Graphics-VulkanBuffer", "interfaces/Crafter.Graphics-VulkanTransition", "interfaces/Crafter.Graphics-WebGPU", "interfaces/Crafter.Graphics-WebGPUBuffer", "interfaces/Crafter.Graphics-WebGPUComputeShader", "interfaces/Crafter.Graphics-Window", }; if (dom) { // DOM impl set. UI-Shared.cpp is backend-agnostic; UI-WebGPU.cpp // is the DOM-only implementation of UIRenderer's GPU-touching // methods. Font / FontAtlas / UIComponents / InputField are now // portable. std::array domImpls = { "implementations/Crafter.Graphics-Clipboard", "implementations/Crafter.Graphics-Dom", "implementations/Crafter.Graphics-Font", "implementations/Crafter.Graphics-FontAtlas", "implementations/Crafter.Graphics-Gamepad", "implementations/Crafter.Graphics-Input", "implementations/Crafter.Graphics-InputField", "implementations/Crafter.Graphics-Mesh-WebGPU", "implementations/Crafter.Graphics-PipelineRTWebGPU", "implementations/Crafter.Graphics-RenderingElement3D-WebGPU", "implementations/Crafter.Graphics-Router", "implementations/Crafter.Graphics-ShaderBindingTableWebGPU", "implementations/Crafter.Graphics-UI-Shared", "implementations/Crafter.Graphics-UI-WebGPU", "implementations/Crafter.Graphics-UIComponents", "implementations/Crafter.Graphics-WebGPUComputeShader", "implementations/Crafter.Graphics-Window", }; cfg.GetInterfacesAndImplementations(ifaces, domImpls); // JS glue shipped alongside the .wasm so the loader has the // env-import surface the Window/Dom bindings expect. cfg.files.emplace_back(fs::path("additional/dom-env.js")); // dom-webgpu.js is opt-out via --no-webgpu, for apps that only use // the Dom/Router partitions and never touch Device/RTPass/UIRenderer. // // Three reasons a DOM-only app wants it gone, in increasing order of // severity: // 1. It is ~182 KB (47 KB gzip) of dead weight. // 2. Its init is an async IIFE that runtime.js awaits before // _start(), and that init does `await navigator.gpu // .requestAdapter()` — so every page load blocks wasm startup on // a GPU adapter request, including on machines with no GPU. // 3. Worst: when navigator.gpu is absent it REPLACES // document.body.innerHTML with an error message and throws, so // _start() never runs. For a server-rendered page that means the // real content is destroyed and replaced with a WebGPU warning on // any browser without WebGPU support. // // Safety: with --gc-sections an app that never calls into the WebGPU // partitions emits no wgpu* env imports, so omitting the bridge links // and instantiates cleanly. An app that DOES use WebGPU and passes // --no-webgpu anyway fails at instantiate with a missing-import error // naming the specific wgpu* symbol. if (!opts.Has("--no-webgpu")) { cfg.files.emplace_back(fs::path("additional/dom-webgpu.js")); } } else { std::array impls = { "implementations/Crafter.Graphics-Clipboard", "implementations/Crafter.Graphics-ComputeShader", "implementations/Crafter.Graphics-Device", "implementations/Crafter.Graphics-Font", "implementations/Crafter.Graphics-FontAtlas", "implementations/Crafter.Graphics-Gamepad", "implementations/Crafter.Graphics-Input", "implementations/Crafter.Graphics-InputField", "implementations/Crafter.Graphics-Mesh", "implementations/Crafter.Graphics-RenderingElement3D", "implementations/Crafter.Graphics-UI", "implementations/Crafter.Graphics-UI-Shared", "implementations/Crafter.Graphics-UIComponents", "implementations/Crafter.Graphics-Window", }; cfg.GetInterfacesAndImplementations(ifaces, impls); // Every UI kernel is built twice from one source: .comp.spv // indexes VK_EXT_descriptor_heap, .comp.sets.spv reads the same // slots from a bindless descriptor set for drivers without the heap // extension (Mesa Turnip). ui-shared.glsl switches on the define and // ComputeShader::Load picks the file at runtime. for (const char* shader : {"shaders/ui-quads.comp.glsl", "shaders/ui-circles.comp.glsl", "shaders/ui-images.comp.glsl", "shaders/ui-text.comp.glsl", "shaders/ui-fused.comp.glsl"}) { cfg.shaders.emplace_back(fs::path(shader), std::string("main"), ShaderType::Compute); Shader& sets = cfg.shaders.emplace_back(fs::path(shader), std::string("main"), ShaderType::Compute); sets.defines = {"CRAFTER_GRAPHICS_DESCRIPTOR_SETS"}; sets.variant = "sets"; } cfg.buildFiles.emplace_back(fs::path("shaders/ui-shared.glsl")); // A test executable built the way the library is (its target, flags, // dependencies and C files) from the library's sources plus // tests//main.cpp. Configuration isn't copyable (it owns the // parsed module list), so the shared settings are copied field by // field, here once for every test. Callers add what else a test needs. auto LibraryTest = [&](std::string_view name) { Test test; Configuration& c = test.config; c.path = cfg.path; c.name = name; c.outputName = name; c.type = ConfigurationType::Executable; c.target = cfg.target; c.march = cfg.march; c.mtune = cfg.mtune; c.debug = cfg.debug; c.sysroot = cfg.sysroot; c.projectArgs = cfg.projectArgs; c.dependencies = cfg.dependencies; c.externalDependencies = cfg.externalDependencies; c.compileFlags = cfg.compileFlags; c.linkFlags = cfg.linkFlags; c.defines = cfg.defines; c.cFiles = cfg.cFiles; std::vector sources(impls.begin(), impls.end()); sources.emplace_back(std::format("tests/{}/main", name)); c.GetInterfacesAndImplementations(ifaces, sources); return test; }; // Regression test for issue #18: drive the NVIDIA descriptor-heap // AS-read workaround's SPIR-V rewrite over real compiled shaders and // check the result with spirv-val (one push-constant block, correct // TLAS offset). glslang and spirv-val are invoked at runtime, so the // test declares them as required tools. Remove with the rest of the // workaround. Test pcTest = LibraryTest("PushConstantRewrite"); pcTest.requires_ = { "tool:glslang", "tool:spirv-val" }; cfg.tests.push_back(std::move(pcTest)); // Regression test for issue #32: the Wayland scroll-wheel chain // (wl_pointer.axis → Window::onMouseScroll). Drives the listener // callback directly, so it needs no compositor — but it does need // the Wayland backend compiled in, hence Linux-only. if (!windows) { cfg.tests.push_back(LibraryTest("MouseScroll")); // Issue #40: multi-frame-in-flight frame pacing (per-image fences // + per-frame semaphores, no steady-state wait-idle). Drives the // real frame loop against a live Wayland compositor for many more // frames than there are in-flight slots and asserts the validation // layer stays silent — the old singleton-semaphore design only // avoided being an active race because the wait-idle masked it, so // a clean multi-frame run is the regression guard. Needs the // Wayland backend + a real compositor, hence inside the !windows // block alongside MouseScroll. cfg.tests.push_back(LibraryTest("FrameLoopSync")); // Issue #153: two frame-loop validation errors. (1) The acquire // barrier hardcoded dstAccessMask = SHADER_WRITE|TRANSFER_WRITE but // used the per-pass stage union (COMPUTE_SHADER for an all-compute // frame) as its dst stage, so TRANSFER_WRITE was unsupported and // VUID-02820 fired every frame — FrameLoopSync can't see it because // it runs with no passes (the union falls back to the conservative // writer set, which includes TRANSFER). (2) Mid-session // StartInit/FinishInit reuse the shared draw command buffer while // the loop's last submission of it is still in flight, re-beginning // (00049) and re-submitting (00071) a pending buffer. Drives a real // compute pass through the loop plus interleaved StartInit rounds // and asserts the layer stays silent. Needs the Wayland backend + a // real compositor, so it lives in the !windows block. cfg.tests.push_back(LibraryTest("SetupCmdBufferReuse")); // The UI path has two binding models: VK_EXT_descriptor_heap, and // a bindless descriptor set for drivers without it (Mesa Turnip). // The test renders one scene through each — the second by // re-running itself with CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP — and // compares the frames, so the fallback cannot drift from the heap // path unnoticed on a machine that never takes it. Unlike the // other tests it loads the real UI kernels, so it carries the // library's shaders (both variants) and a font into its own bin // dir. Needs the Wayland backend + a real compositor. Test parityTest = LibraryTest("UIFallbackParity"); parityTest.config.shaders = cfg.shaders; parityTest.config.buildFiles = cfg.buildFiles; parityTest.config.files.emplace_back(fs::path("examples/HelloUI/font.ttf")); cfg.tests.push_back(std::move(parityTest)); // Reading the TLAS from the descriptor heap (#7/#15): renders a // triangle through traceRayEXT and an inline ray query and checks // the read-back pixels, through the engine's normal path (so on // NVIDIA through the SPIR-V rewrite). `HeapASRead --no-workaround` // is the manual probe for whether a driver still has the fault. // Needs the Wayland backend, a real compositor and an RT device // (skips without RT). Test heapAsTest = LibraryTest("HeapASRead"); heapAsTest.config.shaders.emplace_back(fs::path("tests/HeapASRead/heapas-raygen.glsl"), std::string("main"), ShaderType::RayGen); heapAsTest.config.shaders.emplace_back(fs::path("tests/HeapASRead/heapas-closesthit.glsl"), std::string("main"), ShaderType::ClosestHit); heapAsTest.config.shaders.emplace_back(fs::path("tests/HeapASRead/heapas-miss.glsl"), std::string("main"), ShaderType::Miss); cfg.tests.push_back(std::move(heapAsTest)); // Dynamic descriptor-heap indexing in a hit shader: a closest-hit // picks a storage buffer and a sampled image by a runtime index // (the same across a wave on one instance, different between // neighbouring lanes on the other), which lost the device on // NVIDIA 610.43.02; skips on NVIDIA drivers older than 615.78.08. // Same requirements as HeapASRead. Test dynIndexTest = LibraryTest("HeapDynamicIndexHit"); dynIndexTest.config.shaders.emplace_back(fs::path("tests/HeapDynamicIndexHit/dynidx-raygen.glsl"), std::string("main"), ShaderType::RayGen); dynIndexTest.config.shaders.emplace_back(fs::path("tests/HeapDynamicIndexHit/dynidx-closesthit.glsl"), std::string("main"), ShaderType::ClosestHit); dynIndexTest.config.shaders.emplace_back(fs::path("tests/HeapDynamicIndexHit/dynidx-miss.glsl"), std::string("main"), ShaderType::Miss); cfg.tests.push_back(std::move(dynIndexTest)); // The swapchain is created with TRANSFER_DST where the surface // allows it: clears the image from a pass and reads it back. // Needs the Wayland backend and a real compositor. cfg.tests.push_back(LibraryTest("SwapchainTransferDst")); // A window on its own update clock keeps updating while // minimized, and the loop wakes for a registered eventfd. Needs // the Wayland backend and a real compositor. cfg.tests.push_back(LibraryTest("WindowUpdateClock")); // RTPass pushes the application's push data and traces at an // explicit size: clears the frame, traces a quadrant with a hit // colour that changes every frame, and checks the read-back. // Same requirements as HeapASRead. Test rtPushTest = LibraryTest("RTPassPushData"); rtPushTest.config.shaders.emplace_back(fs::path("tests/RTPassPushData/rtpass-raygen.glsl"), std::string("main"), ShaderType::RayGen); rtPushTest.config.shaders.emplace_back(fs::path("tests/RTPassPushData/rtpass-closesthit.glsl"), std::string("main"), ShaderType::ClosestHit); rtPushTest.config.shaders.emplace_back(fs::path("tests/RTPassPushData/rtpass-miss.glsl"), std::string("main"), ShaderType::Miss); cfg.tests.push_back(std::move(rtPushTest)); } // Issue #36: BLAS build options. Drives the real hardware AS-build // path — records Mesh::Build / Refit / BuildProcedural / // RefitProcedural with fast-build/fast-trace + allow-update flags // into one-time command buffers and submits them, asserting the // requested flags land, that an allowUpdate refit keeps the AS // handle (in-place UPDATE), and that the validation layer reports no // errors. Needs a Vulkan RT device at runtime (same as the RT // examples), so it shares the native build settings. cfg.tests.push_back(LibraryTest("BLASBuildOptions")); // Issue #64: TLAS host-input buffers (instanceBuffer / metadataBuffer) // grow on a high-water mark instead of reallocating to the exact count // every topology change. Drives the real hardware AS-build path — a // cube BLAS plus RenderingElement3D::BuildTLAS at a sequence of // instance counts — and asserts that a shrink (and any growth within // the high-water capacity) reuses the existing allocation while a // growth past it reallocates, with the validation layer reporting no // errors when an oversized instance buffer is fed to the build. Needs a // Vulkan RT device at runtime, so it shares the native build settings. cfg.tests.push_back(LibraryTest("TLASHighWaterMark")); // The barrier BuildTLAS records after the build carries the // acceleration-structure memory dependency to the shaders that read // the TLAS, not just execution order. BuildTlasReadBarrier is pure // CPU logic, so no GPU device is needed at runtime. cfg.tests.push_back(LibraryTest("TLASBuildBarrier")); // BuildTLAS on an empty scene (no elements registered yet) builds a // valid empty TLAS instead of creating zero-size buffers, and adding // and removing instances afterwards still rebuilds it. Needs a Vulkan // RT device at runtime, like TLASHighWaterMark. cfg.tests.push_back(LibraryTest("TLASEmptyBuild")); // Issue #118: the per-frame TLAS instance+metadata host rebuild copies // (and flushes) only the slots whose element's host-authored data // changed, tracked via RenderingElement3D::hostDataVersion against the // per-frame uploadedVersion record; untracked elements (version 0) keep // the always-copy behaviour. Drives the real AS-build path — a cube // BLAS plus BuildTLAS at a sequence of marks/mutations — and reads back // the host-mapped buffers to assert that clean slots are skipped, dirty // slots re-uploaded, and relocation on the refit path re-uploads exactly // the moved slots, with the validation layer reporting no errors over // the ranged FlushDevice the dirty span feeds. Needs a Vulkan RT device // at runtime, so it shares the native build settings. cfg.tests.push_back(LibraryTest("TLASInstanceDirtyTracking")); // Issue #51: FontAtlas only re-uploads the dirty sub-rect now, // tracked via FontAtlas::DirtyRect. The accumulation/clamp math is // pure CPU, so this test drives it directly — no GPU device needed // at runtime (the real copy params are covered by HelloUI rendering // text on both backends). cfg.tests.push_back(LibraryTest("FontAtlasDirtyRect")); // Issue #52: shaped-run cache for UIRenderer::ShapeText. Shapes a // string against a real CPU-side FontAtlas and asserts that a cache // hit is byte-equivalent to the uncached path, that hits don't touch // the atlas, and that translate / alignment / truncation / // InvalidateFont all behave. Needs a headless Vulkan device (the // atlas image is a real GPU image) but no swapchain — same native // build settings as the others. The font file is copied next to the // binary; the test also probes the project-root path. Test textTest = LibraryTest("ShapeTextCache"); textTest.config.files = { fs::path("tests/ShapeTextCache/font.ttf") }; cfg.tests.push_back(std::move(textTest)); // Issue #59: Device::GetMemoryType gained a `preferred` mask and a // required-only fallback so callers combining a mandatory flag with a // perf-only one (HOST_VISIBLE | DEVICE_LOCAL descriptor heaps) no // longer throw on devices without a host-visible device-local heap. // The selection is pure CPU logic over Device::memoryProperties, so // this test installs synthetic memory layouts and drives it directly — // no GPU device needed at runtime. cfg.tests.push_back(LibraryTest("MemoryTypeFallback")); // Issue #70: ImageVulkan's mip-chain upload folds the never-read final // blit destination into a single batched final-transition barrier // instead of giving it a dedicated DST->SRC barrier. The barrier set is // built by BuildMipChainFinalBarriers, pure CPU logic over mip count and // layout, so this test drives it directly — no GPU device at runtime. cfg.tests.push_back(LibraryTest("MipChainBarrierBatch")); // Issue #115: the frame loop's inter-pass and acquire/present barriers // no longer use ALL_COMMANDS / a queue-wide VkMemoryBarrier. The stage // masks are derived per pass via RenderPass::SwapchainStage() (compute // vs ray-tracing) and unioned across the frame by SwapchainStageUnion, // and the inter-pass dependency is scoped to the swapchain image by // BuildSwapchainInterPassBarrier — all pure CPU logic over the pass // list, so this test drives them directly with no GPU at runtime. cfg.tests.push_back(LibraryTest("SwapchainBarrierScope")); // Issue #47: the fused UI uber-kernel (shaders/ui-fused.comp.glsl) and // its C++ push-constant mirror UIFusedHeader. Compiles the real shader // with glslang, validates with spirv-val, and pins the push-constant // member offsets to UIFusedHeader's layout so a GLSL/C++ drift can't // slip through (the C++ static_assert only guards the C++ side). No GPU // device at runtime, but glslang + spirv-val are required tools. Test fusedTest = LibraryTest("UIFusedShader"); fusedTest.requires_ = { "tool:glslang", "tool:spirv-val" }; cfg.tests.push_back(std::move(fusedTest)); // Issue #60: VulkanBuffer::FlushDevice / FlushHost now record the chosen // memory type's propertyFlags at Create time and skip the // flush/invalidate when the memory is HOST_COHERENT. The gate is pure // logic over the recorded flags, so this test stamps them directly and // verifies the coherent path issues no Vulkan call — no GPU device // needed at runtime. cfg.tests.push_back(LibraryTest("VulkanBufferFlushGate")); // VulkanBuffer::Create requests and queries a device address only // when the usage includes SHADER_DEVICE_ADDRESS; other buffers report // address 0. Needs a Vulkan device at runtime. cfg.tests.push_back(LibraryTest("VulkanBufferAddress")); // Ranged FlushDevice: UI descriptor registration now flushes only the // written descriptor byte range instead of the whole heap, rounding the // range outward to nonCoherentAtomSize via AlignMappedFlushRange. The // rounding is pure math and the coherent gate is pure logic, so this // test drives both directly with no GPU device. cfg.tests.push_back(LibraryTest("VulkanBufferRangedFlush")); // Issue #63: VulkanBuffer::Resize now reuses the existing allocation in // place when a new request still fits the created capacity and the // immutable-at-create properties match (usage flags fixed at create; // chosen memory type still satisfies the required flags), instead of // always destroying + reallocating. The reuse guard is pure logic over // recorded fields, so this test stamps a fake handle + capacity/flags and // verifies the in-place path issues no Vulkan call — no GPU device needed. cfg.tests.push_back(LibraryTest("VulkanBufferResizeReuse")); // Issue #101: fence-keyed deferred resource-deletion queue. Since #40 // dropped the per-frame wait-idle, destroying a buffer the GPU may // still read (Resize's reallocate path) is a use-after-free. // VulkanBuffer::DeferredClear / Resize now hand handles to Device's // queue, which ReclaimDeletions frees only after framesInFlight frames // and DrainDeletions frees on a wait-idle. The retire timing is driven // on a real headless device (real buffers so the frees execute and the // validation layer can object) by stepping Device::frameCounter — no // swapchain/window needed, so it shares the native build settings. cfg.tests.push_back(LibraryTest("DeferredDeletion")); // The deletion queue takes the handles that destructors on other // threads queue while the render loop reclaims it. cfg.tests.push_back(LibraryTest("DeletionQueueThreads")); // Issue #67: the compressed Mesh::Build path no longer pins its // host-visible `compressedStaging` for the mesh's life — it releases it // via DeferredClear() right after recording the GPU decompress, so the // fence-keyed deletion queue (#101/#102) frees it once that submit's // frame has cleared. Drives the real VK_EXT_memory_decompression / // GDeflate path on a headless device (no swapchain/window — a decompress // + BLAS build only needs the queue + command pool) and asserts the // staging is enqueued (not pinned), the build is validation-clean with // correct decompressed data, and the entry retires only after // framesInFlight frames. Shares the native build settings; needs the // asset pipeline for SaveCompressed/LoadCompressedMesh (cfg.dependencies // already carries Crafter.Asset). cfg.tests.push_back(LibraryTest("MeshDecompressStagingRelease")); // A Mesh queues its acceleration structure for deferred deletion when // it is rebuilt, destroyed or moved from, and a rebuild builds into // fresh storage. Headless like the test above; needs a Vulkan RT // device at runtime. cfg.tests.push_back(LibraryTest("MeshLifetime")); // A VulkanShader queues its shader module for deferred deletion when // it is destroyed or move-assigned over, and can only be moved. // Headless; needs any Vulkan device. Test shaderLifetimeTest = LibraryTest("VulkanShaderLifetime"); shaderLifetimeTest.config.shaders.emplace_back(fs::path("tests/VulkanShaderLifetime/noop.comp.glsl"), std::string("main"), ShaderType::Compute); cfg.tests.push_back(std::move(shaderLifetimeTest)); // ComputeShader and PipelineRTVulkan queue their pipelines for deferred // deletion when destroyed or move-assigned over, and only move. // Headless; needs any Vulkan device, and ray tracing for half of it. Test pipelineLifetimeTest = LibraryTest("PipelineLifetime"); pipelineLifetimeTest.config.shaders.emplace_back(fs::path("tests/VulkanShaderLifetime/noop.comp.glsl"), std::string("main"), ShaderType::Compute); pipelineLifetimeTest.config.shaders.emplace_back(fs::path("tests/PipelineLifetime/lifetime-raygen.glsl"), std::string("main"), ShaderType::RayGen); cfg.tests.push_back(std::move(pipelineLifetimeTest)); // DescriptorHeapVulkan hands freed slots out again, ranges included. // Headless; needs any Vulkan device. cfg.tests.push_back(LibraryTest("HeapSlotReuse")); // The debug messenger prints info messages only with validation on. // Headless; needs any Vulkan device. cfg.tests.push_back(LibraryTest("DebugMessengerSeverity")); // A re-upload into a buffer the GPU may still read lands after the // reads already submitted. Headless; needs any Vulkan device. cfg.tests.push_back(LibraryTest("UploadInFlight")); // CRAFTER_GRAPHICS_DEVICE picks the GPU. Needs two Vulkan devices. cfg.tests.push_back(LibraryTest("DeviceSelection")); // Issue #114: a static ImageVulkan no longer pins its host-visible // staging `buffer` for the image's life — Update releases it via // DeferredClear() right after recording the buffer→image copy, so the // fence-keyed deletion queue (#101/#102) frees it once that submit's // frame clears, while a `streamed` image (the FontAtlas) keeps its // persistent map. Drives the real upload path on a headless device (no // swapchain/window — a buffer→image copy + readback only needs the queue // + command pool) and asserts the staging is enqueued (not pinned), the // image reads back byte-equal (the released staging outlived the submit), // the entry retires only after framesInFlight frames, and a streamed // image keeps + then frees its staging on Destroy. Shares the native // build settings. cfg.tests.push_back(LibraryTest("ImageStagingRelease")); // A non-square image gets every level of its mip chain, each at least // one texel on a side. Headless; needs any Vulkan device. cfg.tests.push_back(LibraryTest("MipChainNonSquare")); // Issue #89: Device::PreferDirectDeviceWrite chooses the upload strategy // for a CPU-written, GPU-read buffer — direct HOST_VISIBLE|DEVICE_LOCAL // map+write on ReBAR/UMA vs. staged-into-pure-DEVICE_LOCAL on a small // BAR window (#58). The decision is pure CPU logic over // Device::memoryProperties (types + heaps), so this test installs // synthetic ReBAR / UMA / small-window / no-BAR layouts and drives it // directly — no GPU device needed at runtime. cfg.tests.push_back(LibraryTest("UploadStrategy")); // Issue #57: Font::GetLineWidth memoises per-codepoint advances in // font units (Font::AdvanceUnits) and rescales per call, instead of // calling stbtt_GetCodepointHMetrics for every glyph on every caret // query. Pure CPU — Font only touches stb_truetype — so this drives // the public API directly with no Vulkan device. The font file is // copied next to the binary; the test also probes the project root. Test advTest = LibraryTest("FontAdvanceCache"); advTest.config.files = { fs::path("tests/FontAdvanceCache/font.ttf") }; cfg.tests.push_back(std::move(advTest)); // Issue #50: uiResolveScreenPixel now gates its per-pixel clip-rect // compares on the reserved kUIFlagClip bit, which FillHeader sets only // when the clip rect is narrower than the surface. The decision lives // in UIRenderer::ClipFlags — pure CPU logic over the clip rect and the // surface size — so this test drives it directly with synthetic // dimensions, no Window or GPU device needed at runtime. cfg.tests.push_back(LibraryTest("UIClipFlag")); // Issue #56: InputField_HitTestCursor mapped a click x to a cursor byte // offset by re-walking the prefix for every boundary (O(n^2) glyph // metric lookups) over raw byte boundaries. It now delegates to // Font::NearestCursorByte — one cumulative-advance pass over codepoint // boundaries. The mapping is pure CPU over a TrueType file, so this test // drives it directly: no Vulkan device or window. The font is copied // next to the binary; the test also probes the project-root path. Test hitTest = LibraryTest("InputFieldHitTest"); hitTest.config.files = { fs::path("tests/InputFieldHitTest/font.ttf") }; cfg.tests.push_back(std::move(hitTest)); // Issue #128: DrawInputField re-measured the cursor prefix via // Font::GetLineWidth every frame of a focused field even though only the // blink changes frame-to-frame. The prefix WIDTH is now memoised on the // InputField keyed on (prefix bytes, fontSize); the absolute caretX is // deliberately not cached so a relocated field can't get a stale caret. // The memo is transparent presentation logic over a TrueType file — // DrawText no-ops with a null atlas/renderer — so this test drives // DrawInputField directly with no Vulkan device or window. The font is // copied next to the binary; the test also probes the project root. Test caretTest = LibraryTest("InputFieldCaretCache"); caretTest.config.files = { fs::path("tests/InputFieldCaretCache/font.ttf") }; cfg.tests.push_back(std::move(caretTest)); // Issue #69: the engine feeds one shared Device::pipelineCache to every // vkCreate*Pipelines call and persists it across runs, discarding an // on-disk blob whose header doesn't match the current GPU. That gate — // Device::PipelineCacheDataCompatible — is pure logic over the standard // VkPipelineCache header and Device::deviceProperties, so this test // stamps synthetic device identities + headers and drives it directly, // no GPU device needed at runtime. cfg.tests.push_back(LibraryTest("PipelineCacheValidation")); } return cfg; }