Cross-compiled mingw artifact: full DLL+launcher pattern + MSVC target
Some checks failed
CI / build-test-release (pull_request) Has been cancelled

Linux→mingw cross-compile now produces the same architectural shape as
build.cmd (DLL + import lib + launcher exe) instead of a single static
binary. The CI Windows artifact becomes a first-class drop-in: a user
on Windows can run crafter-build.exe against any project.cpp and have
it produce real Windows binaries — for either mingw or MSVC ABI.

What changed:

project.cpp: when target=mingw or target=msvc, crafter.build-lib is
built as LibraryDynamic instead of LibraryStatic so the link emits a
DLL + import lib (matching what build.cmd produces natively).

Crafter.Build-Clang.cpp Build():
- LibraryDynamic now branches per target — mingw emits <name>.dll +
  lib<name>.dll.a via lld --out-implib; msvc emits <name>.dll +
  <name>.lib via /IMPLIB; unix unchanged.
- expectedOutputFor returns .dll for Windows-target dynamic libs.
- Executable on Windows host now branches per target: mingw target
  uses simple link (no -lc++/-nostdlib++/LIBCXX_DIR), msvc target keeps
  the existing path. Both auto-copy LibraryDynamic dep DLLs + import
  libs alongside the launcher exe (Windows resolves DLLs from the exe's
  own directory at load time).
- Mingw-target Executables get -D CRAFTER_BUILD_DLL_IMPORT so
  CRAFTER_API resolves to dllimport in their PCMs.
