- Visual Studio 2022 or later (the
src/eMuleQt.slnprojects target the v143 toolset) - Qt 6.8.3 (or later) for MSVC 2022 x64 (with
httpservermodule) - Qt VS Tools extension (v3.04+)
- vcpkg package manager
# Clone vcpkg if not already installed
git clone https://github.com/microsoft/vcpkg.git C:\vcpkg
C:\vcpkg\bootstrap-vcpkg.bat
# Set VCPKG_ROOT environment variable
[System.Environment]::SetEnvironmentVariable("VCPKG_ROOT", "C:\vcpkg", "User")
# Install dependencies (from src/ where the .sln lives)
cd path\to\eMuleQt\src
C:\vcpkg\vcpkg install --triplet x64-windowsThis installs: zlib, openssl, yaml-cpp, libarchive, miniupnpc, par2-turbo, rapidyenc.
The last two are the Usenet dependencies — PAR2 verify/repair and SIMD yEnc decoding — and neither has
a port in the vcpkg registry. They are built from the overlay ports in src/vcpkg-ports/, which
src/vcpkg-configuration.json declares, so no --overlay-ports flag is needed. That declaration is
also why vcpkg install has to be run from src/: from anywhere else the two ports are not found
at all.
Visual Studio manifest mode: If you have vcpkg integrated with VS (
vcpkg integrate install), opening the solution should auto-install dependencies fromsrc/vcpkg.json. If it doesn't, runvcpkg installmanually fromsrc/as shown above.
The Usenet libraries are not optional here. Unlike the CMake build, which falls back to a scalar yEnc decoder and drops PAR2 when they are missing,
src/eMuleQt.slnhardcodesEMULE_HAVE_RAPIDYENC=1andEMULE_HAVE_PAR2=1insrc/usenet/emuleusenet.vcxprojand linksrapidyenc.lib,par2-turbo.lib,gf16.libandhasher.lib. Ifvcpkg installdid not produce them, the daemon fails to link rather than building without Usenet support.
- Install Qt 6.8.3 (or later) via the Qt Online Installer
- Select the MSVC 2022 64-bit kit and the Qt HTTP Server additional module
- In Qt VS Tools, register the installation as
6.8.3_msvc2022_64
- Open
src/eMuleQt.slnin Visual Studio 2022 - Select Release|x64 or Debug|x64
- Build Solution (Ctrl+Shift+B)
The .vcxproj files reference vcpkg through paths relative to the solution —
..\vcpkg_installed\x64-windows\include and ..\vcpkg_installed\x64-windows\lib — so
VCPKG_ROOT does not affect how MSBuild resolves them. What matters is that
vcpkg install was run from src/ (as shown above), which is what creates
src\vcpkg_installed\. VCPKG_ROOT is still used by the PowerShell scripts to locate
vcpkg.cmake.
The scripts default to x64-windows, which builds every dependency twice (debug and
release). Set VCPKG_TARGET_TRIPLET to override it — CI uses x64-windows-release,
which skips the debug half and roughly halves the dependency build. It has the same
dynamic CRT, the same library linkage and the same DLL names, but no debug\ subtree,
so build-win.ps1 -Config Debug refuses to run against it rather than mixing CRTs.
scripts\build-win.ps1, scripts\generate-vs.ps1, scripts\bundle-win.ps1 and
scripts\debug-gui.ps1 all honour it. The .vcxproj files do not — they are hardcoded
to x64-windows.
If not using vcpkg, install libraries to the paths expected by the project files:
| Library | Expected include path | Expected lib path |
|---|---|---|
| OpenSSL | C:\Program Files\OpenSSL-Win64\include |
C:\Program Files\OpenSSL-Win64\lib |
| zlib | C:\Program Files\zlib\include |
C:\Program Files\zlib\lib |
| miniupnpc | C:\Program Files\miniupnpc\include |
C:\Program Files\miniupnpc\lib |
| yaml-cpp | C:\Program Files\yaml-cpp\include |
C:\Program Files\yaml-cpp\lib |
| libarchive | C:\Program Files\libarchive\include |
C:\Program Files\libarchive\lib |
Note: The zlib library file must be named zlib.lib (standard Windows/vcpkg name), not z.lib.
par2-turbo and rapidyenc have no manual-install path — there is no upstream binary distribution
and the project files expect them in src\vcpkg_installed\. Without vcpkg, the CMake build fetches
and builds both itself; the hand-maintained solution cannot, and will not link.
Five PowerShell scripts cover the command-line workflow. They share their Qt, vcpkg, Visual Studio
and binary detection: build-win.ps1 defines those helpers, and the other four dot-source it.
# Configure (only when needed) and build
scripts\build-win.ps1
scripts\build-win.ps1 -Clean -Config Debug
# On a machine without vcpkg the first run clones and bootstraps it automatically
# (the dependencies are required, so configure cannot succeed without them).
# Opt out only when the libraries are installed to the paths listed above:
scripts\build-win.ps1 -NoVcpkg
# Package a self-contained zip into build\
scripts\bundle-win.ps1
scripts\bundle-win.ps1 -NoBuild # package an existing Visual Studio build
# Wrap the staged bundle into build\eMuleQt-v<version>-win64-setup.exe (Inno Setup)
scripts\installer-win.ps1
scripts\installer-win.ps1 -InstallInno # fetch Inno Setup with Chocolatey when missing
# Generate a Visual Studio solution into build-vs\ with a CMake VS generator
scripts\generate-vs.ps1
scripts\generate-vs.ps1 -Generator "Visual Studio 17 2022"
# Build, start the daemon, then run the GUI against it
scripts\debug-gui.ps1
scripts\debug-gui.ps1 --screenshot C:\temp\kad.png --tab kad --subtab 1 --delay 8000
scripts\debug-gui.ps1 -Debugger # run the GUI under cdb, or the VS debugger
scripts\debug-gui.ps1 -NoBuild # skip the build, use bin\Release\If the execution policy blocks them, run via
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\debug-gui.ps1 ....
Qt is auto-detected from C:\Qt\6.*\msvc*_64 (newest first) or %USERPROFILE%\Qt\..., and can be
overridden with -QtDir. QT_ROOT_DIR, QTDIR and CMAKE_PREFIX_PATH are honoured too, which is
how CI passes its kit.
Bootstraps vcpkg if needed, then configures a CMake build tree with a Visual Studio generator and
manifest-mode dependencies. The result is a generated solution in build-vs\, separate from the
hand-maintained src\eMuleQt.sln above — eMuleQt.slnx with a Visual Studio 2026 generator (the
new XML solution format), eMuleQt.sln with an older one. The script prints the path it produced.
It uses build-vs\ rather than build\ so it can coexist with the Ninja tree the other scripts
use; CMake will not reuse a build directory configured with a different generator. The vcpkg clone
stays shared at build\vcpkg.
The generator is detected at runtime by intersecting the versions cmake --help lists with the
installations vswhere reports, so a newer Visual Studio needs no edit. That intersection matters:
cmake --help lists generators for Visual Studio versions that are not installed, and picking one
of those fails late with MSB8020: The build tools ... (Platform Toolset = 'v143') cannot be found.
Override with -Generator "Visual Studio 17 2022" when several are installed.
The Windows counterpart of scripts/debug-gui.sh: it builds, kills any leftover daemon, starts
emulecored, waits for its IPC port to listen, then runs emuleqt in the foreground with every
remaining argument passed through untouched. The daemon is always stopped again on exit, including
on Ctrl-C.
It is PowerShell rather than a .bat because the daemon lifecycle needs things cmd lacks: a PID
for the background daemon (taskkill /IM would kill every emulecored), a finally block to
replace trap EXIT, sub-second sleeps, and above all a foreground wait — emuleqt is built
WIN32_EXECUTABLE, and start does not wait for a GUI-subsystem process, so the daemon would be
killed before a screenshot ever landed.
.github/workflows/windows.yml builds the same tree with Ninja rather than MSBuild, and is
the only consumer that used to diverge from the scripted path above. It now runs
5:59, down from 17:46 (release run 32633756834 → 32815586508, 2026-08-25):
| Step | Before | Cold cache | Warm cache |
|---|---|---|---|
| Install dependencies (vcpkg) | 8:56 | 15:25 | 0:23 |
| Configure | 2:56 | 0:09 | 0:12 |
| Build | 4:25 | 4:00 | 3:13 |
| Total | 17:46 | ~21:00 | 5:59 |
Almost none of that was compilation — the Build step was always competitive with Linux. The gap was dependency handling, fixed by three changes:
- The vcpkg binary cache is persisted with
actions/cache. vcpkg's default store (~\AppData\Local\vcpkg\archives) lives on the ephemeral runner, so it faithfully saved every build into a directory destroyed minutes later and rebuilt OpenSSL from source on every run. VCPKG_TARGET_TRIPLET: x64-windows-releaseskips the debug variant of every dependency, which alone cost 3:21 of the old 8:56.- CI uses manifest mode against
src/vcpkg.json, like the scripts already did. It previously installed onlyzlib openssland letyaml-cpp,libarchiveandminiupnpcfall through toFetchContent— 304 of 549 ninja targets, and ~167s of the 174s configure spent on libarchive'stry_compileprobes. Ninja now builds 241 targets.
Use the split actions/cache/restore + actions/cache/save pair, not single-step
actions/cache. A cold vcpkg build costs 15 minutes, and the single-step post-action does
not save when a later step fails (save-always is deprecated in v4.2). This is not
theoretical: run 32815586508 restored its cache from run 32810604154, which failed —
single-step caching would have thrown that away and paid the 15 minutes again.
Keys carry ${{ github.run_id }}-${{ github.run_attempt }} because GitHub caches are
immutable: a fixed key would freeze whatever the first run populated, including a partial
population from a run that died half way. Broad restore-keys make that safe — vcpkg's store
is content-addressed by package ABI hash, so a stale restore just misses and rebuilds.
The runner's vcpkg commit SHA is deliberately excluded from the key. builtin-baseline
pins the port trees, so a vcpkg HEAD bump does not change OpenSSL's ABI hash; putting the SHA
in the key would force a total miss on every image update. When MSVC genuinely bumps, the ABI
hash changes and vcpkg rebuilds on its own.
Caches evict after 7 days and this workflow runs on tags and dispatch only, so most release
builds hit a cold cache. .github/workflows/vcpkg-cache-warm.yml.disabled is a scheduled
job that keeps the archives alive; rename it to .yml to enable it. Note that a workflow is
disabled by its file extension — GitHub validates every *.yml under .github/workflows/
on push, so a fully commented-out workflow is an invalid workflow rather than an absent one
and fails on every push.
ccache is wired up (CMAKE_CXX_COMPILER_LAUNCHER) but only caches 13 of 222 compiles.
There are two independent blockers and only one is fixed:
/Zi— fixed.cmake/CompilerSettings.cmakenow emits/Z7for Release, because ccache cannot cache the separate compiler-side PDB that/Ziproduces. This was worth doing: it took the baseline from0 / 522 (0.00%)to non-zero.target_precompile_headers— still open.emulecore(src/core/CMakeLists.txt) andemuleqt(src/gui/CMakeLists.txt) both use PCH, which MSVC implements as/Yu— also uncacheable. 206 of the 222 sources live in those two targets, 16 outside, against exactly 209 uncacheable / 13 cacheable.
/Z7 changes nothing about the shipped PDB — the linker's /DEBUG produces it either way.
Linux is the control: the same two PCH targets, GCC instead of MSVC, and ccache there reports
303 / 469 (64.61%) cacheable at a 40% hit rate. So this is an MSVC-specific ccache
limitation, not an argument against PCH as such.
PCH and ccache are mutually exclusive on MSVC. PCH makes cold builds fast; ccache makes
warm ones fast. PCH is currently the better trade — this workflow runs on tags, so its ccache
was logging No cache found on essentially every historical run anyway. Don't file the 5.86%
hit rate as a bug: it is a known trade-off, not a regression.
The ccache step is kept anyway, because at this hit rate it is nearly free rather than useless:
Set up ccache and its post-step cost 7s of a 6m4s job, and the saved cache is 7 MB.
Keeping the wiring in place is what leaves the alternative below one flag away.
Kept on the table because the numbers that rule it out today are not fixed — more sources, a colder PCH, or moving this workflow off tag-only triggers would each change the arithmetic. To evaluate it:
1. Confirm the blocker before acting on it. ccache -s prints no reasons; ccache -sv
does. Add a step after Build (or set the ccache action's verbose input):
- name: ccache stats
shell: pwsh
run: ccache -svExpect Unsupported compiler option to account for ~209 calls. If it names something else,
the diagnosis above is wrong and the real blocker is still unidentified — stop there.
2. Gate PCH off only when a compiler launcher is set, rather than deleting the
target_precompile_headers calls. Local MSVC builds have no ccache and are where PCH earns
most of its keep:
if(NOT (MSVC AND CMAKE_CXX_COMPILER_LAUNCHER))
target_precompile_headers(emulecore PRIVATE ...)
endif()3. Judge it on the second run, not the first. A cold ccache build is slower than a PCH build by definition; the swap only wins if a warm one beats the current baseline:
| with PCH (today) | |
|---|---|
Build step |
3m13s |
| whole job | 6m4s |
| cacheable | 13 / 222 (5.86%) |
Release builds upload a second artifact, debug-symbols-win64-v<version>. A minidump binds
to a PDB by GUID + age and every rebuild mints a new GUID, so the only way to symbolise a
crash from a published build is the PDB from that exact build.
It deliberately does not match release.yml's pattern: eMuleQt-v*, so it never reaches
the release assets — which also means it expires with the normal 90-day artifact retention
while the release itself is permanent. Attaching symbols to a release is a deliberate change
to that allowlist, not something to do by accident.
After the bundle is verified, scripts\installer-win.ps1 -InstallInno compiles
installer\windows\emuleqt.iss around the staged tree, and a second verification step runs the
result: silent install, check of the installed files, shortcuts and the per-user mode file, an
upgrade with a faked eMule configuration (the import request must appear, and disappear again
when declined), silent uninstall with nothing left behind. It is uploaded as its own artifact,
eMuleQt-v<version>-win64-setup, which release.yml publishes next to the zip.
A dumpbin /dependents step between Bundle and Upload expands the produced zip and validates
it. It exists because Resolve-VcpkgBinDir once hardcoded the triplet: change the triplet
without it and the resolver returned $null, bundle-win.ps1 printed a note and continued,
if-no-files-found: error still passed, and the job stayed green while the released GUI
died at startup with 0xC0000135. Never trust if-no-files-found to mean the artifact is
sound.
Two rules that step had to learn the hard way, both worth preserving in any similar check:
- Never hardcode a dependency DLL name. vcpkg ships zlib as
z.dll, notzlib1.dll— zlib 1.3.2 rewrote its CMakeLists withOUTPUT_NAME zand dropped the old WindowsSUFFIX "1.dll"rule. A hardcodedzlib1.dllfailed a correct bundle. The check derives its expected set frombuild\vcpkg_installed\<triplet>\binand asserts every one reached the zip. (The import library is stillzlib.lib; only the DLL was renamed.) - Do not fail on a missing OS DLL just because the runner lacks it. The runner is Windows
Server; users are on desktop Windows.
mf/mfplat/mfreadwrite/evr(Media Foundation),uiautomationcore,d3d12anddxva2legitimately differ between the two, and Qt's multimedia plugins import all of them.Test-Path System32\xon the runner is evidence about the wrong machine. Fail only on imports that were supposed to be bundled — anything vcpkg built, or aQt6*module — and merely list the rest. Also stop parsing dumpbin atdelay load dependencies:: those are optional at load time and windeployqt drops some deliberately (--no-opengl-sw,--no-system-d3d-compiler).
Sourcing libarchive from vcpkg pulls its default features, so the bundle ships 14 DLLs rather
than the previous 3 — archive, bz2, charset-1, iconv-2, legacy, libcrypto-3-x64,
liblzma, libssl-3-x64, libxml2, lz4, miniupnpc, yaml-cpp, z, zstd.
par2-turbo and rapidyenc are static libraries (.lib only — the overlay portfiles install no
runtime), so they add nothing to that list.
The old FetchContent build logged Could NOT find BZip2 / LibLZMA / LZ4 / ZSTD while
ArchiveReader.cpp calls archive_read_support_filter_all(), so Windows silently lacked
bzip2/xz/zstd/lz4 archive support and now has it. Windows is a superset of Linux and
macOS here — don't report the extra DLLs as bloat or the new formats as a regression.
libxml2 and libiconv come in solely as libarchive dependencies and cost ~4.6 min of a cold
build (libiconv alone is 4.2 min). Dropping libarchive's libxml2 feature would remove both
and still leave Windows ahead of where it was.