From a0d98ba41079e75dd16d0357d08c03516745d4ab Mon Sep 17 00:00:00 2001
From: dcvz <david@dcvz.io>
Date: Mon, 7 Oct 2024 23:20:43 +0200
Subject: [PATCH] Add macOS Support

---
 .github/macos/Info.plist.in      |  33 +++++++
 .github/macos/MoltenVK_icd.json  |   8 ++
 .github/macos/entitlements.plist |  12 +++
 .github/macos/fixup_bundle.cmake |  11 +++
 .github/macos/macports.yaml      |  14 +++
 .github/workflows/validate.yml   | 128 +++++++++++++++++++++++++-
 .gitignore                       |   1 +
 CMakeLists.txt                   | 152 ++++++++++++++++++++++++++++++-
 include/zelda_support.h          |  14 +++
 lib/N64ModernRuntime             |   2 +-
 lib/rt64                         |   2 +-
 src/game/config.cpp              |  10 +-
 src/main/main.cpp                |  17 +++-
 src/main/rt64_render_context.cpp |   2 +-
 src/main/support.cpp             |  10 ++
 src/main/support_apple.mm        |  13 +++
 src/ui/ui_config.cpp             |   8 +-
 src/ui/ui_launcher.cpp           |  70 +++++++-------
 src/ui/ui_renderer.cpp           |  14 ++-
 19 files changed, 472 insertions(+), 49 deletions(-)
 create mode 100644 .github/macos/Info.plist.in
 create mode 100644 .github/macos/MoltenVK_icd.json
 create mode 100644 .github/macos/entitlements.plist
 create mode 100644 .github/macos/fixup_bundle.cmake
 create mode 100644 .github/macos/macports.yaml
 create mode 100644 include/zelda_support.h
 create mode 100644 src/main/support.cpp
 create mode 100644 src/main/support_apple.mm

diff --git a/.github/macos/Info.plist.in b/.github/macos/Info.plist.in
new file mode 100644
index 0000000..44a1a14
--- /dev/null
+++ b/.github/macos/Info.plist.in
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+    <key>CFBundleName</key>
+    <string>${MACOSX_BUNDLE_BUNDLE_NAME}</string>
+    <key>CFBundleIdentifier</key>
+    <string>${MACOSX_BUNDLE_GUI_IDENTIFIER}</string>
+    <key>CFBundleVersion</key>
+    <string>${MACOSX_BUNDLE_BUNDLE_VERSION}</string>
+    <key>CFBundleShortVersionString</key>
+    <string>${MACOSX_BUNDLE_SHORT_VERSION_STRING}</string>
+    <key>CFBundleExecutable</key>
+    <string>Zelda64Recompiled</string>
+    <key>CFBundleIconFile</key>
+    <string>${MACOSX_BUNDLE_ICON_FILE}</string>
+    <key>LSApplicationCategoryType</key>
+    <string>public.app-category.games</string>
+    <key>CFBundlePackageType</key>
+    <string>APPL</string>
+    <key>LSMinimumSystemVersion</key>
+    <string>11</string>
+    <key>LSEnvironment</key>
+    <dict>
+        <key>MVK_CONFIG_USE_METAL_ARGUMENT_BUFFERS</key>
+        <string>1</string>
+        <key>MVK_CONFIG_USE_METAL_PRIVATE_API</key>
+        <string>1</string>
+        <key>MVK_CONFIG_RESUME_LOST_DEVICE</key>
+        <string>1</string>
+    </dict>
+</dict>
+</plist>
diff --git a/.github/macos/MoltenVK_icd.json b/.github/macos/MoltenVK_icd.json
new file mode 100644
index 0000000..ffcdbd9
--- /dev/null
+++ b/.github/macos/MoltenVK_icd.json
@@ -0,0 +1,8 @@
+{
+  "file_format_version": "1.0.0",
+  "ICD": {
+    "library_path": "../../../Frameworks/libMoltenVK.dylib",
+    "api_version": "1.2.0",
+    "is_portability_driver": true
+  }
+}
\ No newline at end of file
diff --git a/.github/macos/entitlements.plist b/.github/macos/entitlements.plist
new file mode 100644
index 0000000..46f6756
--- /dev/null
+++ b/.github/macos/entitlements.plist
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+    <key>com.apple.security.cs.allow-jit</key>
+    <true/>
+    <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
+    <true/>
+    <key>com.apple.security.cs.disable-library-validation</key>
+    <true/>
+</dict>
+</plist>
diff --git a/.github/macos/fixup_bundle.cmake b/.github/macos/fixup_bundle.cmake
new file mode 100644
index 0000000..080806f
--- /dev/null
+++ b/.github/macos/fixup_bundle.cmake
@@ -0,0 +1,11 @@
+include(BundleUtilities)
+
+# Use generator expressions to get the absolute path to the bundle and frameworks
+set(APPS "Zelda64Recompiled.app/Contents/MacOS/Zelda64Recompiled")
+set(DIRS "Zelda64Recompiled.app/Contents/Frameworks")
+
+# The fixup_bundle command needs an absolute path
+file(REAL_PATH ${APPS} APPS)
+file(REAL_PATH ${DIRS} DIRS)
+
+fixup_bundle("${APPS}" "" "${DIRS}")
\ No newline at end of file
diff --git a/.github/macos/macports.yaml b/.github/macos/macports.yaml
new file mode 100644
index 0000000..a51ccec
--- /dev/null
+++ b/.github/macos/macports.yaml
@@ -0,0 +1,14 @@
+version: '2.9.3'
+prefix: '/opt/local'
+variants:
+  select:
+    - aqua
+    - metal
+  deselect: x11
+ports:
+  - name: clang-18
+  - name: llvm-18
+  - name: libsdl2
+    select: universal
+  - name: freetype
+    select: universal
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index e2f5fd7..76eb0a0 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -14,6 +14,14 @@ on:
         type: string
         required: false
         default: '4e6f4e52989aca69739880b40b9f988357f15d10ca03284377b81f1502463ff5'
