diff --git a/BepInEx.Preloader/BepInEx.Preloader.csproj b/BepInEx.Preloader/BepInEx.Preloader.csproj
index f054cf3..ae84455 100644
--- a/BepInEx.Preloader/BepInEx.Preloader.csproj
+++ b/BepInEx.Preloader/BepInEx.Preloader.csproj
@@ -15,10 +15,10 @@
-
+
-
-
+
+
@@ -27,4 +27,4 @@
-
\ No newline at end of file
+
diff --git a/BepInEx.Preloader/Entrypoint.cs b/BepInEx.Preloader/Entrypoint.cs
index b9438cb..ea1d931 100644
--- a/BepInEx.Preloader/Entrypoint.cs
+++ b/BepInEx.Preloader/Entrypoint.cs
@@ -45,8 +45,6 @@ private static void LoadCriticalAssemblies()
public static void PreloaderPreMain()
{
- PlatformUtils.SetPlatform();
-
string bepinPath = Utility.ParentDirectory(Path.GetFullPath(EnvVars.DOORSTOP_INVOKE_DLL_PATH), 2);
Paths.SetExecutablePath(EnvVars.DOORSTOP_PROCESS_PATH, bepinPath, EnvVars.DOORSTOP_MANAGED_FOLDER_DIR, EnvVars.DOORSTOP_DLL_SEARCH_DIRS);
@@ -92,8 +90,8 @@ private static Assembly LocalResolve(object sender, ResolveEventArgs args)
return foundAssembly;
if (Utility.TryResolveDllAssembly(assemblyName, Paths.BepInExAssemblyDirectory, out foundAssembly)
- || Utility.TryResolveDllAssembly(assemblyName, Paths.PatcherPluginPath, out foundAssembly)
- || Utility.TryResolveDllAssembly(assemblyName, Paths.PluginPath, out foundAssembly))
+ || (Directory.Exists(Paths.PatcherPluginPath) && Utility.TryResolveDllAssembly(assemblyName, Paths.PatcherPluginPath, out foundAssembly))
+ || (Directory.Exists(Paths.PluginPath) && Utility.TryResolveDllAssembly(assemblyName, Paths.PluginPath, out foundAssembly)))
return foundAssembly;
return null;
@@ -158,4 +156,4 @@ internal static Assembly ResolveCurrentDirectory(object sender, ResolveEventArgs
}
}
}
-}
\ No newline at end of file
+}
diff --git a/BepInEx.Preloader/Platform.cs b/BepInEx.Preloader/Platform.cs
deleted file mode 100644
index 3c73362..0000000
--- a/BepInEx.Preloader/Platform.cs
+++ /dev/null
@@ -1,151 +0,0 @@
-using System;
-using System.IO;
-using System.Reflection;
-using System.Runtime.InteropServices;
-using MonoMod.Utils;
-
-namespace BepInEx.Preloader
-{
- internal static class PlatformUtils
- {
- [StructLayout(LayoutKind.Sequential, Pack = 1)]
- public struct utsname_osx
- {
- private const int osx_utslen = 256;
-
- [MarshalAs(UnmanagedType.ByValTStr, SizeConst = osx_utslen)]
- public string sysname;
-
- [MarshalAs(UnmanagedType.ByValTStr, SizeConst = osx_utslen)]
- public string nodename;
-
- [MarshalAs(UnmanagedType.ByValTStr, SizeConst = osx_utslen)]
- public string release;
-
- [MarshalAs(UnmanagedType.ByValTStr, SizeConst = osx_utslen)]
- public string version;
-
- [MarshalAs(UnmanagedType.ByValTStr, SizeConst = osx_utslen)]
- public string machine;
- }
-
- [StructLayout(LayoutKind.Sequential, Pack = 1)]
- public struct utsname_linux
- {
- private const int linux_utslen = 65;
-
- [MarshalAs(UnmanagedType.ByValTStr, SizeConst = linux_utslen)]
- public string sysname;
-
- [MarshalAs(UnmanagedType.ByValTStr, SizeConst = linux_utslen)]
- public string nodename;
-
- [MarshalAs(UnmanagedType.ByValTStr, SizeConst = linux_utslen)]
- public string release;
-
- [MarshalAs(UnmanagedType.ByValTStr, SizeConst = linux_utslen)]
- public string version;
-
- [MarshalAs(UnmanagedType.ByValTStr, SizeConst = linux_utslen)]
- public string machine;
-
- [MarshalAs(UnmanagedType.ByValTStr, SizeConst = linux_utslen)]
- public string domainname;
- }
-
- [DllImport("libc.so.6", EntryPoint = "uname", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
- private static extern IntPtr uname_linux(ref utsname_linux utsname);
-
- [DllImport("/usr/lib/libSystem.dylib", EntryPoint = "uname", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
- private static extern IntPtr uname_osx(ref utsname_osx utsname);
-
- ///
- /// Recreation of MonoMod's PlatformHelper.DeterminePlatform method, but with libc calls instead of creating processes.
- ///
- public static void SetPlatform()
- {
- var current = Platform.Unknown;
-
- // For old Mono, get from a private property to accurately get the platform.
- // static extern PlatformID Platform
- PropertyInfo p_Platform = typeof(Environment).GetProperty("Platform", BindingFlags.NonPublic | BindingFlags.Static);
- string platID;
- if (p_Platform != null)
- {
- platID = p_Platform.GetValue(null, new object[0]).ToString();
- }
- else
- {
- // For .NET and newer Mono, use the usual value.
- platID = Environment.OSVersion.Platform.ToString();
- }
- platID = platID.ToLowerInvariant();
-
- if (platID.Contains("win"))
- {
- current = Platform.Windows;
- }
- else if (platID.Contains("mac") || platID.Contains("osx"))
- {
- current = Platform.MacOS;
- }
- else if (platID.Contains("lin") || platID.Contains("unix"))
- {
- current = Platform.Linux;
- }
-
- if (Is(current, Platform.Linux) && Directory.Exists("/data") && File.Exists("/system/build.prop"))
- {
- current = Platform.Android;
- }
- else if (Is(current, Platform.Unix) && Directory.Exists("/System/Library/AccessibilityBundles"))
- {
- current = Platform.iOS;
- }
-
- // Is64BitOperatingSystem has been added in .NET Framework 4.0
- MethodInfo m_get_Is64BitOperatingSystem = typeof(Environment).GetProperty("Is64BitOperatingSystem")?.GetGetMethod();
- if (m_get_Is64BitOperatingSystem != null)
- current |= (((bool)m_get_Is64BitOperatingSystem.Invoke(null, new object[0])) ? Platform.Bits64 : 0);
- else
- current |= (IntPtr.Size >= 8 ? Platform.Bits64 : 0);
-
- if ((Is(current, Platform.MacOS) || Is(current, Platform.Linux)) && Type.GetType("Mono.Runtime") != null)
- {
- string arch;
- IntPtr result;
-
- if (Is(current, Platform.MacOS))
- {
- utsname_osx utsname_osx = new utsname_osx();
- result = uname_osx(ref utsname_osx);
- arch = utsname_osx.machine;
- }
- else
- {
- // Linux
- utsname_linux utsname_linux = new utsname_linux();
- result = uname_linux(ref utsname_linux);
- arch = utsname_linux.machine;
- }
-
- if (result == IntPtr.Zero && (arch.StartsWith("aarch") || arch.StartsWith("arm")))
- current |= Platform.ARM;
- }
- else
- {
- // Detect ARM based on PE info or uname.
- typeof(object).Module.GetPEKind(out PortableExecutableKinds peKind, out ImageFileMachine machine);
- if (machine == (ImageFileMachine)0x01C4 /* ARM, .NET Framework 4.5 */)
- current |= Platform.ARM;
- }
-
- PlatformHelper.Current = current;
- }
-
- private static bool Is(Platform current, Platform expected)
- {
- return (current & expected) == expected;
- }
- }
-}
\ No newline at end of file
diff --git a/BepInEx.Preloader/Preloader.cs b/BepInEx.Preloader/Preloader.cs
index b57ba42..95b28df 100644
--- a/BepInEx.Preloader/Preloader.cs
+++ b/BepInEx.Preloader/Preloader.cs
@@ -73,7 +73,7 @@ public static void Run()
Logger.LogInfo($"Running under Unity v{GetUnityVersion()}");
Logger.LogInfo($"CLR runtime version: {Environment.Version}");
Logger.LogInfo($"Supports SRE: {Utility.CLRSupportsDynamicAssemblies}");
- Logger.LogInfo($"System platform: {PlatformHelper.Current}");
+ Logger.LogInfo($"System platform: {PlatformDetection.OS} {PlatformDetection.Architecture}");
if (runtimePatchException != null)
Logger.LogWarning($"Failed to apply runtime patches for Mono. See more info in the output log. Error message: {runtimePatchException.Message}");
@@ -250,7 +250,7 @@ public static void AllocateConsole()
public static string GetUnityVersion()
{
- if (PlatformHelper.Is(Platform.Windows))
+ if (PlatformDetection.OS.Is(OSKind.Windows))
return FileVersionInfo.GetVersionInfo(Paths.ExecutablePath).FileVersion;
return $"Unknown ({(IsPostUnity2017 ? "post" : "pre")}-2017)";
@@ -318,4 +318,4 @@ private enum MonoModBackend
#endregion
}
-}
\ No newline at end of file
+}
diff --git a/BepInEx.Preloader/RuntimeFixes/XTermFix.cs b/BepInEx.Preloader/RuntimeFixes/XTermFix.cs
index 7db6ef5..bf330e6 100644
--- a/BepInEx.Preloader/RuntimeFixes/XTermFix.cs
+++ b/BepInEx.Preloader/RuntimeFixes/XTermFix.cs
@@ -3,8 +3,6 @@
using System.Linq;
using System.Reflection.Emit;
using HarmonyLib;
-using MonoMod.RuntimeDetour;
-using MonoMod.RuntimeDetour.Platforms;
using MonoMod.Utils;
namespace BepInEx.Preloader.RuntimeFixes
@@ -13,7 +11,7 @@ internal static class XTermFix
{
public static void Apply()
{
- if (PlatformHelper.Is(Platform.Windows))
+ if (PlatformDetection.OS.Is(OSKind.Windows))
return;
if (typeof(Console).Assembly.GetType("System.ConsoleDriver") == null)
@@ -28,13 +26,6 @@ public static void Apply()
return;
}
- // Apparently on older Unity versions (4.x), using Process.Start can run Console..cctor
- // And since MonoMod's PlatformHelper (used by DetourHelper.Native) runs Process.Start to determine ARM/x86 platform,
- // this causes a crash owing to TermInfoReader running before it can be patched and fixed
- // Because Doorstop does not support ARM at the moment, we can get away with just forcing x86 detour platform.
- // TODO: Figure out a way to detect ARM on Unix without running Process.Start
- DetourHelper.Native = new DetourNativeX86Platform();
-
var harmony = new HarmonyLib.Harmony("com.bepinex.xtermfix");
harmony.Patch(AccessTools.Method("System.TermInfoReader:ReadHeader"),
@@ -48,8 +39,6 @@ public static void Apply()
harmony.Patch(AccessTools.Method("System.TermInfoReader:GetStringBytes", new []{ AccessTools.TypeByName("System.TermInfoStrings") }),
transpiler: new HarmonyMethod(typeof(XTermFix), nameof(GetTermInfoStringsTranspiler)));
-
- DetourHelper.Native = null;
}
public static int intOffset;
@@ -133,4 +122,4 @@ public static IEnumerable GetTermInfoStringsTranspiler(IEnumera
return list;
}
}
-}
\ No newline at end of file
+}
diff --git a/BepInEx/BepInEx.csproj b/BepInEx/BepInEx.csproj
index 3451f39..7de0c6f 100644
--- a/BepInEx/BepInEx.csproj
+++ b/BepInEx/BepInEx.csproj
@@ -27,10 +27,10 @@
-
+
-
-
+
+
@@ -40,4 +40,4 @@
-
\ No newline at end of file
+
diff --git a/BepInEx/Bootstrap/Chainloader.cs b/BepInEx/Bootstrap/Chainloader.cs
index 088ae89..7df5aa3 100644
--- a/BepInEx/Bootstrap/Chainloader.cs
+++ b/BepInEx/Bootstrap/Chainloader.cs
@@ -140,7 +140,7 @@ public static void Initialize(string gameExePath, bool startConsole = true, ICol
consoleLogListener.WriteUnityLogs = false;
}
- if (PlatformHelper.Is(Platform.Unix))
+ if (PlatformDetection.OS.Is(OSKind.Posix))
{
Logger.LogInfo($"Detected Unity version: v{UnityVersion}");
}
diff --git a/BepInEx/Console/ConsoleManager.cs b/BepInEx/Console/ConsoleManager.cs
index 337d6d0..47fd5a0 100644
--- a/BepInEx/Console/ConsoleManager.cs
+++ b/BepInEx/Console/ConsoleManager.cs
@@ -54,9 +54,9 @@ static ConsoleManager()
public static void Initialize(bool alreadyActive)
{
- if (PlatformHelper.Is(Platform.Unix))
+ if (PlatformDetection.OS.Is(OSKind.Posix))
Driver = new LinuxConsoleDriver();
- else if (PlatformHelper.Is(Platform.Windows))
+ else if (PlatformDetection.OS.Is(OSKind.Windows))
Driver = new WindowsConsoleDriver();
Driver.Initialize(alreadyActive);
diff --git a/BepInEx/Console/Unix/UnixStreamHelper.cs b/BepInEx/Console/Unix/UnixStreamHelper.cs
index c82e5bb..71ccb4b 100644
--- a/BepInEx/Console/Unix/UnixStreamHelper.cs
+++ b/BepInEx/Console/Unix/UnixStreamHelper.cs
@@ -1,55 +1,50 @@
using System;
-using System.Collections.Generic;
using System.IO;
+using System.Runtime.InteropServices;
using MonoMod.Utils;
namespace BepInEx.Unix
{
internal static class UnixStreamHelper
{
+ private static IntPtr libcHandle;
+
public delegate int dupDelegate(int fd);
- [DynDllImport("libc")]
public static dupDelegate dup;
public delegate IntPtr fdopenDelegate(int fd, string mode);
- [DynDllImport("libc")]
public static fdopenDelegate fdopen;
public delegate IntPtr freadDelegate(IntPtr ptr, IntPtr size, IntPtr nmemb, IntPtr stream);
- [DynDllImport("libc")]
public static freadDelegate fread;
public delegate int fwriteDelegate(IntPtr ptr, IntPtr size, IntPtr nmemb, IntPtr stream);
- [DynDllImport("libc")]
public static fwriteDelegate fwrite;
public delegate int fcloseDelegate(IntPtr stream);
- [DynDllImport("libc")]
public static fcloseDelegate fclose;
public delegate int fflushDelegate(IntPtr stream);
- [DynDllImport("libc")]
public static fflushDelegate fflush;
public delegate int isattyDelegate(int fd);
- [DynDllImport("libc")]
public static isattyDelegate isatty;
static UnixStreamHelper()
{
- var libcMapping = new Dictionary>
- {
- ["libc"] = new List
- {
- "libc.so.6", // Ubuntu glibc
- "libc", // Linux glibc
- "/usr/lib/libSystem.dylib", // OSX POSIX
- }
- };
-
- typeof(UnixStreamHelper).ResolveDynDllImports(libcMapping);
+ libcHandle = DynDll.OpenLibrary(PlatformDetection.OS.Is(OSKind.OSX) ? "/usr/lib/libSystem.dylib" : "libc");
+ dup = AsDelegate(libcHandle.GetExport("dup"));
+ fdopen = AsDelegate(libcHandle.GetExport("fdopen"));
+ fread = AsDelegate(libcHandle.GetExport("fread"));
+ fwrite = AsDelegate(libcHandle.GetExport("fwrite"));
+ fclose = AsDelegate(libcHandle.GetExport("fclose"));
+ fflush = AsDelegate(libcHandle.GetExport("fflush"));
+ isatty = AsDelegate(libcHandle.GetExport("isatty"));
}
+ private static T AsDelegate(IntPtr symbol) where T : class
+ => Marshal.GetDelegateForFunctionPointer(symbol, typeof(T)) as T;
+
public static Stream CreateDuplicateStream(int fileDescriptor)
{
int newFd = dup(fileDescriptor);
@@ -57,4 +52,4 @@ public static Stream CreateDuplicateStream(int fileDescriptor)
return new UnixStream(newFd, FileAccess.Write);
}
}
-}
\ No newline at end of file
+}
diff --git a/BepInEx/Console/Windows/ConsoleWindow.cs b/BepInEx/Console/Windows/ConsoleWindow.cs
index f325844..3430192 100644
--- a/BepInEx/Console/Windows/ConsoleWindow.cs
+++ b/BepInEx/Console/Windows/ConsoleWindow.cs
@@ -118,11 +118,14 @@ private static void Initialize()
// Some games may ship user32.dll with some methods missing. As such, we load the DLL explicitly from system folder
var user32Dll = LoadLibraryEx("user32.dll", IntPtr.Zero, LOAD_LIBRARY_SEARCH_SYSTEM32);
- setForeground = GetProcAddress(user32Dll, "SetForegroundWindow").AsDelegate();
- getForeground = GetProcAddress(user32Dll,"GetForegroundWindow").AsDelegate();
- getSystemMenu = GetProcAddress(user32Dll,"GetSystemMenu").AsDelegate();
- deleteMenu = GetProcAddress(user32Dll,"DeleteMenu").AsDelegate();
+ setForeground = AsDelegate(GetProcAddress(user32Dll, "SetForegroundWindow"));
+ getForeground = AsDelegate(GetProcAddress(user32Dll, "GetForegroundWindow"));
+ getSystemMenu = AsDelegate(GetProcAddress(user32Dll, "GetSystemMenu"));
+ deleteMenu = AsDelegate(GetProcAddress(user32Dll, "DeleteMenu"));
}
+
+ private static T AsDelegate(IntPtr symbol) where T : class
+ => Marshal.GetDelegateForFunctionPointer(symbol, typeof(T)) as T;
[DllImport("kernel32.dll", SetLastError=true)]
static extern IntPtr GetProcAddress(IntPtr hModule, string procName);
diff --git a/BepInEx/Paths.cs b/BepInEx/Paths.cs
index 1e49e41..ffad90f 100644
--- a/BepInEx/Paths.cs
+++ b/BepInEx/Paths.cs
@@ -15,7 +15,7 @@ internal static void SetExecutablePath(string executablePath, string bepinRootPa
ExecutablePath = executablePath;
ProcessName = Path.GetFileNameWithoutExtension(executablePath);
- GameRootPath = PlatformHelper.Is(Platform.MacOS)
+ GameRootPath = PlatformDetection.OS.Is(OSKind.OSX)
? Utility.ParentDirectory(executablePath, 4)
: Path.GetDirectoryName(executablePath);
@@ -112,4 +112,4 @@ internal static void SetPluginPath(string pluginPath)
///
public static string ProcessName { get; private set; }
}
-}
\ No newline at end of file
+}
diff --git a/BepInExTests/BepInExTests.csproj b/BepInExTests/BepInExTests.csproj
index 0233b79..b3f5c8b 100644
--- a/BepInExTests/BepInExTests.csproj
+++ b/BepInExTests/BepInExTests.csproj
@@ -1,6 +1,6 @@
- net45
+ net462
@@ -12,6 +12,7 @@
+
@@ -33,4 +34,4 @@
False
-
\ No newline at end of file
+
diff --git a/TestPlugins/ValheimArm64Smoke.Harmony/Plugin.cs b/TestPlugins/ValheimArm64Smoke.Harmony/Plugin.cs
new file mode 100644
index 0000000..9d9ea04
--- /dev/null
+++ b/TestPlugins/ValheimArm64Smoke.Harmony/Plugin.cs
@@ -0,0 +1,104 @@
+using System;
+using System.IO;
+using System.Reflection;
+using BepInEx;
+using HarmonyLib;
+
+namespace ValheimArm64Smoke.Harmony
+{
+ [BepInPlugin(PluginGuid, PluginName, PluginVersion)]
+ public sealed class Plugin : BaseUnityPlugin
+ {
+ private const string PluginGuid = "dev.bepinex.valheimarm64.harmony";
+ private const string PluginName = "Valheim ARM64 Smoke Test (Harmony)";
+ private const string PluginVersion = "0.1.0";
+
+ private static readonly string[] TargetCandidates =
+ {
+ "FejdStartup:Awake",
+ "FejdStartup:Start",
+ "Game:Awake",
+ "ZNet:Awake"
+ };
+
+ private static BepInEx.Logging.ManualLogSource log;
+ private static string markerPath;
+ private HarmonyLib.Harmony harmony;
+ private static bool prefixLogged;
+
+ private void Awake()
+ {
+ log = Logger;
+ markerPath = Path.Combine(Paths.BepInExRootPath, "valheim-arm64-harmony.txt");
+
+ try
+ {
+ var target = ResolveTargetMethod();
+ if (target == null)
+ {
+ WriteMarker("status=target_not_found");
+ Logger.LogWarning($"{PluginName} could not resolve a Valheim target method");
+ return;
+ }
+
+ harmony = new HarmonyLib.Harmony(PluginGuid);
+ var prefix = typeof(Plugin).GetMethod(nameof(TargetPrefix), BindingFlags.NonPublic | BindingFlags.Static);
+
+ harmony.Patch(target, prefix: new HarmonyMethod(prefix));
+ WriteMarker(
+ "status=patched",
+ $"target={target.DeclaringType.FullName}.{target.Name}",
+ $"module={target.Module.Name}");
+ Logger.LogInfo($"{PluginName} patched {target.DeclaringType.FullName}.{target.Name}");
+ }
+ catch (Exception ex)
+ {
+ WriteMarker("status=patch_failed", ex.ToString());
+ Logger.LogError($"{PluginName} failed while patching: {ex}");
+ }
+ }
+
+ private static MethodBase ResolveTargetMethod()
+ {
+ foreach (var candidate in TargetCandidates)
+ {
+ var splitIndex = candidate.IndexOf(':');
+ if (splitIndex <= 0 || splitIndex >= candidate.Length - 1)
+ continue;
+
+ var typeName = candidate.Substring(0, splitIndex);
+ var methodName = candidate.Substring(splitIndex + 1);
+ var type = AccessTools.TypeByName(typeName);
+ var method = type != null ? AccessTools.DeclaredMethod(type, methodName) : null;
+
+ if (method != null)
+ return method;
+ }
+
+ return null;
+ }
+
+ private static void TargetPrefix(MethodBase __originalMethod)
+ {
+ if (prefixLogged)
+ return;
+
+ prefixLogged = true;
+ var originalName = $"{__originalMethod.DeclaringType.FullName}.{__originalMethod.Name}";
+ WriteMarker("status=prefix_hit", $"method={originalName}", $"timestamp_utc={DateTime.UtcNow:O}");
+ log?.LogInfo($"{PluginName} prefix executed for {originalName}");
+ }
+
+ private static void WriteMarker(params string[] lines)
+ {
+ try
+ {
+ File.WriteAllLines(markerPath, lines);
+ }
+ catch (Exception ex)
+ {
+ log?.LogWarning($"Could not write smoke marker {markerPath}: {ex}");
+ }
+ }
+ }
+}
diff --git a/TestPlugins/ValheimArm64Smoke.Harmony/ValheimArm64Smoke.Harmony.csproj b/TestPlugins/ValheimArm64Smoke.Harmony/ValheimArm64Smoke.Harmony.csproj
new file mode 100644
index 0000000..fe18172
--- /dev/null
+++ b/TestPlugins/ValheimArm64Smoke.Harmony/ValheimArm64Smoke.Harmony.csproj
@@ -0,0 +1,16 @@
+
+
+ net35
+ 8
+ ValheimArm64Smoke.Harmony
+ ValheimArm64Smoke.Harmony
+ false
+ false
+
+
+
+
+
+
+
+
diff --git a/TestPlugins/ValheimArm64Smoke.NoHarmony/Plugin.cs b/TestPlugins/ValheimArm64Smoke.NoHarmony/Plugin.cs
new file mode 100644
index 0000000..ed880c0
--- /dev/null
+++ b/TestPlugins/ValheimArm64Smoke.NoHarmony/Plugin.cs
@@ -0,0 +1,32 @@
+using System;
+using System.IO;
+using BepInEx;
+using UnityEngine;
+
+namespace ValheimArm64Smoke.NoHarmony
+{
+ [BepInPlugin(PluginGuid, PluginName, PluginVersion)]
+ public sealed class Plugin : BaseUnityPlugin
+ {
+ private const string PluginGuid = "dev.bepinex.valheimarm64.noharmony";
+ private const string PluginName = "Valheim ARM64 Smoke Test (No Harmony)";
+ private const string PluginVersion = "0.1.0";
+
+ private void Awake()
+ {
+ var markerPath = Path.Combine(Paths.BepInExRootPath, "valheim-arm64-noharmony.txt");
+ var markerLines = new[]
+ {
+ $"timestamp_utc={DateTime.UtcNow:O}",
+ $"unity_version={Application.unityVersion}",
+ $"unity_platform={Application.platform}",
+ $"pointer_size_bits={IntPtr.Size * 8}",
+ $"bepinex_version={typeof(BaseUnityPlugin).Assembly.GetName().Version}"
+ };
+
+ File.WriteAllLines(markerPath, markerLines);
+ Logger.LogInfo($"{PluginName} loaded successfully");
+ Logger.LogInfo($"Wrote smoke marker to {markerPath}");
+ }
+ }
+}
diff --git a/TestPlugins/ValheimArm64Smoke.NoHarmony/ValheimArm64Smoke.NoHarmony.csproj b/TestPlugins/ValheimArm64Smoke.NoHarmony/ValheimArm64Smoke.NoHarmony.csproj
new file mode 100644
index 0000000..a9cdee7
--- /dev/null
+++ b/TestPlugins/ValheimArm64Smoke.NoHarmony/ValheimArm64Smoke.NoHarmony.csproj
@@ -0,0 +1,15 @@
+
+
+ net35
+ 8
+ ValheimArm64Smoke.NoHarmony
+ ValheimArm64Smoke.NoHarmony
+ false
+ false
+
+
+
+
+
+
+
diff --git a/build.cake b/build.cake
index fa287d7..1b48e67 100644
--- a/build.cake
+++ b/build.cake
@@ -4,7 +4,8 @@
#addin nuget:?package=Cake.Json&version=7.0.1
#addin nuget:?package=Newtonsoft.Json&version=13.0.3
-const string DOORSTOP_VER = "4.4.0";
+const string DOORSTOP_VER = "4.5.0";
+var customMacDoorstopDir = EnvironmentVariable("BEPINEX_MACOS_DOORSTOP_DIR");
var target = Argument("target", "Build");
var isBleedingEdge = Argument("bleeding_edge", false);
@@ -104,16 +105,29 @@ Task("DownloadDoorstop")
var doorstopWinPath = doorstopPath + File("doorstop_win.zip");
var doorstopLinuxPath = doorstopPath + File("doorstop_linux.zip");
var doorstopMacPath = doorstopPath + File("doorstop_macos.zip");
+ var doorstopMacOutputPath = doorstopPath + Directory("macos") + Directory("universal");
CreateDirectory(doorstopPath);
+ CreateDirectory(doorstopMacOutputPath);
DownloadFile($"https://github.com/NeighTools/UnityDoorstop/releases/download/v{DOORSTOP_VER}/doorstop_win_release_{DOORSTOP_VER}.zip", doorstopWinPath);
DownloadFile($"https://github.com/NeighTools/UnityDoorstop/releases/download/v{DOORSTOP_VER}/doorstop_linux_release_{DOORSTOP_VER}.zip", doorstopLinuxPath);
- DownloadFile($"https://github.com/NeighTools/UnityDoorstop/releases/download/v{DOORSTOP_VER}/doorstop_macos_release_{DOORSTOP_VER}.zip", doorstopMacPath);
+
+ if (!string.IsNullOrWhiteSpace(customMacDoorstopDir))
+ {
+ Information($"Using custom macOS Doorstop from {customMacDoorstopDir}");
+ CopyFileToDirectory(File(System.IO.Path.Combine(customMacDoorstopDir, "libdoorstop.dylib")), doorstopMacOutputPath);
+ CopyFileToDirectory(File(System.IO.Path.Combine(customMacDoorstopDir, ".doorstop_version")), doorstopMacOutputPath);
+ }
+ else
+ {
+ DownloadFile($"https://github.com/NeighTools/UnityDoorstop/releases/download/v{DOORSTOP_VER}/doorstop_macos_release_{DOORSTOP_VER}.zip", doorstopMacPath);
+ }
Information("Extracting Doorstop");
ZipUncompress(doorstopWinPath, doorstopPath + Directory("win"));
ZipUncompress(doorstopLinuxPath, doorstopPath + Directory("linux"));
- ZipUncompress(doorstopMacPath, doorstopPath + Directory("macos"));
+ if (string.IsNullOrWhiteSpace(customMacDoorstopDir))
+ ZipUncompress(doorstopMacPath, doorstopPath + Directory("macos"));
});
Task("MakeDist")
@@ -163,7 +177,7 @@ Task("MakeDist")
PackageBepin("win", "x86", "winhttp.dll", "doorstop_config.ini");
PackageBepin("linux", "x64", "libdoorstop.so", "run_bepinex.sh", true);
PackageBepin("linux", "x86", "libdoorstop.so", "run_bepinex.sh", true);
- PackageBepin("macos", "x64", "libdoorstop.dylib", "run_bepinex.sh", true);
+ PackageBepin("macos", "universal", "libdoorstop.dylib", "run_bepinex.sh", true);
CopyFileToDirectory(File("./bin/patcher/BepInEx.Patcher.exe"), distPatcherDir);
});
@@ -179,7 +193,7 @@ Task("Pack")
ZipCompress(distDir + Directory("win_x64"), distDir + File($"BepInEx_win_x64{commitPrefix}{buildVersion}.zip"));
ZipCompress(distDir + Directory("linux_x86"), distDir + File($"BepInEx_linux_x86{commitPrefix}{buildVersion}.zip"));
ZipCompress(distDir + Directory("linux_x64"), distDir + File($"BepInEx_linux_x64{commitPrefix}{buildVersion}.zip"));
- ZipCompress(distDir + Directory("macos_x64"), distDir + File($"BepInEx_macos_x64{commitPrefix}{buildVersion}.zip"));
+ ZipCompress(distDir + Directory("macos_universal"), distDir + File($"BepInEx_macos_universal{commitPrefix}{buildVersion}.zip"));
Information("Packing BepInEx.Patcher");
ZipCompress(distDir + Directory("patcher"), distDir + File($"BepInEx_Patcher{commitPrefix}{buildVersion}.zip"));
@@ -222,4 +236,4 @@ Task("Pack")
}
});
-RunTarget(target);
\ No newline at end of file
+RunTarget(target);
diff --git a/docs/valheim-macos-arm64.md b/docs/valheim-macos-arm64.md
new file mode 100644
index 0000000..f3978af
--- /dev/null
+++ b/docs/valheim-macos-arm64.md
@@ -0,0 +1,73 @@
+# Valheim on macOS Apple Silicon
+
+Tested on April 6, 2026 on an Apple M4 Pro MacBook Pro with the Steam build of Valheim at:
+
+`/Users/$USER/Library/Application Support/Steam/steamapps/common/Valheim/valheim.app`
+
+## What was validated
+
+- Valheim's macOS executable is a universal binary with `x86_64` and `arm64` slices.
+- BepInEx `5.4.23.5` starts natively on macOS Apple Silicon with this branch.
+- HarmonyX `2.16.1` plus MonoMod `25.x` is working for simple Harmony patches on this setup.
+- The following real Valheim mods loaded successfully in native Apple Silicon testing:
+ - `ValheimModding-Jotunn-2.28.0`
+ - `Azumatt-AzuCraftyBoxes-1.8.13`
+
+## The important blocker on newer Apple Silicon
+
+The official UnityDoorstop `4.5.0` macOS release ships `libdoorstop.dylib` as `x86_64, arm64`.
+
+On this M4 Pro test machine, `dyld` rejected that binary before managed code even started:
+
+`missing compatible architecture (have 'x86_64,arm64', need 'arm64e')`
+
+For this machine, the minimal working fix was to rebuild UnityDoorstop so the macOS universal dylib contains `x86_64, arm64e`.
+
+## Install from this branch
+
+Build and install into the default Steam Valheim app bundle:
+
+```bash
+scripts/valheim/install_macos_arm64.sh --with-smoke-tests
+```
+
+Install directly from a Gale or r2modman profile export:
+
+```bash
+scripts/valheim/install_gale_export.sh --export ~/Downloads/Servidor.r2z
+```
+
+Install into a custom Valheim path:
+
+```bash
+scripts/valheim/install_macos_arm64.sh "/path/to/valheim.app"
+```
+
+Copy an exported Windows or Gale plugin directory into the install at the same time:
+
+```bash
+scripts/valheim/install_macos_arm64.sh --mods-dir "/path/to/BepInEx/plugins"
+```
+
+Run the game:
+
+```bash
+cd "/Users/$USER/Library/Application Support/Steam/steamapps/common/Valheim/valheim.app/Contents/MacOS"
+./run_bepinex.sh ./Valheim
+```
+
+## Smoke tests in this repo
+
+Two test plugins are included under `TestPlugins/`:
+
+- `ValheimArm64Smoke.NoHarmony`
+- `ValheimArm64Smoke.Harmony`
+
+The Harmony smoke test successfully patched and hit `FejdStartup.Awake` during native startup on the M4 Pro validation run.
+
+## Remaining risks
+
+- Mods with extra native libraries may still need macOS-specific binaries.
+- Heavy IL manipulation and less common detour paths still need broader coverage than the smoke test and the two real mods above.
+- If a mod pack was exported from Windows, only copy `BepInEx/plugins`, `BepInEx/config`, and `BepInEx/patchers`. Do not copy Windows root files like `winhttp.dll`.
+- Gale `.r2z` exports do not include every mod DLL directly; this repo's installer resolves the package list from `export.r2x` and downloads the exact versions from Thunderstore.
diff --git a/doorstop/run_bepinex.sh b/doorstop/run_bepinex.sh
index 798c3af..d27b658 100644
--- a/doorstop/run_bepinex.sh
+++ b/doorstop/run_bepinex.sh
@@ -8,8 +8,6 @@
# 1. Via CLI: Run ./run_bepinex.sh [doorstop arguments] [game arguments]
# 2. Via config: edit the options below and run ./run.sh without any arguments
-# 0 is false, 1 is true
-
# LINUX: name of Unity executable
# MACOS: name of the .app directory
executable_name=""
@@ -19,6 +17,7 @@ executable_name=""
# General Config Options
# Enable Doorstop?
+# 0 is false, 1 is true
enabled="1"
# Path to the assembly to load and execute
@@ -51,45 +50,53 @@ debug_address="127.0.0.1:10000"
# If 1 and debug_enabled is 1, Mono debugger server will suspend the game execution until a debugger is attached
debug_suspend="0"
+# CoreCLR options (IL2CPP)
+
+# Path to coreclr shared library WITHOUT THE EXTENSION that contains the CoreCLR runtime
+coreclr_path=""
+
+# Path to the directory containing the managed core libraries for CoreCLR (mscorlib, System, etc.)
+corlib_dir=""
+
################################################################################
# Everything past this point is the actual script
-
-# Special case: program is launched via Steam
-# In that case rerun the script via their bootstrapper to ensure Steam overlay works
-if [ "$2" = "SteamLaunch" ]; then
- # Conceptually: exec "$1" "$2" "$3" "$4" "$0" "rest of $@"
- # But newer versions of Steam interleave the $1..$4 with some "--" arguments, so preserve them as well
- # Bash has array subscripting, but POSIX sh doesn't, so avoid it
- to_rotate=4
- rotated=0
- while [ $((to_rotate-=1)) -ge 0 ]; do
- while [ "z$1" = "z--" ]; do
- set -- "$@" "$1"
- shift
- rotated=$((rotated+1))
+set -e
+
+# Special case: program is launched via Steam on Linux
+# In that case rerun the script via their bootstrapper to delay adding Doorstop to LD_PRELOAD
+# This is required until https://github.com/NeighTools/UnityDoorstop/issues/88 is resolved
+for a in "$@"; do
+ if [ "$a" = "SteamLaunch" ]; then
+ rotated=0; max=$#
+ while [ $rotated -lt $max ]; do
+ # Test if argument is prefixed with the value of $PWD
+ if [ "$1" != "${1#"${PWD%/}/"}" ]; then
+ to_rotate=$(($# - rotated))
+ set -- "$@" "$0"
+ while [ $((to_rotate-=1)) -ge 0 ]; do
+ set -- "$@" "$1"
+ shift
+ done
+ exec "$@"
+ else
+ set -- "$@" "$1"
+ shift
+ rotated=$((rotated+1))
+ fi
done
- set -- "$@" "$1"
- shift
- rotated=$((rotated+1))
- done
- to_rotate=$(($# - rotated))
- set -- "$@" "$0"
- while [ $((to_rotate-=1)) -ge 0 ]; do
- set -- "$@" "$1"
- shift
- done
- exec "$@"
-fi
+ echo "Could not determine game executable launched by Steam" 1>&2
+ exit 1
+ fi
+done
# Handle first param being executable name
if [ -x "$1" ] ; then
executable_name="$1"
- echo "Target executable: $1"
shift
fi
if [ -z "${executable_name}" ] || [ ! -x "${executable_name}" ]; then
- echo "Please set executable_name to a valid name in a text editor or as the first command line parameter"
+ echo "Please set executable_name to a valid name in a text editor or as the first command line parameter" 1>&2
exit 1
fi
@@ -100,50 +107,56 @@ arch=""
executable_path=""
lib_extension=""
-# Set executable path and the extension to use for the libdoorstop shared object
+abs_path() {
+ # Resolve relative path to absolute from BASEDIR
+ if [ "$1" = "${1#/}" ]; then
+ set -- "${BASEDIR}/${1}"
+ fi
+ echo "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
+}
+
+# Set executable path and the extension to use for the libdoorstop shared object as well as check whether we're running on Apple Silicon
os_type="$(uname -s)"
case ${os_type} in
Linux*)
- executable_path="${executable_name}"
- # Handle relative paths
- if ! echo "$executable_path" | grep "^/.*$"; then
- executable_path="${BASEDIR}/${executable_path}"
- fi
+ executable_path="$(abs_path "$executable_name")"
lib_extension="so"
;;
Darwin*)
- real_executable_name="${executable_name}"
-
- # Handle relative directories
- if ! echo "$real_executable_name" | grep "^/.*$"; then
- real_executable_name="${BASEDIR}/${real_executable_name}"
- fi
+ real_executable_name="$(abs_path "$executable_name")"
# If we're not even an actual executable, check .app Info for actual executable
- if ! echo "$real_executable_name" | grep "^.*\.app/Contents/MacOS/.*"; then
- # Add .app to the end if not given
- if ! echo "$real_executable_name" | grep "^.*\.app$"; then
- real_executable_name="${real_executable_name}.app"
- fi
- inner_executable_name=$(defaults read "${real_executable_name}/Contents/Info" CFBundleExecutable)
- executable_path="${real_executable_name}/Contents/MacOS/${inner_executable_name}"
- else
- executable_path="${executable_name}"
- fi
+ case $real_executable_name in
+ *.app/Contents/MacOS/*)
+ executable_path="${executable_name}"
+ ;;
+ *)
+ # Add .app to the end if not given
+ if [ "$real_executable_name" = "${real_executable_name%.app}" ]; then
+ real_executable_name="${real_executable_name}.app"
+ fi
+ inner_executable_name=$(defaults read "${real_executable_name}/Contents/Info" CFBundleExecutable)
+ executable_path="${real_executable_name}/Contents/MacOS/${inner_executable_name}"
+ ;;
+ esac
lib_extension="dylib"
+
+ # CPUs for Apple Silicon are in the format "Apple M.."
+ cpu_type="$(sysctl -n machdep.cpu.brand_string)"
+ case "${cpu_type}" in
+ Apple*)
+ is_apple_silicon=1
+ ;;
+ esac
;;
*)
# alright whos running games on freebsd
- echo "Unknown operating system ($(uname -s))"
- echo "Make an issue at https://github.com/NeighTools/UnityDoorstop"
+ echo "Unknown operating system ($(uname -s))" 1>&2
+ echo "Make an issue at https://github.com/NeighTools/UnityDoorstop" 1>&2
exit 1
;;
esac
-abs_path() {
- echo "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
-}
-
_readlink() {
# relative links with readlink (without -f) do not preserve the path info
ab_path="$(abs_path "$1")"
@@ -155,7 +168,6 @@ _readlink() {
echo "$link"
}
-
resolve_executable_path () {
e_path="$(abs_path "$1")"
@@ -165,13 +177,23 @@ resolve_executable_path () {
echo "${e_path}"
}
-# Get absolute path of executable and show to user
+# Get absolute path of executable
executable_path=$(resolve_executable_path "${executable_path}")
-echo "${executable_path}"
# Figure out the arch of the executable with file
file_out="$(LD_PRELOAD="" file -b "${executable_path}")"
case "${file_out}" in
+ *PE32*)
+ echo "The executable is a Windows executable file. You must use Wine/Proton and BepInEx for Windows with this executable." 1>&2
+ echo "Uninstall BepInEx for *nix and install BepInEx for Windows instead." 1>&2
+ echo "More info: https://docs.bepinex.dev/articles/advanced/steam_interop.html#protonwine" 1>&2
+ exit 1
+ ;;
+ *shell\ script*)
+ # Fallback for games that launch a shell script from Steam
+ # default to x64, change as needed
+ arch="x64"
+ ;;
*64-bit*)
arch="x64"
;;
@@ -179,10 +201,10 @@ case "${file_out}" in
arch="x86"
;;
*)
- echo "The executable \"${executable_path}\" is not compiled for x86 or x64 (might be ARM?)"
- echo "If you think this is a mistake (or would like to encourage support for other architectures)"
- echo "Please make an issue at https://github.com/NeighTools/UnityDoorstop"
- echo "Got: ${file_out}"
+ echo "The executable \"${executable_path}\" is not compiled for x86 or x64 (might be ARM?)" 1>&2
+ echo "If you think this is a mistake (or would like to encourage support for other architectures)" 1>&2
+ echo "Please make an issue at https://github.com/NeighTools/UnityDoorstop" 1>&2
+ echo "Got: ${file_out}" 1>&2
exit 1
;;
esac
@@ -200,49 +222,78 @@ doorstop_bool() {
}
# Read from command line
-while :; do
+i=0; max=$#
+while [ $i -lt $max ]; do
case "$1" in
- --doorstop_enabled)
+ --doorstop_enabled) # For backwards compatibility. Renamed to --doorstop-enabled
enabled="$(doorstop_bool "$2")"
shift
+ i=$((i+1))
;;
- --doorstop_target_assembly)
+ --doorstop_target_assembly) # For backwards compatibility. Renamed to --doorstop-target-assembly
target_assembly="$2"
shift
+ i=$((i+1))
+ ;;
+ --doorstop-enabled)
+ enabled="$(doorstop_bool "$2")"
+ shift
+ i=$((i+1))
+ ;;
+ --doorstop-target-assembly)
+ target_assembly="$2"
+ shift
+ i=$((i+1))
;;
--doorstop-boot-config-override)
boot_config_override="$2"
shift
+ i=$((i+1))
;;
--doorstop-mono-dll-search-path-override)
dll_search_path_override="$2"
shift
+ i=$((i+1))
;;
--doorstop-mono-debug-enabled)
debug_enable="$(doorstop_bool "$2")"
shift
+ i=$((i+1))
;;
--doorstop-mono-debug-suspend)
debug_suspend="$(doorstop_bool "$2")"
shift
+ i=$((i+1))
;;
--doorstop-mono-debug-address)
debug_address="$2"
shift
+ i=$((i+1))
+ ;;
+ --doorstop-clr-runtime-coreclr-path)
+ coreclr_path="$2"
+ shift
+ i=$((i+1))
+ ;;
+ --doorstop-clr-corlib-dir)
+ corlib_dir="$2"
+ shift
+ i=$((i+1))
;;
*)
- if [ -z "$1" ]; then
- break
- fi
- rest_args="$rest_args $1"
+ set -- "$@" "$1"
;;
esac
shift
+ i=$((i+1))
done
+target_assembly="$(abs_path "$target_assembly")"
+
# Move variables to environment
export DOORSTOP_ENABLED="$enabled"
export DOORSTOP_TARGET_ASSEMBLY="$target_assembly"
+export DOORSTOP_BOOT_CONFIG_OVERRIDE="$boot_config_override"
export DOORSTOP_IGNORE_DISABLED_ENV="$ignore_disable_switch"
export DOORSTOP_MONO_DLL_SEARCH_PATH_OVERRIDE="$dll_search_path_override"
export DOORSTOP_MONO_DEBUG_ENABLED="$debug_enable"
@@ -269,5 +320,14 @@ else
export DYLD_INSERT_LIBRARIES="${doorstop_name}:${DYLD_INSERT_LIBRARIES}"
fi
-# shellcheck disable=SC2086
-exec "$executable_path" $rest_args
\ No newline at end of file
+if [ -n "${is_apple_silicon}" ]; then
+ export ARCHPREFERENCE="arm64e,arm64,x86_64"
+
+ # We need to use arch for Apple Silicon to allow the executable to be run natively as otherwise if
+ # the executable is universal, supporting both x86_64 and arm64, MacOs will still run it as x86_64
+ # if the parent process is running as x86.
+ # arch also strips the DYLD_INSERT_LIBRARIES env var so we have to pass that in manually
+ exec arch -e DYLD_INSERT_LIBRARIES="${DYLD_INSERT_LIBRARIES}" "$executable_path" "$@"
+else
+ exec "$executable_path" "$@"
+fi
diff --git a/scripts/valheim/build_doorstop_arm64e.sh b/scripts/valheim/build_doorstop_arm64e.sh
new file mode 100755
index 0000000..39d97a9
--- /dev/null
+++ b/scripts/valheim/build_doorstop_arm64e.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)"
+UNITY_DOORSTOP_TAG="${UNITY_DOORSTOP_TAG:-v4.5.0}"
+WORKDIR="${1:-${TMPDIR:-/tmp}/UnityDoorstop-arm64e}"
+
+rm -rf "$WORKDIR"
+git clone --depth 1 --branch "$UNITY_DOORSTOP_TAG" https://github.com/NeighTools/UnityDoorstop "$WORKDIR"
+
+# Newer Apple Silicon machines can require arm64e for injected dylibs.
+perl -0pi -e 's/set_arch\("arm64"\)/set_arch("arm64e")/' "$WORKDIR/xmake.lua"
+
+(
+ cd "$WORKDIR"
+ ./build.sh
+)
+
+OUTPUT_DIR="$WORKDIR/build/macosx/universal/release"
+if ! lipo -archs "$OUTPUT_DIR/libdoorstop.dylib" | grep -q 'arm64e'; then
+ echo "Expected arm64e slice in $OUTPUT_DIR/libdoorstop.dylib" >&2
+ exit 1
+fi
+
+echo "$OUTPUT_DIR"
diff --git a/scripts/valheim/install_gale_export.sh b/scripts/valheim/install_gale_export.sh
new file mode 100755
index 0000000..5bd9bea
--- /dev/null
+++ b/scripts/valheim/install_gale_export.sh
@@ -0,0 +1,203 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)"
+DEFAULT_APP="/Users/$USER/Library/Application Support/Steam/steamapps/common/Valheim/valheim.app"
+APP_PATH="$DEFAULT_APP"
+DOORSTOP_DIR=""
+EXPORT_PATH=""
+SKIP_RUNTIME_INSTALL=0
+WITH_SMOKE_TESTS=0
+WORKDIR="${TMPDIR:-/tmp}/valheim-gale-export"
+
+usage() {
+ cat <<'EOF'
+Usage:
+ install_gale_export.sh --export /path/to/profile.r2z [--app /path/to/valheim.app] [--doorstop-dir DIR]
+ [--skip-runtime-install] [--with-smoke-tests]
+
+Examples:
+ scripts/valheim/install_gale_export.sh --export ~/Downloads/Servidor.r2z
+ scripts/valheim/install_gale_export.sh --export ~/Downloads/Servidor.r2z --app "/Users/me/Library/Application Support/Steam/steamapps/common/Valheim/valheim.app"
+EOF
+}
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --export)
+ EXPORT_PATH="$2"
+ shift 2
+ ;;
+ --app)
+ APP_PATH="$2"
+ shift 2
+ ;;
+ --doorstop-dir)
+ DOORSTOP_DIR="$2"
+ shift 2
+ ;;
+ --skip-runtime-install)
+ SKIP_RUNTIME_INSTALL=1
+ shift
+ ;;
+ --with-smoke-tests)
+ WITH_SMOKE_TESTS=1
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown argument: $1" >&2
+ usage >&2
+ exit 1
+ ;;
+ esac
+done
+
+if [ -z "$EXPORT_PATH" ]; then
+ echo "Missing required --export argument" >&2
+ usage >&2
+ exit 1
+fi
+
+if [ ! -f "$EXPORT_PATH" ]; then
+ echo "Export file not found: $EXPORT_PATH" >&2
+ exit 1
+fi
+
+if [ -d "$APP_PATH/Contents/MacOS" ]; then
+ MACOS_DIR="$APP_PATH/Contents/MacOS"
+else
+ echo "Could not resolve a Valheim app bundle from: $APP_PATH" >&2
+ exit 1
+fi
+
+if [ "$SKIP_RUNTIME_INSTALL" = "0" ]; then
+ install_args=("$APP_PATH")
+ if [ -n "$DOORSTOP_DIR" ]; then
+ install_args+=(--doorstop-dir "$DOORSTOP_DIR")
+ fi
+ if [ "$WITH_SMOKE_TESTS" = "1" ]; then
+ install_args+=(--with-smoke-tests)
+ fi
+
+ "$REPO_ROOT/scripts/valheim/install_macos_arm64.sh" "${install_args[@]}"
+fi
+
+rm -rf "$WORKDIR"
+mkdir -p "$WORKDIR/export" "$WORKDIR/downloads" "$WORKDIR/packages" "$WORKDIR/stage/BepInEx/plugins" "$WORKDIR/stage/BepInEx/patchers" "$WORKDIR/stage/BepInEx/config" "$WORKDIR/stage/BepInEx/core"
+ditto -x -k "$EXPORT_PATH" "$WORKDIR/export"
+
+MANIFEST_PATH="$WORKDIR/export/export.r2x"
+if [ ! -f "$MANIFEST_PATH" ]; then
+ echo "Could not find export.r2x inside $EXPORT_PATH" >&2
+ exit 1
+fi
+
+if [ -d "$WORKDIR/export/BepInEx/plugins" ]; then
+ rsync -a "$WORKDIR/export/BepInEx/plugins/" "$WORKDIR/stage/BepInEx/plugins/"
+fi
+if [ -d "$WORKDIR/export/BepInEx/patchers" ]; then
+ rsync -a "$WORKDIR/export/BepInEx/patchers/" "$WORKDIR/stage/BepInEx/patchers/"
+fi
+if [ -d "$WORKDIR/export/BepInEx/config" ]; then
+ rsync -a "$WORKDIR/export/BepInEx/config/" "$WORKDIR/stage/BepInEx/config/"
+fi
+if [ -d "$WORKDIR/export/BepInEx/core" ]; then
+ rsync -a "$WORKDIR/export/BepInEx/core/" "$WORKDIR/stage/BepInEx/core/"
+fi
+
+package_rows=()
+while IFS= read -r line; do
+ package_rows+=("$line")
+done < <(
+ ruby - "$MANIFEST_PATH" <<'RUBY'
+require "yaml"
+
+manifest = YAML.safe_load(File.read(ARGV[0]))
+mods = manifest.fetch("mods", [])
+
+mods.each do |mod|
+ next unless mod["enabled"]
+
+ author, package = mod.fetch("name").split("-", 2)
+ version = mod.fetch("version")
+
+ puts [author, package, "#{version["major"]}.#{version["minor"]}.#{version["patch"]}"].join("\t")
+end
+RUBY
+)
+
+if [ "${#package_rows[@]}" -eq 0 ]; then
+ echo "No enabled packages found in $MANIFEST_PATH" >&2
+ exit 1
+fi
+
+downloaded_count=0
+for row in "${package_rows[@]}"; do
+ IFS=$'\t' read -r author package version <<<"$row"
+
+ if [ "$author" = "denikson" ] && [ "$package" = "BepInExPack_Valheim" ]; then
+ echo "Skipping runtime package $author-$package-$version; using custom macOS runtime instead"
+ continue
+ fi
+
+ archive_path="$WORKDIR/downloads/${author}-${package}-${version}.zip"
+ package_dir="$WORKDIR/packages/${author}-${package}-${version}"
+ package_url="https://thunderstore.io/package/download/${author}/${package}/${version}/"
+
+ echo "Downloading $author-$package-$version"
+ curl -fsSL "$package_url" -o "$archive_path"
+ rm -rf "$package_dir"
+ mkdir -p "$package_dir"
+ ditto -x -k "$archive_path" "$package_dir"
+
+ while IFS= read -r windows_path; do
+ relative_path="${windows_path#$package_dir/}"
+ normalized_relative_path="${relative_path//\\//}"
+ normalized_path="$package_dir/$normalized_relative_path"
+ mkdir -p "$(dirname "$normalized_path")"
+ mv "$windows_path" "$normalized_path"
+ done < <(find "$package_dir" -type f -name '*\\*')
+
+ find "$package_dir" -maxdepth 1 -type f \( -name '*.dll' -o -name '*.pdb' -o -name '*.mdb' -o -name '*.xml' \) -exec cp {} "$WORKDIR/stage/BepInEx/plugins/" \;
+
+ for relative_dir in plugins patchers config core; do
+ if [ -d "$package_dir/$relative_dir" ]; then
+ rsync -a "$package_dir/$relative_dir/" "$WORKDIR/stage/BepInEx/$relative_dir/"
+ fi
+ if [ -d "$package_dir/BepInEx/$relative_dir" ]; then
+ rsync -a "$package_dir/BepInEx/$relative_dir/" "$WORKDIR/stage/BepInEx/$relative_dir/"
+ fi
+ done
+
+ downloaded_count=$((downloaded_count + 1))
+done
+
+PROFILE_BACKUP_DIR="$MACOS_DIR/.bepinex-profile-backup-$(date +%Y%m%d-%H%M%S)"
+mkdir -p "$PROFILE_BACKUP_DIR"
+for relative_dir in plugins patchers config; do
+ if [ -d "$MACOS_DIR/BepInEx/$relative_dir" ]; then
+ rsync -a "$MACOS_DIR/BepInEx/$relative_dir/" "$PROFILE_BACKUP_DIR/$relative_dir/"
+ fi
+ rm -rf "$MACOS_DIR/BepInEx/$relative_dir"
+ mkdir -p "$MACOS_DIR/BepInEx/$relative_dir"
+done
+
+if [ -d "$WORKDIR/stage/BepInEx/core" ] && [ -n "$(find "$WORKDIR/stage/BepInEx/core" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
+ rsync -a "$WORKDIR/stage/BepInEx/core/" "$MACOS_DIR/BepInEx/core/"
+fi
+rsync -a "$WORKDIR/stage/BepInEx/plugins/" "$MACOS_DIR/BepInEx/plugins/"
+rsync -a "$WORKDIR/stage/BepInEx/patchers/" "$MACOS_DIR/BepInEx/patchers/"
+rsync -a "$WORKDIR/stage/BepInEx/config/" "$MACOS_DIR/BepInEx/config/"
+
+echo
+echo "Installed Gale export from: $EXPORT_PATH"
+echo "Downloaded packages: $downloaded_count"
+echo "Profile backup: $PROFILE_BACKUP_DIR"
+echo "Valheim path: $MACOS_DIR"
+echo
+echo "Run Valheim with:"
+echo " cd \"$MACOS_DIR\" && ./run_bepinex.sh ./Valheim"
diff --git a/scripts/valheim/install_macos_arm64.sh b/scripts/valheim/install_macos_arm64.sh
new file mode 100755
index 0000000..943d10c
--- /dev/null
+++ b/scripts/valheim/install_macos_arm64.sh
@@ -0,0 +1,111 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)"
+DEFAULT_APP="/Users/$USER/Library/Application Support/Steam/steamapps/common/Valheim/valheim.app"
+APP_PATH="$DEFAULT_APP"
+DOORSTOP_DIR=""
+MODS_DIR=""
+WITH_SMOKE_TESTS=0
+STAGE_DIR="${TMPDIR:-/tmp}/bepinex-valheim-macos-arm64"
+
+usage() {
+ cat <<'EOF'
+Usage:
+ install_macos_arm64.sh [path/to/valheim.app] [--doorstop-dir DIR] [--mods-dir DIR] [--with-smoke-tests]
+
+Examples:
+ scripts/valheim/install_macos_arm64.sh
+ scripts/valheim/install_macos_arm64.sh "/Users/me/Library/Application Support/Steam/steamapps/common/Valheim/valheim.app" --with-smoke-tests
+ scripts/valheim/install_macos_arm64.sh --mods-dir /path/to/exported/BepInEx/plugins
+EOF
+}
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --doorstop-dir)
+ DOORSTOP_DIR="$2"
+ shift 2
+ ;;
+ --mods-dir)
+ MODS_DIR="$2"
+ shift 2
+ ;;
+ --with-smoke-tests)
+ WITH_SMOKE_TESTS=1
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ APP_PATH="$1"
+ shift
+ ;;
+ esac
+done
+
+if [ -d "$APP_PATH/Contents/MacOS" ]; then
+ MACOS_DIR="$APP_PATH/Contents/MacOS"
+elif [ -d "$APP_PATH" ] && [ -x "$APP_PATH/Valheim" ]; then
+ MACOS_DIR="$APP_PATH"
+else
+ echo "Could not resolve a Valheim app bundle from: $APP_PATH" >&2
+ exit 1
+fi
+
+mkdir -p "$STAGE_DIR/BepInEx/core" "$STAGE_DIR/BepInEx/plugins" "$STAGE_DIR/BepInEx/patchers"
+rm -rf "$STAGE_DIR/BepInEx/core"/*
+
+dotnet publish "$REPO_ROOT/BepInEx.Preloader/BepInEx.Preloader.csproj" -c Release -o "$STAGE_DIR/BepInEx/core"
+cp "$REPO_ROOT/doorstop/run_bepinex.sh" "$STAGE_DIR/run_bepinex.sh"
+chmod +x "$STAGE_DIR/run_bepinex.sh"
+
+if [ -z "$DOORSTOP_DIR" ]; then
+ if [ "$(sysctl -n machdep.ptrauth_enabled 2>/dev/null || echo 0)" = "1" ]; then
+ DOORSTOP_DIR="$("$REPO_ROOT/scripts/valheim/build_doorstop_arm64e.sh" "${TMPDIR:-/tmp}/UnityDoorstop-arm64e")"
+ else
+ OFFICIAL_DIR="${TMPDIR:-/tmp}/doorstop-macos-official"
+ rm -rf "$OFFICIAL_DIR"
+ mkdir -p "$OFFICIAL_DIR"
+ curl -L "https://github.com/NeighTools/UnityDoorstop/releases/download/v4.5.0/doorstop_macos_release_4.5.0.zip" -o "$OFFICIAL_DIR/doorstop_macos.zip"
+ unzip -q -o "$OFFICIAL_DIR/doorstop_macos.zip" -d "$OFFICIAL_DIR/unpacked"
+ DOORSTOP_DIR="$OFFICIAL_DIR/unpacked/universal"
+ fi
+fi
+
+cp "$DOORSTOP_DIR/libdoorstop.dylib" "$STAGE_DIR/libdoorstop.dylib"
+cp "$DOORSTOP_DIR/.doorstop_version" "$STAGE_DIR/.doorstop_version"
+
+if [ "$WITH_SMOKE_TESTS" = "1" ]; then
+ dotnet build "$REPO_ROOT/TestPlugins/ValheimArm64Smoke.NoHarmony/ValheimArm64Smoke.NoHarmony.csproj" -c Release
+ dotnet build "$REPO_ROOT/TestPlugins/ValheimArm64Smoke.Harmony/ValheimArm64Smoke.Harmony.csproj" -c Release
+ cp "$REPO_ROOT/TestPlugins/ValheimArm64Smoke.NoHarmony/bin/Release/net35/ValheimArm64Smoke.NoHarmony.dll" "$STAGE_DIR/BepInEx/plugins/"
+ cp "$REPO_ROOT/TestPlugins/ValheimArm64Smoke.Harmony/bin/Release/net35/ValheimArm64Smoke.Harmony.dll" "$STAGE_DIR/BepInEx/plugins/"
+fi
+
+if [ -n "$MODS_DIR" ]; then
+ find "$MODS_DIR" -maxdepth 1 -type f \( -name '*.dll' -o -name '*.pdb' -o -name '*.mdb' \) -exec cp {} "$STAGE_DIR/BepInEx/plugins/" \;
+fi
+
+BACKUP_DIR=""
+for path in BepInEx libdoorstop.dylib run_bepinex.sh .doorstop_version; do
+ if [ -e "$MACOS_DIR/$path" ]; then
+ if [ -z "$BACKUP_DIR" ]; then
+ BACKUP_DIR="$MACOS_DIR/.bepinex-backup-$(date +%Y%m%d-%H%M%S)"
+ mkdir -p "$BACKUP_DIR"
+ fi
+ rsync -a "$MACOS_DIR/$path" "$BACKUP_DIR/"
+ fi
+done
+
+rsync -a "$STAGE_DIR/" "$MACOS_DIR/"
+
+echo "Installed BepInEx into: $MACOS_DIR"
+if [ -n "$BACKUP_DIR" ]; then
+ echo "Backup created at: $BACKUP_DIR"
+fi
+echo
+echo "Run Valheim with:"
+echo " cd \"$MACOS_DIR\" && ./run_bepinex.sh ./Valheim"
diff --git a/submodules/BepInEx.Harmony b/submodules/BepInEx.Harmony
index 6830e68..f16083f 160000
--- a/submodules/BepInEx.Harmony
+++ b/submodules/BepInEx.Harmony
@@ -1 +1 @@
-Subproject commit 6830e686c7931164a4e34595a34cbfa15846d1d4
+Subproject commit f16083f4e78f50320afad47a5d0e1c5474a582f5