Crafter.Graphics/project.cpp
Jorijn van der Graaf 3f646ec42b
fix(wayland): the first key repeat lands at the delay, not as a burst of the periods since the press
keyboard_key set lastFireTime to the press and TickKeyRepeats caught up on
every period since it, so the first tick past the compositor's delay fired
floor(delay / period) repeats at once: with rate 25 and delay 600 a held
Backspace deleted sixteen characters at the delay mark and a held letter
put fifteen copies in the field, from a hardware keyboard and from an
on-screen keyboard that sends Backspace as key events. A rate above 1000
made the period zero and the loop endless.

The first repeat now lands at the delay, as wl_keyboard.repeat_info
promises (one after `delay`, then `rate` a second): the first tick past
the delay sets lastFireTime to pressTime + delay - period. The period is
at least a millisecond, and the catch-up after a stalled frame is capped
(kKeyRepeatCatchUp, three) with the rest of the stall dropped, so a held
key is never a burst of edits. TickKeyRepeats takes the clock as a
parameter for the tests; the frame path's call is unchanged.

tests/KeyRepeat drives TickKeyRepeats with an injected clock on a
default-constructed Window: nothing before the delay, one at it, one per
period after, a capped catch-up, a key without text, a rate above 1000, a
released key, rate 0.
2026-11-01 00:53:56 +01:00