+      VULKAN_SDK_VERSION:
+        type: string
+        required: false
+        default: '1.3.296.0'
+      MOLTENVK_COMMIT:
+        type: string
+        required: false
+        default: ''
     secrets:
       ZRE_REPO_WITH_PAT:
         required: true
@@ -95,7 +103,7 @@ jobs:
           rm -rf assets/scss
           tar -czf Zelda64Recompiled.tar.gz Zelda64Recompiled assets/ gamecontrollerdb.txt
       - name: Archive Zelda64Recomp
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4
         with:
           name: Zelda64Recompiled-${{ runner.os }}-X64-${{ matrix.type }}
           path: Zelda64Recompiled.tar.gz
@@ -103,7 +111,7 @@ jobs:
         run: |-
           ./.github/linux/appimage.sh 
       - name: Zelda64Recomp AppImage
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4
         with:
           name: Zelda64Recompiled-AppImage-X64-${{ matrix.type }}
           path: Zelda64Recompiled-*.AppImage
@@ -283,3 +291,119 @@ jobs:
           name: Zelda64Recompiled-PDB-${{ matrix.type }}
           path: |
             Zelda64Recompiled.pdb
+  build-macos:
+    runs-on: blaze/macos-14
+    strategy:
+      matrix:
+        type: [ Debug, Release ]
+    name: macos (x64, arm64, ${{ matrix.type }})
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+        with:
+          ref: ${{ github.event.pull_request.head.sha || github.ref }}
+          submodules: recursive
+      - name: ccache
+        uses: hendrikmuhs/ccache-action@v1.2
+        with:
+          key: ${{ runner.os }}-z64re-ccache-${{ matrix.type }}
+      - name: Homebrew Setup
+        run: |
+          brew install ninja
+          brew uninstall --ignore-dependencies libpng freetype
+      - name: MacPorts Setup
+        uses: melusina-org/setup-macports@v1
+        id: 'macports'
+        with:
+          parameters: '.github/macos/macports.yaml'
+      - name: Prepare Build
+        run: |-
+          git clone ${{ secrets.ZRE_REPO_WITH_PAT }}
+          ./zre/process.sh
+          cp ./zre/mm_shader_cache.bin ./shadercache/
+      - name: Build N64Recomp & RSPRecomp
+        run: |
+          git clone https://github.com/Mr-Wiseguy/N64Recomp.git --recurse-submodules N64RecompSource
+          cd N64RecompSource
+          git checkout ${{ inputs.N64RECOMP_COMMIT }}
+          git submodule update --init --recursive
+          
+          # enable ccache
+          export PATH="/usr/lib/ccache:/usr/local/opt/ccache/libexec:$PATH"
+          
+          # Build N64Recomp & RSPRecomp
+          cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_MAKE_PROGRAM=ninja -G Ninja -S . -B cmake-build
+          cmake --build cmake-build --config Release --target N64Recomp -j $(sysctl -n hw.ncpu)
+          cmake --build cmake-build --config Release --target RSPRecomp -j $(sysctl -n hw.ncpu)
+          
+          # Copy N64Recomp & RSPRecomp to root directory
+          cp cmake-build/N64Recomp ..
+          cp cmake-build/RSPRecomp ..
+      - name: Run N64Recomp & RSPRecomp
+        run: |
+          ./N64Recomp us.rev1.toml
+          ./RSPRecomp aspMain.us.rev1.toml
+          ./RSPRecomp njpgdspMain.us.rev1.toml
+      - name: Cache Vulkan SDK
+        id: cache-vulkan-sdk
+        uses: actions/cache@v3
+        with:
+          path: /Users/runner/VulkanSDK/${{ inputs.VULKAN_SDK_VERSION }}
+          key: ${{ runner.os }}-${{ matrix.type }}-vulkan-sdk-${{ inputs.VULKAN_SDK_VERSION }}
+      - name: Install Vulkan SDK
+        if: steps.cache-vulkan-sdk.outputs.cache-hit != 'true'
+        run: |
+          wget https://sdk.lunarg.com/sdk/download/${{ inputs.VULKAN_SDK_VERSION }}/mac/vulkansdk-macos-${{ inputs.VULKAN_SDK_VERSION }}.zip
+          unzip vulkansdk-macos-${{ inputs.VULKAN_SDK_VERSION }}.zip
+          sudo InstallVulkan.app/Contents/MacOS/InstallVulkan --root ~/VulkanSDK/${{ inputs.VULKAN_SDK_VERSION }} --accept-licenses --default-answer --confirm-command install
+      - name: Checkout MoltenVK
+        if: inputs.MOLTENVK_COMMIT != ''
+        run: |
+          git clone https://github.com/KhronosGroup/MoltenVK.git
+          cd MoltenVK
+          git checkout ${{ inputs.MOLTENVK_COMMIT }}
+      - name: Cache MoltenVK Dependencies
+        if: inputs.MOLTENVK_COMMIT != ''
+        id: cache-mvk-dependencies
+        uses: actions/cache@v3
+        with:
+          path: |
+            MoltenVK/External/build
+            !MoltenVK/External/build/Intermediates
+          key: ${{ runner.os }}-${{ matrix.type }}-${{ hashFiles('MoltenVK/fetchDependencies','MoltenVK/ExternalRevisions/**','MoltenVK/ExternalDependencies.xcodeproj/**','MoltenVK/Scripts/**') }}
+      - name: Fetch MVK Dependencies (Use Built Cache)
+        if: inputs.MOLTENVK_COMMIT != '' && steps.cache-mvk-dependencies.outputs.cache-hit == 'true'
+        run: |
+          cd MoltenVK
+          ./fetchDependencies -v --none
+      - name: Fetch MVK Dependencies
+        if: inputs.MOLTENVK_COMMIT != '' && steps.cache-mvk-dependencies.outputs.cache-hit != 'true'
+        run: |
+          cd MoltenVK
+          ./fetchDependencies -v --macos
+      - name: Build MoltenVK
+        if: inputs.MOLTENVK_COMMIT != ''
+        run: |
+          cd MoltenVK
+          make macos
+          sudo cp Package/Latest/MoltenVK/dylib/macOS/libMoltenVK.dylib ~/VulkanSDK/${{ inputs.VULKAN_SDK_VERSION }}/macOS/lib/libMoltenVK.dylib
+      - name: Build ZeldaRecomp
+        run: |-
+          # enable ccache
+          export PATH="/usr/lib/ccache:/usr/local/opt/ccache/libexec:$PATH"
+          
+          cmake -DCMAKE_BUILD_TYPE=${{ matrix.type }} -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_MAKE_PROGRAM=ninja -G Ninja -S . -B cmake-build \
+          -DPATCHES_LD=/opt/local/bin/ld.lld-mp-18 -DPATCHES_OBJCOPY=/opt/local/bin/llvm-objcopy-mp-18 -DCMAKE_AR=/opt/local/bin/llvm-ar-mp-18 -DPATCHES_C_COMPILER=/opt/local/bin/clang-mp-18 \
+          -DCMAKE_OSX_ARCHITECTURES="x86_64;arm64"
+          cmake --build cmake-build --config ${{ matrix.type }} --target Zelda64Recompiled -j $(sysctl -n hw.ncpu)
+        env:
+          VULKAN_SDK: /Users/runner/VulkanSDK/${{ inputs.VULKAN_SDK_VERSION }}/macOS
+      - name: Prepare Archive
+        run: |
+          mv cmake-build/Zelda64Recompiled.app Zelda64Recompiled.app
+          zip -r -y Zelda64Recompiled.zip Zelda64Recompiled.app
+      - name: Archive Zelda64Recomp
+        uses: actions/upload-artifact@v4
+        with:
+          name: Zelda64Recompiled-${{ runner.os }}-${{ matrix.type }}
+          path: Zelda64Recompiled.zip
diff --git a/.gitignore b/.gitignore
index bb23653..e60a33e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
 .vscode/settings.json
 .vscode/c_cpp_properties.json
 .vscode/launch.json
