The build environment for Wise projects that ship on Windows: a Debian container that produces Windows binaries on a Linux host, with the whole library set, Qt and the toolchain baked in.
  • CMake 34.4%
  • Shell 31.3%
  • Python 18%
  • Dockerfile 16.2%
  • C 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Michael J. Manley cb01f97743
Some checks failed
image / image (push) Has been cancelled
Add wxWidgets, ship the MinGW runtime, add the CI workflow
Three things a build out of this image was missing.

**wxWidgets.** A new overlay port: mainline's, with one change to
vcpkg-cmake-wrapper.cmake, which assumes a Windows target means an MSVC host.
It looked for the import libraries an MSVC build installs (wxbase33u) while the
cross-build installs GNU-style ones, and the base lookup is REQUIRED, so
find_package(wxWidgets) died at configure. The overlay guards that branch with
`AND NOT MINGW` - it also forced CMAKE_CROSSCOMPILING to 0, which is exactly
what FindwxWidgets.cmake reads to pick its mode - sending a cross-build down
the wx-config path, where wx-config is a POSIX shell script that answers with
the target's flags.

wxrc needs more than that: the one it installs is a Windows .exe, and CMake
looks for a host program called `wxrc` for WXWIDGETS_ADD_RESOURCES(). So a
`wxhost` stage builds it for Linux into /opt/wx-host - the same split /opt/qt-host
is there for. It is cheap (wxUSE_GUI=OFF, wxbase and wxxml only, ~20s) and
installs under the plain name, since the build calls it wxrc-3.3 and
FindwxWidgets never matches the versioned one. WX_VERSION must move with the
wxwidgets version in vcpkg.json.

**The compiler runtime.** VCPKG_LIBRARY_LINKAGE governs the vcpkg libraries;
libstdc++-6.dll, libgcc_s_*.dll and libwinpthread-1.dll live in the toolchain,
so nothing deployed them and every binary out of this image imported something
it did not ship. Invisible in the container, where wine resolves them through
WINEPATH, and worst on the static triplets: one .exe that looks self-contained
and still imports two DLLs. cmake/winbuild-deploy.cmake now links the runtime in
on *-mingw-static and copies the imported DLLs beside the binary on
*-mingw-dynamic - not static-linked there on purpose, since a static libstdc++
next to vcpkg DLLs using the shared one puts two C++ runtimes in one process.
Off with -DWINBUILD_AUTO_COPY_RUNTIME=OFF. Worth fixing rather than
documenting: any machine with another MinGW-derived toolchain on PATH has a
libstdc++-6.dll to find, and loading a mismatched one exits silently.

**CI.** .forgejo/workflows/build-image.yml builds on the docker (DinD) runner
label, smoke-tests all four triplets plus Qt, and pushes :latest and a commit
tag, with with_wine/npcap_sdk_url/no_cache as workflow inputs.

The README documents why tools/ stays off PATH - those are Windows .exe files
and would shadow nothing the host can run - and recommends WINEDEBUG=-all with
no WINEPATH, which is what makes running a build here a test rather than a
demonstration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014XwW1XJbzrUwdiE95BfesF
2026-09-12 15:08:53 -07:00
.forgejo/workflows Add wxWidgets, ship the MinGW runtime, add the CI workflow 2026-09-12 15:08:53 -07:00
cmake Add wxWidgets, ship the MinGW runtime, add the CI workflow 2026-09-12 15:08:53 -07:00
examples First Commit 2026-08-30 18:50:29 -07:00
overlay-ports Add wxWidgets, ship the MinGW runtime, add the CI workflow 2026-09-12 15:08:53 -07:00
overlay-triplets Add gdb and the SDL2/OpenAL/wxWidgets/libslirp/libpcap set 2026-09-04 10:55:35 -07:00
scripts Add wxWidgets, ship the MinGW runtime, add the CI workflow 2026-09-12 15:08:53 -07:00
.dockerignore First Commit 2026-08-30 18:50:29 -07:00
build-image.sh Add Node.js LTS, default to wine + registry image name 2026-08-30 23:37:27 -07:00
Dockerfile Add wxWidgets, ship the MinGW runtime, add the CI workflow 2026-09-12 15:08:53 -07:00
README.md Add wxWidgets, ship the MinGW runtime, add the CI workflow 2026-09-12 15:08:53 -07:00
vcpkg.json Add gdb and the SDL2/OpenAL/wxWidgets/libslirp/libpcap set 2026-09-04 10:55:35 -07:00

winbuildenv — Windows cross-build environment on Debian

The build environment for Wise projects that ship on Windows: a Debian container that produces Windows binaries on a Linux host, with the whole library set, Qt and the toolchain baked in.

