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 {