+.vscode/tasks.json
 
 # Input elf and rom files
 *.elf
diff --git a/CMakeLists.txt b/CMakeLists.txt
index c660365..ace72d0 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,5 +1,10 @@
 cmake_minimum_required(VERSION 3.20)
-project(Zelda64Recompiled)
+
+if (APPLE) # has to be set before the first project() or enable_language()
+    set(CMAKE_OSX_DEPLOYMENT_TARGET "11.0" CACHE STRING "Minimum OS X deployment version")
+endif()
+
+project(Zelda64Recompiled LANGUAGES C CXX)
 set(CMAKE_C_STANDARD 17)
 set(CMAKE_CXX_STANDARD 20)
 set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -15,6 +20,30 @@ if (WIN32)
     set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -D_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR")
 endif()
 
+if (APPLE)
+    enable_language(OBJC OBJCXX)
+
+    # Check if VULKAN_SDK environment variable is set
+    if(NOT DEFINED ENV{VULKAN_SDK})
+        message(FATAL_ERROR "VULKAN_SDK environment variable is not set.")
+    else()
+        set(VULKAN_SDK $ENV{VULKAN_SDK})
+    endif()
+
+    # Define paths to Vulkan loader and MoltenVK libraries
+    set(VULKAN_LOADER_PATH "${VULKAN_SDK}/lib/libvulkan.dylib")
+    set(MOLTENVK_PATH "${VULKAN_SDK}/lib/libMoltenVK.dylib")
+
+    # Ensure the Vulkan loader and MoltenVK libraries exist
+    if(NOT EXISTS "${VULKAN_LOADER_PATH}")
+        message(FATAL_ERROR "Vulkan loader not found at ${VULKAN_LOADER_PATH}")
+    endif()
+
+    if(NOT EXISTS "${MOLTENVK_PATH}")
+        message(FATAL_ERROR "MoltenVK not found at ${MOLTENVK_PATH}")
+    endif()
+endif()
+
 # Avoid warning about DOWNLOAD_EXTRACT_TIMESTAMP in CMake 3.24:
 if (CMAKE_VERSION VERSION_GREATER_EQUAL "3.24.0")
     cmake_policy(SET CMP0135 NEW)