942 lines
56 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;
// The whole of `path`, or nullopt when it can't be read.
static std::optional<std::string> ReadFile(const fs::path& path) {
std::ifstream file(path, std::ios::binary);
if (!file) return std::nullopt;
return std::string(std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>());
}
// Makes `lib`, the x86-64 import library for the DLL whose exports `def`
// lists, with the llvm-dlltool beside the toolchain's llvm-ar. A stamp beside
// `lib` holds the command and the .def it was made from, and while both match
// it is not made again: a fetch or a copy can leave a .def whose mtime says
// nothing of what changed in it. Empty on success, otherwise what went wrong.
static std::string MakeImportLibrary(const fs::path& def, const fs::path& lib) {
const std::optional<std::string> exports = ReadFile(def);
if (!exports) return std::format("{} could not be read", def.string());
// llvm-ar is a bare name on PATH, or <prefix>/bin/llvm-ar.
const fs::path dlltool = GetToolchain().llvmAr.parent_path() / "llvm-dlltool";
const std::string command = std::format("{} -m i386:x86-64 -d {} -l {}",
ShellQuote(dlltool.string()), ShellQuote(def.string()), ShellQuote(lib.string()));
const std::string stamp = command + '\n' + *exports;
const fs::path stampFile = fs::path(lib) += ".stamp";
std::error_code ec;
if (fs::exists(lib, ec) && ReadFile(stampFile) == stamp) return {};
fs::create_directories(lib.parent_path(), ec);
// llvm-dlltool writes the archive beside `lib` and renames it into place,
// so two loads of this project at once leave one whole library, and one
// that fails leaves the previous library as it was.
try {
const auto result = RunCommandWithTimeout(command, std::chrono::seconds(60));
if (result.timedOut) return std::format("{} took more than 60 s", dlltool.string());
if (result.crashed) return std::format("{} died of signal {}", dlltool.string(), result.signal);
if (result.exitCode != 0) return std::format("{} exited with {}: {}", dlltool.string(), result.exitCode, result.output);
} catch (const std::exception& error) {
return std::format("{} could not run: {}", dlltool.string(), error.what());
}
// After the library, so a run cut short leaves no stamp to trust.
std::ofstream(stampFile, std::ios::binary) << stamp;
return {};
}
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");
// The IIDs its event handlers compare against, IID_IUnknown and
// IID_IAgileObject. StartSync's Gamepad::Tick pulls the gamepad code
// into every executable, which without libuuid did not link (#184).
cfg.linkFlags.push_back("-luuid");
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" };
if (!windows) {
cfg.linkFlags.push_back("-lvulkan");
} else {
// The MinGW toolchains ship no import library for the Vulkan
// loader, so one is made from the loader's own list of what
// vulkan-1.dll exports: loader/vulkan-1.def, from Vulkan-Loader
// unpinned like the headers above, so the API the library is
// compiled against and the exports it links against move
// together. No new call outgrows that list, and a call the loader
// does not export fails at link time instead of when the exe
// starts. The library goes into the link by its path, absolute
// because this project.cpp is also loaded from a consumer's
// directory, rather than as -L, so it shadows nothing: a
// consumer's own -lvulkan-1, a Vulkan SDK's, still serves the
// functions this list lacks. Without the fetch or llvm-dlltool,
// the library a previous load made does, or else a Vulkan SDK's.
const fs::path importLibrary = fs::absolute(std::format("build/vulkan-1-{}/libvulkan-1.a", cfg.target));
std::string error;
try {
const fs::path loader = GitFetch({ .url = "https://github.com/KhronosGroup/Vulkan-Loader.git" });
error = MakeImportLibrary(loader / "loader" / "vulkan-1.def", importLibrary);
} catch (const std::exception& fetchError) {
error = fetchError.what();
}
if (!error.empty()) {
Progress::Diagnostic(std::format("warning: Crafter.Graphics: the vulkan-1 import library was not made: {}", error));
}
std::error_code ec;
cfg.linkFlags.push_back(fs::exists(importLibrary, ec) ? importLibrary.string() : std::string("-lvulkan-1"));
}
}
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, 15> impls = {
"implementations/Crafter.Graphics-Clipboard",
"implementations/Crafter.Graphics-ComputeShader",
"implementations/Crafter.Graphics-Device",
"implementations/Crafter.Graphics-Device-Features",
"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));
// The Win32 backend's text, UTF-8 to the library and UTF-16 to
// Windows: the title from the constructor and SetTitle, read back
// from the HWND, and the characters WM_CHAR brings. Needs what
// PointerWin32 needs.
Test win32TextTest = LibraryTest("TextWin32");
#ifndef _WIN32
win32TextTest.requires_ = { "tool:wine" };
#endif
cfg.tests.push_back(std::move(win32TextTest));
// A Win32 executable that uses the library as an application does
// links: the vulkan-1 import library made above and -luuid (#184).
// The Win32 library itself is only archived, and the tests above
// are skipped without Wine, so nothing else links one there. With
// no Wine, run it as --runner=cmd:true, which builds it and runs
// nothing; no requires_, since that would skip the build too.
// Under Wine it also runs, opening a window and closing it, which
// needs a Vulkan device.
cfg.tests.push_back(LibraryTest("Win32Link"));
}
// 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"));
// Key repeat as wl_keyboard.repeat_info promises it: the first
// repeat at the delay (not a burst that counts the delay as missed
// periods), one per period after, a capped catch-up after a stall,
// a period of at least a millisecond. Drives TickKeyRepeats with
// an injected clock; no compositor.
cfg.tests.push_back(LibraryTest("KeyRepeat"));
// 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"));
// SetTitle's xdg_toplevel.set_title, read off a wl_display on a
// socketpair: the view's bytes only, and an empty view as an
// empty title, not a null string. No compositor.
cfg.tests.push_back(LibraryTest("TitleWayland"));
// Issue #40: multi-frame-in-flight frame pacing (per-frame fences
// and acquire semaphores, per-image render-finished 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));
// Issue #55: a glyph ShapeText places may take a cell another
// glyph had, so every kernel UIRenderer dispatches uploads the font
// atlas first, a Tier 1 one through ui.Dispatch included. Shapes
// new glyphs in onBuild and checks the atlas is clean after a
// custom ui.Dispatch. Loads the UI kernels like UIFallbackParity.
// Needs the Wayland backend + a real compositor.
Test atlasFlushTest = LibraryTest("UIDispatchAtlasFlush");
atlasFlushTest.config.shaders = cfg.shaders;
atlasFlushTest.config.buildFiles = cfg.buildFiles;
atlasFlushTest.config.files.emplace_back(fs::path("examples/HelloUI/font.ttf"));
cfg.tests.push_back(std::move(atlasFlushTest));
// Text from the UI kernels ramps over one screen pixel at any
// size and aspect. The kernels decoded FontAtlas's SDF with
// kPixelDistScale where 255 / kPixelDistScale belongs: a
// quarter-pixel ramp that made text look pixelated. Draws a
// string at 14 and 64 px and stretched, a glyph turned by its UVs
// and shrunk empty texels, through DispatchText and DispatchFused,
// and compares the frame with stb_truetype's own antialiased
// rasterizer. Loads the UI kernels and a font like
// UIFallbackParity. Needs the Wayland backend + a real compositor.
Test textAATest = LibraryTest("TextAntialiasing");
textAATest.config.shaders = cfg.shaders;
textAATest.config.buildFiles = cfg.buildFiles;
textAATest.config.files.emplace_back(fs::path("examples/HelloUI/font.ttf"));
cfg.tests.push_back(std::move(textAATest));
// Issue #160: a swapchain has as many images as its present mode
// needs, and the frame loop takes any count. Every offered mode
// is kept (no fallback to FIFO), and every frame presents what it
// drew, read back a texel per frame. Where this process's GPU
// never makes more than numFrames images, the frame checks run
// again in child processes on a GPU that does (llvmpipe), on the
// heap and on descriptor sets. Loads the UI kernels like
// UIFallbackParity. Needs the Wayland backend + a real compositor.
Test imageCountTest = LibraryTest("SwapchainImageCount");
imageCountTest.config.shaders = cfg.shaders;
imageCountTest.config.buildFiles = cfg.buildFiles;
cfg.tests.push_back(std::move(imageCountTest));
// 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"));
// Issue #162: xdg-decoration, wp_viewporter and
// wp_fractional_scale_v1 are optional (GNOME has no
// xdg-decoration). Hides each from the Window in turn, and all
// three, and renders, resizes and closes a window each time.
// Needs the Wayland backend and a real compositor.
cfg.tests.push_back(LibraryTest("WindowOptionalGlobals"));
// 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 #55: FontAtlas::Packer, the atlas's shelves and its evictions
// once full: the fill order, least recently used first, never a glyph
// a frame in flight may sample, nothing without `evict`, a glyph taller
// than every shelf spanning several (and the free rows below), Touch
// past a stale handle, dirty spans over everything it hands out or
// clears, and the invariants over a random workload. Pure CPU, with
// made-up boxes: no GPU device at runtime.
cfg.tests.push_back(LibraryTest("FontAtlasPacker"));
// 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 rasterise
// nothing into 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 #55: a glyph without free room keeps its advance, is not
// rasterised until it gets a cell, and gets one when the next frame
// begins, from the glyphs drawn least recently but never one a frame
// in flight may sample. Fills the atlas through ShapeText frame by
// frame on Device::frameCounter and checks the layout, the frames in
// flight, the order of the evictions, the recovery, the cleared
// texels and InvalidateFont. Needs a headless Vulkan device (the
// atlas is a real GPU image) and the ShapeTextCache font, which covers
// enough codepoints to fill the atlas.
Test evictionTest = LibraryTest("FontAtlasEviction");
evictionTest.config.files = { fs::path("tests/ShapeTextCache/font.ttf") };
cfg.tests.push_back(std::move(evictionTest));
// 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 #164: every GLSL shader under examples/ compiles. Nothing here
// builds the examples, so when glslang began rejecting Sponza's
// descriptor_heap sampler2D nothing noticed. Compiles each one as
// crafter-build would for its example, the descriptor-set build too
// where it has one. No GPU; glslang is a required tool.
Test exampleShadersTest = LibraryTest("ExampleShaders");
exampleShadersTest.requires_ = { "tool:glslang" };
cfg.tests.push_back(std::move(exampleShadersTest));
// The text kernels decode FontAtlas's SDF with copies of its two
// encoding constants, in ui-shared.glsl and in the WGSL of
// dom-webgpu.js, which no test renders. Reads the sources and holds
// the copies to FontAtlas's, and checks that ui-text and ui-fused
// decode through the shared helper. No GPU.
cfg.tests.push_back(LibraryTest("FontAtlasDecode"));
// 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"));
// Issue #163: where DescriptorHeapVulkan::Initialize puts the buffer
// descriptors and the reserved ranges. The buffer region's start was
// rounded down, so on a driver whose image descriptors are smaller
// than its buffer descriptors the buffers overlapped the last images
// or ran into the reserved range, which was itself aligned for image
// descriptors only, and on llvmpipe, which reserves nothing, a heap
// without sampler slots was a zero-size buffer.
// DescriptorHeapVulkan::ComputeLayout is pure arithmetic over the
// descriptor sizes, so this test feeds it every legal combination, no
// GPU device needed at runtime.
cfg.tests.push_back(LibraryTest("DescriptorHeapLayout"));
// 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"));
// Which features Device::Initialize enables, which GPU it picks, and
// which SPIR-V capabilities VulkanShader lets through:
// Device::ResolveFeatures, ChooseDevice and CheckShaderCapabilities are
// pure logic, so this test drives them with synthetic GPUs and
// hand-built SPIR-V, no GPU device needed at runtime.
cfg.tests.push_back(LibraryTest("DeviceFeatures"));
// The same on a real GPU, end to end: required and optional features
// through Device::Initialize, and the Vulkan 1.3 path
// (CRAFTER_GRAPHICS_NO_VULKAN_1_4) in a child process. Needs a Vulkan
// device.
cfg.tests.push_back(LibraryTest("FeatureRequests"));
}
return cfg;
}