Initial implementation of msvc-go-wine

A single-binary Go tool for cross compiling with the real MSVC toolchain
on Linux via Wine. Behaves as cl/link/lib/rc/midl/mt/dumpbin/msbuild/
nmake/ml/ml64/armasm/armasm64/cmd/findstr depending on the name it's
invoked as, plus download/install/env/version management subcommands.

- download: fetches the MSVC/WinSDK installer manifest, resolves package
  selection and dependencies, downloads and verifies payloads, unpacks
  VSIX/MSI packages, and applies a handful of compatibility patches so
  VsDevCmd.bat and MSBuild's SDK detection work without a Windows
  Registry (which doesn't exist under Wine).
- install: locates the installed toolchain/SDK versions, normalizes
  header/library name casing, lays out per-architecture tool symlinks
  with an env.json config each, and compiles a small native launcher
  (toolrelay.exe) that lets mt.exe's CMake-compatibility exit code
  survive Wine's own exit-code truncation.
- The wrapper runtime rewrites absolute unix paths in tool arguments into
  Wine's z:\... form, runs the real .exe under wine, and rewrites the
  tool's output back to plain unix paths.

Verified end-to-end against a real MSVC/WinSDK download: cl, link, mt and
the resulting hello.exe all work under Wine, including through the
toolrelay.exe relay path and with paths containing non-ASCII characters.

Offline unit tests cover the wrapper's path-rewrite/output-filter logic,
install-time header lowercasing, and download package-selection/
dependency-resolution.
This commit is contained in:
Cheviiot
2026-07-25 00:57:41 +10:00
commit 6464da7847
41 changed files with 4207 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
// Package assets embeds vendored files that don't belong in Go source: the
// Wine compatibility patches applied after unpacking MSVC/WinSDK, and the
// toolrelay.cpp native launcher helper (see internal/install and
// internal/wrapper).
package assets
import "embed"
//go:embed patches
var Patches embed.FS
//go:embed vendor/toolrelay.cpp
var ToolRelaySource []byte
@@ -0,0 +1,18 @@
msvc-go-wine: skip VsDevCmd's telemetry upload.
There's no telemetry endpoint reachable (or wanted) under Wine. Rather than
touching the conditional logic below, just set the opt-out switch the
script already checks for on its own.
diff --git a/Common7/Tools/VsDevCmd.bat b/Common7/Tools/VsDevCmd.bat
index f925e95..f3710ad 100644
--- a/Common7/Tools/VsDevCmd.bat
+++ b/Common7/Tools/VsDevCmd.bat
@@ -247,6 +247,7 @@ exit /B 0
:end
@REM Send Telemetry if user's VS is opted-in
+set "VSCMD_SKIP_SENDTELEMETRY=1"
if "%VSCMD_SKIP_SENDTELEMETRY%"=="" (
if "%VSCMD_DEBUG%" NEQ "" (
@echo [DEBUG:%~nx0] Sending telemetry
@@ -0,0 +1,20 @@
msvc-go-wine: always record the starting directory.
vsdevcmd_start.bat only saved __VSCMD_CURRENT_DIR when the caller passed
-startdir=none, which doesn't hold up under Wine's cmd.exe (see
https://bugs.winehq.org/show_bug.cgi?id=57137). Just save it
unconditionally instead of gating on the flag.
diff --git a/Common7/Tools/vsdevcmd/core/vsdevcmd_start.bat b/Common7/Tools/vsdevcmd/core/vsdevcmd_start.bat
index 21caae9..fc18228 100644
--- a/Common7/Tools/vsdevcmd/core/vsdevcmd_start.bat
+++ b/Common7/Tools/vsdevcmd/core/vsdevcmd_start.bat
@@ -8,7 +8,7 @@ call "%~dp0parse_cmd.bat" %*
if "%ERRORLEVEL%" NEQ "0" set /A __vscmd_vsdevcmd_errcount=__vscmd_vsdevcmd_errcount+1
@REM Save the current directory, if -startdir=none was specified by the user.
-if /I "%VSCMD_ARG_STARTDIR%" == "none" set "__VSCMD_CURRENT_DIR=%CD%"
+set "__VSCMD_CURRENT_DIR=%CD%"
if "%VSCMD_DEBUG%" GEQ "2" (
@echo [DEBUG:%~n0] Parsing results...
@@ -0,0 +1,66 @@
msvc-go-wine: find the Windows SDK/UCRT dirs on disk before trying the registry.
winsdk.bat locates the Windows 10/11 SDK and Universal CRT SDK by querying
the Windows Registry, which doesn't have real SDK installation entries
under Wine. msvc-wine's own install layout always puts the SDK directly
under "%VSINSTALLDIR%Windows Kits\10", so check that location first and
only fall back to the registry-based lookups when it's not there.
diff --git a/Common7/Tools/vsdevcmd/core/winsdk.bat b/Common7/Tools/vsdevcmd/core/winsdk.bat
index af09494..c303775 100644
--- a/Common7/Tools/vsdevcmd/core/winsdk.bat
+++ b/Common7/Tools/vsdevcmd/core/winsdk.bat
@@ -64,6 +64,11 @@ exit /B 0
if "%VSCMD_DEBUG%" GEQ "3" goto :GetWin10SdkDirVerbose
+if exist "%VSINSTALLDIR%Windows Kits\10\include\" (
+ SET "WindowsSdkDir=%VSINSTALLDIR%Windows Kits\10\"
+ exit /B 0
+)
+
call :GetWin10SdkDirHelper HKLM\SOFTWARE\Wow6432Node > nul 2>&1
if errorlevel 1 call :GetWin10SdkDirHelper HKCU\SOFTWARE\Wow6432Node > nul 2>&1
if errorlevel 1 call :GetWin10SdkDirHelper HKLM\SOFTWARE > nul 2>&1
@@ -73,6 +78,11 @@ exit /B 0
:GetWin10SdkDirVerbose
+if exist "%VSINSTALLDIR%Windows Kits\10\include\" (
+ SET "WindowsSdkDir=%VSINSTALLDIR%Windows Kits\10\"
+ exit /B 0
+)
+
call :GetWin10SdkDirHelper HKLM\SOFTWARE\Wow6432Node
if errorlevel 1 call :GetWin10SdkDirHelper HKCU\SOFTWARE\Wow6432Node
if errorlevel 1 call :GetWin10SdkDirHelper HKLM\SOFTWARE
@@ -231,6 +241,14 @@ set UniversalCRTSdkDir=
if "%VSCMD_DEBUG%" GEQ "3" goto :GetUniversalCRTSdkDirVerbose
+if exist "%WindowsSdkDir%Lib\%WindowsSDKVersion%ucrt\" (
+ SET "UniversalCRTSdkDir=%WindowsSdkDir%"
+ exit /B 0
+) else if exist "%VSINSTALLDIR%Windows Kits\10\Lib\" (
+ SET "UniversalCRTSdkDir=%VSINSTALLDIR%Windows Kits\10\"
+ exit /B 0
+)
+
call :GetUniversalCRTSdkDirHelper HKLM\SOFTWARE\Wow6432Node > nul 2>&1
if errorlevel 1 call :GetUniversalCRTSdkDirHelper HKCU\SOFTWARE\Wow6432Node > nul 2>&1
if errorlevel 1 call :GetUniversalCRTSdkDirHelper HKLM\SOFTWARE > nul 2>&1
@@ -240,6 +258,14 @@ exit /B 0
:GetUniversalCRTSdkDirVerbose
+if exist "%WindowsSdkDir%Lib\%WindowsSDKVersion%ucrt\" (
+ SET "UniversalCRTSdkDir=%WindowsSdkDir%"
+ exit /B 0
+) else if exist "%VSINSTALLDIR%Windows Kits\10\Lib\" (
+ SET "UniversalCRTSdkDir=%VSINSTALLDIR%Windows Kits\10\"
+ exit /B 0
+)
+
call :GetUniversalCRTSdkDirHelper HKLM\SOFTWARE\Wow6432Node
if errorlevel 1 call :GetUniversalCRTSdkDirHelper HKCU\SOFTWARE\Wow6432Node
if errorlevel 1 call :GetUniversalCRTSdkDirHelper HKLM\SOFTWARE
@@ -0,0 +1,19 @@
msvc-go-wine: don't hard-fail devcmd setup when ConnectionManagerExe is missing.
Not every MSVC/WinSDK download selection includes the Linux
ConnectionManagerExe component; a missing optional component shouldn't
abort the whole Developer Command Prompt setup.
diff --git a/Common7/Tools/vsdevcmd/ext/ConnectionManagerExe.bat b/Common7/Tools/vsdevcmd/ext/ConnectionManagerExe.bat
index 871b9af..f069a25 100644
--- a/Common7/Tools/vsdevcmd/ext/ConnectionManagerExe.bat
+++ b/Common7/Tools/vsdevcmd/ext/ConnectionManagerExe.bat
@@ -10,7 +10,7 @@ set "PATH=%PATH%;%VSINSTALLDIR%Common7\IDE\VC\Linux\bin\ConnectionManagerExe"
goto :end
:error_setting_path
-exit /B 1
+exit /B 0
goto :end
@@ -0,0 +1,18 @@
msvc-go-wine: don't hard-fail devcmd setup when the bundled CMake/Ninja is missing.
Same reasoning as the ConnectionManagerExe fix: a missing optional
component shouldn't abort the whole Developer Command Prompt setup.
diff --git a/Common7/Tools/vsdevcmd/ext/cmake.bat b/Common7/Tools/vsdevcmd/ext/cmake.bat
index d9a1088..584f141 100644
--- a/Common7/Tools/vsdevcmd/ext/cmake.bat
+++ b/Common7/Tools/vsdevcmd/ext/cmake.bat
@@ -10,7 +10,7 @@ set "PATH=%PATH%;%VSINSTALLDIR%Common7\IDE\CommonExtensions\Microsoft\CMake\CMak
goto :end
:error_setting_path
-exit /B 1
+exit /B 0
goto :end
@@ -0,0 +1,24 @@
<!--
msvc-go-wine: make MSBuild resolve the Windows SDK / UCRT / preferred tool
architecture from the VS install layout directly, since there's no
Windows Registry under Wine for the stock .props files to query.
Populates MsvcGoWine_ExtraSdkRoots, which
Microsoft.Cpp.WindowsSDK.props.patch (see the assets/patches directory)
passes into GetLatestSDKTargetPlatformVersion as an extra search root.
-->
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup Condition="'$(WindowsSdkDir_10)' == '' and (
'$(WindowsTargetPlatformVersion)' == '' or
'$(WindowsTargetPlatformVersion)' == '10.0' or
Exists('$(VSInstallRoot)\Windows Kits\10\Include\$(WindowsTargetPlatformVersion)\shared\sdkddkver.h'))">
<MsvcGoWine_ExtraSdkRoots>$(VSInstallRoot)</MsvcGoWine_ExtraSdkRoots>
<WindowsSdkDir_10>$(VSInstallRoot)\Windows Kits\10\</WindowsSdkDir_10>
<UniversalCRTSdkDir_10 Condition="'$(UniversalCRTSdkDir_10)' == ''">$(WindowsSdkDir_10)</UniversalCRTSdkDir_10>
<UCRTContentRoot Condition="'$(UCRTContentRoot)' == ''">$(WindowsSdkDir_10)</UCRTContentRoot>
</PropertyGroup>
<PropertyGroup>
<PreferredToolArchitecture Condition="'$(PROCESSOR_ARCHITECTURE)' == 'AMD64'">x64</PreferredToolArchitecture>
</PropertyGroup>
</Project>
@@ -0,0 +1,20 @@
msvc-go-wine: let MSBuild find the Windows SDK without the registry.
GetLatestSDKTargetPlatformVersion normally resolves the SDK via the
registry, which has nothing to find under Wine. Pass it an extra list of
roots (populated by msvc-go-wine.props, see the ImportBefore directory next
to this file) so it can also look directly under the VS install root.
diff --git a/MSBuild/Microsoft/VC/v180/Microsoft.Cpp.WindowsSDK.props b/MSBuild/Microsoft/VC/v180/Microsoft.Cpp.WindowsSDK.props
index 5003a04..2a785a4 100644
--- a/MSBuild/Microsoft/VC/v180/Microsoft.Cpp.WindowsSDK.props
+++ b/MSBuild/Microsoft/VC/v180/Microsoft.Cpp.WindowsSDK.props
@@ -110,7 +110,7 @@ Copyright (C) Microsoft Corporation. All rights reserved.
<SDKVersion Condition="'$(SDKVersion)' == ''">10.0</SDKVersion>
<SDKDisplayName Condition="'$(SDKDisplayName)' == ''">Windows 10</SDKDisplayName>
- <_LatestWindowsTargetPlatformVersion>$([Microsoft.Build.Utilities.ToolLocationHelper]::GetLatestSDKTargetPlatformVersion($(SDKIdentifier), $(SDKVersion)))</_LatestWindowsTargetPlatformVersion>
+ <_LatestWindowsTargetPlatformVersion>$([Microsoft.Build.Utilities.ToolLocationHelper]::GetLatestSDKTargetPlatformVersion($(SDKIdentifier), $(SDKVersion), $(MsvcGoWine_ExtraSdkRoots.Split(';'))))</_LatestWindowsTargetPlatformVersion>
<WindowsTargetPlatformVersion Condition="'$(WindowsTargetPlatformVersion)' == '' and '$(_LatestWindowsTargetPlatformVersion)' != ''">10.0</WindowsTargetPlatformVersion>
</PropertyGroup>
+126
View File
@@ -0,0 +1,126 @@
// toolrelay.exe - a thin native launcher for a Wine-hosted MSVC tool
// invocation, built and used by msvc-go-wine (see internal/install's
// buildToolRelay and internal/wrapper's runViaToolRelay).
//
// It exists to solve two problems that are only solvable from inside a
// real Windows process, not from the Unix side of Wine:
//
// 1. Wine truncates a Windows process's exit code down to a single byte
// when reporting it back to the host OS. mt.exe (the manifest tool)
// can legitimately exit with 0x41020001 ("manifest unchanged"), which
// CMake's own build driver specifically checks for and translates to
// 0xbb before treating it as success - see cmcmd.cxx upstream. If
// Wine has already truncated 0x41020001 down to 0x01 by the time our
// Go wrapper observes it, we can no longer make that translation
// ourselves. Only a native Windows process calling
// GetExitCodeProcess() can see the real, untruncated value - so this
// helper does the translation itself, then exits with the already-
// small 0xbb, which fits in a byte and survives Wine's truncation
// intact.
//
// 2. It gives the launched process its own Job Object (with
// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE), so an aborted build doesn't
// leave orphaned child processes running under Wine.
//
// Usage: toolrelay.exe <real-tool.exe> [args...]
//
// Optional environment variables MSVCGOWINE_STDIN / MSVCGOWINE_STDOUT /
// MSVCGOWINE_STDERR name files to redirect the child's standard handles to
// (used by runViaToolRelay to relay output through named pipes back to the
// Unix side); any that aren't set fall back to this process's own
// inherited handles.
#include <windows.h>
#include <string>
namespace {
constexpr DWORD kMtManifestUnchanged = 0x41020001;
constexpr DWORD kMtManifestUnchangedForCMake = 0xbb;
HANDLE OpenRedirectOrFallback(const wchar_t *envVar, DWORD access,
DWORD disposition, HANDLE fallback) {
wchar_t path[32768];
DWORD n = GetEnvironmentVariableW(envVar, path, ARRAYSIZE(path));
if (n == 0 || n >= ARRAYSIZE(path)) {
return fallback;
}
SECURITY_ATTRIBUTES sa{sizeof(sa), nullptr, TRUE};
HANDLE h = CreateFileW(path, access,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
&sa, disposition, FILE_ATTRIBUTE_NORMAL, nullptr);
return h == INVALID_HANDLE_VALUE ? fallback : h;
}
// CreateProcessW wants a single mutable command line string, quoting each
// argument and escaping embedded quotes.
std::wstring BuildCommandLine(int argc, wchar_t **argv) {
std::wstring cmd;
for (int i = 0; i < argc; i++) {
if (i > 0) cmd += L' ';
cmd += L'"';
for (const wchar_t *c = argv[i]; *c; c++) {
if (*c == L'"') cmd += L'\\';
cmd += *c;
}
cmd += L'"';
}
return cmd;
}
HANDLE MakeKillOnCloseJob() {
HANDLE job = CreateJobObjectW(nullptr, nullptr);
if (!job) return nullptr;
JOBOBJECT_EXTENDED_LIMIT_INFORMATION info{};
info.BasicLimitInformation.LimitFlags =
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK;
SetInformationJobObject(job, JobObjectExtendedLimitInformation, &info, sizeof(info));
return job;
}
bool IsMtExe(const wchar_t *path) {
const wchar_t *name = wcsrchr(path, L'\\');
name = name ? name + 1 : path;
return _wcsicmp(name, L"mt.exe") == 0;
}
} // namespace
int wmain(int argc, wchar_t **argv) {
if (argc < 2) {
return 1;
}
const wchar_t *targetExe = argv[1];
STARTUPINFOW si{sizeof(si)};
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdInput = OpenRedirectOrFallback(L"MSVCGOWINE_STDIN", GENERIC_READ,
OPEN_EXISTING, GetStdHandle(STD_INPUT_HANDLE));
si.hStdOutput = OpenRedirectOrFallback(L"MSVCGOWINE_STDOUT", GENERIC_WRITE,
CREATE_ALWAYS, GetStdHandle(STD_OUTPUT_HANDLE));
si.hStdError = OpenRedirectOrFallback(L"MSVCGOWINE_STDERR", GENERIC_WRITE,
CREATE_ALWAYS, GetStdHandle(STD_ERROR_HANDLE));
HANDLE job = MakeKillOnCloseJob();
std::wstring cmdLine = BuildCommandLine(argc - 1, argv + 1);
PROCESS_INFORMATION pi{};
DWORD exitCode;
if (CreateProcessW(targetExe, cmdLine.empty() ? nullptr : &cmdLine[0], nullptr,
nullptr, TRUE, 0, nullptr, nullptr, &si, &pi)) {
if (job) AssignProcessToJobObject(job, pi.hProcess);
WaitForSingleObject(pi.hProcess, INFINITE);
if (!GetExitCodeProcess(pi.hProcess, &exitCode)) {
exitCode = GetLastError();
}
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
} else {
exitCode = GetLastError();
}
if (IsMtExe(targetExe) && exitCode == kMtManifestUnchanged) {
exitCode = kMtManifestUnchangedForCMake;
}
return static_cast<int>(exitCode);
}