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, every primitive type including triangle fans (rewritten as triangle lists, which Metal lacks).
- Points:
D3DRS_POINTSIZE, per-vertex PSIZE andoPts, the min/max clamp, eye-distance scaling, and point sprites through[[point_coord]]. - User clip planes: six planes through
[[clip_distance]], world-space for the fixed-function pipeline and clip-space for vertex shaders, gated byD3DRS_CLIPPINGand carried byD3DSBT_ALLstate blocks. - 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 and YUY2/UYVY to RGB decoding), GetDC read-back. The
two system-memory pools,
D3DPOOL_SYSTEMMEMandD3DPOOL_SCRATCH, hold their bytes CPU-side and allocate no Metal texture, so a resource that only Locks, feedsUpdateTexture/UpdateSurfaceor receivesGetRenderTargetDatacosts no video memory; binding one for sampling, which D3D9 permits, allocates one then. - 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, per-sampler mipmap LOD bias
(Metal has none, so it is applied at the sample site), sRGB read (32-bit and
compressed formats) and sRGB write, alpha test, scissor, separate alpha
blend, blend factor, color write masks.
D3DRS_SRGBWRITEENABLEbinds the render target's sRGB view, back buffer included, so the encode happens after the blender as theD3DPMISCCAPS_POSTBLENDSRGBCONVERTcap promises; a target whose format has no sRGB Metal counterpart keeps a pixel-shader encode, which is exact only for opaque draws. - Multiple render targets: four simultaneous targets with independent formats and write masks, post-pixel-shader blending on each, and Clear reaching every bound target.
- Multisampling:
CheckDeviceMultiSampleTypereports the sample counts the Metal device accepts (2x and 4x everywhere, 8x where the device offers it), withD3DMULTISAMPLE_NONMASKABLEmapped onto the same ladder. A multisampled swap chain,CreateRenderTargetorCreateDepthStencilSurfacerenders into a multisampled attachment and resolves into the single-sample surface every other operation reads, so Present,StretchRect,GetRenderTargetDataand sampling all see the resolved image.D3DRS_MULTISAMPLEMASKnarrows the samples a draw writes. - 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. A render target or depth-stencil the game creates at the reported back-buffer size rasterizes at that same scale, so a depth resolve into an INTZ texture stays a same-size copy. 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:
- Non-solid fill modes (Metal has no native wireframe).
- TIMESTAMP and the other niche query types: creation reports NOTAVAILABLE, as the spec allows.
- Scaled, sub-rect and format-converting depth→depth StretchRect: the whole-surface 1:1 copy between two same-format DEFAULT-pool depth-stencil surfaces is implemented, including the resolve out of a multisampled source; a source or destination rect short of the full surface, a size mismatch, a differing depth format, or a multisampled destination returns INVALIDCALL.
Divergences from D3D9 kept on purpose, because closing them costs frame time, memory headroom, or the games that rely on the looser behaviour:
-
IDirect3DTexture9::LockRectserves a level of aD3DPOOL_DEFAULT2D texture created withoutD3DUSAGE_DYNAMIC, which D3D9 rejects through that entry point as well as through the level'sIDirect3DSurface9::LockRect(here only the surface entry point rejects it; cube and volume locks reject it as D3D9 does), because a game that streams into a DEFAULT texture it never markedD3DUSAGE_DYNAMICwould otherwise lose every upload it makes that way. The cost is system memory: that texture class is the one whose per-level staging is released once its upload retires, so serving the lock re-creates the buffer, and a partial lock taken after such a release leaves the pixels outside its rect no longer matching the GPU copy (warned once per texture). -
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.
-
A partial
Lockof a dynamic vertex or index buffer hands back a pointer into memory a queued draw may still be reading, unless the game passedD3DLOCK_DISCARDor locked the whole buffer. On D3D9 the runtime keeps the game's writes from landing under a draw the GPU has not reached yet, andD3DLOCK_NOOVERWRITEis how a game opts out of that protection; here it is the other way round, so a game that locks a sub-range and expects the runtime to manage the timing can get corrupted geometry for a frame, with nothing in the log. Matching D3D9 means either stalling until the draw retires or renaming the backing on every suchLock, and a dynamic buffer is precisely the one a UI or particle batcher locks dozens of times per frame. The rename-and-retain path those extra locks would run through has already been observed peaking around 1.4 GB of retained backings, which is whymemory.vbibRetentionCapMBexists and why hitting that cap forces a mid-frame GPU sync. So this one trades memory headroom rather than frame time, and it has no knob: turning it on is the memory growth. Wine's d3d9 test suite probes this and reports it in some runs, so the rationale is written up inCONFORMANCE.mdlike every other kept divergence. -
A partial
LockRectof a texture level hands back a pointer into staging an upload may still be reading, unless the game passedD3DLOCK_NOOVERWRITEorD3DLOCK_READONLY. The same trade as the buffer entry above, on the same kind of caller: a font atlas or a lightmap page written a few rectangles at a time, each rectangle uploaded by the next draw. D3D9 would stall or rename until that upload retires; here the write lands in place, and a game whose rectangle overlaps an upload the GPU has not reached yet can see a frame of wrong texels, with nothing in the log. Thein-placerow of the perf grid's texture section counts the arm firing. A whole-level lock is not part of this: it is renamed, and its contents are preserved whatever the texture's usage says, because a game that locks a whole dynamic page to rewrite a few blocks of it relies on that (Half-Life 2's lightmap pages under animated light styles). -
A
D3DPOOL_DEFAULTD3DUSAGE_WRITEONLYstatic vertex or index buffer keeps no CPU copy of its contents once an upload has carried every byte to the GPU. D3D9 preserves a buffer's contents across a plainLockwhatever its usage said, so a title that locks such a buffer and reads back through the pointer sees zeros rather than what it wrote, and one that writes past the window it announced loses the bytes outside it; the log carries a warning the first time a backing is released and again the first time a lock lands on a re-created one. What it buys is the reason the divergence is here: inside a large-address-aware i386 title those copies have been measured near a gigabyte of the same 4 GiB the title needs for its own data, and running out of it crashes the process.buffer.ignoreLockBoundskeeps the copy for a title that provably writes outside its announced windows.DrawIndexedPrimitiveon a triangle fan is the one path that still needs an index buffer's bytes on the CPU, because Metal has no fan primitive and the fan is rewritten as a triangle list; a released index buffer has them copied back off the GPU, which costs one mid-frame submit and one GPU wait, and the copy is then held for the buffer's life so no buffer stalls twice. -
D3DRS_MULTISAMPLEANTIALIAS = FALSEis ignored. The state asks the rasterizer to drop to a single sample for one draw on a multisampled target. Metal ties a pipeline'srasterSampleCountto the sample count of the pass's attachments and offers no per-draw override, so honouring it would mean rendering the draw into a separate single-sampled target and compositing it back.D3DPRASTERCAPS_MULTISAMPLE_TOGGLEis not advertised, which is how D3D9 tells an application the toggle is unavailable, and the first write is logged.
- 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.
- Physical display-mode switching: a fullscreen device sets the display
mode the game picked through user32, as native D3D9 does, but that mode is
meant to stay virtual. Set
HKCU\Software\Wine\X11 Driver\EmulateModesettoYin the prefix (win32u reads this key whatever the driver; the launcher and the test prefix set it): win32u then leaves the physical display alone, scales the game window onto it and maps mouse input into the mode, so a game's clicks land where its UI is drawn, while the back buffer is the mode and present scales the frame to the display (MetalFX when enlarging). A mode of another aspect than the display's is letterboxed, with the desktop showing in the bars and the menu bar staying, since the window no longer covers the screen; the game's resolution list carries the display's own modes, which fill it. Without the key Wine's mac driver hands the mode-set toCGDisplaySetDisplayModeand the whole desktop switches resolution. A request matching no enumerable mode, which a 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, no minimise.
What the device does answer is the mode contract: deactivation puts the
registry display mode back and activation sets the game's mode and re-covers
the monitor again, both as native does. Presentation is
a composited Metal layer and no exclusive mode is ever taken, so a lost
display is never a lost device:
TestCooperativeLevelreportsD3D_OKacross a focus change and only ever reportsD3DERR_DEVICENOTRESET, which is real, after a failedReset. Reporting a loss that did not happen would make every fullscreen game release and rebuild its wholeD3DPOOL_DEFAULTworking set on each activation change, for a device that lost nothing. - Software paths: no reference or software rasterizer, no software vertex processing, no RegisterSoftwareDevice. HAL on the default Metal device is the only device type; multi-adapter setups are not enumerated. ProcessVertices transforms through the current FVF; an explicit vertex-declaration source is rejected.
- 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.
Below both sits a third layer: a handful of games need options nobody should
have to discover, so mtld3d ships built-in profiles for them. A profile is
matched on the executable name plus the version resource its vendor linked in,
so it follows the game wherever it is installed and never fires on an unrelated
program of the same name. It only supplies starting values, and both the file
and the environment override it key by key.
RUST_LOG=mtld3d::d3d9=info names the profile that matched.
| Profile | Application | What it sets and why |
|---|---|---|
gta-iv |
Grand Theft Auto IV | adapter.spoof=amd, because the renderer branches on the reported vendor and stalls in its own identifier parsing on the NVIDIA identity. caps.dfFormats=false, because with the DF fourccs advertised it picks a mixed DF24 plus INTZ depth path that no hardware of its era offered. query.flushImmediate=false, because its occlusion culling needs real pixel counts rather than an immediate answer. depth.aliasSameSize=true, because its late alpha, sky and glow passes z-test one INTZ depth texture against scene depth rendered into a same-size sibling. |
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.