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>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 20:27:09 +01:00
commit 7555504644
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
6 changed files with 152 additions and 15 deletions

View file

@ -29,16 +29,16 @@ Partitions, by what they are for. Every partition file exists on every target; o
## Dev environment
crafter-build is the only build tool: the Flatpak (`alias crafter-build='flatpak run net.catcrafts.CrafterBuild'`; a locally built one runs with `--branch=local`) or a native build of the sibling checkout. `crafter-build --version` prints the compiler it resolved. Loading `project.cpp` clones Crafter.Event, Crafter.Math and Crafter.Asset into the crafter-build cache and, since they are unpinned, `git pull`s them on every invocation, and clones the two Khronos header repositories, so a build needs the network. `--local` takes the sibling checkouts (`../Crafter.Event`, `../Crafter.Math`, `../Crafter.Asset`) instead and hands the flag on, so Crafter.Asset picks the sibling Crafter.Math too; two copies of one module on the path do not link.
crafter-build, 3.0.0 or later (`project.cpp` uses its `ShellQuote`), is the only build tool: the Flatpak (`alias crafter-build='flatpak run net.catcrafts.CrafterBuild'`; a locally built one runs with `--branch=local`) or a native build of the sibling checkout. `crafter-build --version` prints the compiler it resolved. Loading `project.cpp` clones Crafter.Event, Crafter.Math and Crafter.Asset into the crafter-build cache and, since they are unpinned, `git pull`s them on every invocation, and clones the two Khronos header repositories, and for a Win32 target Vulkan-Loader as well, so a build needs the network. `--local` takes the sibling checkouts (`../Crafter.Event`, `../Crafter.Math`, `../Crafter.Asset`) instead and hands the flag on, so Crafter.Asset picks the sibling Crafter.Math too; two copies of one module on the path do not link.
The host is Linux on Wayland. The native Linux backend is Wayland only (no X11), so every test that calls `Device::Initialize` needs `WAYLAND_DISPLAY`: it connects to the display right after creating the Vulkan instance, window or no window. The build links `wayland-client`, `xkbcommon`, `udev` and the Vulkan loader from the host or the sysroot; libevdev is vendored and built as its own static library, `Crafter.Graphics-libevdev`, once per build. A Vulkan 1.3 driver is the floor; `VK_EXT_descriptor_heap` (with `shaderUntypedPointers`), ray tracing and `swapchain_maintenance1` (with the instance's `surface_maintenance1`, for per-present-mode image counts) are optional groups detected at start-up. Recent Mesa's llvmpipe advertises the heap, so a machine without a GPU runs the UI path. The Khronos validation layer is optional and off by default.
The host is Linux on Wayland. The native Linux backend is Wayland only (no X11), so every test that calls `Device::Initialize` needs `WAYLAND_DISPLAY`: it connects to the display right after creating the Vulkan instance, window or no window. The build links `wayland-client`, `xkbcommon`, `udev` and the Vulkan loader from the host or the sysroot; for a Win32 target it links the loader through an import library that `project.cpp` makes with `llvm-dlltool` from Vulkan-Loader's `loader/vulkan-1.def`, into `build/vulkan-1-<target>/`, since the MinGW toolchains ship none. libevdev is vendored and built as its own static library, `Crafter.Graphics-libevdev`, once per build. A Vulkan 1.3 driver is the floor; `VK_EXT_descriptor_heap` (with `shaderUntypedPointers`), ray tracing and `swapchain_maintenance1` (with the instance's `surface_maintenance1`, for per-present-mode image counts) are optional groups detected at start-up. Recent Mesa's llvmpipe advertises the heap, so a machine without a GPU runs the UI path. The Khronos validation layer is optional and off by default.
Targets, picked with `--target=` and passed down to the dependencies:
| Target | Backend | Notes |
|---|---|---|
| native (`x86_64-unknown-linux-gnu`) | Wayland + Vulkan | the development loop |
| `x86_64-w64-mingw32` | Win32 + Vulkan | cross-compiled; links `vulkan-1`, `kernel32`, `user32`, `gdi32`, `runtimeobject`; gamepads through Windows.Gaming.Input |
| `x86_64-w64-mingw32` | Win32 + Vulkan | cross-compiled; links `vulkan-1` (an import library made from Vulkan-Loader's `loader/vulkan-1.def`), `kernel32`, `user32`, `gdi32`, `runtimeobject`, `uuid`; gamepads through Windows.Gaming.Input |
| `wasm32-wasip1` | DOM + WebGPU | no Vulkan loader, no Wayland; `dom-env.js` and `dom-webgpu.js` ship beside the `.wasm`; `--no-webgpu` drops the WebGPU bridge for DOM-only apps |
| `aarch64-alpine-linux-musl` with `--sysroot=`, `--march=armv8-a --mtune=generic` | Wayland + Vulkan | the Mesa Turnip phone target: no descriptor heap, no ray tracing, the descriptor-set path |
@ -53,6 +53,7 @@ crafter-build # the native library and its
crafter-build --local # against ../Crafter.Event, ../Crafter.Math, ../Crafter.Asset
crafter-build --target=wasm32-wasip1 # the DOM/WebGPU library
crafter-build --target=x86_64-w64-mingw32 # the Win32 library
crafter-build test --target=x86_64-w64-mingw32 --runner=cmd:true Win32Link # links a Win32 executable, without Wine
crafter-build test # build and run every test for the host
crafter-build test 'TLAS*' # name glob
crafter-build test --list
@ -62,11 +63,11 @@ cd examples/HelloUI && crafter-build -r # build an example against t
crafter-build clean
```
`project.cpp` registers no lint rules, so `crafter-build lint` and `format` have nothing to do here; style is kept by hand (see "Code style"). There is no CI. Before a change is done: `crafter-build test` passes on the host with `CRAFTER_GRAPHICS_VALIDATION` set; the examples whose API you touched build (`cd examples/<Name> && crafter-build`), since they are the in-tree consumers; `crafter-build --target=wasm32-wasip1` still compiles when you touched a partition with a DOM half or a backend-agnostic one; and `crafter-build --target=x86_64-w64-mingw32` still compiles when you touched `Window`, `Device`, `Gamepad` or `Clipboard`.
`project.cpp` registers no lint rules, so `crafter-build lint` and `format` have nothing to do here; style is kept by hand (see "Code style"). There is no CI. Before a change is done: `crafter-build test` passes on the host with `CRAFTER_GRAPHICS_VALIDATION` set; the examples whose API you touched build (`cd examples/<Name> && crafter-build`), since they are the in-tree consumers; `crafter-build --target=wasm32-wasip1` still compiles when you touched a partition with a DOM half or a backend-agnostic one; and `crafter-build test --target=x86_64-w64-mingw32 --runner=cmd:true Win32Link` still links when you touched `Window`, `Device`, `Gamepad`, `Clipboard` or the link flags in `project.cpp`.
## Testing instructions
Tests are declared in `project.cpp`, not discovered, through the `LibraryTest(name)` lambda: it copies the library's settings field by field (`Configuration` is not copyable) and compiles the library's sources plus `tests/<name>/main.cpp` into one executable, so a test reaches non-exported internals the way the library does. A new test is a directory, a `cfg.tests.push_back(LibraryTest("Name"));` and a comment above it saying what it covers and what it needs (a GPU, ray tracing, a compositor, a tool). A test that needs shaders adds them to `test.config.shaders` with the same `ShaderType` lines the examples use; a font or asset goes in `test.config.files`; a tool it runs goes in `requires_` (`{"tool:glslang", "tool:spirv-val"}`), which turns the test into a skip when the tool is missing. The declarations sit in the native branch of `project.cpp`: wasm runs no tests. Wayland-only tests sit in `if (!windows)`; the Win32 tests, `PointerWin32` and `TextWin32`, sit in `if (windows)`, with `requires_ = {"tool:wine"}` when built on Linux.
Tests are declared in `project.cpp`, not discovered, through the `LibraryTest(name)` lambda: it copies the library's settings field by field (`Configuration` is not copyable) and compiles the library's sources plus `tests/<name>/main.cpp` into one executable, so a test reaches non-exported internals the way the library does. A new test is a directory, a `cfg.tests.push_back(LibraryTest("Name"));` and a comment above it saying what it covers and what it needs (a GPU, ray tracing, a compositor, a tool). A test that needs shaders adds them to `test.config.shaders` with the same `ShaderType` lines the examples use; a font or asset goes in `test.config.files`; a tool it runs goes in `requires_` (`{"tool:glslang", "tool:spirv-val"}`), which turns the test into a skip when the tool is missing. The declarations sit in the native branch of `project.cpp`: wasm runs no tests. Wayland-only tests sit in `if (!windows)`; the Win32 tests, `PointerWin32` and `TextWin32`, sit in `if (windows)`, with `requires_ = {"tool:wine"}` when built on Linux. `Win32Link` beside them has no `requires_`, since its link is the test: where there is no Wine it runs as `--runner=cmd:true`, which builds it and runs nothing. A `requires_` would skip the build as well, and a test cannot pick its own runner, as the harness derives every test's from its target.
A test is an `int main()`: exit 0 passes, nonzero fails, 77 skips (for a missing capability, never for a failed check). Each runs in its own process, in parallel, with a 60 s default timeout, and the output of a failed, crashed or timed-out test is in `build/test-logs/<name>.log`; a later pass does not delete the log, so check its timestamp. What the tests need, in three groups:
@ -85,7 +86,7 @@ Conventions the existing tests follow:
A bug fix ships with a test that fails before the fix and passes after it. A change to a UI kernel keeps `UIFallbackParity` green: the heap and descriptor-set builds of one source must stay pixel-identical. A category drawn fused must also match the same category drawn standalone. `TextAntialiasing` compares the two for text, and keeps the text's edge ramp at one screen pixel; for quads, circles and images no test does (`UIFusedShader` only pins the push-constant layout): check those by eye, or add the comparison.
Cross targets: `crafter-build test --target=x86_64-w64-mingw32` needs `wine` on the sandbox's `PATH`, which the Flatpak does not have; a MinGW import library for `vulkan-1`, which the toolchain does not ship; `-luuid`, which the Win32 link flags lack (#184); and a definition of `__libc_single_threaded`, which the MinGW build of Crafter.Asset references. The Win32 tests have been run with an import library made by `llvm-dlltool` from the `vk*` symbols `llvm-nm -u` lists for the MinGW `libCrafter.Graphics.a`, and an archive defining `char __libc_single_threaded = 0;`, both under the gitignored `build/`. A temporary edit of `project.cpp` added `-L` to that directory, both libraries and `-luuid` to the Win32 tests' link flags and cleared their `requires_`; the tests were built with `--runner=cmd:true`, and each exe ran from its bin directory under the host's wine in a scratch `WINEPREFIX`. For an aarch64 device, `crafter-build test --target=...` tries qemu, which has no Vulkan: build with the sysroot, copy the test's whole bin directory (binary, `*.spv`, `*.sets.spv`, fonts) to the device and run it there, unlocked and with the window visible, since a locked phone's compositor sends no frame callbacks and the FIFO present then blocks forever.
Cross targets: the Win32 tests run under Wine from the native crafter-build on the host, which has `wine` on its `PATH` and finds the host's mingw-w64 itself: `WINEPREFIX=<scratch prefix> crafter-build test --target=x86_64-w64-mingw32 PointerWin32 TextWin32 Win32Link`. The Flatpak has no `wine`, so there `PointerWin32` and `TextWin32` skip and only `Win32Link`'s link is checked. Under the Flatpak a Win32 executable also links only with `tests/Win32Link/libc-single-threaded.c`. The MinGW sysroot the Flatpak picks is the host's `/usr`, which puts glibc's headers on the MinGW include path, and libstdc++'s `shared_ptr`, inlined from nlohmann/json in Crafter.Asset's GLB loader, then reads glibc's `__libc_single_threaded` (Crafter.Build #43). For an aarch64 device, `crafter-build test --target=...` tries qemu, which has no Vulkan: build with the sysroot, copy the test's whole bin directory (binary, `*.spv`, `*.sets.spv`, fonts) to the device and run it there, unlocked and with the window visible, since a locked phone's compositor sends no frame callbacks and the FIFO present then blocks forever.
## Code style
@ -127,7 +128,7 @@ These were decided deliberately. Do not undo them without the maintainer's say-s
## Security considerations
- `project.cpp` is compiled and run by crafter-build; it clones and pulls three sibling repositories from their default branch and two Khronos repositories from GitHub on every invocation, and the Sponza example `GitFetch`es a 280 MB asset repository (pinned to a commit). Pin a `commit` in a `GitProject` spec when that matters.
- `project.cpp` is compiled and run by crafter-build; it clones and pulls three sibling repositories from their default branch and two Khronos repositories from GitHub on every invocation, for a Win32 target also clones and pulls Vulkan-Loader, unpinned, and runs `llvm-dlltool` on its `loader/vulkan-1.def` when the import library under `build/` was made from another `.def` or command, and the Sponza example `GitFetch`es a 280 MB asset repository (pinned to a commit). Pin a `commit` in a `GitProject` spec when that matters.
- The validation layer, GPU-assisted validation included, is a developer tool and off by default; `CRAFTER_GRAPHICS_VALIDATION` turns it on. GPU-AV has crashed a vendor SPIR-V compiler on the heap pipelines before, which is why it is opt-in.
- Shaders are read from disk relative to the working directory and checked for the SPIR-V magic and word alignment before they reach the driver; the NVIDIA rewrite edits SPIR-V in memory and `PushConstantRewrite` validates its output with spirv-val.
- `pipeline_cache.bin` is written to the working directory at exit and read back only when its header matches the device; it is gitignored here and should be in a consumer.