@@ -29,6 +58,7 @@ endif()
 
 set(RT64_STATIC TRUE)
 set(RT64_SDL_WINDOW_VULKAN TRUE)
+set(RT64_SDL_WINDOW_METAL TRUE)
 add_subdirectory(${CMAKE_SOURCE_DIR}/lib/rt64 ${CMAKE_BINARY_DIR}/rt64)
 
 # set(BUILD_SHARED_LIBS_SAVED "${BUILD_SHARED_LIBS}")
@@ -134,11 +164,12 @@ add_custom_target(DownloadGameControllerDB
     DEPENDS ${CMAKE_SOURCE_DIR}/gamecontrollerdb.txt)
 
 # Main executable
-add_executable(Zelda64Recompiled)
+add_executable(Zelda64Recompiled MACOSX_BUNDLE)
 add_dependencies(Zelda64Recompiled DownloadGameControllerDB)
 
 set (SOURCES
     ${CMAKE_SOURCE_DIR}/src/main/main.cpp
+    ${CMAKE_SOURCE_DIR}/src/main/support.cpp
     ${CMAKE_SOURCE_DIR}/src/main/register_overlays.cpp
     ${CMAKE_SOURCE_DIR}/src/main/register_patches.cpp
     ${CMAKE_SOURCE_DIR}/src/main/rt64_render_context.cpp
@@ -163,6 +194,10 @@ set (SOURCES
     ${CMAKE_SOURCE_DIR}/lib/RmlUi/Backends/RmlUi_Platform_SDL.cpp
 )
 
+if (APPLE)
+    list(APPEND SOURCES ${CMAKE_SOURCE_DIR}/src/main/support_apple.mm)
+endif()
+
 target_include_directories(Zelda64Recompiled PRIVATE
     ${CMAKE_SOURCE_DIR}/include
     ${CMAKE_SOURCE_DIR}/lib/N64ModernRuntime/N64Recomp/include
@@ -241,6 +276,116 @@ if (WIN32)
     )
 
     target_sources(Zelda64Recompiled PRIVATE ${CMAKE_SOURCE_DIR}/icons/app.rc)
+    target_link_libraries(Zelda64Recompiled PRIVATE SDL2)
+endif()
+
+if (APPLE)
+    find_package(SDL2 REQUIRED)
+    target_include_directories(Zelda64Recompiled PRIVATE ${SDL2_INCLUDE_DIRS})
+
+    add_compile_definitions("RT64_SDL_WINDOW_METAL")
+
+    set(CMAKE_THREAD_PREFER_PTHREAD TRUE)
+    set(THREADS_PREFER_PTHREAD_FLAG TRUE)
+    find_package(Threads REQUIRED)
+
+    target_link_libraries(Zelda64Recompiled PRIVATE ${CMAKE_DL_LIBS} Threads::Threads SDL2::SDL2)
+
+    # Set bundle properties
+    set_target_properties(Zelda64Recompiled PROPERTIES
+            MACOSX_BUNDLE TRUE
+            MACOSX_BUNDLE_BUNDLE_NAME "Zelda64Recompiled"
+            MACOSX_BUNDLE_GUI_IDENTIFIER "com.github.zelda64recompiled"
+            MACOSX_BUNDLE_BUNDLE_VERSION "1.0"
+            MACOSX_BUNDLE_SHORT_VERSION_STRING "1.0"
+            MACOSX_BUNDLE_ICON_FILE "AppIcon.icns"
+            MACOSX_BUNDLE_INFO_PLIST ${CMAKE_BINARY_DIR}/Info.plist
+    )
+
+    set(ICON_SOURCE ${CMAKE_SOURCE_DIR}/icons/512.png)
+    set(ICONSET_DIR ${CMAKE_BINARY_DIR}/AppIcon.iconset)
+    set(ICNS_FILE ${CMAKE_BINARY_DIR}/resources/AppIcon.icns)
+
+    # Create iconset directory and add PNG file
+    add_custom_command(
+            OUTPUT ${ICONSET_DIR}
+            COMMAND ${CMAKE_COMMAND} -E make_directory ${ICONSET_DIR}
+            COMMAND ${CMAKE_COMMAND} -E copy ${ICON_SOURCE} ${ICONSET_DIR}/icon_512x512.png
+            COMMAND ${CMAKE_COMMAND} -E copy ${ICON_SOURCE} ${ICONSET_DIR}/icon_512x512@2x.png
+            COMMAND touch ${ICONSET_DIR}
+            COMMENT "Creating iconset directory and copying PNG file"
+    )
+
+    # Convert iconset to icns
+    add_custom_command(
+            OUTPUT ${ICNS_FILE}
+            DEPENDS ${ICONSET_DIR}
+            COMMAND iconutil -c icns ${ICONSET_DIR} -o ${ICNS_FILE}
+            COMMENT "Converting iconset to icns"
+    )
+
+    # Custom target to ensure icns creation
+    add_custom_target(create_icns ALL DEPENDS ${ICNS_FILE})
+
+    # Set source file properties for the resulting icns file
+    set_source_files_properties(${ICNS_FILE} PROPERTIES
+            MACOSX_PACKAGE_LOCATION "Resources"
+    )
+
+    # Add the icns file to the executable target
+    target_sources(Zelda64Recompiled PRIVATE ${ICNS_FILE})
+
+    # Ensure Zelda64Recompiled depends on create_icns
+    add_dependencies(Zelda64Recompiled create_icns)
+
+    # Configure Info.plist
+    configure_file(${CMAKE_SOURCE_DIR}/.github/macos/Info.plist.in ${CMAKE_BINARY_DIR}/Info.plist @ONLY)
+
+    # Install the app bundle
+    install(TARGETS Zelda64Recompiled BUNDLE DESTINATION .)
+
+    # Copy required frameworks to bundle
+    target_link_libraries(Zelda64Recompiled PRIVATE ${MOLTENVK_PATH} ${VULKAN_LOADER_PATH})
+
+    # Define the path to the entitlements file
+    set(ENTITLEMENTS_FILE ${CMAKE_SOURCE_DIR}/.github/macos/entitlements.plist)
+
+    # Ensure the entitlements file exists
+    if(NOT EXISTS ${ENTITLEMENTS_FILE})
+        message(FATAL_ERROR "Entitlements file not found at ${ENTITLEMENTS_FILE}")
+    endif()
+
+    # Post-build steps for macOS bundle
+    add_custom_command(TARGET Zelda64Recompiled POST_BUILD
+        # Copy and fix frameworks first
+        COMMAND ${CMAKE_COMMAND} -P ${CMAKE_SOURCE_DIR}/.github/macos/fixup_bundle.cmake
+
+        # Copy all resources
+        COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/assets ${CMAKE_BINARY_DIR}/temp_assets
+        COMMAND ${CMAKE_COMMAND} -E remove_directory ${CMAKE_BINARY_DIR}/temp_assets/scss
+        COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_BINARY_DIR}/temp_assets $<TARGET_BUNDLE_DIR:Zelda64Recompiled>/Contents/Resources/assets
+        COMMAND ${CMAKE_COMMAND} -E remove_directory ${CMAKE_BINARY_DIR}/temp_assets
+
+        # Copy Vulkan ICD files
+        COMMAND ${CMAKE_COMMAND} -E make_directory $<TARGET_BUNDLE_DIR:Zelda64Recompiled>/Contents/Resources/vulkan/icd.d
+        COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_SOURCE_DIR}/.github/macOS/MoltenVK_icd.json $<TARGET_BUNDLE_DIR:Zelda64Recompiled>/Contents/Resources/vulkan/icd.d/
+
+        # Copy controller database
+        COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_SOURCE_DIR}/gamecontrollerdb.txt $<TARGET_BUNDLE_DIR:Zelda64Recompiled>/Contents/Resources/
+
+        # Set RPATH
+        COMMAND install_name_tool -add_rpath "@executable_path/../Frameworks/" $<TARGET_BUNDLE_DIR:Zelda64Recompiled>/Contents/MacOS/Zelda64Recompiled
+
+        # Apply JIT workaround
+        COMMAND ${CMAKE_COMMAND} -E echo "Applying JIT compilation workaround"
+        COMMAND /bin/bash -c "printf '\\x07' | dd of=$<TARGET_FILE:Zelda64Recompiled> bs=1 seek=160 count=1 conv=notrunc"
+
+        # Finally sign the whole bundle with runtime option and entitlements
+        COMMAND codesign --deep --force --sign - --entitlements ${ENTITLEMENTS_FILE} $<TARGET_BUNDLE_DIR:Zelda64Recompiled>
+
+        COMMENT "Performing post-build steps for macOS bundle"
+        VERBATIM
+    )
 endif()
 
 if (CMAKE_SYSTEM_NAME MATCHES "Linux")
