Companion page to 3ds Max 9 on Linux using Wine, which covers the usual prefix conventions. This page covers the command-line toolchain only (
cl.exe,link.exe,lib.exe,nmake.exe) — not the Visual Studio IDE, which is not worth fighting under Wine.
Why this exists: the
~/pipeline_exportreference outputs were produced by 3ds Max 2010, a VS2008 x86 build (see the provenance note in the pipeline_max design document, §10i). Reproducing the reference exporter's float arithmetic bit-for-bit therefore means VS2008 x86 codegen. This environment is for building small kernel probe harnesses (decompose/normal/vertex-path kernels compiled with the same compiler, run under Wine, outputs compared against the headless x64 exporters) and, if ever needed, theplugin_maxbinaries themselves.
Ubuntu's own repos are enough — no WineHQ PPA required. wine pulls in both wine64 and wine32:i386 (i.e. 32-bit support) automatically:
sudo dpkg --add-architecture i386 # skip if `dpkg --print-foreign-architectures` already lists i386
sudo apt update
sudo apt install -y wine winetricks xvfb
xvfb is needed on any box with no $DISPLAY (plain ssh, CI, tty-only session) — some Wine/winetricks component installers try to open a window even when silenced, and hang without a virtual display to open it on.
The default
~/.wineprefix defaults to 64-bit on a 64-bit host, and there is no in-place conversion afterward (only delete-and-redo). If~/.winedoesn't exist yet, make sure the firstwine/winebootinvocation ever run on the box setsWINEARCH=win32explicitly, even if you only ever intend to use the dedicatedvs2008prefix below — e.g.:WINEARCH=win32 WINEDLLOVERRIDES="mscoree,mshtml=" xvfb-run -a wineboot -iVerify with
grep '#arch' ~/.wine/system.reg— should read#arch=win32.
Same conventions as the Max 9 page: a dedicated 32-bit prefix under ~/.local/share/wineprefixes/. Unlike ~/.wine, Wine will not create missing parent directories for a custom WINEPREFIX — create the directory yourself first, or the first command below fails with chdir to ... No such file or directory.
Headless / scripted setup — winetricks' unattended mode makes the whole prefix reproducible from a script. Every command sets its own env vars inline (VAR=val command) rather than export, so nothing persists to the shell — repeat the WINEARCH/WINEPREFIX pair on every command that touches this prefix:
mkdir -p ~/.local/share/wineprefixes/vs2008/
WINEARCH=win32 WINEPREFIX=~/.local/share/wineprefixes/vs2008/ WINEDLLOVERRIDES="mscoree,mshtml=" xvfb-run -a wineboot -i
WINEARCH=win32 WINEPREFIX=~/.local/share/wineprefixes/vs2008/ xvfb-run -a winetricks -q vcrun2008 msxml6
Verify with grep '#arch' ~/.local/share/wineprefixes/vs2008/system.reg (expect #arch=win32), and confirm vcrun2008/msxml6 landed: ls ~/.local/share/wineprefixes/vs2008/drive_c/windows/system32/{msvcr90,msxml6}.dll.
A wall of err:ole:StdMarshalImpl_MarshalInterface/err:mscoree:LoadLibraryShim ... registry key for installroot noise during wineboot -i and the vcrun2008 install is expected and harmless — RPC/OLE services and .NET detection aren't available in a minimal headless prefix, and neither is needed here.
Interactive alternative (needs a display): drop the xvfb-run -a/-q/WINEDLLOVERRIDES and run winecfg/winetricks directly; in Winetricks, Install a Windows DLL or component → check vcrun2008 (the CRT the produced binaries and the toolchain itself expect) and msxml6 (some SDK tools want it; harmless to have). The .NET components are only needed if you attempt the full installer (below) — the toolchain itself does not use .NET.
vcrun2008/msxml6 are reliable unattended either way; the dotnet35 verb (Route B only) is slow and flaky under -q, one more reason to prefer Route A.
Route A — copy an existing install (recommended, and the route this page assumes). The compiler does not need registry state; a working install tree is enough. From the existing local VS2008 install, copy into the prefix's drive_c:
Program Files/Microsoft Visual Studio 9.0/VC/ — bin/, include/, lib/ (x86 only; bin/amd64 etc. are irrelevant here)Program Files/Microsoft Visual Studio 9.0/Common7/IDE/ — cl.exe loads mspdb80.dll (and friends msobj80.dll, mspdbcore.dll) from here; either keep the directory and add it to PATH, or copy those DLLs next to cl.exeProgram Files/Microsoft SDKs/Windows/v6.0A/ — Include/ and Lib/ (the Windows SDK headers/libs VS2008 shipped with)If the local install already lives in a Wine prefix (e.g. it was used to build plugin_max), point the environment below at that prefix instead — nothing more to do.
Route B — run the installer under Wine. Known-troublesome: the VS2008 setup chain wants Windows Installer sequencing and .NET 3.5 (winetricks dotnet35 in a win32 prefix, slow and fragile), and partial-failure states are common. If the installer must be used, deselect everything except Visual C++ and expect to iterate. Route A is strictly less pain when any Windows machine or existing install is available.
Wine maps the Unix environment into the Windows process environment, so vcvars32.bat is unnecessary — export the three variables from the shell. Keep a wrapper script, e.g. ~/bin/winecl:
#!/bin/sh
export WINEPREFIX=~/.local/share/wineprefixes/vs2008/
VS='C:\Program Files\Microsoft Visual Studio 9.0'
SDK='C:\Program Files\Microsoft SDKs\Windows\v6.0A'
export INCLUDE="$VS\\VC\\include;$SDK\\Include"
export LIB="$VS\\VC\\lib;$SDK\\Lib"
export WINEPATH="$VS\\VC\\bin;$VS\\Common7\\IDE"
export WINEDEBUG=-all
exec wine "$VS\\VC\\bin\\cl.exe" "$@"
(WINEPATH appends to the Windows PATH; it is how cl.exe finds mspdb80.dll in Common7/IDE without copying DLLs around.)
Smoke test:
printf '#include <stdio.h>\nint main(){printf("%%a\\n", 1.0f/3.0f);return 0;}\n' > /tmp/t.c
winecl /nologo /O2 /Z7 /Fet.exe t.c /link /DEBUG:NONE /INCREMENTAL:NO # run from the source directory
wine t.exe
Expect 0x1.555556p-2 (the hex bit pattern of 1.0f/3.0f).
/Z7, never /Zi. /Zi spawns mspdbsrv.exe (the PDB server process), which is unreliable under Wine and hangs builds. /Z7 embeds the debug info in the object files and never starts it. For probe harnesses just omit debug info entirely.LINK : fatal error LNK1101: incorrect MSPDB80.DLL version. Not actually a DLL version mismatch (verified: cl.exe /c alone always succeeds; the DLLs can be internally consistent and still hit this) — it's link.exe's use of mspdb80.dll for its own PDB-based incremental-link/debug bookkeeping breaking under Wine. Triggered by /DEBUG on the link line, which /Z7 (or /Zi) adds implicitly even though /Z7 needs no separate .pdb. Fix: pass /link /DEBUG:NONE /INCREMENTAL:NO — /INCREMENTAL:NO alone is not sufficient, /DEBUG:NONE is the switch that actually matters. Confirmed harmless to the object code: the resulting .exe still runs and produces correct output, /Z7's debug info stays embedded in the .obj regardless of what the linker does with /DEBUG./Fe:name.exe (colon-attached output name) silently miscompiles the filename — under this Wine setup it writes to a file literally named :name.exe (leading colon), not name.exe. Use the older no-colon form /Fename.exe instead; only the output-filename switch is affected.cl : Command line error D8004 : '/FI' requires an argument. /FI<path> (force-include, e.g. CMake's PCH mechanism) is stricter than /Fo<path>//I<path> about a glued value starting with / — the translate_args wrapper below handles bare tokens but needs a glued-prefix case to also cover this one.#include <WinDef.h> with arbitrary casing, and — worse — vendor SDK headers sometimes #include each other with inconsistent casing (two different casings of the same file defeat #pragma once's file-identity tracking, silently double-processing the header — C2011: class type redefinition is the symptom). ciopfs (case-insensitive FUSE overlay) looked like the fix but turned out to be broken on a stock Ubuntu 24.04 box (doesn't list any subdirectory, even space-free ones, in an isolated test) — a lowercase-alias symlink farm across the SDK trees is the practical fix; see Building ryzomcore's 3ds Max plugin (PluginMax) under Wine for the script.cl @args.rsp); prefer them over heroic shell quoting of \-ridden paths.cl /MP works, but each cl is a Wine process — for the small probe harnesses this page targets, plain serial compiles are simpler and fast enough.To match the Max 2010-era plugin codegen, compile probes the way the plugin was built:
/O2 /fp:precise (VS2008 defaults for release; no /arch:SSE* — the x87 blended default is the point of the exercise)_PC_53); a probe that wants Max-identical behavior should leave it alone (do not call _controlfp), since the reference ran with the CRT default./SUBSYSTEM:CONSOLE; dump results as text (hex float bits, printf("%08x") on the bit pattern) so outputs diff cleanly against the Linux x64 exporters' dumps.decomp_affine and the other decomp.h/geom utilities are exports of Max's own geom.dll (import library geom.lib in the Max 2010 SDK) — they are Autodesk-compiled code, not headers, which is exactly why no recompilation on the Linux side can reproduce their bits. A probe exe that links geom.lib and sits next to Max 2010's geom.dll (plus its direct dependencies; check with winedump -j import geom.dll) runs the actual reference decompose. That is the scoped experiment for the DefaultRotQuat/Scale byte-identity question (design doc §10i). Deeper Max internals (Mesh::buildRenderNormals in mesh.dll) pull progressively more of the Max runtime as dependencies — feasibility drops fast; the angle-weighted replication in pipeline_max_export_shape plus its 1e-4 verdict tier is the accepted answer there.
Verified end-to-end (2026-07-07): CMake's Ninja generator can drive this Wine-hosted cl.exe/link.exe directly — cmake -G Ninja configures, ninja builds, and the resulting .exe runs under wine and produces correct output, no manual winecl invocation needed. Four Wine-specific issues had to be worked around, on top of the raw-invocation traps above; all four are baked into the toolchain file and wrapper scripts below rather than left as manual steps.
Wrapper scripts — ~/bin/wine-vs2008/winecl-{cc,link,lib,mt,rc}, sharing ~/bin/wine-vs2008/winecl-env:
# winecl-env (sourced by the others)
export WINEPREFIX=~/.local/share/wineprefixes/vs2008/
VS='C:\Program Files\Microsoft Visual Studio 9.0'
SDK='C:\Program Files\Microsoft SDKs\Windows\v6.0A'
export INCLUDE="$VS\\VC\\include;$VS\\VC\\atlmfc\\include;$SDK\\Include"
export LIB="$VS\\VC\\lib;$VS\\VC\\atlmfc\\lib;$SDK\\Lib"
export WINEPATH="$VS\\VC\\bin;$VS\\Common7\\IDE"
export WINEDEBUG=-all
# Translate absolute Unix-path arguments to Windows form. MSVC's cl.exe/
# link.exe treat a leading '/' as a switch marker, so an absolute Unix path
# passed as a standalone token (e.g. the bare source file CMake's ABI-check
# passes) gets misparsed as an unrecognized switch. Paths glued directly
# after most recognized prefixes (/Fo<path>, /I<path>, ...) are unaffected
# since cl.exe only inspects the leading characters of the token for its
# prefix, not any '/' that appears deeper in - but /FI (force-include) is
# stricter and rejects a glued value that itself starts with '/' ("/FI
# requires an argument", confirmed empirically against CMake's PCH command
# line) - so glued-prefix forms need the same translation as bare tokens,
# just with the short alpha prefix preserved and only the path part (from
# the second '/' onward) converted.
translate_args() {
ARGS=()
for a in "$@"; do
case "$a" in
/*)
if [ -e "$a" ]; then
ARGS+=("$(winepath -w "$a" 2>/dev/null)")
elif [[ "$a" =~ ^(/[A-Za-z]{1,4})(/.*)$ ]] && [ -e "${BASH_REMATCH[2]}" ]; then
ARGS+=("${BASH_REMATCH[1]}$(winepath -w "${BASH_REMATCH[2]}" 2>/dev/null)")
else
ARGS+=("$a")
fi
;;
*)
ARGS+=("$a")
;;
esac
done
}
# winecl-cc
#!/bin/bash
. "$(dirname "$0")/winecl-env"
translate_args "$@"
exec wine "C:\\Program Files\\Microsoft Visual Studio 9.0\\VC\\bin\\cl.exe" "${ARGS[@]}"
# winecl-link
#!/bin/bash
. "$(dirname "$0")/winecl-env"
translate_args "$@"
# CMake's own `cmake -E vs_link_exe --manifests` helper unconditionally
# appends /MANIFEST:EMBED,ID=1 for MSVC+Ninja regardless of target MSVC
# version - that syntax postdates VS2008's link.exe (LNK1117 syntax
# error). Manifest embedding doesn't matter for these CLI tool/plugin
# builds, so strip it rather than fight CMake's helper or touch the
# project's own (deliberately version-general) linker flags.
FILTERED=()
for a in "${ARGS[@]}"; do
case "$a" in
/MANIFEST:EMBED*) ;;
*) FILTERED+=("$a") ;;
esac
done
exec wine "C:\\Program Files\\Microsoft Visual Studio 9.0\\VC\\bin\\link.exe" "${FILTERED[@]}" /MANIFEST:NO /DEBUG:NONE /INCREMENTAL:NO
winecl-lib, winecl-mt, winecl-rc follow winecl-cc's simple pattern, pointing at lib.exe/mt.exe/RC.Exe respectively (mt.exe/RC.Exe live under the Windows SDK Bin/, not VC/bin/).
No xvfb-run needed for any of these — only the initial wineboot -i/winetricks setup needs a virtual display; plain compiler/linker invocations run fine without one.
Toolchain file — ~/.local/share/wineprefixes/vs2008-toolchain.cmake:
set(CMAKE_SYSTEM_NAME Windows)
set(CMAKE_SYSTEM_PROCESSOR x86)
set(CMAKE_C_COMPILER $ENV{HOME}/bin/wine-vs2008/winecl-cc)
set(CMAKE_CXX_COMPILER $ENV{HOME}/bin/wine-vs2008/winecl-cc)
set(CMAKE_LINKER $ENV{HOME}/bin/wine-vs2008/winecl-link)
set(CMAKE_AR $ENV{HOME}/bin/wine-vs2008/winecl-lib)
set(CMAKE_MT $ENV{HOME}/bin/wine-vs2008/winecl-mt)
set(CMAKE_RC_COMPILER $ENV{HOME}/bin/wine-vs2008/winecl-rc)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
# /Zi triggers cl.exe's own PDB-writing path via mspdb80.dll, which is
# broken under Wine (C1902 at compile time, LNK1101 at link time) - not a
# DLL version mismatch. CMake's default MSVC flags always include /Zi for
# Debug/RelWithDebInfo, even for the plain "does this compiler work"
# sanity check run before CMAKE_BUILD_TYPE is consulted. The toolchain
# file runs before Platform/Windows-MSVC.cmake sets the
# CMAKE_*_FLAGS_<CONFIG>_INIT defaults, so patching those here is a no-op
# - instead force the final CACHE entries directly (with FORCE), so the
# platform module's later non-FORCE `set(... CACHE ...)` becomes a no-op
# and our /Z7 value sticks.
#
# /MDd (default Debug CRT) needs msvcr90d.dll/msvcp90d.dll, which live in
# the toolchain archive's VC/redist/Debug_NonRedist/ but aren't installed
# to system32 (only the release CRT is, via winetricks vcrun2008) -
# running an /MDd binary fails silently (exit 53, no output). /MD is also
# the semantically correct choice regardless: Max's own exporters were
# Release builds.
set(CMAKE_C_FLAGS_DEBUG "/MD /Z7 /Ob0 /Od /RTC1" CACHE STRING "" FORCE)
set(CMAKE_CXX_FLAGS_DEBUG "/MD /Z7 /Ob0 /Od /RTC1" CACHE STRING "" FORCE)
set(CMAKE_C_FLAGS_RELWITHDEBINFO "/MD /Z7 /O2 /Ob1 /DNDEBUG" CACHE STRING "" FORCE)
set(CMAKE_CXX_FLAGS_RELWITHDEBINFO "/MD /Z7 /O2 /Ob1 /DNDEBUG" CACHE STRING "" FORCE)
# Manifest embedding goes through mt.exe's /notify_update incremental
# contract, which signals via specific exit codes CMake expects - our
# wine-wrapped mt.exe doesn't reproduce that contract and the merge step
# fails (separately from the /Zi issue above). Irrelevant for CLI
# tool/probe binaries anyway - disable it outright rather than debug
# mt.exe's exit-code semantics under Wine.
set(CMAKE_EXE_LINKER_FLAGS_INIT "/MANIFEST:NO")
set(CMAKE_SHARED_LINKER_FLAGS_INIT "/MANIFEST:NO")
set(CMAKE_MODULE_LINKER_FLAGS_INIT "/MANIFEST:NO")
Usage, on any trivial CMakeLists.txt + Ninja generator:
cmake -G Ninja -DCMAKE_TOOLCHAIN_FILE=~/.local/share/wineprefixes/vs2008-toolchain.cmake /path/to/src
ninja
wine ./hello.exe
CMake's compiler-ID detection (The C compiler identification is MSVC 15.0.30729.1) and ABI detection both work transparently through the wrapper — no special-casing needed there, since try_compile's marker-string check just inspects the compiled object, and only actually running a cross-compiled binary would need CMAKE_CROSSCOMPILING_EMULATOR (not set up here, not needed for configure/build).
CMAKE_FIND_ROOT_PATH_MODE_LIBRARY/_INCLUDEare set toONLYabove, which is fine for this trivial standalone-probe example (nofind_packagecalls to trip it), but is a real trap for anything usingFind*.cmakemodules: withCMAKE_FIND_ROOT_PATHempty (never set here),ONLYmode makesfind_path/find_libraryfind nothing at all, even with correct explicitHINTS— it only engages during a genuineproject()-driven cross-compile, not in a barecmake -Ptest, which made it easy to miss at first. Building the real ryzomcore PluginMax target usesNEVERfor all three instead.
Pointing this at the real ryzomcore build: see Building ryzomcore's 3ds Max plugin (PluginMax) under Wine — verified end-to-end, covers the additional environment variables, the VC_DIR shadow-directory trick FindMSVC.cmake needs, the case-insensitivity symlink farm the Max SDK/Windows SDK/externals need, the exact CMake flags matching tool/quick_start/configure_targets.py's real PluginMax spec, and two genuine upstream CMakeModules/FindWindowsSDK.cmake bugs found along the way.
mspdb80.dll not found — verify WINEPATH includes Common7\IDE, or copy mspdb80.dll, mspdbcore.dll, msobj80.dll next to cl.exe.
Mixed mspdb* DLL versions between VC/bin and Common7/IDE (e.g. trees copied from different service-pack levels). Copy both directories from the same install.
LIB doesn't reach the Windows SDK Lib directory — the VC lib directory alone only has the CRT.