32-bit 64-bit
target triple i686-w64-mingw32 x86_64-w64-mingw32
C runtime msvcrt UCRT
exceptions DWARF-2 SEH
threads win32 posix
gcc 14.2 14.2
debugger i686-w64-mingw32-gdb x86_64-w64-mingw32-gdb

Every library is built four ways: x64-mingw-dynamic, x64-mingw-static, x86-mingw-dynamic, x86-mingw-static. Qt is the exception: 64-bit dynamic only, built from source as shared libraries so LGPL relinking stays possible.

Libraries

argon2, gtest, liblzma, libmariadb[iconv], libpcap, libpq[lz4,openssl,zlib], libslirp, libsodium, lua, lz4, openal-soft, openssl, pugixml, sdl2, sqlite3[omit-load-extension], wxwidgets, zlib, zxing-cpp.

Versions are pinned in vcpkg.json (openssl 3.6.3, sqlite3 3.53.4, lua 5.5.0, libpq 18.4, wxWidgets 3.3.3, SDL 2.32.10, …) so every project builds against the same set. Qt is not in the manifest — it is built from source, below.

Every library is built for all four triplets. stb also appears in the installed set — it is a header-only build dependency of zxing-cpp's C API, and so do the dependencies the GUI and media half drags in: wxWidgets brings expat, libjpeg-turbo, libpng, libwebp, tiff, nanosvg and pcre2; libslirp brings glib and with it libffi, libiconv and gettext; openal-soft brings fmt.

wxWidgets here is wxMSW, the native Windows backend, built from vcpkg's port unchanged. That is worth saying only because the Linux side is not: there wxWidgets is built as wxQt from an overlay port, to keep GTK and its GNOME dependencies out of the library set — see linuxbuildenv. Nothing about that reaches this image; the two produce the same wxWidgets 3.3.3 API against different toolkits, which is what wxWidgets is for.

libpcap cannot capture without an SDK

This is the one library in the set whose Windows build is not complete out of the box, and it fails quietly, so it is worth stating plainly. Packet capture on Windows goes through a kernel driver — Npcap today, WinPcap before it — and libpcap reaches it through Packet.lib from that project's SDK. Neither SDK is redistributable, so neither is baked into this image, and without one the vcpkg port configures itself with PCAP_TYPE=null: headers, import library and DLL all install, everything links, and pcap_findalldevs finds nothing at runtime.

Supply an SDK and the real thing gets built. Either at image build time:

./build-image.sh --build-arg NPCAP_SDK_URL=https://npcap.com/dist/npcap-sdk-1.15.zip

which unpacks it into /opt/npcap-sdk, or inside a running container:

NPCAP_SDK_ROOT=/mnt/npcap-sdk TRIPLETS=x64-mingw-dynamic build-libs.sh

