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
+39
View File
@@ -0,0 +1,39 @@
package wrapper
import (
"os"
"strings"
)
// clPostProcess mirrors cl's post-run fixup for `/P /Fi<file>` (preprocess-
// to-file): the generated file still has CRLF endings and z:-prefixed
// #line directives, so run it through the same line-directive rewrite used
// for stdout.
func clPostProcess(origArgs []string) {
var hasP bool
var fiFile string
for _, a := range origArgs {
switch {
case a == "-P" || a == "/P":
hasP = true
case strings.HasPrefix(a, "-Fi") || strings.HasPrefix(a, "/Fi"):
fiFile = a[3:]
}
}
if !hasP || fiFile == "" {
return
}
data, err := os.ReadFile(fiFile)
if err != nil {
return
}
lines := strings.Split(string(data), "\n")
for i, line := range lines {
line = stripCR(line)
if reLineDirective.MatchString(line) {
line = strings.ReplaceAll(stripFirstZDrive(line), `\\`, `/`)
}
lines[i] = line
}
_ = os.WriteFile(fiFile, []byte(strings.Join(lines, "\n")), 0o644)
}
+72
View File
@@ -0,0 +1,72 @@
package wrapper
import (
"regexp"
"strings"
)
// lineFilter rewrites a single line of tool output. nil means "no extra
// rewriting" (CR-stripping still always happens, see stripCR).
type lineFilter func(line string) string
// reZDrive matches the wine "z:" drive prefix followed by a path separator,
// case-insensitively, anywhere in the line - mirroring sed's `s/z:([\\/])/\1/i`
// (no /g flag: only the first occurrence is touched).
var reZDrive = regexp.MustCompile(`(?i)z:([\\/])`)
// stripFirstZDrive removes the first "z:" preceding a path separator,
// keeping the separator itself, matching the original's un-global sed rule.
func stripFirstZDrive(line string) string {
loc := reZDrive.FindStringSubmatchIndex(line)
if loc == nil {
return line
}
sep := line[loc[2]:loc[3]]
return line[:loc[0]] + sep + line[loc[1]:]
}
var (
reNoteIncluding = regexp.MustCompile(`^Note: including file: `)
reLineDirective = regexp.MustCompile(`^[ \t]*#[ \t]*line[ \t]`)
reNoteErrorWarning = regexp.MustCompile(`(?i)^z:.*\([0-9]+\): (note|error c[0-9]{4}|warning c[0-9]{4}): `)
reDumpbinPath = regexp.MustCompile(`^(Dump of file | PDB file found at )`)
)
// clStdoutFilter rewrites cl's "Note: including file:", "#line", and
// note/warning/error diagnostic lines from wine's z:\... notation back to
// plain unix paths.
func clStdoutFilter(line string) string {
switch {
case reNoteIncluding.MatchString(line):
return strings.ReplaceAll(stripFirstZDrive(line), `\`, `/`)
case reLineDirective.MatchString(line):
return strings.ReplaceAll(stripFirstZDrive(line), `\\`, `/`)
case reNoteErrorWarning.MatchString(line):
return strings.ReplaceAll(stripFirstZDrive(line), `\`, `/`)
default:
return line
}
}
// clStderrFilter only rewrites "Note: including file:" lines - cl's stderr
// diagnostics don't carry the z:\... prefix the stdout ones do.
func clStderrFilter(line string) string {
if reNoteIncluding.MatchString(line) {
return strings.ReplaceAll(stripFirstZDrive(line), `\`, `/`)
}
return line
}
// dumpbinStdoutFilter mirrors dumpbin's unixify_path variant.
func dumpbinStdoutFilter(line string) string {
if reDumpbinPath.MatchString(line) {
return strings.ReplaceAll(stripFirstZDrive(line), `\`, `/`)
}
return line
}
// stripCR mirrors the base `s/\r//` rule every wrapper prepends: removes the
// first carriage return in the line (CRLF line endings only ever carry one).
func stripCR(line string) string {
return strings.Replace(line, "\r", "", 1)
}
+78
View File
@@ -0,0 +1,78 @@
package wrapper
import "testing"
func TestClStdoutFilterNoteIncluding(t *testing.T) {
in := `Note: including file: z:\home\user\project\test.h`
want := `Note: including file: /home/user/project/test.h`
if got := clStdoutFilter(in); got != want {
t.Errorf("got %q want %q", got, want)
}
}
func TestClStdoutFilterLineDirective(t *testing.T) {
in := `#line 5 "z:\\home\\user\\project\\test.c"`
want := `#line 5 "/home/user/project/test.c"`
if got := clStdoutFilter(in); got != want {
t.Errorf("got %q want %q", got, want)
}
}
func TestClStdoutFilterErrorLine(t *testing.T) {
in := `z:\home\user\project\test.c(5): warning C4996: 'foo' was declared deprecated`
want := `/home/user/project/test.c(5): warning C4996: 'foo' was declared deprecated`
if got := clStdoutFilter(in); got != want {
t.Errorf("got %q want %q", got, want)
}
}
func TestClStdoutFilterNoteLine(t *testing.T) {
in := `z:\home\user\project\test.h(3): note: see declaration of 'foo'`
want := `/home/user/project/test.h(3): note: see declaration of 'foo'`
if got := clStdoutFilter(in); got != want {
t.Errorf("got %q want %q", got, want)
}
}
func TestClStdoutFilterPassthrough(t *testing.T) {
in := "test.c"
if got := clStdoutFilter(in); got != in {
t.Errorf("got %q want %q", got, in)
}
}
func TestClStderrFilter(t *testing.T) {
in := `Note: including file: z:\a\b.h`
want := `Note: including file: /a/b.h`
if got := clStderrFilter(in); got != want {
t.Errorf("got %q want %q", got, want)
}
// stderr does NOT get the error/warning/line-directive rewrite.
in2 := `z:\a\b.c(1): error C2065: undeclared identifier`
if got := clStderrFilter(in2); got != in2 {
t.Errorf("stderr should pass through error lines unchanged, got %q", got)
}
}
func TestDumpbinStdoutFilter(t *testing.T) {
in := ` PDB file found at z:\a\b\file.pdb`
want := ` PDB file found at /a/b/file.pdb`
if got := dumpbinStdoutFilter(in); got != want {
t.Errorf("got %q want %q", got, want)
}
in2 := `Dump of file z:\a\b\file.exe`
want2 := `Dump of file /a/b/file.exe`
if got := dumpbinStdoutFilter(in2); got != want2 {
t.Errorf("got %q want %q", got, want2)
}
}
func TestStripCR(t *testing.T) {
if got := stripCR("hello\r"); got != "hello" {
t.Errorf("got %q", got)
}
if got := stripCR("no cr here"); got != "no cr here" {
t.Errorf("got %q", got)
}
}
+39
View File
@@ -0,0 +1,39 @@
package wrapper
import (
"os"
"os/exec"
)
// runNative handles the two tool names that need no Wine/MSVC install at
// all: `cmd` (strips a leading "//c" and execs the rest) and `findstr`
// (delegates to grep).
func runNative(tool string, args []string) int {
switch tool {
case "cmd":
if len(args) > 0 && args[0] == "//c" {
args = args[1:]
}
return execInherit(args)
case "findstr":
return execInherit(append([]string{"grep"}, args...))
}
return 127
}
func execInherit(args []string) int {
if len(args) == 0 {
return 0
}
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode()
}
return 127
}
return 0
}
+59
View File
@@ -0,0 +1,59 @@
package wrapper
import (
"os"
"path/filepath"
"regexp"
)
// Argument path rewriting: MSVC/Wine sometimes fails to resolve relative-
// looking includes passed as plain unix absolute paths (see
// https://bugs.winehq.org/show_bug.cgi?id=55200); the fix is to rewrite
// `-I/abs/path` into `-Iz:/abs/path` and similar, trying each option-prefix
// shape in priority order until one matches.
var (
reOpt1 = regexp.MustCompile(`^[-/][A-Za-z](/.*)$`) // -I/path, /I/path
reOpt2 = regexp.MustCompile(`^[-/][A-Za-z][A-Za-z](/.*)$`) // -Fo/path
reOpt3 = regexp.MustCompile(`^[-/][A-Za-z][A-Za-z][A-Za-z]*:(/.*)$`) // -MANIFESTINPUT:/path
reBare = regexp.MustCompile(`^(/.*)$`) // /abs/path alone
)
// RewriteArgs rewrites absolute unix paths embedded in tool arguments into
// "z:/abs/path" form, only when the path's parent directory actually exists
// on disk and isn't "/" - matching the original bash's `[ -d "$(dirname ..)" ]`
// guard, which keeps short flags like "/P" or "/D" untouched.
func RewriteArgs(args []string) []string {
out := make([]string, len(args))
for i, a := range args {
out[i] = rewriteArg(a)
}
return out
}
func rewriteArg(a string) string {
var path string
switch {
case reOpt1.MatchString(a):
path = reOpt1.FindStringSubmatch(a)[1]
case reOpt2.MatchString(a):
path = reOpt2.FindStringSubmatch(a)[1]
case reOpt3.MatchString(a):
path = reOpt3.FindStringSubmatch(a)[1]
case reBare.MatchString(a):
path = reBare.FindStringSubmatch(a)[1]
default:
return a
}
dir := filepath.Dir(path)
if dir == "/" {
return a
}
fi, err := os.Stat(dir)
if err != nil || !fi.IsDir() {
return a
}
prefix := a[:len(a)-len(path)]
return prefix + "z:" + path
}
+49
View File
@@ -0,0 +1,49 @@
package wrapper
import (
"os"
"path/filepath"
"testing"
)
func TestRewriteArg(t *testing.T) {
dir := t.TempDir()
sub := filepath.Join(dir, "inc")
if err := os.Mkdir(sub, 0o755); err != nil {
t.Fatal(err)
}
file := filepath.Join(sub, "foo.h")
if err := os.WriteFile(file, nil, 0o644); err != nil {
t.Fatal(err)
}
cases := []struct {
name string
in string
want string
}{
{"single-letter option + abs dir", "-I" + sub, "-Iz:" + sub},
{"two-letter option + abs file", "-Fo" + file, "-Foz:" + file},
{"long colon option + abs file", "-MANIFESTINPUT:" + file, "-MANIFESTINPUT:z:" + file},
{"bare absolute path", file, "z:" + file},
{"plain flag untouched", "-nologo", "-nologo"},
{"nonexistent dir untouched", "-I/does/not/exist/at/all", "-I/does/not/exist/at/all"},
{"root-level bare path untouched", "/nologo", "/nologo"},
{"relative path untouched", "test.c", "test.c"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := rewriteArg(tc.in); got != tc.want {
t.Errorf("rewriteArg(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestRewriteArgsPreservesOrderAndLength(t *testing.T) {
in := []string{"/nologo", "-c", "test.c"}
out := RewriteArgs(in)
if len(out) != len(in) {
t.Fatalf("length changed: %v -> %v", in, out)
}
}
+255
View File
@@ -0,0 +1,255 @@
package wrapper
import (
"bufio"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"github.com/Cheviiot/msvc-go-wine/internal/wineenv"
)
// toolRelayName is where `msvc-go-wine install` places the compiled
// toolrelay.exe helper, shared across all arch bin dirs.
const toolRelayName = "toolrelay.exe"
// Run executes the named multi-call tool with args, exactly as the original
// bash wrappers would, and returns the process exit code.
func Run(tool string, args []string) int {
if nativeTools[tool] {
return runNative(tool, args)
}
s, ok := Tools[tool]
if !ok {
fmt.Fprintf(os.Stderr, "msvc-go-wine: unknown tool %q\n", tool)
return 127
}
// os.Executable() (backed by /proc/self/exe on Linux) fully resolves
// symlinks, unlike os.Args[0]: not every shell passes a PATH-resolved
// absolute path as argv[0] (some just pass the bare command name), which
// would make an argv[0]-based lookup resolve against the caller's cwd
// instead of the actual install dir. `install` sets each arch dir up
// with its own local copy of the binary precisely so this resolves to
// <dest>/bin/<arch>, not <dest>/bin.
exePath, err := os.Executable()
if err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine:", err)
return 1
}
scriptDir := filepath.Dir(exePath)
cfg, err := wineenv.Load(scriptDir)
if err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine: loading install config:", err)
return 1
}
baseUnix, err := wineenv.FindBaseUnix(scriptDir)
if err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine: locating installation root:", err)
return 1
}
paths := wineenv.NewPaths(cfg, baseUnix)
toolExePath := filepath.Join(s.exeDir(paths), s.exeName)
wineBin, err := wineenv.FindWine()
if err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine:", err)
return 1
}
rewritten := RewriteArgs(args)
var exitCode int
switch {
case s.rawStdout:
// MSBuild: skip all filtering/toolrelay, inherit stdio directly.
cmd := exec.Command(wineBin, append([]string{toolExePath}, rewritten...)...)
cmd.Env = buildEnv(paths)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
exitCode = runAndWait(cmd)
default:
relay := filepath.Join(paths.BaseUnix, "bin", toolRelayName)
if fi, err := os.Stat(relay); err == nil && !fi.IsDir() {
exitCode = runViaToolRelay(wineBin, relay, toolExePath, rewritten, paths, s.stdoutFilter, s.stderrFilter)
} else {
cmd := exec.Command(wineBin, append([]string{toolExePath}, rewritten...)...)
cmd.Env = buildEnv(paths)
cmd.Stdin = os.Stdin
exitCode = runFiltered(cmd, s.stdoutFilter, s.stderrFilter)
}
}
if s.postProcess != nil {
s.postProcess(args)
}
return exitCode
}
// runViaToolRelay runs exePath through the compiled toolrelay.exe helper:
// toolrelay.exe spawns the real tool natively under Windows, redirecting
// its stdio to two named FIFOs we create and read from here. This is what
// lets `mt.exe`'s CMake-compatibility exit code translation (0x41020001 ->
// 0xbb) survive Wine's own exit-code truncation, since toolrelay.exe
// observes the real 32-bit exit code via Win32 before translating and
// re-exiting with a value that fits in a byte.
func runViaToolRelay(wineBin, relayExe, exePath string, args []string, paths *wineenv.Paths, stdoutF, stderrF lineFilter) int {
stdoutFifo := filepath.Join(os.TempDir(), fmt.Sprintf("msvc-go-wine.stdout.%d", os.Getpid()))
stderrFifo := filepath.Join(os.TempDir(), fmt.Sprintf("msvc-go-wine.stderr.%d", os.Getpid()))
os.Remove(stdoutFifo)
os.Remove(stderrFifo)
if err := syscall.Mkfifo(stdoutFifo, 0o600); err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine:", err)
return 1
}
defer os.Remove(stdoutFifo)
if err := syscall.Mkfifo(stderrFifo, 0o600); err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine:", err)
return 1
}
defer os.Remove(stderrFifo)
cmdArgs := append([]string{relayExe, exePath}, args...)
cmd := exec.Command(wineBin, cmdArgs...)
cmd.Env = append(buildEnv(paths), "MSVCGOWINE_STDOUT="+stdoutFifo, "MSVCGOWINE_STDERR="+stderrFifo)
if devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0); err == nil {
defer devNull.Close()
cmd.Stdout = devNull
cmd.Stderr = devNull
}
if err := cmd.Start(); err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine:", err)
return 1
}
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
f, err := os.Open(stdoutFifo) // blocks until toolrelay.exe opens its end
if err != nil {
return
}
defer f.Close()
pumpLines(f, os.Stdout, stdoutF)
}()
go func() {
defer wg.Done()
f, err := os.Open(stderrFifo)
if err != nil {
return
}
defer f.Close()
pumpLines(f, os.Stderr, stderrF)
}()
err := cmd.Wait()
wg.Wait()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode()
}
fmt.Fprintln(os.Stderr, "msvc-go-wine:", err)
return 1
}
return 0
}
func buildEnv(p *wineenv.Paths) []string {
overrides := map[string]string{
"INCLUDE": p.Include,
"LIB": p.Lib,
"LIBPATH": p.LibPath,
"WINEPATH": p.WinePath,
"WINEDLLOVERRIDES": p.WineDLLOverrides,
}
base := os.Environ()
if _, set := os.LookupEnv("WINEDEBUG"); !set {
overrides["WINEDEBUG"] = "-all"
}
out := make([]string, 0, len(base)+len(overrides))
for _, kv := range base {
key := kv[:strings.IndexByte(kv, '=')]
if _, skip := overrides[key]; skip {
continue
}
out = append(out, kv)
}
for k, v := range overrides {
out = append(out, k+"="+v)
}
return out
}
// runFiltered streams stdout/stderr line by line through the tool's
// filters (CR-stripping always applied first), then waits for completion.
func runFiltered(cmd *exec.Cmd, stdoutF, stderrF lineFilter) int {
stdout, err := cmd.StdoutPipe()
if err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine:", err)
return 1
}
stderr, err := cmd.StderrPipe()
if err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine:", err)
return 1
}
if err := cmd.Start(); err != nil {
fmt.Fprintln(os.Stderr, "msvc-go-wine:", err)
return 1
}
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); pumpLines(stdout, os.Stdout, stdoutF) }()
go func() { defer wg.Done(); pumpLines(stderr, os.Stderr, stderrF) }()
wg.Wait()
if err := cmd.Wait(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode()
}
fmt.Fprintln(os.Stderr, "msvc-go-wine:", err)
return 1
}
return 0
}
// pumpLines reads r line by line, CR-stripping and applying filter (if
// non-nil) before writing each line to w.
func pumpLines(r io.Reader, w *os.File, filter lineFilter) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
for scanner.Scan() {
line := stripCR(scanner.Text())
if filter != nil {
line = filter(line)
}
fmt.Fprintln(w, line)
}
}
func runAndWait(cmd *exec.Cmd) int {
if err := cmd.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode()
}
fmt.Fprintln(os.Stderr, "msvc-go-wine:", err)
return 1
}
return 0
}
+58
View File
@@ -0,0 +1,58 @@
// Package wrapper implements the per-tool command dispatch (cl, link, lib,
// ml, ml64, mc, midl, mt, rc, dumpbin, msbuild, nmake, armasm, armasm64, cmd,
// findstr): loading each tool's environment, invoking it under Wine, and
// filtering its output.
package wrapper
import "github.com/Cheviiot/msvc-go-wine/internal/wineenv"
// dirKind selects which install directory a tool's real .exe lives in.
type dirKind int
const (
dirBin dirKind = iota // <bindir> - vc/tools/msvc/<ver>/bin/Host<host>/<arch>
dirSDK // <sdkbindir> - kits/10/bin/<sdkver>/<host>
dirMSBuild
)
type spec struct {
exeName string
dir dirKind
rawStdout bool // MSBuild: skip line filtering, inherit stdio directly
stdoutFilter lineFilter
stderrFilter lineFilter
postProcess func(origArgs []string) // cl: fix up /P /Fi<file> output
}
// Tools lists every multi-call name this binary answers to, besides its own
// management-CLI name.
var Tools = map[string]spec{
"cl": {exeName: "cl.exe", dir: dirBin, stdoutFilter: clStdoutFilter, stderrFilter: clStderrFilter, postProcess: clPostProcess},
"link": {exeName: "link.exe", dir: dirBin},
"lib": {exeName: "lib.exe", dir: dirBin},
"ml": {exeName: "ml.exe", dir: dirBin},
"ml64": {exeName: "ml64.exe", dir: dirBin},
"nmake": {exeName: "nmake.exe", dir: dirBin},
"armasm": {exeName: "armasm.exe", dir: dirBin},
"armasm64": {exeName: "armasm64.exe", dir: dirBin},
"dumpbin": {exeName: "dumpbin.exe", dir: dirBin, stdoutFilter: dumpbinStdoutFilter},
"mc": {exeName: "mc.exe", dir: dirSDK},
"midl": {exeName: "midl.exe", dir: dirSDK},
"mt": {exeName: "mt.exe", dir: dirSDK},
"rc": {exeName: "rc.exe", dir: dirSDK},
"msbuild": {exeName: "MSBuild.exe", dir: dirMSBuild, rawStdout: true},
}
// nativeTools are handled entirely without Wine.
var nativeTools = map[string]bool{"cmd": true, "findstr": true}
func (s spec) exeDir(p *wineenv.Paths) string {
switch s.dir {
case dirSDK:
return p.SDKBinDir
case dirMSBuild:
return p.MSBuildBinDir
default:
return p.BinDir
}
}