Direct3D 9 translation layer for Wine on macOS, backed by Metal.
mtld3d replaces Wine's built-in d3d9.dll with an implementation that translates D3D9 calls through Wine's PE/Unix boundary into Metal command buffers on the host. The pure-Rust core (mtld3d-core) handles DXSO → MSL shader translation, render-pass scheduling, and fixed-function state.
Download the release bundle (mtld3d.tar.xz) from
GitHub Releases. It runs on Apple
Silicon macOS 15 or newer with Rosetta 2, inside Wine 8.0+ or CrossOver 24+
(64-bit prefix). The bundle supports two install routes:
- Builtin: the bundle's
wine/tree mirrorslib/wine/with every PE builtin-marked. Dropped into a Wine installation you own, it replaces the stock d3d9 for that whole tree. - Native override: the bundle's
native/tree holds an unmarkedd3d9.dll, loaded per game (or per prefix) through ad3d9=nativeDLL override while the Wine installation stays untouched. This is the route for CrossOver bottles, where it is self-contained in the bottle and survives CrossOver updates.
Step-by-step walkthroughs for stock Wine and CrossOver, and the tradeoffs
between the routes, live in INSTALL.md, which also ships
inside the bundle.
D3D9-era games do their floating-point math in x87 instructions, which Rosetta 2 translates slowly. For full performance, run the game together with x87sidecar, a JIT that replaces Rosetta's x87 handling. Its cooperative attach mode requires a Wine that performs the sidecar handshake at startup: the Wine builds from wine-build carry that patch, which lets the x87sidecar binary work without any entitlements.
mtld3d aims to be the most faithful Direct3D 9 implementation for Wine on Apple Silicon: correctness against native D3D9 behaviour first, performance second. Direct3D 8 support on the same core is planned. Every other Direct3D version is a non-goal; D3D10/11/12 are already well served on macOS by Apple's D3DMetal and by DXMT.
Whatever is not implemented is reported honestly, through capability bits or the documented error returns, so applications take their own fallback paths instead of breaking.
- Programmable pipeline: vertex and pixel shader models 1.x through 3.0 (DXSO bytecode → MSL translation), including vPos/vFace, flat shading, and the D3D9 half-pixel rasterization convention.
- Fixed-function pipeline, vertex and pixel: lighting (directional, point, spot), texture-coordinate generation, the full texture-stage cascade, material color sources, hardware vertex blending.
- Fog: vertex fog and per-pixel table fog, across the fixed-function, pre-transformed (RHW), and programmable paths.
- All four draw paths: DrawPrimitive / DrawIndexedPrimitive and both UP variants.
- State blocks: recorded (Begin/End) and D3DSBT_* snapshots.
- Queries: occlusion queries backed by real Metal visibility results; event queries.
- Resources: DXT1–5 and ATI1 compressed textures, the common uncompressed integer and float formats, volume textures, mipmap auto-generation, managed-pool dirty-region uploads, StretchRect (including cross-format blits via a conversion pass), GetDC read-back.
- Depth: sampleable depth textures (INTZ, DF16, DF24) with hardware shadow-compare PCF, depth bias and slope-scale bias, depth clamp for pre-transformed geometry.
- Sampling and output: anisotropic filtering, sRGB read (compressed formats) and sRGB write, alpha test, scissor, separate alpha blend, blend factor, color write masks.
- Presentation: windowed and fullscreen swap chains, adapter mode
enumeration, hardware color cursors. A fullscreen device takes its window
borderless over the monitor without ever changing the display mode; the back
buffer follows the window, and
render.scaledecides how many pixels are actually rendered before MetalFX upscales the result to the screen.
Missing features a D3D9 application can reasonably want. Each fails cleanly, with an absent cap bit or a documented error return, so applications fall back:
- Stencil: the caps report no stencil support; stencil render states are ignored and stencil clears are skipped.
- MSAA: multisampled creates are rejected; CheckDeviceMultiSampleType only accepts D3DMULTISAMPLE_NONE.
- Multiple render targets: a single simultaneous render target is advertised.
- Cube-map sampling: the cap is off and cube textures are never GPU-backed. CreateCubeTexture in the CPU pools still returns a real, lockable cube texture (faces work via GetCubeMapSurface/LockRect), so applications that probe cube support degrade gracefully; DEFAULT-pool creates are rejected.
- Point sprites and non-solid fill modes (Metal has no native wireframe).
- TIMESTAMP and the other niche query types: creation reports NOTAVAILABLE, as the spec allows.
- YUV conversion: YUY2/UYVY surfaces can be created and locked, but no YUV→RGB blit is performed.
- Depth→depth StretchRect.
- D3D9Ex: no Direct3DCreate9Ex, no shared resource handles, no D3D9On12. The extended interface is a different contract (device removal, OS-managed memory) built for the Vista+ compositor; the games this project targets are plain D3D9.
- Display-mode switching: a fullscreen device owns its window but never
the desktop mode. Wine's mac driver would hand the request to
CGDisplaySetDisplayMode, which reconfigures the user's screen and rearranges every other window, and avoiding that would depend on a Wine registry setting. A monitor-covering borderless window keepsGetClientRect, mouse input and the back buffer in agreement without any of it, so the resolution a game picks in fullscreen is ignored andrender.scalecontrols the render resolution instead. - The fullscreen focus lifecycle: the focus dance around a fullscreen device is not emulated — no device loss on deactivation, no focus-window subclassing, no synthesized activation messages. Presentation is a composited Metal layer, so a lost display is never a lost device.
- Software paths: no reference or software rasterizer, no software vertex processing, no ProcessVertices, no RegisterSoftwareDevice. HAL on the default Metal device is the only device type; multi-adapter setups are not enumerated.
- Hardware instancing (SetStreamSourceFreq): the renderer is single-stream by architecture.
- Clip-plane application: SetClipPlane state round-trips but planes are not applied on the GPU.
- Legacy remnants: N-patch/RT-patch tessellation, vertex tweening, palettized textures, gamma ramp. Dead features in real-world content, accepted or rejected per spec but non-functional.
mtld3d is developed and tested against World of Warcraft 1.12 and 3.3.5a under Wine and CrossOver. No other games have been exercised yet; reports are welcome.
Beyond the game workloads, the implementation is hardened against Wine's
d3d9 test suite, the de-facto D3D9 conformance suite. A dedicated runner
(make conformance) executes it against the installed builtin on both PE
architectures and gates on a per-site tracked baseline in which every
remaining divergence is triaged and classified with a documented rationale.
The methodology, classification scheme, and current audit live in
unix/conformance/CONFORMANCE.md. The
unit and end-to-end suites (make test) run the pure-Rust core natively on
the host and the full stack under Wine.
mtld3d builds and runs on Apple Silicon macOS. The shipped mtld3d.so is
an x86_64 Mach-O that runs under Rosetta 2 inside Wine (install it with
softwareupdate --install-rosetta), and the Metal backend targets macOS 15
or newer (unix/.cargo/config.toml pins MACOSX_DEPLOYMENT_TARGET = 15.0).
The following must be available on PATH:
- A Wine build or install providing
winebuild,wine,wineserver, andwineboot, plus its development tree (lib/wine/{i386,x86_64}-windows/andlibwinecrt0.a). - Homebrew, which
make setupuses to install LLVM and lld. - A rustup toolchain: stable (1.97 or newer, per
rust-versionin the Cargo manifests) for builds, nightly formake fmt(rustfmt.tomluses nightly-only options).
Two environment variables drive the Makefile. WINE_SDK points at the Wine
development tree consumed by windows/shim/build.rs for libwinecrt0.a /
ntdll.a linking, and must be exported before running any target, including
make setup. WINE_INSTALL_DIR is where make install copies the built
binaries (typically the same path as WINE_SDK).
make setup # install cross-compilation toolchain (first time only)
make # build all (windows i386+x64 + unix)
make install # install to Wine distribution
make bundle # pack the distributable tarball + its debug symbols (PROD=1 by default)
make test # build, install, run i386 + x64 test binaries
make check # the pre-commit gate: fmt + clippy + audit + doc
make fmt # format all workspaces (requires nightly)
make clippy # run clippy on all workspaces
make audit # the conventions clippy can't express (see docs/CONVENTIONS.md)
make doc # build the docs with rustdoc warnings denied
make clean # cargo clean both workspaces
make upgrade # cargo update (semver-compatible) in both workspaces
make upgrade-incompat # cargo upgrade --incompatible + cargo update; requires cargo-editmake setup installs LLVM and lld via Homebrew, adds the
i686-pc-windows-msvc, x86_64-pc-windows-msvc, and x86_64-apple-darwin
rustup targets, installs xwin and cargo-edit, and splats the Windows SDK
(~3 GB) to /opt/xwin; creating that root-owned directory takes a one-time
sudo prompt. It does not install Wine or Rosetta. Windows targets
cross-compile from macOS via lld-link + xwin (see
windows/.cargo/config.toml); unix/ targets x86_64-apple-darwin
explicitly because Wine's unix .so must be x86_64 Mach-O. The internal
crates use path dependencies and are not published to crates.io.
make install copies the PE DLLs into lib/wine/{i386,x86_64}-windows/ and
the unix .so into lib/wine/x86_64-unix/ under WINE_INSTALL_DIR, stamping
the Wine-builtin signature onto the d3d9.dll copies (the loader ignores
unsigned PEs on the builtin search path). The build outputs themselves stay
unsigned native PEs so they can also be deployed as a native DLL override;
make bundle packs both flavors into windows/target/mtld3d.tar.xz, the
release bundle described in INSTALL.md.
Debug symbols travel with each binary: a .pdb beside every PE, and a .dSYM
beside the .so (on Mach-O the DWARF otherwise stays behind in the compiler's
object files, so make runs dsymutil to gather it). make install copies
both, and make bundle writes a second archive,
windows/target/mtld3d-debug.tar.xz, holding the symbols for exactly that
bundle. Every DLL logs its release and its linker-assigned image ID on load, so
a crash report from a release build names the debug archive that symbolicates
it.
test.exe → d3d9.dll → mtld3d.dll → mtld3d.so
(i386 PE) (i386 PE) (i386 PE) (x86_64 Mach-O)
test.exe → d3d9.dll → mtld3d.dll → mtld3d.so
(x64 PE) (x64 PE) (x64 PE) (x86_64 Mach-O)
d3d9.dll: D3D9 API implementation, COM vtables, caps, state management.mtld3d.dll: PE shim that owns Wine's unix-call globals and exportsmtld3d_unix_call.mtld3d.so: native macOS side, a pure Metal abstraction layer.mtld3d-core: host-testable pure-Rust rlib linked intod3d9.dll.
At runtime the frame flows through a three-thread pipeline:
API thread (the game's) Encoder thread Submit thread
─────────────────────── ────────────── ─────────────
record frame N+1 → encode frame N → submit + present frame N−1
- The API thread is the game's own render thread and the frame-time
bottleneck, so it never waits on translation, Metal, or the GPU. Each D3D9
call only snapshots the state it needs into a closure on the current
frame's op list;
Presenthands the finished frame to the encoder and immediately starts recording the next. - The encoder thread (one per device) runs the closures: D3D9 → Metal translation, render-pass scheduling and load/store optimization, pipeline and sampler caches, lazy resource creation and texture uploads.
- The submit thread crosses the PE/Unix boundary to replay the encoder's finished command stream into Metal encoders, waits for the drawable, and presents and commits, overlapping the encoder's build of the next frame.
Each hand-off has capacity one, so the pipeline never runs more than one frame ahead per stage: backpressure, not queueing, bounds latency.
For the PE/Unix boundary contract, the threading details, perf instrumentation, and the shader/heap debugging toolkits, see docs/ARCHITECTURE.md.
Two Cargo workspaces, one per target platform: windows/ builds the PE side
for i686-pc-windows-msvc and x86_64-pc-windows-msvc, unix/ builds the
Mach-O side for x86_64-apple-darwin. Open each in a separate editor window
for rust-analyzer to work correctly. The shipped crates and their outputs
(the workspaces also hold test, types, and conformance support crates):
| Crate | Workspace | Output |
|---|---|---|
d3d9 |
windows/ |
d3d9.dll |
mtld3d |
windows/ |
mtld3d.dll |
mtld3d-core |
windows/ |
rlib (linked into d3d9.dll) |
mtld3d-unix |
unix/ |
mtld3d.so |
shared |
unix/ |
rlib (shared by d3d9.dll, mtld3d.dll, mtld3d.so) |
mtld3d-core is a pure-Rust rlib holding every platform-independent helper:
DXSO → MSL emission, the render-pass state machine, the slab allocator,
format / FVF / vertex-decl / dirty-rect math, fixed-function state. It
compiles on both Windows PE and the macOS host, so
cargo test -p mtld3d-core --target aarch64-apple-darwin runs unit tests
natively instead of through Wine.
unix/shared is the crate every linkage unit depends on. Its primary role is
the PE↔Unix wire format (the Command enum, the Thunks enum, param
structs, typed mtl:: wire values). Pure data and pure-Rust helpers only, no
FFI and no #[link], so both workspaces can depend on it cleanly.
User-facing runtime options live in mtld3d.conf (key = value, comments are
#-prefixed). mtld3d reads it once at Direct3DCreate9 from the directory
of the running .exe; restart the game to pick up edits. A missing file is
fine, defaults apply. The repo root holds a sample with every option, its
default, and a short explanation.
Every key is also overridable at process launch via MTLD3D_CONFIG, a
semicolon-separated list of key=value entries using the same syntax as the
file. Env entries are merged on top of the file, and env wins on conflict.
Resolved options are logged once at startup under
RUST_LOG=mtld3d::d3d9=info; unknown keys, malformed lines, and unparseable
values warn once instead of silently falling back to the default.
Every crate logs via log + env_logger. All targets sit under mtld3d::*
and env_logger matches by ::-separated prefix, so RUST_LOG=mtld3d=warn
is the single switch for the whole project. Examples: RUST_LOG=mtld3d=warn,
RUST_LOG=mtld3d::d3d9=trace, RUST_LOG=mtld3d=warn,mtld3d::unix=trace,
RUST_LOG=mtld3d::perf=info.
| Target | Scope |
|---|---|
mtld3d::d3d9 |
windows/d3d9/ + windows/core/ (everything except dxso and perf) |
mtld3d::d3d9::cursor |
hardware cursor (HCURSOR) lifecycle, bitmap cache, wndproc |
mtld3d::dxso |
DXSO → MSL emitter |
mtld3d::perf |
5-second averaged performance summary (PERF=1 builds only) |
mtld3d::shim |
Wine unix-call PE shim DLL |
mtld3d::unix |
Metal-side .so |
Levels: info! for one-shot milestones, warn! for unimplemented stubs and
fallback paths, error! for unexpected internal failures, trace! for
per-call breadcrumbs, debug! for routine per-call noise useful in deep
debugging.
Each cdylib initializes the logger independently and idempotently;
mtld3d.so has no owning entry point, so d3d9.dll dispatches a one-shot
InitLogger thunk from its init path. The mtld3d::perf summary and its
counter aggregation rules are documented in
docs/ARCHITECTURE.md.
The development conventions (module layout, visibility, encapsulation,
unsafe discipline, warning suppressions, the log_once_warn! rule,
doc-comment shape, typed objc2-* bindings) live in
docs/CONVENTIONS.md.
Run make check before every commit: cargo fmt --check, clippy with
nursery and pedantic enabled, make audit (the rules clippy cannot
express), and make doc (rustdoc, so doc links have to resolve). The check
legs deny every warning via cargo's build.warnings = "deny"; normal builds
and a plain cargo clippy only warn. Each audit finding names the section of
docs/CONVENTIONS.md it comes from.
zlib.