diff --git a/Assets/EmuHawkMono.sh b/Assets/EmuHawkMono.sh
index 8b600082fb3..edbded5d207 100755
--- a/Assets/EmuHawkMono.sh
+++ b/Assets/EmuHawkMono.sh
@@ -1,24 +1,74 @@
#!/bin/sh
cd "$(dirname "$(realpath "$0")")"
-libpath=""
-if [ "$(command -v lsb_release)" ]; then
- case "$(lsb_release -i | head -n1 | cut -c17- | tr A-Z a-z)" in
- "arch"|"artix"|"manjarolinux") libpath="/usr/lib";;
- "fedora"|"gentoo"|"nobaralinux"|"opensuse") libpath="/usr/lib64";;
- "nixos") libpath="/usr/lib"; printf "Running on NixOS? Why aren't you using the Nix expr?\n" >&2;;
- "debian"|"linuxmint"|"pop"|"ubuntu") libpath="/usr/lib/x86_64-linux-gnu";;
- esac
-else
- printf "Distro does not provide LSB release info API! (You've met with a terrible fate, haven't you?)\n" >&2
-fi
-if [ -z "$libpath" ]; then
- printf "%s\n" "Unknown distro, assuming system-wide libraries are in /usr/lib..." >&2
- libpath="/usr/lib"
-fi
-export LD_LIBRARY_PATH="$PWD/dll:$PWD:$libpath"
+case "$(uname -s)" in
+Darwin)
+ # macOS (x86_64; natively on Intel or via Rosetta 2 on Apple silicon). Mono's WinForms is
+ # X11-based, so XQuartz must be running; see the macOS section in the readme. The display
+ # method defaults to GdiPlus on macOS (in Config.cs) since XQuartz's OpenGL is unusable.
+ #
+ # BizHawk's P/Invokes and Mono load some libraries by Linux/soname-style names, and the
+ # Homebrew/XQuartz deps have their own versioned names. We can't ship those symlinks (they
+ # point at the user's Homebrew install), so (re)create them in ./dll at launch, similar to
+ # the NixOS launch script. They all resolve to ONE Homebrew libX11 so that the X11 clients
+ # (WinForms, cairo/libgdiplus, SDL) agree.
+ dll="$PWD/dll"
+ ln_dep() { [ -e "$1" ] && ln -sf "$1" "$dll/$2"; } # silently skip optional libs that aren't installed
+ ln_dep /usr/local/opt/lua@5.4/lib/liblua5.4.dylib liblua54.dylib
+ ln_dep /usr/local/opt/openal-soft/lib/libopenal.dylib libopenal.dylib
+ ln_dep /usr/local/opt/openal-soft/lib/libopenal.1.dylib libopenal.1.dylib
+ ln_dep /usr/local/opt/zstd/lib/libzstd.1.dylib libzstd.so.1
+ ln_dep /usr/local/opt/sqlite/lib/libsqlite3.dylib libe_sqlite3.dylib
+ ln_dep /usr/local/opt/libx11/lib/libX11.6.dylib libX11.6.dylib
+ ln_dep /usr/local/opt/libxext/lib/libXext.6.dylib libXext.6.dylib
+ ln_dep /usr/local/opt/libxrender/lib/libXrender.1.dylib libXrender.1.dylib
+ ln_dep /usr/local/opt/libxcursor/lib/libXcursor.1.dylib libXcursor.1.dylib
+ ln_dep /usr/local/opt/libxinerama/lib/libXinerama.1.dylib libXinerama.1.dylib
+ ln_dep /usr/local/opt/libxi/lib/libXi.6.dylib libXi.6.dylib
+ ln_dep /usr/local/opt/libxrandr/lib/libXrandr.2.dylib libXrandr.2.dylib
+ ln_dep /usr/local/opt/libxtst/lib/libXtst.6.dylib libXtst.6.dylib
+ ln_dep /usr/local/opt/libxfixes/lib/libXfixes.3.dylib libXfixes.3.dylib
+ ln_dep /usr/local/opt/libxscrnsaver/lib/libXss.1.dylib libXss.1.dylib
+ ln_dep /usr/local/opt/libxau/lib/libXau.6.dylib libXau.6.dylib
+ ln_dep /usr/local/opt/libxdmcp/lib/libXdmcp.6.dylib libXdmcp.6.dylib
+ ln_dep /usr/local/opt/libxcb/lib/libxcb.1.dylib libxcb.1.dylib
+ # soname/unversioned aliases hardcoded by BizHawk's P/Invokes and Mono (resolve within ./dll)
+ [ -e "$dll/libX11.6.dylib" ] && { ln -sf libX11.6.dylib "$dll/libX11.dylib"; ln -sf libX11.6.dylib "$dll/libX11.so.6"; }
+ [ -e "$dll/libXi.6.dylib" ] && ln -sf libXi.6.dylib "$dll/libXi.so.6"
+ [ -e "$dll/libXfixes.3.dylib" ] && ln -sf libXfixes.3.dylib "$dll/libXfixes.so.3"
+ [ -e "$dll/libgdiplus.0.dylib" ] && ln -sf libgdiplus.0.dylib "$dll/libgdiplus.dylib"
+ # Let the loader find ./dll, the Homebrew deps under /usr/local, and XQuartz under /opt/X11.
+ export DYLD_LIBRARY_PATH="$dll:$PWD:/usr/local/lib:/opt/X11/lib${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}"
+ export DYLD_FALLBACK_LIBRARY_PATH="$dll:$PWD:/usr/local/lib:/opt/X11/lib:/usr/lib${DYLD_FALLBACK_LIBRARY_PATH:+:$DYLD_FALLBACK_LIBRARY_PATH}"
+ # Prefer the x86_64 Homebrew Mono via PATH (so a Nix Mono etc. is still respected). Don't run
+ # Mono through `arch` — it's SIP-protected and strips the DYLD_* vars.
+ [ -d /usr/local/bin ] && export PATH="/usr/local/bin:$PATH"
+ export MONO_MWF_MAC_FORCE_X11=1 # the default macOS WinForms driver (Carbon) is unported to 64-bit
+ [ -z "$DISPLAY" ] && export DISPLAY=:0
+ export MONO_GC_PARAMS="nursery-size=256m${MONO_GC_PARAMS:+,}$MONO_GC_PARAMS"
+ ;;
+*)
+ # GNU+Linux (and other Unix)
+ libpath=""
+ if [ "$(command -v lsb_release)" ]; then
+ case "$(lsb_release -i | head -n1 | cut -c17- | tr A-Z a-z)" in
+ "arch"|"artix"|"manjarolinux") libpath="/usr/lib";;
+ "fedora"|"gentoo"|"nobaralinux"|"opensuse") libpath="/usr/lib64";;
+ "nixos") libpath="/usr/lib"; printf "Running on NixOS? Why aren't you using the Nix expr?\n" >&2;;
+ "debian"|"linuxmint"|"pop"|"ubuntu") libpath="/usr/lib/x86_64-linux-gnu";;
+ esac
+ else
+ printf "Distro does not provide LSB release info API! (You've met with a terrible fate, haven't you?)\n" >&2
+ fi
+ if [ -z "$libpath" ]; then
+ printf "%s\n" "Unknown distro, assuming system-wide libraries are in /usr/lib..." >&2
+ libpath="/usr/lib"
+ fi
+ export LD_LIBRARY_PATH="$PWD/dll:$PWD:$libpath"
+ ;;
+esac
export MONO_CRASH_NOFILE=1
export MONO_WINFORMS_XIM_STYLE=disabled # see https://bugzilla.xamarin.com/show_bug.cgi?id=28047#c9
-if (ps -C "mono" -o "cmd" --no-headers | grep -Fq "EmuHawk.exe"); then
+if (ps -A -o command= | grep -F "EmuHawk.exe" | grep -Fvq "grep"); then
printf "(it seems EmuHawk is already running, NOT capturing output)\n" >&2
exec mono EmuHawk.exe "$@"
fi
diff --git a/Dist/.InvokeCLIOnMainSln.sh b/Dist/.InvokeCLIOnMainSln.sh
index fa09f310984..c8892ed81bc 100755
--- a/Dist/.InvokeCLIOnMainSln.sh
+++ b/Dist/.InvokeCLIOnMainSln.sh
@@ -10,6 +10,6 @@ config="$1"
shift
if [ -z "$NUGET_PACKAGES" ]; then export NUGET_PACKAGES="$HOME/.nuget/packages"; fi
printf "running 'dotnet %s' in %s configuration, extra args: %s\n" "$cmd" "$config" "$*"
-version=$(grep -Po "MainVersion = \"\K(.*)(?=\")" src/BizHawk.Common/VersionInfo.cs)
+version=$(sed -n 's/.*MainVersion = "\([^"]*\)";.*/\1/p' src/BizHawk.Common/VersionInfo.cs)
git_hash="$(git rev-parse --verify HEAD 2>/dev/null || printf "0000000000000000000000000000000000000000")"
dotnet "$cmd" BizHawk.sln -c "$config" -m -clp:NoSummary -p:Version="$version" -p:SourceRevisionId="$git_hash" "$@"
diff --git a/README.md b/README.md
index 140148fb9ed..1c0a501e68f 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@ Jump to:
* Installing
* [Windows](#windows)
* [Unix](#unix)
- * [macOS](#macos-legacy-bizhawk)
+ * [macOS](#macos-experimental-x86_64-port)
* [Nix/NixOS](#nixnixos)
* [Development builds](#development-builds)
* [Building](#building)
@@ -172,14 +172,31 @@ As with Apple silicon Macs, not available.
If you were looking to emulate iOS apps, see [#3956](https://github.com/TASEmulators/BizHawk/issues/3956).
-#### macOS (legacy BizHawk)
+#### macOS (experimental x86_64 port)
-EmuHawk depends on certain libraries for graphics, and these don't work on macOS. Users on macOS have three options:
-* Use another machine with Windows or Linux, or install either in an x86_64 VM (Wine is not a VM).
-* Use an older 1.x release, which was ported to macOS by @Sappharad (with replacements for the missing libraries), via Rosetta. Links and more details are in [this TASVideos forum thread](https://tasvideos.org/Forum/Topics/12659) (jump to last page for latest binaries). See [#3697](https://github.com/TASEmulators/BizHawk/issues/3697) for details.
-* For the technically-minded, download the [source](https://github.com/Sappharad/BizHawk/tree/MacUnixMonoCompat) of an older 2.x release. @Sappharad put a lot of work into it but ultimately decided to stop.
- * ...or use the Nix expression as a starting point instead.
- * Either way, this probably won't work on Apple silicon without a lot more effort. You'll probably want to build for x86_64 and run Mono via Rosetta. See [#4052](https://github.com/TASEmulators/BizHawk/issues/4052) re: Linux AArch64.
+> [!WARNING]
+> **This macOS port is EXPERIMENTAL and community-maintained — it is *not* officially supported by the BizHawk team.**
+> It runs the **x86_64** build under Mono + XQuartz (natively on Intel Macs, via Rosetta 2 on Apple silicon). Expect rough edges: software (GdiPlus) video only, no hardware OpenGL (so no HD/upscaled rendering or GL shader filters), and it needs a manual dependency setup.
+> **Please do not open issues about this port on the main tracker** unless you can also reproduce them on Windows/Linux — most of the dev team is on Windows and cannot support it. Discuss macOS-specific problems in the relevant macOS thread instead.
+
+Requirements: **macOS 14 (Sonoma) or newer** (this is the floor imposed by the Homebrew dependencies; the bundled native libraries themselves target macOS 11). x86_64 only — on Apple silicon everything runs through **Rosetta 2**.
+
+Setup (all the brew commands must be the **x86_64** Homebrew under `/usr/local`):
+
+1. Apple silicon only: install Rosetta 2 — `softwareupdate --install-rosetta --agree-to-license`.
+2. Install an **x86_64 Homebrew** at `/usr/local`. On Intel Macs the normal Homebrew is already x86_64. On Apple silicon, install a second one under Rosetta:
+ `arch -x86_64 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"`
+ (prefix every command below with `arch -x86_64` and use `/usr/local/bin/brew`).
+3. Install the runtime dependencies:
+ `brew install mono mono-libgdiplus sdl2 openal-soft lua@5.4 zstd sqlite libx11 libxext libxrender libxcursor libxinerama libxi libxrandr libxtst libxfixes libxscrnsaver libxau libxdmcp libxcb`
+ `brew install --cask xquartz` (then log out/in so the X server is registered)
+4. Get a build: either download the macOS dev build, or build from source (see [*Building*](#building)).
+
+Run `EmuHawkMono.sh` to start EmuHawk. **XQuartz must be running.** The script forces the X11 WinForms driver and symlinks the Homebrew/XQuartz dependencies into `dll/` on each launch; the display method defaults to GdiPlus (software) on macOS. It takes the same command-line arguments as on Windows: see [*Passing command-line arguments*](#passing-command-line-arguments), e.g. `./EmuHawkMono.sh --lua=/path/to/script.lua /path/to/rom.nds`.
+
+What works: most non-GL cores including **Game Boy/Color (Gambatte)**, **GBA (mGBA)**, and **Nintendo DS (melonDS)**, plus Lua scripting. Cores that require host OpenGL fall back to their software renderers. N64 and other GL-only paths are not expected to work.
+
+If you need an older 1.x macOS build instead, @Sappharad's Rosetta port is described in [this TASVideos forum thread](https://tasvideos.org/Forum/Topics/12659); see also [#3697](https://github.com/TASEmulators/BizHawk/issues/3697) and [#1430](https://github.com/TASEmulators/BizHawk/issues/1430).
#### Nix/NixOS
diff --git a/src/BizHawk.Bizware.Graphics/OpenGL/SDL2OpenGLContext.cs b/src/BizHawk.Bizware.Graphics/OpenGL/SDL2OpenGLContext.cs
index d314b04fac9..52a26b47053 100644
--- a/src/BizHawk.Bizware.Graphics/OpenGL/SDL2OpenGLContext.cs
+++ b/src/BizHawk.Bizware.Graphics/OpenGL/SDL2OpenGLContext.cs
@@ -21,9 +21,15 @@ public class SDL2OpenGLContext : IDisposable
#pragma warning disable CA1065 // not sure how else to handle failure other than throwing with a good message
static SDL2OpenGLContext()
{
- if (OSTailoredCode.IsUnixHost)
+ if (OSTailoredCode.CurrentOS is OSTailoredCode.DistinctOS.macOS)
+ {
+ // mono winforms uses x11 (via XQuartz) on macOS too, so use the x11 video driver,
+ // but XQuartz only provides GLX (not EGL), so don't force EGL like on Linux
+ SDL_SetHintWithPriority("SDL_VIDEODRIVER", "x11", SDL_HintPriority.SDL_HINT_OVERRIDE);
+ }
+ else if (OSTailoredCode.IsUnixHost)
{
- // make sure that Linux uses the x11 video driver
+ // make sure that we use the x11 video driver
// we need this as mono winforms uses x11
// and the user could potentially try to force the wayland video driver via env vars
SDL_SetHintWithPriority("SDL_VIDEODRIVER", "x11", SDL_HintPriority.SDL_HINT_OVERRIDE);
diff --git a/src/BizHawk.Bizware.Input/KeyMouseInput/KeyMouseInputFactory.cs b/src/BizHawk.Bizware.Input/KeyMouseInput/KeyMouseInputFactory.cs
index c7739d8749c..c6abdddb6a4 100644
--- a/src/BizHawk.Bizware.Input/KeyMouseInput/KeyMouseInputFactory.cs
+++ b/src/BizHawk.Bizware.Input/KeyMouseInput/KeyMouseInputFactory.cs
@@ -9,7 +9,10 @@ internal static class KeyMouseInputFactory
public static IKeyMouseInput CreateKeyMouseInput() => OSTailoredCode.CurrentOS switch
{
OSTailoredCode.DistinctOS.Linux => new X11KeyMouseInput(),
- OSTailoredCode.DistinctOS.macOS => new QuartzKeyMouseInput(),
+ // macOS EmuHawk runs under XQuartz (Mono WinForms is X11), so read the keyboard/mouse
+ // through X11 like Linux. The Quartz path (CGEventSourceKeyState) needs Accessibility
+ // permission and Mac HID keycodes, and doesn't integrate with the XQuartz window.
+ OSTailoredCode.DistinctOS.macOS => new X11KeyMouseInput(),
OSTailoredCode.DistinctOS.Windows => new RawKeyMouseInput(),
_ => throw new InvalidOperationException("Unknown OS"),
};
diff --git a/src/BizHawk.Bizware.Input/KeyMouseInput/X11KeyMouseInput.cs b/src/BizHawk.Bizware.Input/KeyMouseInput/X11KeyMouseInput.cs
index 25a04de3d9a..40ea6f98fca 100644
--- a/src/BizHawk.Bizware.Input/KeyMouseInput/X11KeyMouseInput.cs
+++ b/src/BizHawk.Bizware.Input/KeyMouseInput/X11KeyMouseInput.cs
@@ -1,10 +1,8 @@
-#nullable enable
+#nullable enable
using System.Collections.Generic;
using System.Runtime.InteropServices;
-using BizHawk.Common;
-
using static BizHawk.Common.XInput2Imports;
using static BizHawk.Common.XlibImports;
@@ -23,11 +21,8 @@ internal sealed class X11KeyMouseInput : IKeyMouseInput
public X11KeyMouseInput()
{
- if (OSTailoredCode.CurrentOS != OSTailoredCode.DistinctOS.Linux)
- {
- throw new NotSupportedException("X11 is Linux only");
- }
-
+ // no OS check: X11 may work on any host with an X server (e.g. macOS via XQuartz);
+ // XOpenDisplay below fails cleanly otherwise, and Windows uses RawKeyMouseInput
Display = XOpenDisplay(null);
if (Display == IntPtr.Zero)
diff --git a/src/BizHawk.Client.Common/config/Config.cs b/src/BizHawk.Client.Common/config/Config.cs
index 6df54b0a31a..bf80db7d275 100644
--- a/src/BizHawk.Client.Common/config/Config.cs
+++ b/src/BizHawk.Client.Common/config/Config.cs
@@ -264,7 +264,9 @@ public void SetWindowScaleFor(string sysID, int windowScale)
public int DispPrescale { get; set; } = 1;
- public EDispMethod DispMethod { get; set; } = HostCapabilityDetector.HasD3D11 ? EDispMethod.D3D11 : EDispMethod.OpenGL;
+ public EDispMethod DispMethod { get; set; } = HostCapabilityDetector.HasD3D11
+ ? EDispMethod.D3D11
+ : OSTailoredCode.CurrentOS is OSTailoredCode.DistinctOS.macOS ? EDispMethod.GdiPlus : EDispMethod.OpenGL;
public int DispChromeFrameWindowed { get; set; } = 2;
public bool DispChromeStatusBarWindowed { get; set; } = true;
diff --git a/src/BizHawk.Client.EmuHawk/GraphicsImplementations/OpenGLProvider.cs b/src/BizHawk.Client.EmuHawk/GraphicsImplementations/OpenGLProvider.cs
index b4793c7e8d4..7d6c1526589 100644
--- a/src/BizHawk.Client.EmuHawk/GraphicsImplementations/OpenGLProvider.cs
+++ b/src/BizHawk.Client.EmuHawk/GraphicsImplementations/OpenGLProvider.cs
@@ -1,4 +1,5 @@
using BizHawk.Bizware.Graphics;
+using BizHawk.Common;
using BizHawk.Emulation.Common;
namespace BizHawk.Client.EmuHawk
@@ -9,7 +10,11 @@ namespace BizHawk.Client.EmuHawk
public class OpenGLProvider : IOpenGLProvider
{
public bool SupportsGLVersion(int major, int minor)
- => OpenGLVersion.SupportsVersion(major, minor);
+ // On macOS the only GL available (XQuartz/GLX) is the legacy Apple bridge, which is
+ // capped at GL 2.1 and aborts under Rosetta; probing it crashes. Report no host GL so
+ // cores (melonDS, N64, ...) fall back to their software renderers.
+ => OSTailoredCode.CurrentOS != OSTailoredCode.DistinctOS.macOS
+ && OpenGLVersion.SupportsVersion(major, minor);
public object RequestGLContext(int major, int minor, bool coreProfile)
=> new SDL2OpenGLContext(major, minor, coreProfile);
diff --git a/src/BizHawk.Common/IImportResolver.cs b/src/BizHawk.Common/IImportResolver.cs
index 017b4a784bf..6f1c11ee2df 100644
--- a/src/BizHawk.Common/IImportResolver.cs
+++ b/src/BizHawk.Common/IImportResolver.cs
@@ -13,6 +13,19 @@ public interface IImportResolver
public class DynamicLibraryImportResolver : IDisposable, IImportResolver
{
+ ///
+ /// Maps a bare library name (as you'd pass to [DllImport]) to
+ /// the host's conventional filename: name.dll on Windows, libname.dylib on macOS, and
+ /// libname.so on Linux/BSD. Use this instead of hardcoding IsUnixHost ? "lib…so" : "….dll"
+ /// at each call site.
+ ///
+ public static string PlatformFileName(string baseName) => OSTailoredCode.CurrentOS switch
+ {
+ OSTailoredCode.DistinctOS.Windows => $"{baseName}.dll",
+ OSTailoredCode.DistinctOS.macOS => $"lib{baseName}.dylib",
+ _ => $"lib{baseName}.so", // Linux/BSD
+ };
+
private IntPtr _p;
public readonly bool HasLimitedLifetime;
diff --git a/src/BizHawk.Common/MemoryBlock/MemoryBlockLinuxPal.cs b/src/BizHawk.Common/MemoryBlock/MemoryBlockLinuxPal.cs
index d2b56125393..3401668f946 100644
--- a/src/BizHawk.Common/MemoryBlock/MemoryBlockLinuxPal.cs
+++ b/src/BizHawk.Common/MemoryBlock/MemoryBlockLinuxPal.cs
@@ -7,6 +7,11 @@ namespace BizHawk.Common
{
internal sealed class MemoryBlockLinuxPal : IMemoryBlockPal
{
+ private const int MAP_PRIVATE = 0x2;
+
+ // MAP_ANON is 0x20 on Linux/BSD but 0x1000 on macOS
+ private static readonly int MAP_ANON = OSTailoredCode.CurrentOS is OSTailoredCode.DistinctOS.macOS ? 0x1000 : 0x20;
+
public ulong Start { get; }
private readonly ulong _size;
private bool _disposed;
@@ -19,7 +24,7 @@ internal sealed class MemoryBlockLinuxPal : IMemoryBlockPal
///
public MemoryBlockLinuxPal(ulong size)
{
- var ptr = mmap(IntPtr.Zero, Z.UU(size), MemoryProtection.None, 0x22 /* MAP_PRIVATE | MAP_ANON */, -1, IntPtr.Zero);
+ var ptr = mmap(IntPtr.Zero, Z.UU(size), MemoryProtection.None, MAP_PRIVATE | MAP_ANON, -1, IntPtr.Zero);
if (ptr == new IntPtr(-1))
{
throw new InvalidOperationException($"{nameof(mmap)}() failed with error {Marshal.GetLastWin32Error()}");
diff --git a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/3DS/Encore.cs b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/3DS/Encore.cs
index eecb6b7a93b..04d49b1b19d 100644
--- a/src/BizHawk.Emulation.Cores/Consoles/Nintendo/3DS/Encore.cs
+++ b/src/BizHawk.Emulation.Cores/Consoles/Nintendo/3DS/Encore.cs
@@ -33,7 +33,7 @@ public sealed class CIAInstallThrowable(string message) : Exception(message);
private static void ResetEncoreResolver()
{
_resolver?.Dispose();
- _resolver = new(OSTailoredCode.IsUnixHost ? "libencore.so" : "encore.dll", hasLimitedLifetime: true);
+ _resolver = new(DynamicLibraryImportResolver.PlatformFileName("encore"), hasLimitedLifetime: true);
_core = BizInvoker.GetInvoker(_resolver, CallingConventionAdapters.Native);
}
diff --git a/src/BizHawk.Emulation.Cores/Waterbox/WaterboxHost.cs b/src/BizHawk.Emulation.Cores/Waterbox/WaterboxHost.cs
index 4779521bf33..2205a6519c5 100644
--- a/src/BizHawk.Emulation.Cores/Waterbox/WaterboxHost.cs
+++ b/src/BizHawk.Emulation.Cores/Waterbox/WaterboxHost.cs
@@ -75,7 +75,9 @@ public sealed class WaterboxHost : IMonitor, IImportResolver, IStatable, IDispos
static WaterboxHost()
{
NativeImpl = BizInvoker.GetInvoker(
- new DynamicLibraryImportResolver(OSTailoredCode.IsUnixHost ? "libwaterboxhost.so" : "waterboxhost.dll", hasLimitedLifetime: false),
+ new DynamicLibraryImportResolver(
+ DynamicLibraryImportResolver.PlatformFileName("waterboxhost"),
+ hasLimitedLifetime: false),
CallingConventionAdapters.Native);
#if !DEBUG
NativeImpl.wbx_set_always_evict_blocks(false);
diff --git a/waterbox/waterboxhost/build-release-macos.sh b/waterbox/waterboxhost/build-release-macos.sh
new file mode 100755
index 00000000000..907e5eb14e4
--- /dev/null
+++ b/waterbox/waterboxhost/build-release-macos.sh
@@ -0,0 +1,40 @@
+#!/bin/sh
+# Build libwaterboxhost for macOS as an x86_64 Mach-O dylib (runs under Rosetta 2 on
+# Apple Silicon). Cross-compiles from any host; only needs rustup + the x86_64-apple-darwin
+# std for a compatible nightly. See build-release.sh for the Linux/.so equivalent.
+#
+# NOTE: this builds the host. Guest-entry on macOS is not yet fully correct; see the
+# TODO[macOS] in src/context/mod.rs (the %gs / TLS scratch mechanism still needs work).
+set -e
+if [ -z "$BIZHAWKBUILD_HOME" ]; then export BIZHAWKBUILD_HOME="$(realpath "$(dirname "$0")/../..")"; fi
+cd "$(dirname "$0")"
+
+# This crate predates several nightly API changes (try_trait_v2, unsafe intrinsics), so pin
+# a known-good nightly rather than the floating channel in rust-toolchain.toml.
+TOOLCHAIN="${WBX_NIGHTLY:-nightly-2024-10-18}"
+TARGET="x86_64-apple-darwin"
+# Build for an older baseline so the dylib loads on older Macs, not just the build host.
+# (The overall floor is set by the Homebrew deps, currently macOS 14.)
+export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-11.0}"
+
+rustup toolchain install "$TOOLCHAIN" --profile minimal
+rustup target add --toolchain "$TOOLCHAIN" "$TARGET"
+
+# cargo invokes a bare `rustc`; if another rustc (e.g. Homebrew's) precedes ~/.cargo/bin on
+# PATH it gets picked and lacks the cross std. Pin RUSTC to the toolchain's rustc explicitly.
+RUSTC_BIN="$(rustup which --toolchain "$TOOLCHAIN" rustc)"
+
+# Regenerate the interop blobs (needs nasm: `brew install nasm`). The macOS variant
+# (interop_macos.bin) handles %gs differently — see src/context/interop_macos.s.
+if command -v nasm >/dev/null 2>&1; then
+ make -C src/context
+fi
+
+RUSTC="$RUSTC_BIN" cargo "+$TOOLCHAIN" build --release --target "$TARGET"
+
+OUT="target/$TARGET/release/libwaterboxhost.dylib"
+cp "$OUT" "$BIZHAWKBUILD_HOME/Assets/dll/libwaterboxhost.dylib"
+if [ -e "$BIZHAWKBUILD_HOME/output" ]; then
+ cp "$OUT" "$BIZHAWKBUILD_HOME/output/dll/libwaterboxhost.dylib"
+fi
+printf "copied libwaterboxhost.dylib (%s) into Assets/dll\n" "$TARGET"
diff --git a/waterbox/waterboxhost/src/context/Makefile b/waterbox/waterboxhost/src/context/Makefile
index 2f147bd6eff..3e1518c592d 100644
--- a/waterbox/waterboxhost/src/context/Makefile
+++ b/waterbox/waterboxhost/src/context/Makefile
@@ -1,2 +1,7 @@
-interop.bin: interop.s
- nasm -f bin -Wall -o $@ $<
\ No newline at end of file
+all: interop.bin interop_macos.bin
+
+interop.bin: interop.s
+ nasm -f bin -Wall -o $@ $<
+
+interop_macos.bin: interop_macos.s
+ nasm -f bin -Wall -o $@ $<
diff --git a/waterbox/waterboxhost/src/context/interop_macos.bin b/waterbox/waterboxhost/src/context/interop_macos.bin
new file mode 100644
index 00000000000..1a582fdc9c0
Binary files /dev/null and b/waterbox/waterboxhost/src/context/interop_macos.bin differ
diff --git a/waterbox/waterboxhost/src/context/interop_macos.s b/waterbox/waterboxhost/src/context/interop_macos.s
new file mode 100644
index 00000000000..e50443dabe9
--- /dev/null
+++ b/waterbox/waterboxhost/src/context/interop_macos.s
@@ -0,0 +1,185 @@
+bits 64
+org 0x35f00000000
+%define RVA(addr) (addr - 0x35f00000000)
+
+; macOS variant of interop.s. See interop.s for the canonical/commented version.
+;
+; Difference from Linux/Windows: on macOS x86-64 (incl. under Rosetta 2) the %gs base
+; is the OS thread-self-data (TSD) and CANNOT be repointed (no arch_prctl; rdgsbase/
+; wrgsbase #UD under Rosetta). The guest musl only reads its Context pointer from
+; gs:0x18 (= TSD slot 3, mach_thread_self). gs:0x08/0x10 are macOS errno/mig_reply and
+; must NOT be touched (host C code uses errno). So here we:
+; * never write gs:0x08/0x10 (the "TIB stack base/limit" dance is dropped),
+; * stash the real mach_thread_self in a free TSD slot (gs:REAL_TLS) on guest entry,
+; * set gs:0x18 = Context while guest runs, and restore gs:0x18 = real at every
+; guest->host boundary (and on final exit) so host/macOS code sees a valid TSD.
+%define REAL_TLS 0x60
+
+struc Context
+ .thread_area resq 1
+ .host_rsp resq 1
+ .guest_rsp resq 1
+ .host_rsp_alt resq 1
+ .guest_rsp_alt resq 1
+ .dispatch_syscall resq 1
+ .host_ptr resq 1
+ .extcall_slots resq 64
+endstruc
+
+times 0x80-($-$$) int3
+; called by guest when it wishes to make a syscall
+guest_syscall:
+ push rbp
+ mov r10, [gs:0x18]
+ mov [r10 + Context.guest_rsp], rsp
+ mov rsp, [r10 + Context.host_rsp]
+
+ ; save context, restore real mach_thread_self for the host call
+ push r10
+ mov r11, [gs:REAL_TLS]
+ mov [gs:0x18], r11
+
+ mov r11, [r10 + Context.host_ptr]
+ push r11 ; arg 8 to dispatch_syscall: host
+ push rax ; arg 7 to dispatch_syscall: nr
+ mov rax, [r10 + Context.dispatch_syscall]
+ call rax
+
+ ; Restore Context ptr for the guest
+ mov r10, [rsp + 16]
+ mov [gs:0x18], r10
+
+ mov rsp, [r10 + Context.guest_rsp]
+ pop rbp
+ ret
+guest_syscall_end:
+
+times 0x100-($-$$) int3 ; CALL_GUEST_SIMPLE_ADDR
+call_guest_simple:
+ mov r11, rdi
+ mov r10, rsi
+ jmp call_guest_impl
+
+times 0x200-($-$$) int3 ; CALL_GUEST_IMPL_ADDR
+call_guest_impl:
+ ; check if we need to swap stacks for a reentrant call
+ mov rax, [r10 + Context.host_rsp]
+ test rax, rax
+ je do_tib
+ mov rax, [r10 + Context.host_rsp_alt]
+ test rax, rax
+ je do_swap
+ int3 ; both stacks exhausted
+
+do_swap:
+ mov rax, [r10 + Context.host_rsp]
+ mov [r10 + Context.host_rsp_alt], rax
+ mov rax, [r10 + Context.guest_rsp]
+ xchg rax, [r10 + Context.guest_rsp_alt]
+ mov [r10 + Context.guest_rsp], rax
+
+do_tib:
+ ; keep two pushes so host_rsp layout + stack balance match interop.s exactly
+ ; (values are unused on macOS; we do not touch gs:0x08/0x10)
+ push rax
+ push rax
+
+ ; stash real mach_thread_self, then set Context ptr for the guest
+ mov rax, [gs:0x18]
+ mov [gs:REAL_TLS], rax
+ mov [gs:0x18], r10
+
+ mov [r10 + Context.host_rsp], rsp
+ mov rsp, [r10 + Context.guest_rsp]
+ call r11 ; stack hygiene note - this host address is saved on the guest stack
+ mov r10, [gs:0x18]
+ mov [r10 + Context.guest_rsp], rsp ; restore stack so next call using same Context will work
+ mov rsp, [r10 + Context.host_rsp]
+ mov r11, 0
+ mov [r10 + Context.host_rsp], r11 ; zero out host_rsp so we'll know this callstack is no longer in use
+
+ ; restore real mach_thread_self now that we are back in host land
+ ; (use r11, NOT rax: rax holds the guest function's return value)
+ mov r11, [gs:REAL_TLS]
+ mov [gs:0x18], r11
+
+ ; check to see if we need to swap back stacks
+ mov r11, [r10 + Context.host_rsp_alt]
+ test r11, r11
+ je do_restore_tib
+
+ mov [r10 + Context.host_rsp], r11
+ mov r11, 0
+ mov [r10 + Context.host_rsp_alt], r11
+ mov r11, [r10 + Context.guest_rsp_alt]
+ xchg r11, [r10 + Context.guest_rsp]
+ mov [r10 + Context.guest_rsp_alt], r11
+
+do_restore_tib:
+ ; discard the two pushed qwords (balance); do NOT touch gs:0x08/0x10
+ pop r10
+ pop r10
+ ret
+
+times 0x300-($-$$) int3 ; EXTCALL_THUNK_ADDR
+%macro guest_extcall_thunk 1
+ mov rax, %1
+ jmp guest_extcall_impl
+ align 16, int3
+%endmacro
+%assign j 0
+%rep 64
+ guest_extcall_thunk j
+ %assign j j+1
+%endrep
+
+; called by individual extcall thunks when the guest wishes to make an external call
+guest_extcall_impl:
+ mov r10, [gs:0x18]
+ mov [r10 + Context.guest_rsp], rsp
+ mov rsp, [r10 + Context.host_rsp]
+
+ ; save context, restore real mach_thread_self for the host call
+ push r10
+ mov r11, [gs:REAL_TLS]
+ mov [gs:0x18], r11
+
+ mov r11, [r10 + Context.extcall_slots + rax * 8] ; get slot ptr
+ call r11
+
+ ; Restore Context ptr for the guest
+ mov r10, [rsp]
+ mov [gs:0x18], r10
+
+ mov rsp, [r10 + Context.guest_rsp]
+ ret
+guest_extcall_impl_end:
+
+times 0x800-($-$$) int3 ; RUNTIME_TABLE_ADDR
+; (Windows-only SEH table; unused on macOS but kept for identical byte layout)
+runtime_function_table:
+ dd RVA(guest_syscall)
+ dd RVA(guest_syscall_end)
+ dd RVA(guest_syscall_unwind)
+
+ dd RVA(guest_extcall_impl)
+ dd RVA(guest_extcall_impl_end)
+ dd RVA(guest_extcall_impl_unwind)
+guest_syscall_unwind:
+ db 1
+ db 5
+ db 1
+ db 0
+
+ db 5
+ db 0x42
+ dw 0
+guest_extcall_impl_unwind:
+ db 1
+ db 5
+ db 1
+ db 0
+
+ db 5
+ db 0x22
+ dw 0
diff --git a/waterbox/waterboxhost/src/context/mod.rs b/waterbox/waterboxhost/src/context/mod.rs
index ca7f13a5ffd..7ff77a1b5f2 100644
--- a/waterbox/waterboxhost/src/context/mod.rs
+++ b/waterbox/waterboxhost/src/context/mod.rs
@@ -24,6 +24,9 @@ pub fn get_callback_ptr(slot: usize) -> usize{
fn init_interop_area() -> AddressRange {
unsafe {
+ #[cfg(target_os = "macos")]
+ let bytes = include_bytes!("interop_macos.bin");
+ #[cfg(not(target_os = "macos"))]
let bytes = include_bytes!("interop.bin");
let addr = pal::map_anon(
AddressRange { start: ORG, size: bytes.len() }.align_expand(),
@@ -108,7 +111,7 @@ impl Context {
}
}
-#[cfg(unix)]
+#[cfg(target_os = "linux")]
thread_local!(static TIB: Box<[usize; 4]> = Box::new([0usize; 4]));
/// Prepares this host thread to be allowed to call guest code. Noop if already called.
@@ -121,7 +124,7 @@ pub fn prepare_thread() {
// We stomp over [gs:0x18] and use it for our own mini-TLS to track the stack marshalling
// On windows, that's a (normally unused and free for the plundering?) field in TIB
// On linux, that register is not normally in use, so we put some bytes there and then use it
- #[cfg(unix)]
+ #[cfg(target_os = "linux")]
unsafe {
use libc::*;
let mut gs = 0usize;
@@ -133,4 +136,16 @@ pub fn prepare_thread() {
});
}
}
+ // TODO[macOS]: Unlike Linux, on macOS/x86-64 the %gs base is already established by the
+ // OS and points at the thread's TSD (thread self data); it must NOT be relocated, and
+ // there is no arch_prctl. The interop assembly stashes its stack-marshalling scratch at
+ // [gs:0x18], which collides with reserved TSD slots on macOS. Making guest entry correct
+ // here requires either claiming a free TSD slot or using an alternate scratch mechanism,
+ // then re-assembling context/interop.s to match and validating under Rosetta 2. Until
+ // that's done this is a no-op: the host builds and non-guest-entry paths work, but
+ // actually entering guest code is not yet correct on macOS.
+ #[cfg(target_os = "macos")]
+ {
+ // intentionally empty; see TODO above
+ }
}
diff --git a/waterbox/waterboxhost/src/host.rs b/waterbox/waterboxhost/src/host.rs
index b755bc8ef19..40d1dac9e5e 100644
--- a/waterbox/waterboxhost/src/host.rs
+++ b/waterbox/waterboxhost/src/host.rs
@@ -196,7 +196,7 @@ impl IStateable for WaterboxHost {
fn unimp(nr: SyscallNumber) -> SyscallResult {
eprintln!("Stopped on unimplemented syscall {}", lookup_syscall(&nr));
- std::intrinsics::breakpoint();
+ unsafe { std::intrinsics::breakpoint(); }
Err(ENOSYS)
}
diff --git a/waterbox/waterboxhost/src/memory_block/pal.rs b/waterbox/waterboxhost/src/memory_block/pal.rs
index 09f3098a2b8..016a3581128 100644
--- a/waterbox/waterboxhost/src/memory_block/pal.rs
+++ b/waterbox/waterboxhost/src/memory_block/pal.rs
@@ -160,12 +160,25 @@ mod nix {
use super::*;
use libc::*;
+ #[cfg(target_os = "linux")]
+ unsafe fn errno() -> i32 { *__errno_location() }
+ #[cfg(target_os = "macos")]
+ unsafe fn errno() -> i32 { *__error() }
+
fn error() -> anyhow::Error {
unsafe {
- let err = *__errno_location();
- anyhow!("Libc failure code: {}", err)
+ anyhow!("Libc failure code: {}", errno())
}
}
+
+ /// mmap flags for a fixed-address mapping.
+ /// Linux has `MAP_FIXED_NOREPLACE` (fails instead of clobbering an existing mapping);
+ /// macOS only has `MAP_FIXED` (which silently replaces). Waterbox manages its own
+ /// reserved address space, so a plain `MAP_FIXED` matches the intended behaviour there.
+ #[cfg(target_os = "linux")]
+ fn fixed_flags() -> i32 { MAP_FIXED | MAP_FIXED_NOREPLACE }
+ #[cfg(target_os = "macos")]
+ fn fixed_flags() -> i32 { MAP_FIXED }
fn ret(code: i32) -> anyhow::Result<()> {
match code {
0 => Ok(()),
@@ -175,6 +188,7 @@ mod nix {
/// Open a file (not backed by the fs) for memory mapping
/// Caller must close_handle() later or else leak
+ #[cfg(target_os = "linux")]
pub fn open_handle(size: usize) -> anyhow::Result {
unsafe {
let s = std::ffi::CString::new("MemoryBlockUnix").unwrap();
@@ -190,6 +204,32 @@ mod nix {
}
}
+ /// macOS has no `memfd_create`. We must NOT use POSIX shared memory (`shm_open`)
+ /// here: macOS refuses to `mprotect` a `MAP_SHARED` shm region to executable
+ /// (EACCES), and waterbox needs to execute guest code from this handle's mapping.
+ /// A regular file CAN be mapped executable (like a dylib) even via `MAP_SHARED`,
+ /// so back the block with an immediately-unlinked temp file (anonymous once
+ /// unlinked; the fd keeps it alive until closed).
+ #[cfg(target_os = "macos")]
+ pub fn open_handle(size: usize) -> anyhow::Result {
+ unsafe {
+ let mut template = *b"/tmp/wbxhost-XXXXXX\0";
+ let fd = mkstemp(template.as_mut_ptr() as *mut libc::c_char);
+ if fd == -1 {
+ return Err(error())
+ }
+ // Unlink right away; the open fd alone backs the mapping from here on.
+ unlink(template.as_ptr() as *const libc::c_char);
+ if ftruncate(fd, size as off_t) != 0 {
+ let e = error();
+ close(fd);
+ Err(e)
+ } else {
+ Ok(Handle(fd as usize))
+ }
+ }
+ }
+
/// close a handle returned by open_handle()
/// Unsafe: Only call with handle returned by open_handle(). Do not call when that handle is mapped
pub unsafe fn close_handle(handle: Handle) -> anyhow::Result<()> {
@@ -211,7 +251,7 @@ mod nix {
unsafe {
let mut flags = MAP_SHARED;
if addr.start != 0 {
- flags |= MAP_FIXED | MAP_FIXED_NOREPLACE;
+ flags |= fixed_flags();
}
let ptr = mmap(addr.start as *mut c_void,
addr.size,
@@ -251,7 +291,7 @@ mod nix {
unsafe {
let mut flags = MAP_PRIVATE | MAP_ANONYMOUS;
if addr.start != 0 {
- flags |= MAP_FIXED | MAP_FIXED_NOREPLACE;
+ flags |= fixed_flags();
}
let ptr = mmap(addr.start as *mut c_void, addr.size, prottoprot(initial_prot), flags, -1, 0);
match ptr {
diff --git a/waterbox/waterboxhost/src/memory_block/tripguard.rs b/waterbox/waterboxhost/src/memory_block/tripguard.rs
index e2e5c7f3150..1696c77e3cb 100644
--- a/waterbox/waterboxhost/src/memory_block/tripguard.rs
+++ b/waterbox/waterboxhost/src/memory_block/tripguard.rs
@@ -121,18 +121,30 @@ mod trip_pal {
type SaHandler = unsafe extern "C" fn(i32) -> ();
type SaSigaction = unsafe extern "C" fn(i32, *const siginfo_t, *const ucontext_t) -> ();
static mut SA_OLD: Option> = None;
+ // macOS delivers SIGBUS (not SIGSEGV) for protection violations on mapped memory,
+ // which is exactly the fault the tripguard catches, so we hook it as well.
+ #[cfg(target_os = "macos")]
+ static mut SA_OLD_BUS: Option> = None;
pub fn initialize() {
use std::mem::{transmute, zeroed};
unsafe extern "C" fn handler(sig: i32, info: *const siginfo_t, ucontext: *const ucontext_t) {
let fault_address = (*info).si_addr() as usize;
+ // Bit 1 (value 2) of the x86 page-fault error code means the access was a write.
+ // The error code lives in different places depending on the OS' mcontext layout.
+ #[cfg(target_os = "linux")]
let write = (*ucontext).uc_mcontext.gregs[REG_ERR as usize] & 2 != 0;
+ #[cfg(target_os = "macos")]
+ let write = ((*(*ucontext).uc_mcontext).__es.__err & 2) != 0;
let rethrow = !write || match trip(fault_address) {
TripResult::NotHandled => true,
_ => false
};
if rethrow {
+ #[cfg(target_os = "macos")]
+ let sa_old = if sig == SIGBUS { SA_OLD_BUS.as_ref().unwrap() } else { SA_OLD.as_ref().unwrap() };
+ #[cfg(not(target_os = "macos"))]
let sa_old = SA_OLD.as_ref().unwrap();
if sa_old.sa_flags & SA_SIGINFO != 0 {
transmute::(sa_old.sa_sigaction)(sig, info, ucontext);
@@ -156,14 +168,18 @@ mod trip_pal {
// };
// assert!(sigaltstack(&ss, &mut ss_old) == 0, "sigaltstack failed");
SA_OLD = Some(Box::new(zeroed()));
- let mut sa = sigaction {
- sa_mask: zeroed(),
- sa_sigaction: transmute::(handler),
- sa_flags: SA_ONSTACK | SA_SIGINFO,
- sa_restorer: None,
- };
+ // Build via zeroed() + field assignment so this compiles on both Linux
+ // (which has the extra `sa_restorer` field) and macOS (which does not).
+ let mut sa: sigaction = zeroed();
+ sa.sa_sigaction = transmute::(handler);
+ sa.sa_flags = SA_ONSTACK | SA_SIGINFO;
sigfillset(&mut sa.sa_mask);
assert!(sigaction(SIGSEGV, &sa, &mut **SA_OLD.as_mut().unwrap() as *mut sigaction) == 0, "sigaction failed");
+ #[cfg(target_os = "macos")]
+ {
+ SA_OLD_BUS = Some(Box::new(zeroed()));
+ assert!(sigaction(SIGBUS, &sa, &mut **SA_OLD_BUS.as_mut().unwrap() as *mut sigaction) == 0, "sigaction SIGBUS failed");
+ }
}
}
}
diff --git a/waterbox/waterboxhost/src/threading.rs b/waterbox/waterboxhost/src/threading.rs
index c319764604a..a37080677a4 100644
--- a/waterbox/waterboxhost/src/threading.rs
+++ b/waterbox/waterboxhost/src/threading.rs
@@ -176,7 +176,7 @@ impl GuestThreadSet {
pub fn exit(&mut self, context: &mut Context) -> SyscallReturn {
if self.active_tid == 1 {
- std::intrinsics::breakpoint()
+ unsafe { std::intrinsics::breakpoint() }
}
let addr = self.threads.get_mut(&self.active_tid).unwrap().tid_address;
if addr != 0 {