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

@ -6,6 +6,46 @@ 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());
@ -78,6 +118,10 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
// 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", ""});
@ -145,7 +189,36 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
vkUtility.source.url = "https://github.com/KhronosGroup/Vulkan-Utility-Libraries.git";
vkUtility.builder = ExternalBuilder::None;
vkUtility.includeDirs = { "include" };
cfg.linkFlags.push_back(windows ? "-lvulkan-1" : "-lvulkan");
if (!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", ""});
@ -347,6 +420,19 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
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