Crafter.Graphics/project.cpp
Jorijn van der Graaf c62109caf3
fix(win32): restore mouse moves, add enter and leave, capture drags, and close and destroy windows cleanly
Win32
- WM_MOUSEMOVE is handled again: c9ebd44 put the WM_CHAR case where it
  was, so currentMousePos stopped following the mouse and onMouseMove
  never fired. Positions are read signed (GET_X_LPARAM), since under
  capture they run past the client area.
- onMouseEnter and onMouseLeave. The first move over the client area
  after a leave is the enter, and arms the TME_LEAVE whose WM_MOUSELEAVE
  is the leave. An enter counts only once that is armed, a leave only
  after an enter, and a move past the client area is a move only.
- A press captures the mouse, so a drag that leaves the window still
  sends its moves and its release, as Wayland's implicit grab does.
  Losing the capture releases what is held, and a button-up without its
  press fires nothing.
- WM_CLOSE fires onClose and clears open, as xdg_toplevel.close does,
  instead of destroying the HWND under the swapchain. WM_DESTROY no
  longer posts WM_QUIT, which nothing read.
- ~Window detaches the HWND and destroys it. It stayed on screen, and its
  messages went to the freed Window. WndProc hands messages without a
  Window to DefWindowProc, and looks the Window up again after an event
  that more work follows, in case a listener destroyed it.
- The constructor's message pump leaves input queued for StartSync: the
  WM_MOUSEMOVE sent when a window appears under the cursor made an
  onMouseEnter nobody could hear yet. The mouse state is reset before
  the platform code, no longer after it.
- A window after the first finds its class registered, which showed an
  error box.
- The wheel remainder is the window's, no longer a function static that
  every window shared, and a leave drops it.

Window
- PointerEnter, PointerMove, PointerLeave and PointerScroll hold the rules
  both native backends report the pointer by. An enter puts the entry
  position in currentMousePos before onMouseEnter fires, and is followed
  by a move, on Wayland too now. scrollRemainder replaces
  Device::scrollDetentRemainder, and mouseInside is shared.

Tests
- PointerEnterLeave: enter, move and leave through the wl_pointer
  callbacks.
- PointerWin32: posts a real HWND the messages above and pumps them as
  StartSync does. Built for x86_64-w64-mingw32 it runs under Wine, and is
  skipped where Wine is not on PATH.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:29:50 +01:00

725 lines
43 KiB
C++

//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<const std::string_view> args) {
std::vector<std::string> 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<Configuration> 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 <libevdev/libevdev.h>.
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<fs::path, 42> 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<fs::path, 17> 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<fs::path, 14> 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: <name>.comp.spv
// indexes VK_EXT_descriptor_heap, <name>.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/<name>/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<fs::path> 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));
// The Win32 backend's pointer, capture, close and teardown handling,
// through a real HWND: posts it the messages Windows would and checks
// what the Window made of them. The Window needs Vulkan for its
// swapchain. Built on Linux it runs under Wine, which may be absent.
if (windows) {
Test win32PointerTest = LibraryTest("PointerWin32");
#ifndef _WIN32
win32PointerTest.requires_ = { "tool:wine" };
#endif
cfg.tests.push_back(std::move(win32PointerTest));
}
// 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"));
// The pointer's enter, move and leave through the helpers both
// native backends report with (Window::PointerEnter and co.).
// Drives the wl_pointer callbacks directly, as MouseScroll does.
cfg.tests.push_back(LibraryTest("PointerEnterLeave"));
// 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. The
// acceleration structure follows the same high-water mark: a rebuild
// that fits its storage builds into the same handle. Needs a Vulkan
// RT device at runtime, so it shares the native build settings.
cfg.tests.push_back(LibraryTest("TLASHighWaterMark"));
// The barriers BuildTLAS records around the build: the one before it
// orders earlier builds, traces and compute ray queries before the
// build overwrites the TLAS, and the one after it carries the
// acceleration-structure memory dependency to the shaders that read
// the TLAS, not just execution order. BuildTlasWriteBarrier and
// BuildTlasReadBarrier are pure CPU logic, so no GPU device is needed
// at runtime.
cfg.tests.push_back(LibraryTest("TLASBuildBarrier"));
// A TlasWithBuffer queues its acceleration structure for deferred
// deletion, ahead of its storage, when it is destroyed or assigned
// over, and moving one hands the acceleration structure over.
// Headless; needs a Vulkan RT device at runtime, like MeshLifetime.
cfg.tests.push_back(LibraryTest("TLASLifetime"));
// 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;
}