Complete setup from a clean Mac to a playable game, including the four separate bugs that must be fixed and the one that can't be.
Battle.net was discontinued for macOS and D2R is a DirectX 12 title, so nothing here works out of the box. Every fix below was root-caused and verified by measurement — the reasoning is included so you can check it rather than trust it.
Verified on: Apple M3 Max · macOS 26 (Darwin 25.5) · Wine 10.0 · Battle.net 2.52.6 (17627) · D2R 3.2.92777 · MoltenVK 1.4.2
Status: fully playable in Legacy (2D) graphics mode. HD mode runs at good framerate but has a terrain lighting defect — see Known limitation.
D2R running on an M3 Max under Wine — Rogue Encampment in Legacy mode, terrain rendering correctly, smooth framerate. Online play through Battle.net.
- Read this first
- The bugs
- Requirements
- Step 1 — Wine
- Step 2 — Prefix
- Step 3 — Battle.net
- Step 4 — Patch libcef.dll
- Step 5 — Lock the patch
- Step 6 — MoltenVK 1.4.2
- Step 7 — Launcher
- Step 8 — Download D2R
- Step 9 — Play
- Known limitation — HD terrain
- Recovery / after updates
- Troubleshooting
- Appendix — how each bug was found
Step 4 modifies a Blizzard binary (libcef.dll) — two bytes, so a debug assertion
stops firing. It is not a cheat, gives no gameplay advantage, and affects only the
launcher's embedded browser, not the game or anti-cheat.
Blizzard's ToS discourages modifying their client and modified files can in principle be detected. Your account, your risk. Originals are backed up; reverting is one command. Without it the launcher cannot complete a login at all.
Performance note: D2R runs x86-64 under Rosetta 2 with DX12 translated to Metal. It plays well, but this is not native.
| # | Symptom | Root cause | Fix |
|---|---|---|---|
| 1 | Launcher traps with int3 during login |
libcef.dll asserts VirtualProtect returned PAGE_READWRITE (4); Wine reports image-backed copy-on-write pages as PAGE_WRITECOPY (8), so a successful call trips the assert |
Patch 2 branches to NOP (Step 4) |
| 2 | Launcher window solid black; native chrome fine | Chromium's GPU process shares surfaces with the browser process via Windows handle duplication Wine can't reproduce | --in-process-gpu (Step 7) |
| 3 | D2R shows title screen then freezes; cursor still moves | MoltenVK falls back to "semaphores using Metal implicit guarantees within a single queue", which can't satisfy DX12 cross-queue fences | MoltenVK ≥ 1.4 + MVK_CONFIG_VK_SEMAPHORE_SUPPORT_STYLE=2 (Steps 6–7) |
| 4 | Half-second hitches while running | Pipeline state objects compiled on-demand, stalling the frame. Plus MoltenVK writing 194 MB of descriptor warnings mid-session | SHOULD_MAXIMIZE_CONCURRENT_COMPILATION=1, USE_METAL_ARGUMENT_BUFFERS=1, LOG_LEVEL=1 (Step 7) |
| 5 | HD terrain renders black in ambient-lit areas | Metal cannot blend integer-format render targets — see Known limitation | Play in Legacy mode (G) |
Plus a critical non-bug: launching D2R.exe directly hangs on "Connecting to
Battle.net". The client performs a session handoff supplying account credentials;
a direct launch skips it and dies with D2BackendServices error 7. Always use Play.
- Apple Silicon Mac, macOS Sonoma or later
- Homebrew
- ~30 GB free for D2R
- A Battle.net account that owns Diablo II: Resurrected
brew install --cask wine-stable
wine --version # expect wine-10.0Wine 11.x devel/staging was tested and is worse — Battle.net deadlocks at startup and never draws a window. Stay on 10.0.
Blizzard refuses to serve D2R to a client reporting an old Windows version.
export WINEPREFIX="$HOME/.wine"
wineboot -i
winecfg -v win10
wine reg query "HKLM\Software\Microsoft\Windows NT\CurrentVersion" /v ProductNameThe macOS installer won't work; fetch the Windows one:
cd ~/Downloads
curl -L -o Battle.net-Setup.exe \
"https://battle.net/download/getInstallerForGame?os=win&locale=enUS&version=live&gameProgram=BATTLENET_APP"
file Battle.net-Setup.exe # expect PE32 executable
WINEPREFIX="$HOME/.wine" wine Battle.net-Setup.exeClick through, then quit. Don't try to log in yet — it crashes until Step 4.
patch_cef_scan.py (in this folder) scans for this sequence and neuters both traps:
FF 15 xx xx xx xx call [VirtualProtect]
85 C0 test eax, eax
74 15 je -> __debugbreak <-- NOP NOP
83 7D F8 04 cmp dword [ebp-8], 4 (PAGE_READWRITE)
75 12 jne -> __debugbreak <-- NOP NOP
Dry run (changes nothing without --apply):
cd ~/Desktop/D2R-macOS-Setup
python3 patch_cef_scan.py ~/.wine/drive_c/Program\ Files\ \(x86\)/Battle.net/Battle.net.*/libcef.dllExpect unpatched sites: 1 and preceded by call: True. If it reports 0 sites and
0 already-patched, stop — the binary differs from what this targets.
Apply (creates .orig backups):
python3 patch_cef_scan.py ~/.wine/drive_c/Program\ Files\ \(x86\)/Battle.net/Battle.net.*/libcef.dll --applyThe client verifies and restores libcef.dll at startup (observed happening
mid-session). Make it immutable:
chflags uchg ~/.wine/drive_c/Program\ Files\ \(x86\)/Battle.net/Battle.net.*/libcef.dllOptional — stop Wine's crash dialog wedging the app behind a modal:
WINEPREFIX="$HOME/.wine" wine reg add "HKCU\Software\Wine\WineDbg" \
/v ShowCrashDialog /t REG_DWORD /d 0 /fWine 10.0 bundles MoltenVK 1.3.0, whose semaphore emulation deadlocks DX12 games.
cd ~/Downloads
curl -L -O https://github.com/KhronosGroup/MoltenVK/releases/download/v1.4.2/MoltenVK-macos.tar
tar -xf MoltenVK-macos.tar
TGT="/Applications/Wine Stable.app/Contents/Resources/wine/lib/libMoltenVK.dylib"
cp "$TGT" "$TGT.orig" # back up 1.3.0
# Wine runs x86_64 under Rosetta -- use that slice
lipo MoltenVK/MoltenVK/dynamic/dylib/macOS/libMoltenVK.dylib \
-thin x86_64 -output /tmp/libMoltenVK_x64.dylib
cp /tmp/libMoltenVK_x64.dylib "$TGT"
codesign --force --sign - "$TGT" # replacing a dylib breaks the signatureVerify in the log after launching: MoltenVK version 1.4.2, and no mention of
"within a single queue".
Copy battlenet-launch.sh from this folder, or recreate it:
#!/bin/bash
LOG="$HOME/bnet-crash.log"
echo "===== launch $(date) =====" >> "$LOG"
export WINEPREFIX="$HOME/.wine"
export WINEDLLOVERRIDES="mfplat,mf,mfreadwrite,msmpeg2vdec,winedmo,mfmp4srcsnk,msauddecmft,rtworkq="
# Bug 3: real Metal events instead of the single-queue fallback (needs MoltenVK >=1.4)
export MVK_CONFIG_VK_SEMAPHORE_SUPPORT_STYLE=2
# Bug 4a: correct descriptor path. Without it MoltenVK creates pools with 0
# uniform-buffer descriptors and allocates dynamically PER DRAW, logging 1.25M
# warnings / 194 MB in one session.
export MVK_CONFIG_USE_METAL_ARGUMENT_BUFFERS=1
# Bug 4b: keep logging at errors only. Warning-level logging alone causes hitching.
export MVK_CONFIG_LOG_LEVEL=1
# Bug 4c: compile pipelines concurrently instead of stalling the frame that needs
# them. This is what made it "buttery smooth".
export MVK_CONFIG_SHOULD_MAXIMIZE_CONCURRENT_COMPILATION=1
export MVK_CONFIG_USE_METAL_PRIVATE_API=1
export MVK_CONFIG_MAX_ACTIVE_METAL_COMMAND_BUFFERS_PER_QUEUE=1024
# DO NOT SET MVK_CONFIG_SYNCHRONOUS_QUEUE_SUBMITS=1 -- tested twice, causes stutter
# and does not fix terrain (texture cancellations went 35 -> 0 with no visual change).
cd "$WINEPREFIX/drive_c/Program Files (x86)/Battle.net" || exit 1
# Bug 2. Do NOT add --disable-gpu*: that leaves no compositor (blank grey window)
# because SwANGLE can't initialise under Wine (no VK_KHR_win32_surface).
exec /opt/homebrew/bin/wine "Battle.net.exe" --in-process-gpu >> "$LOG" 2>&1Make it a double-clickable .app
APP="$HOME/Desktop/Battle.net.app"
mkdir -p "$APP/Contents/MacOS"
cp ~/Desktop/D2R-macOS-Setup/battlenet-launch.sh "$APP/Contents/MacOS/launch"
chmod +x "$APP/Contents/MacOS/launch"
cat > "$APP/Contents/Info.plist" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleName</key><string>Battle.net</string>
<key>CFBundleIdentifier</key><string>local.wine.battlenet</string>
<key>CFBundleExecutable</key><string>launch</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>NSHighResolutionCapable</key><true/>
</dict></plist>
PLIST
touch "$APP"Run the launcher, sign in, select Diablo II: Resurrected, click Install (~30 GB).
Downloads are handled by Agent.exe, a plain Win32 process that doesn't touch the
browser component — it's reliable and resumes if interrupted.
Launch the game with the client's Play button. Never run D2R.exe directly.
Direct launch renders fine but hangs on "Connecting to Battle.net" — the session
handoff never happens (presence=0, D2BackendServices error 7). Play also passes
the MVK_CONFIG_* variables to the game by inheritance, which is what keeps graphics
working.
Then press G in-game to switch to Legacy (2D) graphics mode. See below.
In HD mode, ground renders black in areas that rely on ambient light. Directly-lit and emissive areas are fine (River of Flame looks perfect); dark wilderness and interiors lose most of the ground. Everything else — props, walls, characters, effects, UI — renders correctly.
Workaround: press G for Legacy graphics mode. Legacy uses the original 2D sprite
renderer, bypasses the HD terrain pipeline entirely, and works perfectly.
Cause — Wine's vkd3d cannot create cube-map SRV descriptors.
Running with WINEDEBUG=+d3d12, the D3D12 layer reports once at startup:
vkd3d:fixme:vkd3d_create_null_srv Unhandled view dimension 0x9.
0x9 is D3D12_SRV_DIMENSION_TEXTURECUBE. Cube maps are how the renderer supplies
ambient / image-based lighting — the environment probe lighting everything not hit
by a direct light source.
Dumping the translated Metal shaders (MVK_CONFIG_SHADER_DUMP_DIR) confirms the
connection: 7 shaders sample texturecube<float>, and the SPIR-V declares
SPV_EXT_descriptor_indexing, so those cube maps are fetched bindlessly from a
descriptor heap. A heap whose cube entries were never created yields no ambient term:
- surfaces lit directly (torches) or emissively (lava) render correctly
- surfaces relying on ambient light render black
This is a Wine/vkd3d limitation, not a Metal one, and is fixable only upstream in Wine's D3D12 implementation. All 152 shaders translate cleanly with zero unsupported constructs, so the shader code is fine — the fault is in what gets bound to it.
Ruled out by measurement — don't repeat these:
| Theory | Disproof |
|---|---|
| Primitive-restart pipelines failing | Disassembled libMoltenVK.dylib: calls MVKBaseObject::reportWarning (0xfa20), not reportError — non-fatal, pipelines are created |
R32_UINT blending breaking terrain |
Same disassembly test — also reportWarning, non-fatal. (An earlier revision of this README wrongly blamed this.) |
| Descriptor pool exhaustion | Argument buffers took spam 1.25M → 0; ground still black |
| Texture streaming race | Sync submits took cancellations 35 → 0; ground still black |
| Channel swizzle | MVK_CONFIG_FULL_IMAGE_VIEW_SWIZZLE=1; no change |
| Texture resolution / AO / any quality setting | All settings at minimum; ground still black |
| vkd3d-proton as a replacement D3D12 | Fails to initialise on Wine's winevulkan even with MoltenVK 1.4.2 |
| Broken shader translation | 152 SPIR-V → 152 MSL, zero conversion failures |
An update can restore the original libcef.dll and the launcher crash returns.
BN=~/.wine/drive_c/Program\ Files\ \(x86\)/Battle.net
chflags nouchg $BN/Battle.net.*/libcef.dll # unlock
python3 ~/Desktop/D2R-macOS-Setup/patch_cef_scan.py $BN/Battle.net.*/libcef.dll --apply
chflags uchg $BN/Battle.net.*/libcef.dll # re-lockThe patcher finds the pattern by signature, so it survives version changes.
Revert everything:
# libcef
BN=~/.wine/drive_c/Program\ Files\ \(x86\)/Battle.net
chflags nouchg $BN/Battle.net.*/libcef.dll
for d in $BN/Battle.net.*/; do cp "$d/libcef.dll.orig" "$d/libcef.dll"; done
# MoltenVK
TGT="/Applications/Wine Stable.app/Contents/Resources/wine/lib/libMoltenVK.dylib"
cp "$TGT.orig" "$TGT" && codesign --force --sign - "$TGT"| Symptom | Cause | Fix |
|---|---|---|
int3 / "Exception raised" at login |
Patch reverted by update | Recovery section above |
| Launcher window solid black | --in-process-gpu missing |
Step 7 |
| Launcher window blank grey | --disable-gpu* present |
Remove those flags |
| Game freezes at title, cursor moves | MoltenVK too old, or vars not inherited | Step 6; launch via Play |
| Stuck on "Connecting to Battle.net" | Ran D2R.exe directly, or stale session |
Quit D2R and Battle.net, relaunch client, use Play |
| Client spins at 100% CPU, frozen UI | Transient; also seen once with argument buffers | Quit fully and relaunch |
| Stuttering returned | SYNCHRONOUS_QUEUE_SUBMITS got re-added |
Remove it |
| "Not available on macOS" | Prefix reports wrong Windows version | Step 2 |
| Exits instantly, no window | WINEESYNC mismatch with running wineserver |
wineserver -k, relaunch |
Logs
tail -f ~/bnet-crash.log # launcher + MoltenVK
tail -f ~/.wine/drive_c/Program\ Files\ \(x86\)/Diablo\ II\ Resurrected/blz-log.txt # game (truncated per launch)Harmless noise: NVSDK_NGX_D3D12_Init failed (DLSS, NVIDIA-only),
no Kerberos/NTLM support, Failed to find: data/hd/pak_manifest.json
(assets live in CASC archives), Metal does not support disabling primitive restart
(non-fatal), most fixme: lines.
Bug 1 — the libcef crash, disassembled
Always libcef.dll + 0x16D00E1, raising 0x80000003 STATUS_BREAKPOINT with no
Check failed: message — which is why normal Chromium debugging advice doesn't apply.
0x116D00A0: push ebp
0x116D00BA: push edx ; arg4 = &old_protection
0x116D00BB: push 2 ; arg3 = PAGE_READONLY
0x116D00BF: call dword ptr [...] ; KERNEL32!VirtualProtect
0x116D00C5: test eax, eax
0x116D00C7: je 0x116D00DE ; call failed -> int3
0x116D00C9: cmp dword ptr [ebp-8], 4 ; old == PAGE_READWRITE?
0x116D00CD: jne 0x116D00E1 ; no -> int3 <-- crash
The IAT slot resolves to KERNEL32.dll!VirtualProtect. This is Chromium's
protected-memory guard: flip a page read-only and assert it was previously writable.
Windows always satisfies it; Wine reports image-backed COW pages as PAGE_WRITECOPY,
so the check fails on a call that succeeded. It fires whenever a renderer touches
protected memory — guaranteed during login.
Bug 3 — proving the DX12 freeze with frame-diff
Path: D2R (DX12) → d3d12.dll → vkd3d → Vulkan → MoltenVK → Metal.
MoltenVK 1.3.0 logs "Vulkan semaphores using Metal implicit guarantees within a single queue". Metal has no native cross-queue semaphore, so it's emulated; DX12 uses multiple queues, so a fence never signals. Frames stop, the process stays alive at low CPU, the cursor still moves (that's the OS, not the game).
Measured by screenshotting the window twice 8s apart and counting changed pixels:
| Config | Frame delta | Result |
|---|---|---|
| MoltenVK 1.3.0, default | 0.000% |
dead |
SEMAPHORE_SUPPORT_STYLE=2 |
1.9–6.1% |
animating |
| MoltenVK 1.4.2 + style 2 | ~12% |
animating (menu) |
Rejected alternatives: vkd3d-proton 3.0.1 can't initialise on Wine's winevulkan
(Failed to load instance procs); Apple GPTK/D3DMetal (Gcenx build) is wine 7.7, too
old and exits before init; Wine 11.x deadlocks at startup; Whisky is deprecated since
April 2026 and won't launch.
Bug 4 — the 194 MB log
Without argument buffers MoltenVK creates descriptor pools with zero uniform
buffer descriptors and falls back to a dynamic allocation per draw call — logging
VkDescriptorPool exhausted pool of 0 VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER 1,252,090
times, writing 194 MB to disk during play. Fixing the cause (argument buffers) and
silencing the channel (LOG_LEVEL=1) removed most hitching; concurrent pipeline
compilation removed the rest.
Produced from a full root-cause debugging session: PE disassembly of libcef.dll and
libMoltenVK.dylib, Chromium/CEF process-model analysis, and MoltenVK synchronization
testing with objective frame-diff measurement.
Public domain / CC0. No warranty — you are modifying software on your own machine at your own risk.