overlay-triplets/*-mingw-*.cmake turns either into the -DPacket_ROOT=… the port is looking for. A WinPcap developer pack works in the same place — same Include/ + Lib/ layout. Machines running the result still need the Npcap driver installed; the SDK only gets it built.

The Linux side has no such problem: linuxbuildenv builds libpcap against AF_PACKET, which is in the kernel.

Overlay ports

overlay-ports/ carries three ports of our own:

  • zxing-cpp 3.1.1 — not in vcpkg mainline (the port was renamed to nu-book-zxing-cpp and left at 2.3.0). The port fetches the zint commit that zxing-cpp pins as a git submodule (release tarballs omit submodules) and points the build at vcpkg's stb headers, which ship without a .pc file.

  • libmariadb 3.4.8 — mainline port plus stdcall-load-plugin.diff. Upstream declares mysql_load_plugin without STDCALL in mysql.h and client_plugin.h but defines it with STDCALL. On x86_64 STDCALL expands to nothing so nobody notices; on 32-bit Windows it is __stdcall and the build fails with conflicting types. The patch adds it to both declarations, matching the definition and the neighbouring mysql_load_plugin_v.

  • wxwidgets 3.3.3 — mainline port with one change, to vcpkg-cmake-wrapper.cmake. The port compiles fine under MinGW; the wrapper is what does not. It assumes a Windows target means a Windows host with MSVC, so it looks for the import libraries an MSVC build installs — wxbase33u, wxmsw33u_core — while the cross-build installs GNU-style ones, libwx_baseu-3.3-Windows.dll.a. The lookup for the base library is REQUIRED, so find_package(wxWidgets) against this image died at configure time with Could not find WX_base using the following names: wxbase33u, wxbase.

    The same branch also expects setup.h under lib/mswu, where the cross-build puts it at lib/wx/include/msw-unicode-3.3/wx/setup.h, and it forces CMAKE_CROSSCOMPILING 0 — which is the line that matters, because FindwxWidgets.cmake picks its mode with if(WIN32 AND NOT CYGWIN AND NOT MSYS AND NOT CMAKE_CROSSCOMPILING) and would otherwise have chosen the right one unaided. The overlay guards that branch with AND NOT MINGW, which sends a cross-build down the wx-config path instead: the wx-config the build installs is a POSIX shell script that runs on the Linux host and answers with the target's own -I, -L and -l flags. Nothing else in the port changes.

Qt

Qt 6.11.2 from the official whole-source tarball,

https://download.qt.io/official_releases/qt/6.11/6.11.2/single/qt-everywhere-src-6.11.2.tar.xz

built in two passes, both driven by Qt's own top-level configure:

pass prefix what it is
host /opt/qt-host a Linux build, for the code generators — moc, rcc, uic, qmlcachegen, qsb, lrelease, repc, qscxmlc, qtprotobufgen, …
target /opt/qt the cross build for 64-bit Windows (x86_64-w64-mingw32, UCRT), which takes its tools from the host build

Shared libraries only — no static Qt, no 32-bit Qt.

The whole-source tarball is used rather than per-module tarballs so Qt's own build system works out module ordering and skips anything whose dependencies are missing; there is no module list to curate. Only what genuinely cannot target MinGW is skipped (QT_SKIP, a build arg): qtwebengine (Chromium needs MSVC or clang-cl on Windows), qtwayland, qtdoc, qtactiveqt (its idc/dumpcpp tools are Windows-only programs that Qt insists on finding in QT_HOST_PATH).

The cross build gets its host tools the one way Qt still supports: configure -qt-host-path /opt/qt-host, which sets QT_HOST_PATH. It does not use QT_BUILD_TOOLS_WHEN_CROSSCOMPILING — Qt 6.11 warns that it is deprecated in favour of QT_FORCE_BUILD_TOOLS, and neither belongs here, since forcing tools in a MinGW cross build only produces .exe files the build cannot run. That flag is a CMake cache variable, so a build tree that has ever seen it keeps warning; scripts/build-qt.sh wipes any tree that still carries it.

SQL drivers: all four that build without a proprietary client SDK — QSQLITE (against the sqlite3 in the library set), QMYSQL (libmariadb), QPSQL (libpq) and QODBC (odbc32 from the MinGW sysroot). Oracle (OCI), DB2, InterBase and Mimer need vendor SDKs and are not built.

Building a Qt app needs nothing special — setvars64.sh exports QT_ROOT, QT_HOST_PATH and puts the Qt tools on PATH:

. setvars64.sh
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build

setvars32.sh unsets QT_ROOT/QT_HOST_PATH, so a 32-bit build can never half-find Qt. examples/qt-smoke/ builds a program that prints the Qt version, TLS backend and the available SQL drivers.

Which qmake you get

There are two Qt installs and several tool names exist in both, so PATH order decides whether a command answers for Windows or for Linux. setvars64.sh puts them in this order:

holds why it comes where it does
/opt/qt/bin qmake, qmake6, qtpaths, qtpaths6, qt-cmake, windeployqt /bin/sh wrappers that run the host binary with -qtconf target_qt.conf, so they run on Linux and answer for Windows. First, because the host build has tools by these names that answer for Linux, and qmake picking those up produces a Linux build with no error anywhere.
/opt/qt-host/bin lrelease, lupdate, qsb, qmllint, qmlformat, designer, … everything with no target-side counterpart
/opt/qt-host/libexec moc, rcc, uic, qmlcachegen, qmlimportscanner, qmltyperegistrar, … the code generators. Qt keeps these out of bin on purpose — CMake finds them through the Qt6*Tools packages, not PATH — but a hand-rolled build needs moc findable.

qmake -query QMAKE_XSPEC should say win32-g++. If it says linux-g++, PATH is wrong and anything built with it is a Linux binary.

Deployment: windeployqt

scripts/windeployqt is a native replacement, on PATH and linked into /opt/qt/bin so Qt's own CMake deployment API finds it. The real tool cannot be here: Qt only adds qttools/src/windeployqt to the build when the target is Windows, and the cross pass has QT_BUILD_TOOLS_BY_DEFAULT off (that is what -qt-host-path means), so it is built by neither pass — /opt/qt-host has androiddeployqt and wasmdeployqt and no windeployqt.

The replacement does the same job with binutils instead of a PE loader: it walks the import table with x86_64-w64-mingw32-objdump -p, transitively, copies every DLL that resolves inside the Qt / vcpkg / MinGW roots and leaves the rest alone (that residue is the Windows system DLLs). Plugins are chosen by asking whether the deployed module set satisfies each plugin's own Qt imports — which is the question windeployqt's hardcoded module→plugin table approximates — so the PostgreSQL driver arrives with libpq.dll behind it without anybody naming either. It takes the real tool's command line, and ignores rather than rejects options that mean nothing here.

. setvars64.sh
cmake --install build --prefix dist     # via qt_generate_deploy_app_script()
windeployqt dist/bin/app.exe --qmldir src   # or by hand

One thing an import walk cannot see: Qt is configured --openssl=yes, so the TLS backend loads OpenSSL through QLibrary at runtime and no import table mentions it. libssl-3-x64.dll and libcrypto-3-x64.dll are deployed from a list whenever the OpenSSL backend plugin is (--no-openssl opts out) — without that, the first https request fails with No functional TLS backend was found.

QML needs --qmldir <source-dir> so qmlimportscanner has a tree to scan; the script warns if a binary uses Qt Qml and none was given.

check-host-qt.sh

The image's last build step runs scripts/check-host-qt.sh, which fails the build unless every host Qt tool loads, /opt/qt/bin/qmake reports win32-g++, and windeployqt can resolve qtdiag.exe. The final stage is FROM libs, so nothing the qt stage apt-installed comes with it — only /opt/qt-host and /opt/qt are copied across, and every shared library the host tools link against has to be re-installed by hand in that stage. A missing one is invisible until someone runs a build: the tools are present, executable, the right size, and die with moc: error while loading shared libraries: libpcre2-16.so.0. Run it by hand (check-host-qt.sh, or --list for just the unresolved sonames) after bumping Qt.

To pin a different Qt: docker build --build-arg QT_VERSION=6.11.1 ... (the download URL is derived from it; override with --build-arg QT_URL=...).

The tarball, the extracted sources, the ccache and both build trees live in BuildKit cache mounts, so an interrupted Qt build resumes instead of restarting. scripts/build-qt.sh fingerprints the full configure command line — plus the toolchain file and the header case shim, which change how a tree compiles without appearing on the command line — and resets a build tree whenever it changes. CMake keeps cache entries that are no longer passed, and CMAKE_CXX_FLAGS is seeded from the toolchain file only on the first configure, so a tree kept across a change would otherwise be quietly wrong.

The header case shim

The Windows SDK spells its headers mixed-case — Windows.h, Winsock2.h, VersionHelpers.h — and NTFS does not care, so Windows-born code writes them that way. mingw-w64 ships those headers all-lowercase, and a Linux filesystem does care:

qtquick3dphysics/src/3rdparty/PhysX/source/foundation/src/windows/PsWindowsSocket.cpp:37:10:
    fatal error: Winsock2.h: No such file or directory

scripts/mingw-case-shim.sh builds a directory of symlinks under the spellings the sources actually use, and the toolchain files hand it to the compiler with -isystem. Only names the sysroot does not already have are linked, so nothing real is shadowed — on a case-sensitive filesystem Winsock2.h and winsock2.h are simply different files.

It is generated during the Qt pass, because that is where the Qt sources are available to scan; the result is baked into the image for both arches. A short built-in list (Windows.h, Ws2tcpip.h, ShlObj.h, …) covers what ordinary Windows code writes even when nothing scanned asked for it. To add whatever your own tree needs:

mingw-case-shim.sh "$MINGW_SYSROOT/include" "$WINBUILD_CASE_SHIM" /work

Build the image

./build-image.sh                     # code.wisesourcery.com/docker/winbuildenv:latest
IMAGE=winbuildenv:local ./build-image.sh          # somewhere else
WITH_WINE=0 ./build-image.sh                      # without wine

First build compiles the whole library set (openssl, libpq, mariadb, … ×4) plus Qt twice (host + cross), so budget a couple of hours on 16 cores; afterwards it is a cached layer, and the vcpkg binary cache (a BuildKit cache mount) restores all four triplets in seconds when only the manifest or a port changes. The wine, Node.js and .NET layers are last, so toggling wine or bumping either runtime never rebuilds libraries.

Node.js comes from the official binary tarball (Debian trixie only has Node 20, out of LTS maintenance) and is pinned to a version + SHA-256 in the Dockerfile. To move it, bump both from https://nodejs.org/dist/v<version>/SHASUMS256.txt, or override at build time:

docker build --build-arg NODE_VERSION=22.23.2 \
             --build-arg NODE_SHA256=<sha256 of node-v22.23.2-linux-x64.tar.xz> ...

The .NET SDK is pinned the same way, to a version + SHA-512. Bump both from https://builds.dotnet.microsoft.com/dotnet/Sdk/<version>/dotnet-sdk-<version>-linux-x64.tar.gz.sha512, or override at build time:

docker build --build-arg DOTNET_VERSION=10.0.401 \
             --build-arg DOTNET_SHA512=<sha512 of dotnet-sdk-10.0.401-linux-x64.tar.gz> ...

That layer also bakes in the Windows runtime packs (~290 MB, downloaded from NuGet at build time) — --build-arg DOTNET_RIDS= skips it, see below.

One more optional build arg, off by default:

./build-image.sh --build-arg NPCAP_SDK_URL=https://npcap.com/dist/npcap-sdk-1.15.zip

which is what gives libpcap a working capture backend — see above.

On the build server

.forgejo/workflows/build-image.yml does all of the above on the docker runner label: it builds through this same build-image.sh, runs examples/smoke (all four triplets) and examples/qt-smoke inside the result, and pushes :latest alongside a tag naming the commit. A push to dev that touches anything but Markdown triggers it; Run workflow takes with_wine, npcap_sdk_url and no_cache inputs, so the two optional build args and the periodic proof that the Dockerfile still builds against today's upstream are all reachable without editing anything.

That label is Docker-in-Docker — an agent whose jobs get a daemon of their own rather than the host's socket. It is a runner instance of its own, because the privileged it needs is a per-runner setting; dockerbuildenv/README.md in the buildenv tree has the image and the act_runner configuration.

Use it

docker run --rm -it \
    -v "$PWD":/work \
    -v ~/plxlib:/opt/plxlib:ro \
    code.wisesourcery.com/docker/winbuildenv:latest

The shell starts in the 64-bit environment (setvars64.sh). Switch with:

. setvars32.sh     # 32-bit
. setvars64.sh     # back to 64-bit

Both set MINGW_ROOT, TOOLS_ROOT, PLXLIB_ROOT, LIBS_ROOT, LIBS_STATIC_ROOT, CMAKE_PREFIX_PATH and PATH, plus CMAKE_TOOLCHAIN_FILE, PKG_CONFIG_*, WINBUILD_CASE_SHIM (the header case shim; CMake builds pick it up from the toolchain file, a hand-rolled compile wants -isystem "$WINBUILD_CASE_SHIM") and WINBUILD_GDBSERVER (the gdbserver.exe matching this target — see Debugging).

Building a CMake project

CMAKE_TOOLCHAIN_FILE is already exported, so:

. setvars64.sh
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build

Link against the static set with the triplet:

cmake -B build-static -G Ninja -DCMAKE_BUILD_TYPE=Release \
      -DVCPKG_TARGET_TRIPLET=x64-mingw-static

The default toolchain (cmake/winbuild-vcpkg.cmake) goes through vcpkg's buildsystem integration, which matters: several vcpkg-generated package configs (libsodium's, for one) only resolve correctly that way. It also points PKG_CONFIG_LIBDIR at the selected triplet — without that, CMake's FindOpenSSL takes a pkg-config hint from the dynamic set and a "static" build quietly links import libraries.

Asking for a triplet whose architecture does not match the sourced environment is a hard error, not a silent x86/x64 mix — source setvars32.sh first for 32-bit builds.

For a plain MinGW build with no vcpkg involvement, use $WINBUILD_PLAIN_TOOLCHAIN (cmake/toolchain-mingw{32,64}.cmake) and set CMAKE_PREFIX_PATH yourself.

Building a .NET project

The .NET 10 SDK is in /opt/dotnet, with dotnet on PATH and DOTNET_ROOT exported. It is not arch-specific — the setvars scripts only pick the runtime identifier that matches the sourced target, win-x64 or win-x86:

. setvars64.sh
dotnet publish -c Release -r "$WINBUILD_DOTNET_RID" --self-contained

The Windows runtime packs are baked into the image ($DOTNET_ROOT/packs, which the SDK checks before it reaches for NuGet), so a publish needs no network of its own — verified with docker run --network none:

win-x64 win-x86
framework-dependent ✔ ✔
self-contained ✔ ✔
PublishSingleFile / PublishTrimmed ✔ ✔
PublishReadyToRun ✔ ✔
ASP.NET Core ✔ ✔

That is Microsoft.NETCore.App.Runtime.win-* and .Host.win-*, the ASP.NET Core runtime packs, Microsoft.NET.ILLink.Tasks (the trimmer) and Microsoft.NETCore.App.Crossgen2.linux-x64 (the ReadyToRun pre-compiler, a Linux binary that cross-targets Windows) — about 290 MB. Restoring the project's own PackageReferences still needs a feed, as always; mount a NuGet cache with -v ~/.nuget:/root/.nuget if you want that offline too.

dotnet build/test run the managed code on Linux as usual. NativeAOT does not cross-compile — publishing AOT for win-* needs MSVC's link.exe, so that one build has to happen on Windows.

The pack set follows DOTNET_RIDS ("win-x64 win-x86"); build with --build-arg DOTNET_RIDS= to skip the step and get a smaller image that downloads packs on first publish instead.

Shipping a dynamically linked build

Windows needs the gcc runtime DLLs next to the executable:

collect-runtime-dlls.sh dist/ x64      # libgcc_s_seh-1, libstdc++-6, libwinpthread-1
cp $LIBS_ROOT/bin/*.dll dist/          # the vcpkg-built DLLs

Static builds (LIBS_STATIC_ROOT) still link the gcc runtime dynamically unless you add -static -static-libgcc -static-libstdc++.

Shipping what you built

A build produces something that runs on a Windows machine without further steps. That was not always true, and the way it failed is worth knowing:

VCPKG_LIBRARY_LINKAGE governs the vcpkg libraries. GCC's own runtime -- libstdc++-6.dll, libgcc_s_seh-1.dll (libgcc_s_dw2-1.dll on x86), libwinpthread-1.dll -- lives in the toolchain, not in the vcpkg prefix, so nothing that walks that prefix deployed it. Every binary out of this image imported it and nothing shipped it. In the container that is invisible, because wine resolves those DLLs through WINEPATH. The static triplets were the worst of it: a single .exe that looks self-contained and still imports two DLLs.

Two different fixes, because one answer does not fit both:

triplet what happens
*-mingw-static the runtime is linked in (-static). One file, no DLLs.
*-mingw-dynamic the DLLs it imports are copied beside it after it links.

The dynamic side is not static-linked on purpose. A static libstdc++ inside the executable, with the vcpkg DLLs beside it still using the shared one, puts two C++ runtimes in one process -- and objects or exceptions crossing that boundary fail in ways far harder to find than a missing file.

The copy is automatic: cmake/winbuild-deploy.cmake finds the executable targets itself and runs scripts/winbuild-copy-runtime over them, which walks the import table and copies only what is named -- three DLLs, not the nine that collect-runtime-dlls.sh takes. How it attaches depends on where the target was declared, because add_custom_command(TARGET) refuses a target from another directory: executables in the top-level CMakeLists.txt get a post-build step, and everything in an add_subdirectory() is collected into one winbuild-deploy-runtime target that is part of all. Either way a plain cmake --build . ships the runtime; building a single subdirectory target by name (ninja <target>) is the one case where the copy waits for the next full build. Executables held out of all are skipped rather than dragged into it -- a vendored add_subdirectory(... EXCLUDE_FROM_ALL) should not start building its own test programs because this file went looking for executables. Turn the whole thing off with -DWINBUILD_AUTO_COPY_RUNTIME=OFF; force the static side either way with -DWINBUILD_STATIC_RUNTIME=ON|OFF. Qt applications already had this from the windeployqt replacement, which collects the compiler runtime itself; the two do not fight, since each copies only what is missing.

A missing runtime does not reliably announce itself, which is why this was worth fixing rather than documenting. Windows names the DLL it could not find only when it finds nothing at all -- and a machine with any other MinGW-derived toolchain on PATH (MSYS2, a Qt installer, Strawberry Perl, R, Octave) has a libstdc++-6.dll for it to find. Loading a mismatched one usually means a silent exit or a crash with no message.

Running what you built

Wine is in the image by default, so the produced binaries run in the container:

export WINEPREFIX=/tmp/wp WINEARCH=win64   # win32 for 32-bit binaries
export WINEDEBUG=-all                      # see below
wineboot -i
wine ./yourapp.exe

WINEDEBUG=-all is for readability, not correctness. A headless container has no display, no D-Bus and no Bluetooth stack, so wine reports the parts of itself it could not start, on stderr, before your program runs:

err:winediag:nodrv_CreateWindow Application tried to create a window, but no driver could be loaded.
err:ntoskrnl:ZwLoadDriver failed to create driver L"...\winebth": c00000e5
err:systray:initialize_systray Could not create tray window

Those are wine talking about wine. winebth is its Bluetooth driver; c00000e5 is STATUS_INTERNAL_ERROR. None of them come from your binary, none of them stop it -- a console program runs to completion and exits 0 with all three on screen -- and none of them can say anything about how it behaves on a real Windows machine, where there is no wine to print them. The err: prefix makes them look like the failure when a run does fail, which is the only reason they are worth turning off.

Deliberately with no WINEPATH: that is what makes this a test rather than a demonstration. Setting it points wine at the toolchain's copy of the runtime and hides exactly the class of problem above -- the binary runs here and fails on a real machine. If it runs with nothing but the files beside it, it will run there.

examples/smoke/ is a small program linking OpenSSL, zlib, sqlite3, pugixml, libsodium, liblzma and ZXing; examples/smoke/build-all.sh builds it for all four triplets. All four were verified to build and to run under wine, reporting the pinned versions (openssl 3.6.3, sqlite3 3.53.4, libsodium 1.0.22, liblzma 5.8.3, zxing-cpp 3.1.1, zlib 1.3.2).

Debugging

Three debuggers, because there are three things to debug.

gdb --args cmake ...                      # host tools: they are Linux binaries
$MINGW_TARGET-gdb build/yourapp.exe       # a .exe, statically: symbols, disassembly

x86_64-w64-mingw32-gdb and i686-w64-mingw32-gdb are Linux programs that read PE and mingw DWARF. They can inspect a Windows binary and set up a session, but they cannot execute one — nothing in this container can, without wine. So there are two ways to get a running process under them.

In the container, with WITH_WINE=1 (the default in build-image.sh):

export WINEPREFIX=/tmp/wp WINEARCH=win64
wineboot -i
collect-runtime-dlls.sh . x64 && cp $LIBS_ROOT/bin/*.dll .
wine "$WINBUILD_GDBSERVER" :2345 ./yourapp.exe &
$MINGW_TARGET-gdb ./yourapp.exe -ex 'target remote :2345'

Or on a real Windows machine — the case that actually matters, because a driver bug or a pcap permission problem does not reproduce under wine. Copy $WINBUILD_GDBSERVER (/usr/share/ucrt64/gdbserver.exe for the 64-bit target, /usr/share/win32/gdbserver.exe for the 32-bit one; setvars*.sh picks the right one) next to the program, run gdbserver.exe :2345 yourapp.exe there, and attach from here with target remote <host>:2345. The symbols stay in the container, so nothing debuggable has to be shipped to the Windows box.

Build with -DCMAKE_BUILD_TYPE=RelWithDebInfo or Debug; mingw keeps DWARF in the .exe itself, so there is no separate symbol file to keep in step.

Layout

/opt/vcpkg                     vcpkg, pinned to the commit in the Dockerfile
/opt/libs/<triplet>/           installed libraries, one tree per triplet
/opt/libs/<triplet>/tools/     the ports' own programs -- Windows .exe files
/opt/npcap-sdk                 packet-capture SDK, if NPCAP_SDK_URL was given
/opt/qt                        Qt for x86_64-w64-mingw32 (shared)
/opt/qt-host                   Linux Qt supplying the build tools (QT_HOST_PATH)
/opt/wx-host                   Linux wxrc, the XRC compiler (see below)
/opt/winbuildenv/cmake/        standalone CMake toolchain files
/opt/winbuildenv/scripts/      setvars32.sh, setvars64.sh, helpers
/opt/winbuildenv/mingw-case-shim/<target>/   Windows-SDK header spellings (symlinks)
/opt/node                      Node.js LTS + npm (linked into /usr/local/bin)
/opt/dotnet                    .NET SDK 10 + Windows runtime packs
/opt/plxlib                    bind-mount point for the sanctuarylib SDK
/work                          your source tree

The tools/ trees are cross-built like everything else, so what is in them are Windows binaries: wxrc.exe, glib-compile-resources.exe, gresource.exe, gio.exe, ecpg.exe and the DLLs they need. They do not run on the host. The handful that do are the ones that were never compiled in the first place -- gdbus-codegen, glib-genmarshal, glib-mkenums and gtester-report are Python, glib-gettextize and pcre2-config are shell scripts -- plus bin/wx-config, which is a shell script and is what find_package(wxWidgets) goes through.

This is why the setvars scripts do not put tools/ on PATH. linuxbuildenv walks that directory and adds every subdirectory holding an executable, which is right there and wrong here: these are the same file mode and the same names, and adding them would put a PATH entry in front of a build where glib-compile-resources resolves to something the host cannot execute. It is the same hazard the PATH comment in setvars64.sh describes for Qt's host and target tools, one step further along.

The host half: wxrc

For wxrc the answer is not wine but a second build, which is the same answer Qt gets: /opt/qt-host supplies moc and rcc as Linux binaries while /opt/qt supplies the target libraries, and /opt/wx-host now does the same for the one wxWidgets tool a build actually calls.

It is cheap, because wxrc is not a GUI program. utils/wxrc/wxrc.cpp includes wx/cmdline.h, wx/xml/xml.h, wx/ffile.h, wx/filename.h, wx/wfstream.h, wx/utils.h and wx/mimetype.h, and build/cmake/utils/CMakeLists.txt links it against wxbase and wxxml only. So scripts/build-wx-host.sh configures wxWidgets with wxUSE_GUI=OFF and builds the one target: no GTK, no Qt, no X11, about twenty seconds, and a wxhost stage of its own so the sources and the build tree never enter a layer.

wxUSE_XRC=ON matters even with the GUI off -- build/cmake/utils/CMakeLists.txt opens with if(wxUSE_XRC), so without it the wxrc target does not exist. So does installing it under the plain name: the build calls it wxrc-3.3 whenever MSVC naming is off, and FindwxWidgets.cmake looks for

find_program(wxWidgets_wxrc_EXECUTABLE NAMES $ENV{WXRC_CMD} wxrc ...)

which the versioned name never matches. That is why the cross-built one, which installs as bin/wxrc-3.3.exe, left wxWidgets_wxrc_EXECUTABLE as NOTFOUND and made WXWIDGETS_ADD_RESOURCES() fail at configure time.

Nothing in a project has to know about any of this. /opt/wx-host/bin is on PATH in both setvars scripts, so:

find_package(wxWidgets REQUIRED COMPONENTS core base xml xrc)
include(${wxWidgets_USE_FILE})
wxwidgets_add_resources(GENERATED res.xrc)   # -- wxWidgets_wxrc_EXECUTABLE
add_executable(app main.cpp ${GENERATED})    #    = /opt/wx-host/bin/wxrc
target_link_libraries(app PRIVATE ${wxWidgets_LIBRARIES})

builds a Windows .exe whose resources were compiled by a Linux program.

WX_VERSION in the Dockerfile has to match the wxwidgets version pinned in vcpkg.json -- the C++ wxrc generates is compiled against the target-side wx headers. Move the two together.

The host and cross-built wxrc were compared on the same input with the same relative paths: the derived-class header is byte-identical, and the embedded C++ differs only in line endings (wxrc writes CRLF on Windows) and in the internal memory-filesystem key, which encodes the input path as it was spelled on the command line. That key is written and read inside the one generated file, so either spelling is self-consistent.

Running a tools/ binary anyway

Nothing else in tools/ has a host build here, so for those it is wine, and they need the MinGW runtime: wxrc.exe imports libgcc_s_seh-1.dll and libstdc++-6.dll, which live in the toolchain rather than beside it, and libstdc++-6.dll in turn pulls libwinpthread-1.dll out of a second directory. Both have to be on WINEPATH or the program does not start at all -- wine exits 53 with nothing on stdout:

export WINEPREFIX=/tmp/wp WINEARCH=win64
wineboot -i
export WINEPATH="$(winepath -w /usr/lib/gcc/x86_64-w64-mingw32ucrt/14);$(winepath -w /usr/x86_64-w64-mingw32ucrt/lib)"
cd $LIBS_ROOT/tools/wxwidgets && wine ./wxrc.exe -c -o Z:/tmp/out.cpp Z:/tmp/in.xrc

plxlib is not baked into the image (proprietary SDK). Mount the directory holding plxlib-mingw32/ and plxlib-mingw64/ at /opt/plxlib, and each setvars script picks the matching one. Point elsewhere with PLXLIB_HOME, or override a single arch with PLXLIB_ROOT32 / PLXLIB_ROOT64.

Adding a library

Add it to vcpkg.json, then rebuild the image — or, inside a running container, build-libs.sh (optionally TRIPLETS="x64-mingw-static" build-libs.sh).

Toolchain notes

  • The cross compiler is Debian's packaged mingw-w64 gcc 14.2. Object files built by other toolchains are not mixed in; the one prebuilt artifact consumed here is the sanctuarylib.dll import library, which is fine across gcc versions.
  • The 64-bit compiler uses posix threads — Debian ships no win32-threads UCRT variant. Both arches give full C++11 threading; dynamically linked 64-bit output therefore needs libwinpthread-1.dll.
  • Host tools (cmake, ninja, doxygen) are the Debian Linux builds. They drive the build; only the compiler output targets Windows.
  • Debian's UCRT toolchain is prefixed x86_64-w64-mingw32ucrt-; the image bridges that to the x86_64-w64-mingw32- spelling everything else expects, with symlinks in /usr/local/bin. The compiler drivers are bridged a second time, in /usr/bin, and that copy is load-bearing: windres finds its preprocessor beside itself rather than on PATH, so /usr/bin/x86_64-w64-mingw32-windres (from the msvcrt binutils, which this image installs headers for on the 32-bit side only) would otherwise fall back to the host gcc and fail every .rc file with windows.h: No such file or directory. CMake is what walks into it — CMAKE_RC_COMPILER resolves out of /usr/bin while every other tool resolves out of /usr/local/bin — so the symptom is that ports with resources (glib, and libslirp behind it) fail and nothing else does. The image build compiles a probe .rc for both arches rather than take it on trust.