Every guide to modding a Unity game on an Apple Silicon Mac ends the same way: run it under Rosetta. That advice is correct, and it is worth understanding why, because the reason is a single failed system call and the fix is about forty lines of C and C#.
This post is about the bug and about not needing Rosetta. If you just want mods working today, installing BepInEx for Valheim on macOS covers the setup and the one-line workaround.
The symptom Link to heading
Valheim ships a universal macOS build, so it runs natively on Apple Silicon. BepInEx 5 ships a universal macOS build too. Put them together, let the game run as arm64, and you get a process that launches, plays fine, and loads exactly zero plugins.
There is no crash and no error. There is no BepInEx/LogOutput.log. lsof shows
libdoorstop.dylib, BepInEx.dll, 0Harmony.dll and MonoMod.RuntimeDetour.dll all mapped into
the process, and the plugin absent. Everything is loaded. Nothing works.
Switch the same install to x86_64 and it all works. That is the whole shape of the bug:
| Run | Process | Plugins | Log |
|---|---|---|---|
ARCHPREFERENCE="arm64" | Code Type: ARM64 | no | none written |
ARCHPREFERENCE="x86_64,arm64" | Code Type: X86-64 (translated) | yes | complete |
What I tested against Link to heading
| Component | Version |
|---|---|
| Machine | Apple M4 Pro, macOS 26.6.2 (Darwin 25.6.0) |
| Valheim | build 25390630, Unity 6000.0.75f1, Mono |
| BepInEx | 5.4.23.5, BepInEx_macos_universal_5.4.23.5.zip |
| UnityDoorstop | 4.5.0 |
Four explanations were tested and refuted before the real one turned up, and they are worth listing because each looks convincing:
- A missing arm64 slice. No.
lipo -archsreportsx86_64 arm64for the game executable,libdoorstop.dylib,libmonobdwgc-2.0.dylibandlibmono-native.dylib. - A different Mach-O link format per slice. No.
otool -lon both slices reportsLC_DYLD_INFO_ONLYand neither reportsLC_DYLD_CHAINED_FIXUPS, soplthooktakes the same path either way. - Doorstop failing to inject. No. It injects, and the managed assemblies map in.
- The plugin itself. No. It is architecture-neutral IL, and it is never reached.
The cause Link to heading
MonoMod picks DetourNativeMonoPosixPlatform when the runtime is Mono. That platform’s
MakeWritable and MakeExecutable call mprotect with PROT_READ|PROT_WRITE|PROT_EXEC.
macOS on Apple Silicon answers EACCES, and the entitlements say why:
codesign -d --entitlements - Valheim.app
[Key] com.apple.security.cs.allow-jit
[Value]
[Bool] true
com.apple.security.cs.allow-unsigned-executable-memory is not in that list at all. Without it, no
page may be writable and executable at the same time. Unity ships allow-jit instead, so Mono’s
generated code lives in a MAP_JIT region.
That region will not take mprotect. It will not take mach_vm_write either — that returns
KERN_INVALID_ADDRESS, while the same call against ordinary heap memory in the same process
works fine, which rules out the task port and the call itself being wrong.
The only supported way in is pthread_jit_write_protect_np, and it cannot be called from managed
code. It drops execute permission for the entire calling thread, and that thread is itself running
JIT-compiled code, so the caller faults on its next instruction. I tried it. It is a SIGBUS.
The toggle and the copy have to live inside one native function.
Under Rosetta none of this applies, because the x86_64 translation path does not put Mono’s code in
a MAP_JIT region. That single difference is the entire reason the workaround works.
Why it says nothing Link to heading
Two separate silences stack up, and each one sends you somewhere useless.
The managed side. DetourHelper.get_Runtime sets its _RuntimeInit flag before it
constructs the platform, and returns null on every later call once that flag is set:
IL_0028: ldsfld _RuntimeInit
IL_002d: brfalse.s IL_0033 ; not tried yet -> build the platform
IL_002f: ldnull ; already tried -> return null
So the EACCES escapes the constructor exactly once. From then on every Harmony patch dies on a
NullReferenceException in DetourHelper.GetIdentifiable — three frames from the cause, and with
no mention of mprotect anywhere in it. HarmonyX swallows the first one inside Harmony..cctor.
The rest are not lost, they are just filed somewhere nobody looks. Doorstop.Entrypoint.Start
writes them to preloader_*.log beside the game executable, not to LogOutput.log:
ls Valheim.app/Contents/MacOS/preloader_*.log
The native side. Doorstop’s own LOG() calls only exist when VERBOSE is defined, which
release builds do not define, so the four messages that report a failed hook compile to nothing.
Meanwhile plthook_osx.c hard-defines four PLTHOOK_DEBUG_* macros to 1 with no guard, so the
same release binary writes about 7,100 lines of Mach-O bind opcodes to stderr on every launch.
A release build that is loud about routine work and silent about its own failures is a defect on its own terms, so I sent it separately as UnityDoorstop #113. If you have ever seen BepInEx dump a wall of pointers on macOS and then say nothing useful, that is this.
The fix Link to heading
Two changes, in two projects, with an ordering dependency:
UnityDoorstop #114 doorstop_jit_memcpy for MAP_JIT writes
|
v
BepInEx #1402 v5: fix plugin loading on native arm64 macOS
UnityDoorstop #114 adds
src/nix/jit_memcpy.c, one function that exports doorstop_jit_memcpy: a memcpy bracketed by
pthread_jit_write_protect_np and followed by sys_icache_invalidate. Doorstop is already loaded
before any managed code runs, so a runtime can find it with dlsym and RTLD_DEFAULT. The file is
#if defined(__APPLE__), so nothing else changes.
BepInEx #1402 adds AppleSiliconDetourFix, which
installs an IDetourNativePlatform that keeps DetourNativeARMPlatform’s AArch64 encoding and
routes every code write through that helper. The AArch64 detour is position independent, so it is
encoded into a scratch buffer and copied over the method whole.
It also skips XTermFix, which forced DetourNativeX86Platform on every non-Windows target. On
Apple Silicon that encodes x86 branches into arm64 method bodies and reinstates the failing
mprotect, so it had to go. And Preloader.Run now logs a warning if the fix cannot install, so
this particular failure can never be silent again.
Neither is released. #1402 can do nothing until a Doorstop release carries #114, so #114 is the one that gates the outcome. Until then, building both from source is the only way to run native — which is what I do. My own install has run as arm64 since, with mods loaded and their patches applying, and I have not gone back to Rosetta.
Building it Link to heading
Doorstop builds with xmake. Build the two slices in this order — its after_build sleeps to wait
for the sibling target, so building them out of order or in parallel can lipo a stale slice into
the universal binary:
xmake f -m release --include_logging=y --plthook_debug=n -y
xmake build doorstop_x86_64
xmake build doorstop_arm64
lipo -archs build/**/universal/*.dylib # expect: x86_64 arm64
BepInEx builds with ./build.sh, which runs Cake and needs the .NET SDK.
One trap: the shipped 5.4.23.5 binaries will not do. Commit b10beef, which wraps XTermFix and
ConsoleSetOutFix in TryDo, is not in that tag and is required — without it the preloader dies
before it reaches the code this fix repairs.
Copy the resulting universal libdoorstop.dylib and BepInEx.Preloader.dll over the shipped ones,
keeping the originals:
cd ~/Library/Application\ Support/Steam/steamapps/common/Valheim
cp libdoorstop.dylib libdoorstop.dylib.stock
cp <built>/libdoorstop.dylib .
cp <built>/BepInEx.Preloader.dll BepInEx/core/
Then set the architecture in run_bepinex.sh, which is line 324 in 5.4.23.5:
export ARCHPREFERENCE="arm64"
A Steam update restores that file, so I keep the edit in a wrapper rather than my head. That
wrapper, and the Steam launch options that call it, are in
the setup post — use arm64 in place of
x86_64,arm64 there.
Confirming it worked Link to heading
With the game running:
vmmap $(pgrep -f Valheim.app/Contents/MacOS) | grep 'Code Type'
ARM64 is native. X86-64 (translated) is Rosetta. Activity Monitor says the same thing in its
Kind column, Apple against Intel.
Then check the log actually got written, and that patches applied rather than merely loaded:
grep -E 'Chainloader startup complete|Loading \[' BepInEx/LogOutput.log
On the patched build the arm64 run reaches Chainloader startup complete and plugins report their
Harmony patches applied. That last part answers a question nobody had tested: MonoMod and Harmony
can emit working detours under arm64 Mono. The encoding was never the problem. Only the write was.
Why bother Link to heading
Rosetta works, and on an M4 Pro the frame rate cost is livable. But a workaround that every player has to apply by hand, to a file Steam overwrites on every update, is not a solution — it is a thing you re-explain to each friend who wants to join your server.
Feedback Link to heading
The pull requests above could use eyes on them, particularly on whether the same fix covers games other than Valheim. I could only test one.
It has been reported against Silksong too (BepInEx #1310), where the symptom matches exactly: the universal build loads no plugins and writes no log, while an older non-universal build works. That is the Rosetta difference showing through. I cannot test it, so it is a likely match rather than a confirmed one.