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
+46
View File
@@ -0,0 +1,46 @@
// Package wineenv computes the environment (INCLUDE/LIB/WINEPATH/...) needed
// to run a Wine-hosted MSVC tool.
package wineenv
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// ConfigFileName is the per-architecture config dropped next to the tool
// symlinks by `msvc-go-wine install`.
const ConfigFileName = "env.json"
// Config is the per-architecture info generated at install time.
type Config struct {
Arch string `json:"arch"` // x86, x64, arm, arm64 (target arch)
Host string `json:"host"` // x64 or arm64 (Host<Host> bin dir suffix)
DotnetHost string `json:"dotnet_host"` // amd64 or arm64 (.NET tool host dir suffix)
MSVCVer string `json:"msvc_ver"`
SDKVer string `json:"sdk_ver"`
}
// Load reads env.json from dir (the directory the running binary was
// invoked from, i.e. <dest>/bin/<arch>).
func Load(dir string) (*Config, error) {
data, err := os.ReadFile(filepath.Join(dir, ConfigFileName))
if err != nil {
return nil, fmt.Errorf("reading %s: %w", ConfigFileName, err)
}
var c Config
if err := json.Unmarshal(data, &c); err != nil {
return nil, fmt.Errorf("parsing %s: %w", ConfigFileName, err)
}
return &c, nil
}
// Save writes env.json into dir.
func (c *Config) Save(dir string) error {
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, ConfigFileName), data, 0o644)
}
+108
View File
@@ -0,0 +1,108 @@
package wineenv
import (
"os"
"path/filepath"
"strings"
)
// Paths holds every path/env-var derived from a Config plus the on-disk
// installation root - INCLUDE, LIB, WINEPATH and friends, in the Windows
// notation Wine expects.
type Paths struct {
BaseUnix string // <dest>, absolute unix path
MSVCDirUnix string // <dest>/vc/tools/msvc/<ver>
BinDir string // <dest>/vc/tools/msvc/<ver>/bin/Host<host>/<arch> - cl/link/lib/ml/nmake/armasm live here
SDKBinDir string // <dest>/kits/10/bin/<sdkver>/<host> - mc/midl/mt/rc live here
MSBuildBinDir string // <dest>/MSBuild/Current/Bin/<dotnetHost> - MSBuild.exe lives here
Include string
Lib string
LibPath string
WinePath string
WineDLLOverrides string
}
// FindBaseUnix locates the installation root starting from the directory the
// tool wrapper was invoked from (scriptDir), searching one or two levels up
// for a "vc" entry - matching the original wrappers' `dirname "$0"`/".." walk,
// which tolerates wrappers living either directly in <dest>/bin/<arch> or
// nested one level deeper.
func FindBaseUnix(scriptDir string) (string, error) {
base, err := filepath.Abs(filepath.Join(scriptDir, ".."))
if err != nil {
return "", err
}
if _, err := os.Stat(filepath.Join(base, "vc")); err != nil {
base = filepath.Join(base, "..")
}
return filepath.Abs(base)
}
// NewPaths computes all derived paths/env-vars for cfg installed at baseUnix.
func NewPaths(cfg *Config, baseUnix string) *Paths {
winBase := "z:" + toWindowsSlashes(baseUnix)
msvcBase := winBase + `\vc`
sdkBase := winBase + `\kits\10`
msvcDirWin := msvcBase + `\tools\msvc\` + cfg.MSVCVer
sdkIncludeWin := sdkBase + `\include\` + cfg.SDKVer
sdkLibWin := sdkBase + `\lib\` + cfg.SDKVer
msvcDirUnix := filepath.Join(baseUnix, "vc", "tools", "msvc", cfg.MSVCVer)
binDir := filepath.Join(msvcDirUnix, "bin", "Host"+cfg.Host, cfg.Arch)
sdkBinDir := filepath.Join(baseUnix, "kits", "10", "bin", cfg.SDKVer, cfg.Host)
msbuildBinDir := filepath.Join(baseUnix, "MSBuild", "Current", "Bin", cfg.DotnetHost)
include := strings.Join([]string{
msvcDirWin + `\atlmfc\include`,
msvcDirWin + `\include`,
sdkIncludeWin + `\shared`,
sdkIncludeWin + `\ucrt`,
sdkIncludeWin + `\um`,
sdkIncludeWin + `\winrt`,
sdkIncludeWin + `\km`,
}, ";")
lib := strings.Join([]string{
msvcDirWin + `\atlmfc\lib\` + cfg.Arch,
msvcDirWin + `\lib\` + cfg.Arch,
sdkLibWin + `\ucrt\` + cfg.Arch,
sdkLibWin + `\um\` + cfg.Arch,
sdkLibWin + `\km\` + cfg.Arch,
}, ";")
// WINEPATH: unix dirs with slashes swapped for backslashes (no drive
// letter - Wine accepts this for locating DLLs), plus the MSVC host bin
// dir in full windows notation, deliberately always the *host* (not
// target) x64/arm64 tool dir for the third entry - that's where DLLs
// like mspdbcore.dll actually live.
winePath := strings.Join([]string{
toWindowsSlashes(binDir),
toWindowsSlashes(sdkBinDir),
msvcDirWin + `\bin\Host` + cfg.Host + `\` + cfg.Host,
}, ";")
return &Paths{
BaseUnix: baseUnix,
MSVCDirUnix: msvcDirUnix,
BinDir: binDir,
SDKBinDir: sdkBinDir,
MSBuildBinDir: msbuildBinDir,
Include: include,
Lib: lib,
LibPath: lib,
WinePath: winePath,
WineDLLOverrides: "vcruntime140=n;vcruntime140_1=n",
}
}
func toWindowsSlashes(p string) string {
return strings.ReplaceAll(p, "/", `\`)
}
// ToWinPath prefixes a unix absolute path with the "z:" drive Wine maps the
// unix root to, converting slashes to backslashes.
func ToWinPath(unixPath string) string {
return "z:" + toWindowsSlashes(unixPath)
}
+56
View File
@@ -0,0 +1,56 @@
package wineenv
import "testing"
// Values lifted from the original wrappers/cl template
// (MSVCVER=14.13.26128, SDKVER=10.0.16299.0, ARCH=x86) to cross-check the Go
// port produces byte-identical INCLUDE/LIB strings.
func TestNewPathsMatchesOriginalTemplate(t *testing.T) {
cfg := &Config{
Arch: "x86",
Host: "x64",
DotnetHost: "amd64",
MSVCVer: "14.13.26128",
SDKVer: "10.0.16299.0",
}
p := NewPaths(cfg, "/opt/msvc")
wantInclude := `z:\opt\msvc\vc\tools\msvc\14.13.26128\atlmfc\include;` +
`z:\opt\msvc\vc\tools\msvc\14.13.26128\include;` +
`z:\opt\msvc\kits\10\include\10.0.16299.0\shared;` +
`z:\opt\msvc\kits\10\include\10.0.16299.0\ucrt;` +
`z:\opt\msvc\kits\10\include\10.0.16299.0\um;` +
`z:\opt\msvc\kits\10\include\10.0.16299.0\winrt;` +
`z:\opt\msvc\kits\10\include\10.0.16299.0\km`
if p.Include != wantInclude {
t.Errorf("INCLUDE mismatch:\n got: %s\nwant: %s", p.Include, wantInclude)
}
wantLib := `z:\opt\msvc\vc\tools\msvc\14.13.26128\atlmfc\lib\x86;` +
`z:\opt\msvc\vc\tools\msvc\14.13.26128\lib\x86;` +
`z:\opt\msvc\kits\10\lib\10.0.16299.0\ucrt\x86;` +
`z:\opt\msvc\kits\10\lib\10.0.16299.0\um\x86;` +
`z:\opt\msvc\kits\10\lib\10.0.16299.0\km\x86`
if p.Lib != wantLib {
t.Errorf("LIB mismatch:\n got: %s\nwant: %s", p.Lib, wantLib)
}
if p.WineDLLOverrides != "vcruntime140=n;vcruntime140_1=n" {
t.Errorf("WINEDLLOVERRIDES mismatch: %s", p.WineDLLOverrides)
}
wantBinDir := "/opt/msvc/vc/tools/msvc/14.13.26128/bin/Hostx64/x86"
if p.BinDir != wantBinDir {
t.Errorf("BinDir mismatch:\n got: %s\nwant: %s", p.BinDir, wantBinDir)
}
wantSDKBinDir := "/opt/msvc/kits/10/bin/10.0.16299.0/x64"
if p.SDKBinDir != wantSDKBinDir {
t.Errorf("SDKBinDir mismatch:\n got: %s\nwant: %s", p.SDKBinDir, wantSDKBinDir)
}
wantMSBuildBinDir := "/opt/msvc/MSBuild/Current/Bin/amd64"
if p.MSBuildBinDir != wantMSBuildBinDir {
t.Errorf("MSBuildBinDir mismatch:\n got: %s\nwant: %s", p.MSBuildBinDir, wantMSBuildBinDir)
}
}
+18
View File
@@ -0,0 +1,18 @@
package wineenv
import (
"fmt"
"os/exec"
)
// FindWine locates the wine binary to use, preferring wine64 like the
// original wrappers (`command -v wine64 || command -v wine`).
func FindWine() (string, error) {
if p, err := exec.LookPath("wine64"); err == nil {
return p, nil
}
if p, err := exec.LookPath("wine"); err == nil {
return p, nil
}
return "", fmt.Errorf("neither wine64 nor wine found in PATH")
}