- mingw link adds -static-libstdc++ -static-libgcc -Wl,-Bstatic
  -lpthread so produced .exe/.dll don't depend on a particular
  libstdc++-6.dll / libwinpthread-1.dll being on the consumer's PATH
  (avoids the Arch UCRT vs msys2 UCRT vs msys2 MSVCRT ABI rabbit hole).
  Drops the old auto-copy of /usr/x86_64-w64-mingw32/bin/*.dll which
  is now dead weight.
- -r flag resolves to an absolute path before std::system, otherwise
  cmd.exe rejects "./bin/..." with "'.' is not recognized...".

Crafter.Build-Platform.cpp:
- Split the Windows-host block into shared shell helpers (#if MSVC ||
  MINGW) plus separate #if MSVC and #if MINGW blocks for LoadProject /
  EnsureCrafterBuildPcms / GetBaseCommand / BuildStdPcm.
- Mingw-host LoadProject compiles project.cpp with --target=mingw,
  --sysroot=C:\msys64\ucrt64 (default; override with CRAFTER_MINGW_DIR),
  -femulated-tls, -Wl,--export-all-symbols (mingw-lld doesn't accept
  /EXPORT:NAME), and links against libcrafter-build.dll.a from the
  launcher's directory.
- Mingw-host GetBaseCommand and BuildStdPcm dispatch on config.target
  so a mingw-host crafter-build can also build msvc-target outputs
  (uses LIBCXX_DIR + libc++ headers, same as native build.cmd) when
  the user sets cfg.target = "x86_64-pc-windows-msvc".

README adds a Quick start (Windows) section covering both build paths
(native MSVC via build.cmd and the cross-compiled mingw artifact),
documenting the msys2 UCRT toolchain prerequisite.

Verified end-to-end on the winvm:
- mingw target: cross-compiled crafter-build.exe builds hello-world's
  project.cpp, compiles main.cpp, links a hello.exe that runs without
  any custom PATH (only Windows system DLLs needed).
- msvc target: same crafter-build.exe builds an MSVC-ABI hello.exe
  linked against c++.dll (auto-copied from LIBCXX_DIR), runs cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-04-29 02:23:42 +02:00
commit bed4a7c9e4
4 changed files with 363 additions and 37 deletions

View file

@ -393,6 +393,14 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
#endif
} else if(config.type == ConfigurationType::Executable) {
command += " -D CRAFTER_BUILD_CONFIGURATION_TYPE_EXECUTABLE";
// On Windows targets the API uses __declspec(dllimport) when consuming
// a DLL. Set the macro for executables so CRAFTER_API resolves to
// dllimport in their PCM cache (separate from the lib's PCM cache,
// which gets dllexport). Harmless if the exe doesn't actually link a
// crafter DLL — CRAFTER_API only matters at API call sites.
if (config.target == "x86_64-w64-mingw32" || config.target == "x86_64-pc-windows-msvc") {
command += " -D CRAFTER_BUILD_DLL_IMPORT";
}
} else {
command += " -D CRAFTER_BUILD_CONFIGURATION_TYPE_LIBRARY";
}
@ -628,13 +636,18 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
// the resulting binary stands alone. Auto-link so user projects don't
// carry boilerplate.
if (config.target == "x86_64-w64-mingw32") {
// mingw runtime DLLs (libstdc++-6.dll, libgcc_s_seh-1.dll,
// libwinpthread-1.dll) get auto-copied next to the .exe by the
// post-build step below, so dynamic linkage is fine. -lstdc++exp =
// C++26 std::print/format extras; -lpthread = winpthreads symbols
// that libstdc++ uses for std::atomic_wait, counting_semaphore,
// stop_token. clang++ adds the main -lstdc++ implicitly.
linkExtras += " -lstdc++exp -lpthread";
// Static-link libstdc++/libgcc/libwinpthread so the resulting .exe
// or .dll doesn't depend on a specific runtime DLL being on the
// consumer's PATH. The mingw runtime ABI varies subtly between
// distributions (Arch UCRT vs msys2 UCRT vs msys2 MSVCRT) and
// STATUS_ENTRYPOINT_NOT_FOUND at LoadLibrary time is the symptom
// of a mismatch. Static linkage trades binary size for portability.
// The -Bstatic/-Bdynamic bracketing forces -lpthread to resolve
// against libwinpthread.a rather than the import lib; everything
// else (KERNEL32, UCRT) stays dynamic. -lstdc++exp adds C++26
// std::print/format extras. -femulated-tls is already on the
// compile so __once_callable et al resolve in static libstdc++.
linkExtras += " -lstdc++exp -static-libstdc++ -static-libgcc -Wl,-Bstatic -lpthread -Wl,-Bdynamic";
}
// Force a relink if the expected output is missing or older than any dep
@ -657,6 +670,11 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
? dir / std::format("{}.lib", c.outputName)
: dir / std::format("lib{}.a", c.outputName);
}
// LibraryDynamic — point at the .dll on Windows targets so the
// mtime check sees the newly-built DLL; on Unix the .so suffices.
if (c.target == "x86_64-w64-mingw32" || c.target == "x86_64-pc-windows-msvc") {
return dir / std::format("{}.dll", c.outputName);
}
return dir / std::format("lib{}.so", c.outputName);
};
@ -680,20 +698,33 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
#ifdef CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_linux_gnu
if(config.target == "x86_64-w64-mingw32") {
try {
// Iterate over the source directory
for (const auto& entry : fs::directory_iterator("/usr/x86_64-w64-mingw32/bin/")) {
// Check if the file is a regular file and ends with ".dll"
if (fs::is_regular_file(entry) && entry.path().extension() == ".dll") {
// Construct the destination file path
fs::path dest_file = outputDir / entry.path().filename();
// Check if the destination file exists and if it is older than the source file
if (!fs::exists(dest_file) || fs::last_write_time(entry.path()) > fs::last_write_time(dest_file)) {
// Copy the file if it doesn't exist or is older
fs::copy(entry.path(), dest_file, fs::copy_options::overwrite_existing);
// Copy any LibraryDynamic dependency DLLs alongside
// the launcher exe — Windows resolves DLLs from the exe's
// own directory at load time, so this is the simplest
// equivalent of rpath $ORIGIN.
std::unordered_set<Configuration*> dllSeen;
std::function<void(Configuration*)> copyDepDlls = [&](Configuration* dep) {
if (!dllSeen.insert(dep).second) return;
if (dep->type == ConfigurationType::LibraryDynamic && dep->target == "x86_64-w64-mingw32") {
fs::path depDir = dep->path / "bin" / std::format("{}-{}-{}", dep->name, dep->target, dep->march);
// The DLL itself (Windows resolves it from the
// exe's directory at load time) and the mingw
// import lib (so a downstream `crafter-build.exe`
// can link a fresh project.dll against it without
// hunting through sibling output dirs).
for (auto fname : {std::format("{}.dll", dep->outputName),
std::format("lib{}.dll.a", dep->outputName)}) {
fs::path src = depDir / fname;
if (!fs::exists(src)) continue;
fs::path dest = outputDir / src.filename();
if (!fs::exists(dest) || fs::last_write_time(src) > fs::last_write_time(dest)) {
fs::copy(src, dest, fs::copy_options::overwrite_existing);
}
}
}
}
for (Configuration* sub : dep->dependencies) copyDepDlls(sub);
};
for (Configuration* dep : config.dependencies) copyDepDlls(dep);
} catch (const fs::filesystem_error& e) {
return {e.what(), false, {}};
}
@ -706,8 +737,39 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
#endif
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
std::system(std::format("copy \"%LIBCXX_DIR%\\lib\\c++.dll\" \"{}\\c++.dll\"", outputDir.string()).c_str());
buildResult.result = RunCommand(std::format("{}{} -o {}.exe -fuse-ld=lld -L %LIBCXX_DIR%\\lib -lc++ -nostdinc++ -nostdlib++{}", command, files, (outputDir/config.outputName).string(), linkExtras));
if (config.target == "x86_64-w64-mingw32") {
// Windows host, mingw target: same shape as the Linux→mingw
// path (no LIBCXX_DIR / -lc++ / -nostdlib++ — those are MSVC
// libc++ flags). Copy LibraryDynamic dep DLLs + import libs
// alongside the launcher exe so Windows resolves them from
// the exe's own directory at load time. Runtime DLLs (libstdc++,
// libgcc, libwinpthread) come from msys2 on PATH.
std::unordered_set<Configuration*> dllSeen;
std::function<void(Configuration*)> copyDepDlls = [&](Configuration* dep) {
if (!dllSeen.insert(dep).second) return;
if (dep->type == ConfigurationType::LibraryDynamic && dep->target == "x86_64-w64-mingw32") {
fs::path depDir = dep->path / "bin" / std::format("{}-{}-{}", dep->name, dep->target, dep->march);
for (auto fname : {std::format("{}.dll", dep->outputName),
std::format("lib{}.dll.a", dep->outputName)}) {
fs::path src = depDir / fname;
if (!fs::exists(src)) continue;
fs::path dest = outputDir / src.filename();
if (!fs::exists(dest) || fs::last_write_time(src) > fs::last_write_time(dest)) {
fs::copy(src, dest, fs::copy_options::overwrite_existing);
}
}
}
for (Configuration* sub : dep->dependencies) copyDepDlls(sub);
};
for (Configuration* dep : config.dependencies) copyDepDlls(dep);
buildResult.result = RunCommand(std::format(
"{}{} -o {} -fuse-ld=lld{}",
command, files, (outputDir/config.outputName).string(), linkExtras));
} else {
std::system(std::format("copy \"%LIBCXX_DIR%\\lib\\c++.dll\" \"{}\\c++.dll\"", outputDir.string()).c_str());
buildResult.result = RunCommand(std::format("{}{} -o {}.exe -fuse-ld=lld -L %LIBCXX_DIR%\\lib -lc++ -nostdinc++ -nostdlib++{}", command, files, (outputDir/config.outputName).string(), linkExtras));
}
#endif
} else if(config.type == ConfigurationType::LibraryStatic) {
#ifdef CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_linux_gnu
@ -718,7 +780,28 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
buildResult.result = RunCommand(std::format("llvm-lib.exe {} /OUT:{}.lib", files, (outputDir/fs::path(config.outputName)).string()));
#endif
} else {
buildResult.result = RunCommand(std::format("{}{} -shared -o {}.so -Wl,-rpath,'$ORIGIN' -fuse-ld=lld{}", command, files, (outputDir/(std::string("lib")+config.outputName)).string(), linkExtras));
// LibraryDynamic. Output names follow each target's convention so
// consumers can link via the standard linker search:
// mingw: <name>.dll + lib<name>.dll.a (lld --out-implib)
// msvc: <name>.dll + <name>.lib (lld /IMPLIB)
// unix: lib<name>.so (rpath $ORIGIN)
if (config.target == "x86_64-w64-mingw32") {
fs::path dll = outputDir / std::format("{}.dll", config.outputName);
fs::path implib = outputDir / std::format("lib{}.dll.a", config.outputName);
buildResult.result = RunCommand(std::format(
"{}{} -shared -o {} -Wl,--out-implib,{} -fuse-ld=lld{}",
command, files, dll.string(), implib.string(), linkExtras));
} else if (config.target == "x86_64-pc-windows-msvc") {
fs::path dll = outputDir / std::format("{}.dll", config.outputName);
fs::path implib = outputDir / std::format("{}.lib", config.outputName);
buildResult.result = RunCommand(std::format(
"{}{} -shared -o {} -Wl,/IMPLIB:{} -fuse-ld=lld{}",
command, files, dll.string(), implib.string(), linkExtras));
} else {
buildResult.result = RunCommand(std::format(
"{}{} -shared -o {}.so -Wl,-rpath,'$ORIGIN' -fuse-ld=lld{}",
command, files, (outputDir/(std::string("lib")+config.outputName)).string(), linkExtras));
}
}
}
@ -832,6 +915,11 @@ int Crafter::Run(int argc, char** argv) {
} else if (config.target == "x86_64-w64-mingw32" || config.target == "x86_64-pc-windows-msvc") {
artifact += ".exe";
}
// Resolve to absolute — cmd.exe on Windows mishandles a leading
// "./" by trying to interpret it as a command. system() invokes
// through cmd /c, so the relative-prefixed path makes cmd error
// with "'.' is not recognized as an internal or external command".
artifact = fs::absolute(artifact);
return std::system(artifact.string().c_str()) == 0 ? 0 : 1;
}