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.
mtld3d aims to be the fastest Direct3D 9 implementation for Wine on macOS. Direct3D 8, on the same core, is planned and part of that goal. Every other Direct3D version is a non-goal; D3D10/11/12 are already well served on macOS by Apple's D3DMetal and by DXMT.
Conformance serves that goal rather than defining it: the implementation is
developed against Wine's d3d9 test suite and every divergence it reports is
triaged and written down (see Testing). But where matching D3D9
exactly would cost frame time, speed wins, as long as the divergence does not
keep a game from running. Those trades are listed under
Faster than conformant, and where a knob makes sense
they are revertible from mtld3d.conf.
Apple Silicon is what is developed and tested today. Intel Macs are a goal, not a claim: the non-uniform-memory paths are written but unverified on real hardware.
Download the release bundle (mtld3d.tar.xz) from
GitHub Releases.
INSTALL.md, which also ships inside the bundle, has the
requirements and the walkthroughs for both routes: builtin, which drops
into a Wine installation you own and replaces the stock d3d9 for that whole
tree, and native override, loaded per game through d3d9=native while the
Wine installation stays untouched, which is what
CrossOver bottles use. It also covers
x87sidecar, worth running under an
x86_64 Wine because Rosetta 2 is slow at the x87 math D3D9-era games do.
- 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.
- Vertex streams: all sixteen
SetStreamSourcestreams feed a draw, with per-stream offsets and strides; a declared stream with nothing bound reads zeros, as on hardware. - Hardware instancing:
SetStreamSourceFreqwithINDEXEDDATA/INSTANCEDATA, including per-instance step rates, on the indexed draws (D3D9 never instances a non-indexed draw). - 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, cube and 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.
- Stencil: the full test, both faces, and every stencil operation, with
Clear(D3DCLEAR_STENCIL)bounded to the viewport independently of depth. - Sampling and output: anisotropic filtering, sRGB read (compressed formats) and sRGB write, alpha test, scissor, separate alpha blend, blend factor, color write masks.
- Multiple render targets: four simultaneous targets with independent formats and write masks, post-pixel-shader blending on each, and Clear reaching every bound target.
- Presentation: windowed and fullscreen swap chains, adapter mode
enumeration, hardware color cursors, and MetalFX upscaling on the way to the
screen with
render.scalechoosing the render resolution. Fullscreen never changes the display mode; what it does instead is under Display-mode switching. - HDR: on an EDR-capable display the layer is upgraded to
extendedDynamicRangeand present routes through a BT.2446-A inverse-tone-mapping pass in ICtCp, its peak following the display's live headroom. On by default;color.hdr.enableandcolor.spacecontrol it.
Missing features a D3D9 application can reasonably want. Each fails cleanly, with an absent cap bit or a documented error return, so applications take their own fallback paths instead of breaking:
- MSAA: multisampled creates are rejected; CheckDeviceMultiSampleType only accepts D3DMULTISAMPLE_NONE.
- 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.
Divergences from D3D9 kept on purpose, because closing them costs frame time and no game depends on them:
GetData(D3DGETDATA_FLUSH)returnsS_OKimmediately for a pending occlusion query instead of blocking until the GPU has the count. Games use that poll loop as a fence against 2004-era drivers without hazard tracking, which Metal does explicitly, so the wait buys no correctness and stalls the API thread, the frame-time bottleneck.query.flushImmediate = falserestores the spec-correct wait.- Depth stores are elided where nothing reads the buffer back, so content that relies on depth surviving a pass it never cleared can read stale depth. Preserving it unconditionally would cost the optimization on every frame of every game that does clear, which is all of the tested ones.
- 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, which Wine's mac driver would set through
CGDisplaySetDisplayMode, rearranging every other window on the screen. The window goes borderless over the monitor instead, while the back buffer keeps the display mode the game picked, exactly as a real mode-set would leave it, and present scales the frame to the display (MetalFX when enlarging). A request matching no enumerable mode, which a real mode-set would reject, follows the window instead: games that ask for such sizes derived them from their window and keep sizing their rendering and input from it. - The fullscreen focus lifecycle: 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.
- 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 it is hardened against Wine's d3d9 test suite,
the de-facto D3D9 conformance suite. make conformance runs it against the
installed builtin, one runner process per PE architecture, and gates on a
per-site baseline; every remaining divergence is classified with a written
rationale, the ones kept for speed included, 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. mtld3d.so ships as both an
x86_64 and an arm64 Mach-O, since Wine loads it from the lib/wine/<cpu>-unix
directory matching its own build; the PE side is x86 either way, translated by
Rosetta 2 under an x86_64 Wine and by FEX under an arm64 one. The Metal
backend targets macOS 15 or newer (unix/.cargo/config.toml pins
MACOSX_DEPLOYMENT_TARGET = 15.0).
Two things have to exist before a build: a Wine build or install providing
wine, winebuild and wineserver plus its development tree
(lib/wine/{i386,x86_64}-windows/ and libwinecrt0.a), and rustup.
Everything else is make setup's job.
WINE_SDK points at that Wine tree and must be exported before any target,
including make setup: the Makefile takes the Wine binaries from it by
absolute path, windows/shim/build.rs reads libwinecrt0.a and ntdll.a
there for linking, and make conformance finds Wine's d3d9 test binaries in
it. WINE_INSTALL_DIR is a second tree make install copies into, alongside
WINE_SDK itself.
make setup # one-time toolchain bootstrap
make # build all (windows i386+x64 + unix)
make install # install into both Wine trees
make bundle # pack the distributable tarball + its debug symbols (PROD=1 by default)
make test # unit tests on the host, e2e under Wine, one leg per PE arch
make conformance # Wine's d3d9 suite vs the checked-in baseline, one leg per PE arch
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 updateEvery leg is also its own CI job, with the toolchains and tools the Makefile
leaves floating for a developer pinned in
.github/workflows/ci.yml.
make setup is the one-time bootstrap: both rustup toolchains (stable, 1.97 or
newer per rust-version in the manifests, for everything; nightly only for
make fmt, whose rustfmt.toml uses nightly-only options) with the four
cross-compilation targets, the cargo tools, the PE
linker and archiver as symlinks onto the toolchain's own rust-lld and
llvm-ar, Rosetta 2 if it is missing, and the pinned Windows SDK splatted to
/opt/xwin (~3 GB, and one sudo prompt to create that root-owned directory).
It does not install Wine. The internal crates are path dependencies and are not
published to crates.io.
Frame pointers are off by default; FP=1 make forces them on for the guest-pc
sampling profiler, whose stack walks follow the guest frame-pointer chain.
make install copies the PE DLLs into lib/wine/{i386,x86_64}-windows/ and
the .so into lib/wine/{x86_64,aarch64}-unix/, stamping the Wine-builtin
signature onto the d3d9.dll copies, because the loader ignores unsigned PEs
on the builtin search path. The build outputs themselves stay unsigned so they
can also serve as a native DLL override, and make bundle packs both flavors
into windows/target/mtld3d.tar.xz. Debug symbols travel with every binary, a
.pdb beside each PE and a .dSYM beside the .so (make runs dsymutil,
since Mach-O DWARF otherwise stays behind in the object files); make bundle
writes them as windows/target/mtld3d-debug.tar.xz, and every DLL logs its
release and linker-assigned image ID on load, so a crash report names the
archive that symbolicates it.
game.exe → d3d9.dll → mtld3d.dll → mtld3d.so
(PE, i386 or x64: one chain per arch) (Mach-O, Wine's own arch)
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 call only snapshots the state it needs into a closure on the frame's op list.
- The encoder thread (one per device) runs those 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 finished command stream, waits for the drawable, presents and commits.
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 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/ the Mach-O
side for x86_64-apple-darwin and aarch64-apple-darwin (the latter is also
the native test target). Open each in its own editor window for rust-analyzer
to work. The shipped crates (both 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 holds 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) and compiles for the macOS host as well
as PE, so cargo test -p mtld3d-core --target aarch64-apple-darwin runs its
unit tests natively instead of through Wine.
unix/shared is the crate every linkage unit depends on, primarily for 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 the optional mtld3d.conf, read once at
Direct3DCreate9 from the directory of the running .exe, so a change needs a
restart. The sample in the repo root documents every option, its default, and
the MTLD3D_CONFIG environment override that beats the file.
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; narrow it per target, for example
RUST_LOG=mtld3d=warn,mtld3d::unix=trace.
| 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.
CONTRIBUTING.md is the operating manual: what to read
before changing anything, the gates and how to read their output, how
conformance work is organised, and what a pull request is expected to contain.
The development conventions themselves live in
docs/CONVENTIONS.md.
The short version: make check and make test are green before every
commit, and one pull request is one clearly defined change.
zlib.