@@ -276,7 +421,7 @@ if (CMAKE_SYSTEM_NAME MATCHES "Linux")
     message(STATUS "FREETYPE_LIBRARIES = ${FREETYPE_LIBRARIES}")
 
     include_directories(${FREETYPE_LIBRARIES})
-    target_link_libraries(Zelda64Recompiled PRIVATE ${FREETYPE_LIBRARIES})
+    target_link_libraries(Zelda64Recompiled PRIVATE ${FREETYPE_LIBRARIES} SDL2::SDL2)
 
     set(CMAKE_THREAD_PREFER_PTHREAD TRUE)
     set(THREADS_PREFER_PTHREAD_FLAG TRUE)
@@ -288,7 +433,6 @@ endif()
 target_link_libraries(Zelda64Recompiled PRIVATE
     PatchesLib
     RecompiledFuncs
-    SDL2
     librecomp
     ultramodern
     rt64
diff --git a/include/zelda_support.h b/include/zelda_support.h
new file mode 100644
index 0000000..42f74c2
--- /dev/null
+++ b/include/zelda_support.h
@@ -0,0 +1,14 @@
+#ifndef __ZELDA_SUPPORT_H__
+#define __ZELDA_SUPPORT_H__
+
+#include <functional>
+
+namespace zelda64 {
+    void dispatch_on_main_thread(std::function<void()> func);
+
+#ifdef __APPLE__
+    const char* get_bundle_resource_directory();
+#endif
+}
+
+#endif
diff --git a/lib/N64ModernRuntime b/lib/N64ModernRuntime
index d17a3f3..45d1db0 160000
--- a/lib/N64ModernRuntime
+++ b/lib/N64ModernRuntime
@@ -1 +1 @@
-Subproject commit d17a3f34cb223ef51865ae99d1eb8339ab796043
+Subproject commit 45d1db07b0a151c89277f5dc89c53c58bfcf74eb
diff --git a/lib/rt64 b/lib/rt64
index 0ca92ee..45ef4dc 160000
--- a/lib/rt64
+++ b/lib/rt64
@@ -1 +1 @@
-Subproject commit 0ca92eeb6c2f58ce3581c65f87f7261b8ac0fea0
+Subproject commit 45ef4dc0aec7ee4fdc5e3da7faea06cd5170459c
diff --git a/src/game/config.cpp b/src/game/config.cpp
index ab24e0a..2a1fdd4 100644
--- a/src/game/config.cpp
+++ b/src/game/config.cpp
@@ -13,6 +13,8 @@
 #elif defined(__linux__)
 #include <unistd.h>
 #include <pwd.h>
+#elif defined(__APPLE__)
+#include "common/rt64_apple.h"
 #endif
 
 constexpr std::u8string_view general_filename = u8"general.json";
@@ -145,8 +147,8 @@ std::filesystem::path zelda64::get_app_folder_path() {
    }
 
    CoTaskMemFree(known_path);
-#elif defined(__linux__)
-   // check for APP_FOLDER_PATH env var used by AppImage
+#elif defined(__linux__) || defined(__APPLE__)
+   // check for APP_FOLDER_PATH env var
    if (getenv("APP_FOLDER_PATH") != nullptr) {
        return std::filesystem::path{getenv("APP_FOLDER_PATH")};
    }
@@ -154,7 +156,11 @@ std::filesystem::path zelda64::get_app_folder_path() {
    const char *homedir;
 
    if ((homedir = getenv("HOME")) == nullptr) {
+    #if defined(__linux__)
        homedir = getpwuid(getuid())->pw_dir;
+    #elif defined(__APPLE__)
+        homedir = GetHomeDirectory();
+    #endif
    }
 
    if (homedir != nullptr) {
diff --git a/src/main/main.cpp b/src/main/main.cpp
index 49cd8fa..588d294 100644
--- a/src/main/main.cpp
+++ b/src/main/main.cpp
@@ -25,6 +25,7 @@
 #include "zelda_config.h"
 #include "zelda_sound.h"
 #include "zelda_render.h"
+#include "zelda_support.h"
 #include "ovl_patches.hpp"
 #include "librecomp/game.hpp"
 #include "librecomp/mods.hpp"
@@ -44,7 +45,7 @@ void exit_error(const char* str, Ts ...args) {
     // TODO pop up an error
     ((void)fprintf(stderr, str, args), ...);
     assert(false);
-    std::quick_exit(EXIT_FAILURE);
+    ULTRAMODERN_QUICK_EXIT();
 }
 
 ultramodern::gfx_callbacks_t::gfx_data_t create_gfx() {
@@ -120,6 +121,8 @@ ultramodern::renderer::WindowHandle create_window(ultramodern::gfx_callbacks_t::
 
 #if defined(RT64_SDL_WINDOW_VULKAN)
     flags |= SDL_WINDOW_VULKAN;
+#elif defined(RT64_SDL_WINDOW_METAL)
+    flags |= SDL_WINDOW_METAL;
 #endif
 
     window = SDL_CreateWindow("Zelda 64: Recompiled", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 1600, 960,  flags);
@@ -144,6 +147,9 @@ ultramodern::renderer::WindowHandle create_window(ultramodern::gfx_callbacks_t::
     return ultramodern::renderer::WindowHandle{ wmInfo.info.win.window, GetCurrentThreadId() };
 #elif defined(__linux__) || defined(__ANDROID__)
     return ultramodern::renderer::WindowHandle{ window };
+#elif defined(__APPLE__)
+    SDL_MetalView view = SDL_Metal_CreateView(window);
+    return ultramodern::renderer::WindowHandle{ wmInfo.info.cocoa.window,  SDL_Metal_GetLayer(view) };
 #else
     static_assert(false && "Unimplemented");
 #endif
@@ -582,8 +588,13 @@ int main(int argc, char** argv) {
     SDL_InitSubSystem(SDL_INIT_AUDIO);
     reset_audio(48000);
 
-    // Source controller mappings file
-    if (SDL_GameControllerAddMappingsFromFile("gamecontrollerdb.txt") < 0) {
+#if defined(__APPLE__)
+    const char* resource_directory = zelda64::get_bundle_resource_directory();
+    std::string mapping_file_path = std::string(resource_directory) + "gamecontrollerdb.txt";
+#else
+    std::string mapping_file_path = "gamecontrollerdb.txt";
+#endif
+    if (SDL_GameControllerAddMappingsFromFile(mapping_file_path.c_str()) < 0) {
         fprintf(stderr, "Failed to load controller mappings: %s\n", SDL_GetError());
     }
 
diff --git a/src/main/rt64_render_context.cpp b/src/main/rt64_render_context.cpp
index 126ab70..5bc54cb 100644
--- a/src/main/rt64_render_context.cpp
+++ b/src/main/rt64_render_context.cpp
@@ -204,7 +204,7 @@ zelda64::renderer::RT64Context::RT64Context(uint8_t* rdram, ultramodern::rendere
     appCore.window = window_handle;
 #elif defined(__APPLE__)
     appCore.window.window = window_handle.window;
-    appCore.window.view = window_handle.view;
+    appCore.window.layer = window_handle.view;
 #endif
 
     appCore.checkInterrupts = dummy_check_interrupts;
diff --git a/src/main/support.cpp b/src/main/support.cpp
new file mode 100644
index 0000000..4c7631d
--- /dev/null
+++ b/src/main/support.cpp
@@ -0,0 +1,10 @@
+#ifndef __APPLE__
+
+#include "zelda_support.h"
+#include <functional>
+
+void zelda64::dispatch_on_main_thread(std::function<void()> func) {
+    func();
+}
+
+#endif
\ No newline at end of file
diff --git a/src/main/support_apple.mm b/src/main/support_apple.mm
new file mode 100644
index 0000000..ceba22c
--- /dev/null
+++ b/src/main/support_apple.mm
@@ -0,0 +1,13 @@
+#include "zelda_support.h"
+#import <Foundation/Foundation.h>
+
+void zelda64::dispatch_on_main_thread(std::function<void()> func) {
+    dispatch_async(dispatch_get_main_queue(), ^{
+        func();
+    });
+}
+
+const char* zelda64::get_bundle_resource_directory() {
+    NSString *bundlePath = [[NSBundle mainBundle] resourcePath];
+    return strdup([bundlePath UTF8String]);
+}
diff --git a/src/ui/ui_config.cpp b/src/ui/ui_config.cpp
index 12bb20e..18158cd 100644
--- a/src/ui/ui_config.cpp
+++ b/src/ui/ui_config.cpp
@@ -2,6 +2,7 @@
 #include "recomp_input.h"
 #include "zelda_sound.h"
 #include "zelda_config.h"
+#include "zelda_support.h"
 #include "zelda_debug.h"
 #include "zelda_render.h"
 #include "promptfont.h"
@@ -519,6 +520,11 @@ public:
 
 	}
 	Rml::ElementDocument* load_document(Rml::Context* context) override {
+#if defined(__APPLE__)
+        const Rml::String asset = "/assets/config_menu.rml";
+        return context->LoadDocument(zelda64::get_bundle_resource_directory() + asset);
+#endif
+
         return context->LoadDocument("assets/config_menu.rml");
 	}
 	void register_events(recompui::UiEventListenerInstancer& listener) override {
@@ -725,7 +731,7 @@ public:
 			throw std::runtime_error("Failed to make RmlUi data model for the controls config menu");
 		}
 
-		constructor.BindFunc("input_count", [](Rml::Variant& out) { out = recomp::get_num_inputs(); } );
+		constructor.BindFunc("input_count", [](Rml::Variant& out) { out = static_cast<uint64_t>(recomp::get_num_inputs()); } );
 		constructor.BindFunc("input_device_is_keyboard", [](Rml::Variant& out) { out = cur_device == recomp::InputDevice::Keyboard; } );
 
 		constructor.RegisterTransformFunc("get_input_name", [](const Rml::VariantList& inputs) {
diff --git a/src/ui/ui_launcher.cpp b/src/ui/ui_launcher.cpp
index f699eb7..c876674 100644
--- a/src/ui/ui_launcher.cpp
+++ b/src/ui/ui_launcher.cpp
@@ -1,5 +1,6 @@
 #include "recomp_ui.h"
 #include "zelda_config.h"
+#include "zelda_support.h"
 #include "librecomp/game.hpp"
 #include "ultramodern/ultramodern.hpp"
 #include "RmlUi/Core.h"
@@ -15,41 +16,43 @@ extern std::vector<recomp::GameEntry> supported_games;
 
 void select_rom() {
 	nfdnchar_t* native_path = nullptr;
-	nfdresult_t result = NFD_OpenDialogN(&native_path, nullptr, 0, nullptr);
+    zelda64::dispatch_on_main_thread([&native_path] {
+        nfdresult_t result = NFD_OpenDialogN(&native_path, nullptr, 0, nullptr);
 
-	if (result == NFD_OKAY) {
-		std::filesystem::path path{native_path};
+        if (result == NFD_OKAY) {
+            std::filesystem::path path{native_path};
 
-		NFD_FreePathN(native_path);
-		native_path = nullptr;
+            NFD_FreePathN(native_path);
+            native_path = nullptr;
 
-		recomp::RomValidationError rom_error = recomp::select_rom(path, supported_games[0].game_id);
-        switch (rom_error) {
-            case recomp::RomValidationError::Good:
-                mm_rom_valid = true;
-                model_handle.DirtyVariable("mm_rom_valid");
-                break;
-            case recomp::RomValidationError::FailedToOpen:
-                recompui::message_box("Failed to open ROM file.");
-                break;
-            case recomp::RomValidationError::NotARom:
-                recompui::message_box("This is not a valid ROM file.");
-                break;
-            case recomp::RomValidationError::IncorrectRom:
-                recompui::message_box("This ROM is not the correct game.");
-                break;
-            case recomp::RomValidationError::NotYet:
-                recompui::message_box("This game isn't supported yet.");
-                break;
-            case recomp::RomValidationError::IncorrectVersion:
-                recompui::message_box(
-                        "This ROM is the correct game, but the wrong version.\nThis project requires the NTSC-U N64 version of the game.");
-                break;
-            case recomp::RomValidationError::OtherError:
-                recompui::message_box("An unknown error has occurred.");
-                break;
+            recomp::RomValidationError rom_error = recomp::select_rom(path, supported_games[0].game_id);
+            switch (rom_error) {
+                case recomp::RomValidationError::Good:
+                    mm_rom_valid = true;
+                    model_handle.DirtyVariable("mm_rom_valid");
+                    break;
+                case recomp::RomValidationError::FailedToOpen:
+                    recompui::message_box("Failed to open ROM file.");
+                    break;
+                case recomp::RomValidationError::NotARom:
+                    recompui::message_box("This is not a valid ROM file.");
+                    break;
+                case recomp::RomValidationError::IncorrectRom:
+                    recompui::message_box("This ROM is not the correct game.");
+                    break;
+                case recomp::RomValidationError::NotYet:
+                    recompui::message_box("This game isn't supported yet.");
+                    break;
+                case recomp::RomValidationError::IncorrectVersion:
+                    recompui::message_box(
+                            "This ROM is the correct game, but the wrong version.\nThis project requires the NTSC-U N64 version of the game.");
+                    break;
+                case recomp::RomValidationError::OtherError:
+                    recompui::message_box("An unknown error has occurred.");
+                    break;
+            }
         }
-    }
+    });
 }
 
 class LauncherMenu : public recompui::MenuController {
@@ -61,6 +64,11 @@ public:
 
 	}
 	Rml::ElementDocument* load_document(Rml::Context* context) override {
+#if defined(__APPLE__)
+        const Rml::String asset = "/assets/launcher.rml";
+        return context->LoadDocument(zelda64::get_bundle_resource_directory() + asset);
+#endif
+
         return context->LoadDocument("assets/launcher.rml");
 	}
 	void register_events(recompui::UiEventListenerInstancer& listener) override {
diff --git a/src/ui/ui_renderer.cpp b/src/ui/ui_renderer.cpp
index a234d7c..a93d7ad 100644
--- a/src/ui/ui_renderer.cpp
+++ b/src/ui/ui_renderer.cpp
@@ -16,6 +16,7 @@
 #include "librecomp/game.hpp"
 #include "zelda_config.h"
 #include "ui_rml_hacks.hpp"
+#include "zelda_support.h"
 
 #include "concurrentqueue.h"
 
@@ -1144,8 +1145,6 @@ void init_hook(RT64::RenderInterface* interface, RT64::RenderDevice* device) {
     Rml::Debugger::Initialise(ui_context->rml.context);
 
     {
-        const Rml::String directory = "assets/";
-
         struct FontFace {
             const char* filename;
             bool fallback_face;
@@ -1162,7 +1161,13 @@ void init_hook(RT64::RenderInterface* interface, RT64::RenderDevice* device) {
         };
 
         for (const FontFace& face : font_faces) {
+            #if defined(__APPLE__)
+            const Rml::String directory = "/assets/";
+            Rml::LoadFontFace(zelda64::get_bundle_resource_directory() + directory + face.filename, face.fallback_face);
+            #else
+            const Rml::String directory = "assets/";
             Rml::LoadFontFace(directory + face.filename, face.fallback_face);
+            #endif
         }
     }
 
@@ -1506,6 +1511,9 @@ recompui::Menu recompui::get_current_menu() {
 }
 
 void recompui::message_box(const char* msg) {
-    SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, zelda64::program_name.data(), msg, nullptr);
+    std::string message(msg);
+    zelda64::dispatch_on_main_thread([message] {
+        SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, zelda64::program_name.data(), message.c_str(), nullptr);
+    });
     printf("[ERROR] %s\n", msg);
 }