Crafter.Graphics/project.cpp
Jorijn van der Graaf 7555504644
fix(win32): link the Vulkan loader without a Vulkan SDK, link libuuid, and check that a Win32 executable links
Build
- The MinGW toolchains ship no import library for vulkan-1.dll, so
  without a Vulkan SDK's every Win32 executable stopped at "unable to
  find library -lvulkan-1". project.cpp now makes one with the
  llvm-dlltool beside the toolchain's llvm-ar, from Vulkan-Loader's
  loader/vulkan-1.def. That is the loader's own list of what
  vulkan-1.dll exports, fetched with GitFetch, unpinned like
  Vulkan-Headers, so the API the library compiles against and the
  exports it links against move together. The import library the Wine
  runs used, made from the calls the library happened to make, went
  stale with the next new call (234fcce added
  vkEnumerateInstanceExtensionProperties). The loader's list does not,
  and a call it does not export fails at link time instead of when the
  exe starts.
- It is made into build/vulkan-1-<target>/ when a stamp beside it,
  holding the command and the .def it was made from, no longer matches;
  a fetch or a copy says nothing through the .def's mtime. It goes into
  the link by its absolute path rather than through -L, so it shadows
  no consumer's Vulkan SDK library. A failed fetch or llvm-dlltool run
  is a Progress::Diagnostic warning, and the link falls back to the
  library a previous load made, or to -lvulkan-1.
- -luuid joins the Win32 link flags: the gamepad code compares against
  IID_IUnknown and IID_IAgileObject, and StartSync's Gamepad::Tick pulls
  it into every executable (#184).
- project.cpp uses ShellQuote, exported from crafter-build 3.0.0, which
  is now the minimum.

Tests
- Win32Link: an executable that uses the library as an application does
  (Device::Initialize, a Window, StartSync) links. Without Wine it runs
  as --runner=cmd:true, which builds it and runs nothing; under Wine it
  also opens a window and closes it. It fails to link without -luuid
  (IID_IUnknown, IID_IAgileObject) and without the import library
  ("unable to find library -lvulkan-1"). Under the Flatpak it needs
  tests/Win32Link/libc-single-threaded.c, which stands in for
  Catcrafts/Crafter.Build#43 and goes with it.
- With the native crafter-build on the host, which has Wine and finds
  the host's mingw-w64 itself, PointerWin32, TextWin32 and Win32Link now
  build and pass under Wine with nothing added.

Docs
- AGENTS.md: the minimum crafter-build, the Vulkan-Loader fetch and what
  project.cpp runs, the Win32Link check in the commands and the
  done-list, and a one-line Wine recipe in place of the hand-built
  import library, shim and project.cpp edit. AGENTS.consumer.md: the
  minimum, how the import library is made, and the
  __libc_single_threaded workaround under the Flatpak. README: the
  minimum, the Win32 build command, and that it needs no Vulkan SDK.

Closes #184.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:27:09 +01:00

938 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. libc-single-threaded.c stands in for
// Crafter.Build #43 and goes with it.
Test win32LinkTest = LibraryTest("Win32Link");
win32LinkTest.config.cFiles.push_back("tests/Win32Link/libc-single-threaded");
cfg.tests.push_back(std::move(win32LinkTest));
}
// 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"));
// 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;
}