diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml new file mode 100644 index 00000000..53a89f1f --- /dev/null +++ b/.github/workflows/macos.yml @@ -0,0 +1,47 @@ +name: macOS + +on: + pull_request: + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: macos-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: macos-26 + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + # act uses the current working tree, including uncommitted changes. + if: ${{ !env.ACT }} + with: + fetch-depth: 0 + submodules: recursive + + - name: Install build tools + run: | + if ! command -v cmake >/dev/null || ! command -v ninja >/dev/null; then + brew install cmake ninja + fi + + - name: Build + working-directory: ${{ env.NXEMU_LOCAL_WORKSPACE || github.workspace }} + run: sh src/script/build_macos.sh Release "${NXEMU_BUILD_DIRECTORY:-build}" + + # The disk image preserves the bundle and includes an Applications shortcut. + - name: Package + working-directory: ${{ env.NXEMU_LOCAL_WORKSPACE || github.workspace }} + run: sh src/script/package_dmg_macos.sh NxEmu-macos-arm64.dmg + + - uses: actions/upload-artifact@v4 + with: + name: NxEmu-macos-arm64 + path: ${{ env.NXEMU_LOCAL_WORKSPACE || github.workspace }}/package/NxEmu-macos-arm64.dmg + if-no-files-found: error diff --git a/.gitignore b/.gitignore index fd0b112f..a1d6a857 100644 --- a/.gitignore +++ b/.gitignore @@ -20,12 +20,14 @@ *.o Thumbs.db +.DS_Store /.vs /bin /build /git.properties /ipch /modules +/package /src/nxemu-core/version.h /src/nxemu-video/version.h /src/nxemu-cpu/version.h diff --git a/CMakeLists.txt b/CMakeLists.txt index ea82bf50..13cbec53 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,9 +1,26 @@ cmake_minimum_required(VERSION 3.22) +# Match the minimum supported by the bundled MoltenVK runtime, instead of +# silently targeting the macOS version of the developer's machine. +if(CMAKE_HOST_APPLE AND NOT CMAKE_OSX_DEPLOYMENT_TARGET) + set(CMAKE_OSX_DEPLOYMENT_TARGET "12.0" CACHE STRING "Minimum macOS version" FORCE) +endif() + project(nxemu) +if(APPLE) + if(CMAKE_OSX_DEPLOYMENT_TARGET VERSION_LESS "12.0") + message(FATAL_ERROR "The bundled MoltenVK runtime requires macOS 12.0 or newer") + endif() + enable_language(OBJC OBJCXX) +endif() + find_package(Threads REQUIRED) +if(APPLE) + option(ENABLE_SDL2 "Enable SDL game controller input" ON) +endif() + set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_POSITION_INDEPENDENT_CODE ON) @@ -11,18 +28,26 @@ set(CMAKE_POSITION_INDEPENDENT_CODE ON) # ============================================================ # Architecture detection # ============================================================ -if(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64|arm64|ARM64") +set(NXEMU_TARGET_PROCESSOR "${CMAKE_SYSTEM_PROCESSOR}") +if(APPLE AND CMAKE_OSX_ARCHITECTURES) + list(LENGTH CMAKE_OSX_ARCHITECTURES _nxemu_arch_count) + if(_nxemu_arch_count GREATER 1) + message(FATAL_ERROR "Build each macOS architecture separately: the JIT backend is architecture-specific") + endif() + list(GET CMAKE_OSX_ARCHITECTURES 0 NXEMU_TARGET_PROCESSOR) +endif() +if(NXEMU_TARGET_PROCESSOR MATCHES "aarch64|arm64|ARM64") set(ARCHITECTURE_arm64 TRUE) set(ARCHITECTURE "arm64") set(NXEMU_PLATFORM_DIR "arm64") add_compile_definitions(ARCHITECTURE_arm64=1) -elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|AMD64|amd64") +elseif(NXEMU_TARGET_PROCESSOR MATCHES "x86_64|AMD64|amd64") add_compile_definitions(ARCHITECTURE_x86_64=1) set(ARCHITECTURE_x86_64 TRUE) set(ARCHITECTURE "x86_64") set(NXEMU_PLATFORM_DIR "x64") else() - set(NXEMU_PLATFORM_DIR "${CMAKE_SYSTEM_PROCESSOR}") + set(NXEMU_PLATFORM_DIR "${NXEMU_TARGET_PROCESSOR}") endif() # CMake modules diff --git a/external/CMakeLists.txt b/external/CMakeLists.txt index 81910255..b36e9e9a 100644 --- a/external/CMakeLists.txt +++ b/external/CMakeLists.txt @@ -3,14 +3,46 @@ if (NOT TARGET fmt::fmt) add_subdirectory(fmt) endif() -# AppleClang 21 rejects the consteval path used by the bundled fmt 10.x. -# fmt 10.1.1 controls the constructor qualifier with FMT_CONSTEVAL; the newer -# FMT_USE_CONSTEVAL switch does not exist in this bundled version. Define -# FMT_CONSTEVAL to an empty replacement on the affected toolchain, matching -# the upstream workaround for fmt 10.x on AppleClang 21. -if(APPLE AND CMAKE_CXX_COMPILER_ID STREQUAL "AppleClang" AND - CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 21) - target_compile_definitions(fmt PUBLIC FMT_CONSTEVAL=) +# macOS controller adapters are bundled against the application deployment target. +if(APPLE) + option(ENABLE_LIBUSB "Enable direct GameCube USB adapter input" ON) + if(ENABLE_LIBUSB AND NOT TARGET libusb::usb) + set(_nxemu_usb "${CMAKE_CURRENT_SOURCE_DIR}/libusb/libusb") + add_library(nxemu_libusb STATIC + ${_nxemu_usb}/core.c + ${_nxemu_usb}/descriptor.c + ${_nxemu_usb}/hotplug.c + ${_nxemu_usb}/io.c + ${_nxemu_usb}/strerror.c + ${_nxemu_usb}/sync.c + ${_nxemu_usb}/os/events_posix.c + ${_nxemu_usb}/os/threads_posix.c + ${_nxemu_usb}/os/darwin_usb.c) + target_include_directories(nxemu_libusb PUBLIC "${_nxemu_usb}" + PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/libusb/Xcode") + target_compile_options(nxemu_libusb PRIVATE -fvisibility=hidden) + target_link_libraries(nxemu_libusb PRIVATE Threads::Threads + "-framework IOKit" "-framework CoreFoundation" "-framework Security") + add_library(libusb::usb ALIAS nxemu_libusb) + unset(_nxemu_usb) + endif() +endif() + +# Keep macOS controller support self-contained in the application bundle. +if(APPLE AND ENABLE_SDL2 AND NOT TARGET SDL2::SDL2) + set(SDL_SHARED OFF CACHE BOOL "Build shared SDL" FORCE) + set(SDL_STATIC ON CACHE BOOL "Build static SDL" FORCE) + set(SDL_TEST OFF CACHE BOOL "Build SDL test library" FORCE) + set(SDL_TESTS OFF CACHE BOOL "Build SDL tests" FORCE) + set(SDL2_DISABLE_INSTALL ON CACHE BOOL "Disable SDL installation" FORCE) + # SDL's bundled checks use an older minimum; CMake 4 requires at least 3.5. + set(_nxemu_saved_policy_minimum "${CMAKE_POLICY_VERSION_MINIMUM}") + if(NOT CMAKE_POLICY_VERSION_MINIMUM OR CMAKE_POLICY_VERSION_MINIMUM VERSION_LESS "3.5") + set(CMAKE_POLICY_VERSION_MINIMUM "3.5") + endif() + add_subdirectory(sdl) + set(CMAKE_POLICY_VERSION_MINIMUM "${_nxemu_saved_policy_minimum}") + unset(_nxemu_saved_policy_minimum) endif() # Boost @@ -184,5 +216,44 @@ if(NOT TARGET tsl::robin_map) target_include_directories(robin_map INTERFACE "${CMAKE_SOURCE_DIR}/external/robin-map/include") endif() +# MoltenVK runtime (macOS) +if(APPLE) + include(ExternalProject) + + find_program(NXEMU_XCODEBUILD xcodebuild REQUIRED) + execute_process(COMMAND "${NXEMU_XCODEBUILD}" -version + RESULT_VARIABLE _nxemu_xcode_result OUTPUT_QUIET ERROR_VARIABLE _nxemu_xcode_error) + if(NOT _nxemu_xcode_result EQUAL 0) + message(FATAL_ERROR + "Building MoltenVK requires full Xcode, not only Command Line Tools. " + "Select Xcode with xcode-select. ${_nxemu_xcode_error}") + endif() + + set(_nxemu_moltenvk_root "${CMAKE_BINARY_DIR}/external/moltenvk") + set(_nxemu_moltenvk_source "${_nxemu_moltenvk_root}/src") + set(_nxemu_moltenvk_runtime + "${_nxemu_moltenvk_source}/MoltenVK/Package/Latest/MoltenVK/dynamic/dylib/macOS/libMoltenVK.dylib") + + ExternalProject_Add(nxemu_moltenvk + GIT_REPOSITORY "https://github.com/V380-Ori/Ryujinx.MoltenVK.git" + GIT_TAG "45f9b641e45f1d150f3521d74808477add9e975b" + GIT_SHALLOW FALSE + GIT_SUBMODULES_RECURSE TRUE + UPDATE_COMMAND "" + SOURCE_DIR "${_nxemu_moltenvk_source}" + CONFIGURE_COMMAND "" + BUILD_IN_SOURCE TRUE + BUILD_COMMAND /bin/sh -c + "cd MoltenVK && (git apply --reverse --check ../000*.patch || git apply --verbose ../000*.patch) && ./fetchDependencies --macos && make macos" + BUILD_BYPRODUCTS "${_nxemu_moltenvk_runtime}" + INSTALL_COMMAND "" + ) + + set(NXEMU_MOLTENVK_RUNTIME "${_nxemu_moltenvk_runtime}" CACHE INTERNAL "" FORCE) + unset(_nxemu_moltenvk_root) + unset(_nxemu_moltenvk_source) + unset(_nxemu_moltenvk_runtime) +endif() + # sciter -add_subdirectory(sciterui) \ No newline at end of file +add_subdirectory(sciterui) diff --git a/external/ffmpeg b/external/ffmpeg index 772cea1a..7bce512d 160000 --- a/external/ffmpeg +++ b/external/ffmpeg @@ -1 +1 @@ -Subproject commit 772cea1ac12d5cd11ff17df97dc8a9858d9c1c01 +Subproject commit 7bce512dcae670a7349088b564b0e6251c0a487e diff --git a/external/fmt b/external/fmt index f5e54359..1be298e1 160000 --- a/external/fmt +++ b/external/fmt @@ -1 +1 @@ -Subproject commit f5e54359df4c26b6230fc61d38aa294581393084 +Subproject commit 1be298e1bd68957e4cd352e1f676f00e07dcfb57 diff --git a/external/sciterui b/external/sciterui index 303c6855..dcceefb6 160000 --- a/external/sciterui +++ b/external/sciterui @@ -1 +1 @@ -Subproject commit 303c68557c469a76da5ee8b9d07c1594a8798a9f +Subproject commit dcceefb6dfcb92a0ef5d98ec6f368f82c83fe934 diff --git a/lang/english/css/base.css b/lang/english/css/base.css index 47973e95..238b0206 100644 --- a/lang/english/css/base.css +++ b/lang/english/css/base.css @@ -143,3 +143,19 @@ html > header > window-buttons > window-button[role="window-minimize"] { foreground-image: url(icon:window-minimize); } + +/* macOS uses the native AppKit title bar instead of Sciter window chrome. */ +html[data-platform="macos"] +{ + border: none; +} + +html[data-platform="macos"] > header +{ + display: none; +} + +html[data-platform="macos"][window-state="maximized"] +{ + border: none; +} diff --git a/lang/english/css/main_window.css b/lang/english/css/main_window.css index 8c540a01..bff1f46b 100644 --- a/lang/english/css/main_window.css +++ b/lang/english/css/main_window.css @@ -46,7 +46,7 @@ } #StatusBar #volume:hover, -#StatusBar #renderer:hover, +#StatusBar #renderer.interactive:hover, #StatusBar #dockedMode:hover, #StatusBar #gpuCommandSynchronization:hover, #StatusBar #volumePopupBtn:hover diff --git a/lang/english/html/main_window.html b/lang/english/html/main_window.html index cb83d814..2b04a078 100644 --- a/lang/english/html/main_window.html +++ b/lang/english/html/main_window.html @@ -1,94 +1,8 @@ - + NxEmu -
- NxEmu - - - - - -
- -
-
- -
-
-
- -
- -
-
-
-
-
-
-
-
-
- -
-
-
-
-
Paused
-
-
-
-
- - - - - - - -
-
- -
  • - - Start Game -
  • -
    -
  • - - Open Save Data Location -
  • -
  • - - Open Mod Data Location -
  • -
    -
  • - - Configure Game -
  • - + - -
    - -
    -
    \ No newline at end of file diff --git a/lang/english/html/main_window_content.html b/lang/english/html/main_window_content.html new file mode 100644 index 00000000..0f2058f2 --- /dev/null +++ b/lang/english/html/main_window_content.html @@ -0,0 +1,87 @@ +
    + NxEmu + + + + + +
    + +
    +
    + +
    +
    +
    + +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    Paused
    +
    +
    +
    +
    + + + + + + + +
    +
    + +
  • + + Start Game +
  • +
    +
  • + + Open Save Data Location +
  • +
  • + + Open Mod Data Location +
  • +
    +
  • + + Configure Game +
  • + + +
    + +
    +
    diff --git a/lang/english/html/main_window_macos.html b/lang/english/html/main_window_macos.html new file mode 100644 index 00000000..71ebe4e4 --- /dev/null +++ b/lang/english/html/main_window_macos.html @@ -0,0 +1,8 @@ + + + + + NxEmu + + + diff --git a/src/cmake/modules/nxemu_macos_bundle.cmake b/src/cmake/modules/nxemu_macos_bundle.cmake new file mode 100644 index 00000000..a629c269 --- /dev/null +++ b/src/cmake/modules/nxemu_macos_bundle.cmake @@ -0,0 +1,45 @@ +# Invoked at link time and by nxemu_bundle, so resources never depend on a relink. +foreach(required BUNDLE LOADER CPU VIDEO OS MOLTENVK SCITER LANG CODESIGN INFO_PLIST ICON EXECUTABLE_NAME SHORT_VERSION BUILD_VERSION MINIMUM_MACOS) + if(NOT DEFINED ${required} OR "${${required}}" STREQUAL "") + message(FATAL_ERROR "Missing bundle input: ${required}") + endif() +endforeach() +set(runtime "${BUNDLE}/Contents/MacOS") +set(frameworks "${BUNDLE}/Contents/Frameworks") +file(MAKE_DIRECTORY "${frameworks}" "${BUNDLE}/Contents/Resources") +# CMake normally generates Info.plist only during configuration. Restore it during +# packaging too, including incremental builds after bundle metadata was removed. +set(MACOSX_BUNDLE_EXECUTABLE_NAME "${EXECUTABLE_NAME}") +set(MACOSX_BUNDLE_SHORT_VERSION_STRING "${SHORT_VERSION}") +set(MACOSX_BUNDLE_BUNDLE_VERSION "${BUILD_VERSION}") +set(CMAKE_OSX_DEPLOYMENT_TARGET "${MINIMUM_MACOS}") +configure_file("${INFO_PLIST}" "${BUNDLE}/Contents/Info.plist") +file(COPY_FILE "${ICON}" "${BUNDLE}/Contents/Resources/nxemu.icns" ONLY_IF_DIFFERENT) +foreach(pair "LOADER|loader" "CPU|cpu" "VIDEO|video" "OS|operating_system") + string(REPLACE "|" ";" parts "${pair}") + list(GET parts 0 input) + list(GET parts 1 folder) + get_filename_component(filename "${${input}}" NAME) + file(MAKE_DIRECTORY "${frameworks}/modules/${folder}") + file(COPY_FILE "${${input}}" "${frameworks}/modules/${folder}/${filename}" ONLY_IF_DIFFERENT) +endforeach() +file(COPY_FILE "${MOLTENVK}" "${frameworks}/libMoltenVK.dylib" ONLY_IF_DIFFERENT) +file(COPY_FILE "${SCITER}" "${runtime}/libsciter.dylib" ONLY_IF_DIFFERENT) +file(COPY "${LANG}" DESTINATION "${BUNDLE}/Contents/Resources") +# Remove only locations generated by the older bundle layout, before sealing. +file(REMOVE_RECURSE "${runtime}/lang" "${runtime}/modules") +# Ad-hoc signing enables local execution; distribution/notarization needs a +# developer identity. Sign nested code before sealing the application resources. +file(GLOB_RECURSE libraries "${runtime}/*.dylib" "${frameworks}/*.dylib") +foreach(library IN LISTS libraries) + execute_process(COMMAND "${CODESIGN}" --force --sign - "${library}" + RESULT_VARIABLE result OUTPUT_QUIET ERROR_VARIABLE error) + if(NOT result EQUAL 0) + message(FATAL_ERROR "Cannot sign ${library}: ${error}") + endif() +endforeach() +execute_process(COMMAND "${CODESIGN}" --force --sign - "${BUNDLE}" + RESULT_VARIABLE result OUTPUT_QUIET ERROR_VARIABLE error) +if(NOT result EQUAL 0) + message(FATAL_ERROR "Cannot sign bundle: ${error}") +endif() diff --git a/src/cmake/modules/nxemu_output_directories.cmake b/src/cmake/modules/nxemu_output_directories.cmake index 584d6a60..47b564fd 100644 --- a/src/cmake/modules/nxemu_output_directories.cmake +++ b/src/cmake/modules/nxemu_output_directories.cmake @@ -8,7 +8,13 @@ function(nxemu_set_app_output target) endfunction() function(nxemu_set_module_output target module_name) - set(_out "${CMAKE_SOURCE_DIR}/modules/${NXEMU_PLATFORM_DIR}/${module_name}") + if(APPLE) + # Keep modules from different build trees/configurations from overwriting + # each other. The bundle receives exactly these target files. + set(_out "${CMAKE_BINARY_DIR}/modules/${NXEMU_PLATFORM_DIR}/$/${module_name}") + else() + set(_out "${CMAKE_SOURCE_DIR}/modules/${NXEMU_PLATFORM_DIR}/${module_name}") + endif() set_target_properties(${target} PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${_out}" LIBRARY_OUTPUT_DIRECTORY "${_out}" diff --git a/src/nxemu-cpu/dynarmic/backend/arm64/emit_arm64_a64.cpp b/src/nxemu-cpu/dynarmic/backend/arm64/emit_arm64_a64.cpp index 67fd91f2..c83be614 100644 --- a/src/nxemu-cpu/dynarmic/backend/arm64/emit_arm64_a64.cpp +++ b/src/nxemu-cpu/dynarmic/backend/arm64/emit_arm64_a64.cpp @@ -10,6 +10,7 @@ #include "dynarmic/backend/arm64/abi.h" #include "dynarmic/backend/arm64/emit_arm64.h" #include "dynarmic/backend/arm64/emit_context.h" +#include "dynarmic/backend/arm64/fpsr_manager.h" #include "dynarmic/backend/arm64/reg_alloc.h" #include "dynarmic/interface/halt_reason.h" #include "dynarmic/ir/basic_block.h" @@ -278,6 +279,7 @@ void EmitIR(oaknut::CodeGenerator& code, EmitContext& ct template<> void EmitIR(oaknut::CodeGenerator& code, EmitContext& ctx, IR::Inst* inst) { + ctx.fpsr.Spill(); auto Wresult = ctx.reg_alloc.WriteW(inst); RegAlloc::Realize(Wresult); diff --git a/src/nxemu-cpu/dynarmic_cp15.cpp b/src/nxemu-cpu/dynarmic_cp15.cpp index b1a2697a..5104ebc2 100644 --- a/src/nxemu-cpu/dynarmic_cp15.cpp +++ b/src/nxemu-cpu/dynarmic_cp15.cpp @@ -22,7 +22,7 @@ struct fmt::formatter return ctx.begin(); } template - auto format(const Dynarmic::A32::CoprocReg & reg, FormatContext & ctx) + auto format(const Dynarmic::A32::CoprocReg & reg, FormatContext & ctx) const { return fmt::format_to(ctx.out(), "cp{}", static_cast(reg)); } diff --git a/src/nxemu-os/core/hle/service/nfc/common/device.cpp b/src/nxemu-os/core/hle/service/nfc/common/device.cpp index 1d021bea..58308257 100644 --- a/src/nxemu-os/core/hle/service/nfc/common/device.cpp +++ b/src/nxemu-os/core/hle/service/nfc/common/device.cpp @@ -18,6 +18,7 @@ #endif #include +#include #include "core/core.h" #include "core/hle/kernel/k_event.h" diff --git a/src/nxemu-os/core/hle/service/psc/time/common.h b/src/nxemu-os/core/hle/service/psc/time/common.h index a03b0c42..ecbb228c 100644 --- a/src/nxemu-os/core/hle/service/psc/time/common.h +++ b/src/nxemu-os/core/hle/service/psc/time/common.h @@ -167,7 +167,7 @@ constexpr inline Result GetSpanBetweenTimePoints(s64* out_seconds, const SteadyC template <> struct fmt::formatter : fmt::formatter { template - auto format(Service::PSC::Time::TimeType type, FormatContext& ctx) { + auto format(Service::PSC::Time::TimeType type, FormatContext& ctx) const { const string_view name = [type] { using Service::PSC::Time::TimeType; switch (type) { @@ -270,4 +270,4 @@ struct fmt::formatter time_point.rtc_offset, time_point.diff_scale, time_point.shift_amount, time_point.lower, time_point.upper); } -}; \ No newline at end of file +}; diff --git a/src/nxemu-video/render_window.cpp b/src/nxemu-video/render_window.cpp index f8ab22ff..93d9a239 100644 --- a/src/nxemu-video/render_window.cpp +++ b/src/nxemu-video/render_window.cpp @@ -310,10 +310,15 @@ RenderWindow::RenderWindow(IRenderWindow & renderWindow) : window_info.type = Core::Frontend::WindowSystemType::Windows; #elif defined(__ANDROID__) window_info.type = Core::Frontend::WindowSystemType::Android; +#elif defined(__APPLE__) + window_info.type = Core::Frontend::WindowSystemType::Cocoa; #else window_info.type = Core::Frontend::WindowSystemType::Headless; #endif window_info.render_surface = renderWindow.RenderSurface(); +#ifdef __APPLE__ + window_info.render_surface_scale = renderWindow.PixelRatio(); +#endif NotifyClientAreaSizeChanged({0, 0}); UpdateCurrentFramebufferLayout(640, 480); } @@ -351,4 +356,4 @@ std::unique_ptr RenderWindow::CreateSharedConte bool RenderWindow::IsShown() const { return true; -} \ No newline at end of file +} diff --git a/src/nxemu/CMakeLists.txt b/src/nxemu/CMakeLists.txt index 517a4cdf..409fb9b2 100644 --- a/src/nxemu/CMakeLists.txt +++ b/src/nxemu/CMakeLists.txt @@ -1,3 +1,5 @@ +option(NXEMU_MACOS_BUNDLE "Build NxEmu as a macOS application bundle" ON) + add_executable(nxemu main.cpp startup_checks.cpp @@ -72,6 +74,21 @@ add_executable(nxemu applets/web_browser.h ) +if(APPLE) + target_sources(nxemu PRIVATE + user_interface/file_dialogs_macos.mm + user_interface/render_window_macos.mm + ) + find_library(APPKIT_FRAMEWORK AppKit REQUIRED) + find_library(QUARTZCORE_FRAMEWORK QuartzCore REQUIRED) + find_library(UNIFORMTYPEIDENTIFIERS_FRAMEWORK UniformTypeIdentifiers REQUIRED) + target_link_libraries(nxemu PRIVATE + ${APPKIT_FRAMEWORK} + ${QUARTZCORE_FRAMEWORK} + ${UNIFORMTYPEIDENTIFIERS_FRAMEWORK} + ) +endif() + target_include_directories(nxemu PRIVATE ${CMAKE_SOURCE_DIR}/src ${CMAKE_SOURCE_DIR}/src/nxemu @@ -99,8 +116,10 @@ target_link_libraries(nxemu PRIVATE ${CMAKE_DL_LIBS} ) nxemu_set_app_output(nxemu) -include(nxemu_setup_environment) -nxemu_setup_environment(nxemu) +if(NOT APPLE OR NOT NXEMU_MACOS_BUNDLE) + include(nxemu_setup_environment) + nxemu_setup_environment(nxemu) +endif() # Runtime needs the Sciter engine beside the executable if(WIN32) @@ -117,9 +136,92 @@ else() set(_nxemu_sciter_dest "libsciter.so") endif() -if(EXISTS "${_nxemu_sciter_lib}") - add_custom_command(TARGET nxemu POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_nxemu_sciter_lib}" "$/${_nxemu_sciter_dest}" - COMMENT "Copy Sciter engine next to nxemu" +if(APPLE AND NXEMU_MACOS_BUNDLE) + set(_nxemu_icon "${CMAKE_CURRENT_SOURCE_DIR}/macos/nxemu.icns") + set_source_files_properties("${_nxemu_icon}" PROPERTIES MACOSX_PACKAGE_LOCATION Resources) + target_sources(nxemu PRIVATE "${_nxemu_icon}") + set_target_properties(nxemu PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/macos/Info.plist.in" + MACOSX_BUNDLE_ICON_FILE "nxemu.icns" + MACOSX_BUNDLE_GUI_IDENTIFIER "org.nxemu" + MACOSX_BUNDLE_BUNDLE_NAME "NxEmu" + MACOSX_BUNDLE_SHORT_VERSION_STRING "${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_REVISION}" + MACOSX_BUNDLE_BUNDLE_VERSION "${VERSION_BUILD}" + OUTPUT_NAME "NxEmu" ) + if(NOT NXEMU_MOLTENVK_RUNTIME OR NOT EXISTS "${_nxemu_sciter_lib}") + message(FATAL_ERROR "The macOS bundle requires MoltenVK and Sciter") + endif() + if(TARGET nxemu_moltenvk) + add_dependencies(nxemu nxemu_moltenvk) + endif() + add_dependencies(nxemu nxemu-loader nxemu-cpu nxemu-video nxemu-os) + find_program(NXEMU_CODESIGN codesign REQUIRED) + set(_nxemu_bundle_script "${CMAKE_SOURCE_DIR}/src/cmake/modules/nxemu_macos_bundle.cmake") + set(_nxemu_bundle_command ${CMAKE_COMMAND} + "-DBUNDLE=$" + "-DLOADER=$" + "-DCPU=$" + "-DVIDEO=$" + "-DOS=$" + "-DMOLTENVK=${NXEMU_MOLTENVK_RUNTIME}" + "-DSCITER=${_nxemu_sciter_lib}" + "-DLANG=${CMAKE_SOURCE_DIR}/lang" + "-DCODESIGN=${NXEMU_CODESIGN}" + "-DINFO_PLIST=${CMAKE_CURRENT_SOURCE_DIR}/macos/Info.plist.in" + "-DICON=${_nxemu_icon}" + "-DEXECUTABLE_NAME=$" + "-DSHORT_VERSION=${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_REVISION}" + "-DBUILD_VERSION=${VERSION_BUILD}" + "-DMINIMUM_MACOS=${CMAKE_OSX_DEPLOYMENT_TARGET}" + -P "${_nxemu_bundle_script}") + # Module-only rebuilds must refresh the app even when its own code did not change. + # LINK_DEPENDS also covers an explicit --target nxemu build. + set_property(TARGET nxemu APPEND PROPERTY LINK_DEPENDS + "$" "$" + "$" "$" + "${NXEMU_MOLTENVK_RUNTIME}" "${_nxemu_sciter_lib}" "${_nxemu_bundle_script}" + "${CMAKE_CURRENT_SOURCE_DIR}/macos/Info.plist.in" "${_nxemu_icon}") + add_custom_command(TARGET nxemu POST_BUILD + COMMAND ${_nxemu_bundle_command} VERBATIM + COMMENT "Package and sign NxEmu.app with this build's modules") + add_custom_target(nxemu_bundle ALL + COMMAND ${_nxemu_bundle_command} + DEPENDS nxemu + VERBATIM + COMMENT "Refresh NxEmu.app runtime and language resources") +else() + if(EXISTS "${_nxemu_sciter_lib}") + add_custom_command(TARGET nxemu POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${_nxemu_sciter_lib}" "$/${_nxemu_sciter_dest}" + VERBATIM) + endif() + if(APPLE) + if(NOT NXEMU_MOLTENVK_RUNTIME) + message(FATAL_ERROR "MoltenVK runtime was not configured") + endif() + if(TARGET nxemu_moltenvk) + add_dependencies(nxemu nxemu_moltenvk) + endif() + add_dependencies(nxemu nxemu-loader nxemu-cpu nxemu-video nxemu-os) + set_property(TARGET nxemu APPEND PROPERTY LINK_DEPENDS + "$" "$" + "$" "$") + add_custom_command(TARGET nxemu POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${NXEMU_MOLTENVK_RUNTIME}" "$/libMoltenVK.dylib" + COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/lang" "$/lang" + COMMAND ${CMAKE_COMMAND} -E make_directory + "$/modules/loader" + "$/modules/cpu" + "$/modules/video" + "$/modules/operating_system" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "$" "$/modules/loader/" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "$" "$/modules/cpu/" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "$" "$/modules/video/" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "$" "$/modules/operating_system/" + VERBATIM) + endif() endif() diff --git a/src/nxemu/applets/profile_select.cpp b/src/nxemu/applets/profile_select.cpp index 050e9e65..97803c95 100644 --- a/src/nxemu/applets/profile_select.cpp +++ b/src/nxemu/applets/profile_select.cpp @@ -52,6 +52,22 @@ void ProfileSelectApplet::Detach() m_parameters = {}; } +void ProfileSelectApplet::DetachAfterWindowDestroyed() +{ + m_sciterUI = nullptr; + m_modules = nullptr; + m_rootElement = {}; + m_parentHwnd = nullptr; + m_window = nullptr; + m_dialogOpen = false; + + std::lock_guard lock(m_mutex); + m_pending = false; + m_userData = nullptr; + m_finished = nullptr; + m_parameters = {}; +} + void ProfileSelectApplet::Close() { ProfileSelectFinishedFn finished = nullptr; diff --git a/src/nxemu/applets/profile_select.h b/src/nxemu/applets/profile_select.h index 1a1e9fba..5b5069a6 100644 --- a/src/nxemu/applets/profile_select.h +++ b/src/nxemu/applets/profile_select.h @@ -18,6 +18,7 @@ public: void Attach(ISciterUI & sciterUI, SystemModules & modules, SciterElement rootElement, void * parentHwnd); void Detach(); + void DetachAfterWindowDestroyed(); // IProfileSelectApplet void Close() override; diff --git a/src/nxemu/macos/Info.plist.in b/src/nxemu/macos/Info.plist.in new file mode 100644 index 00000000..2e008cff --- /dev/null +++ b/src/nxemu/macos/Info.plist.in @@ -0,0 +1,39 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + ${MACOSX_BUNDLE_EXECUTABLE_NAME} + CFBundleIdentifier + org.nxemu + CFBundleIconFile + nxemu.icns + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + NxEmu + CFBundlePackageType + APPL + CFBundleShortVersionString + ${MACOSX_BUNDLE_SHORT_VERSION_STRING} + CFBundleVersion + ${MACOSX_BUNDLE_BUNDLE_VERSION} + LSMinimumSystemVersion + ${CMAKE_OSX_DEPLOYMENT_TARGET} + LSApplicationCategoryType + public.app-category.games + LSSupportsGameMode + + + GCSupportsGameMode + + NSMicrophoneUsageDescription + NxEmu uses the microphone for games that request audio input. + NSLocalNetworkUsageDescription + Games running in NxEmu may use the local network to communicate with other devices. + NSHighResolutionCapable + + + diff --git a/src/nxemu/macos/nxemu.icns b/src/nxemu/macos/nxemu.icns new file mode 100644 index 00000000..f3002466 Binary files /dev/null and b/src/nxemu/macos/nxemu.icns differ diff --git a/src/nxemu/main.cpp b/src/nxemu/main.cpp index 0d0648f6..3d0a68a5 100644 --- a/src/nxemu/main.cpp +++ b/src/nxemu/main.cpp @@ -4,8 +4,12 @@ #include "user_interface/sciter_main_window.h" #include #include +#include #include #include +#ifdef __APPLE__ +#include +#endif #include #include #include @@ -97,6 +101,21 @@ static int RunApplication(const char * arg0) #endif bool res = AppInit(&Notification::GetInstance(), Path(Path::MODULE_DIRECTORY), Common::FS::GetYuzuPathString(Common::FS::YuzuPath::YuzuDir).c_str()); +#ifdef __APPLE__ + const auto exe_dir = Common::FS::GetExeDirectory(); + const bool is_bundle = exe_dir.filename() == "MacOS" && exe_dir.parent_path().filename() == "Contents"; + const bool has_local_runtime = std::filesystem::is_directory(exe_dir / "modules") && + std::filesystem::is_directory(exe_dir / "lang"); + if (res && (is_bundle || has_local_runtime)) { + // A bundle must load its matching modules/resources even when reusing a + // development config. Keep the stored path values unchanged for raw runs. + const auto module_dir = is_bundle ? exe_dir.parent_path() / "Frameworks/modules" : exe_dir / "modules"; + const auto language_dir = is_bundle ? exe_dir.parent_path() / "Resources/lang" : exe_dir / "lang"; + coreSettings.moduleDir = Path(module_dir.string(), ""); + uiSettings.languageDir = Path(language_dir.string(), ""); + } +#endif + if (res && StartupChecks(arg0, &has_broken_vulkan, uiSettings.performVulkanCheck)) { return 0; } diff --git a/src/nxemu/startup_checks.cpp b/src/nxemu/startup_checks.cpp index 75a20d39..b8c26bf1 100644 --- a/src/nxemu/startup_checks.cpp +++ b/src/nxemu/startup_checks.cpp @@ -23,6 +23,8 @@ #include "startup_checks.h" #include "vulkan_probe.h" #include "yuzu_common/common_types.h" +#include "yuzu_common/logging/log.h" +#include "yuzu_common/scope_exit.h" void CheckVulkan() { // Just start the Vulkan loader, this will crash if something is wrong @@ -483,6 +485,98 @@ void PopulateVulkanRecords(std::vector & records, void * renderS { FreeLibrary(library); } +#elif defined(__APPLE__) + records.clear(); + try + { + // Use the same bundled MoltenVK as rendering, with portability enumeration enabled. + const auto library = Vulkan::OpenLibrary(); + Vulkan::vk::InstanceDispatch dispatch; + const auto instance = Vulkan::CreateInstance(*library, dispatch, VK_API_VERSION_1_1, + renderSurface ? Vulkan::WindowSystemType::Metal : Vulkan::WindowSystemType::Headless); + + u32 count = 0; + std::vector devices; + VkResult result; + do + { + Vulkan::vk::Check(dispatch.vkEnumeratePhysicalDevices(*instance, &count, nullptr)); + devices.resize(count); + result = dispatch.vkEnumeratePhysicalDevices(*instance, &count, devices.data()); + } while (result == VK_INCOMPLETE); + Vulkan::vk::Check(result); + devices.resize(count); + + // Match the renderer's device indices: vendor, discrete GPUs, then descending name. + const auto vendor_rank = [](u32 vendor) { + return vendor == 0x10de ? 0 : vendor == 0x1002 ? 1 : vendor == 0x8086 ? 2 : 3; + }; + std::stable_sort(devices.begin(), devices.end(), [&](auto lhs, auto rhs) { + VkPhysicalDeviceProperties a{}, b{}; + dispatch.vkGetPhysicalDeviceProperties(lhs, &a); + dispatch.vkGetPhysicalDeviceProperties(rhs, &b); + if (vendor_rank(a.vendorID) != vendor_rank(b.vendorID)) + return vendor_rank(a.vendorID) < vendor_rank(b.vendorID); + const bool a_discrete = a.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU; + const bool b_discrete = b.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU; + if (a_discrete != b_discrete) + return a_discrete; + return std::string_view{a.deviceName} > std::string_view{b.deviceName}; + }); + + const auto get_proc = [&](const char* name) { + return dispatch.vkGetInstanceProcAddr(*instance, name); + }; + const auto create_surface = reinterpret_cast(get_proc("vkCreateMetalSurfaceEXT")); + const auto destroy_surface = reinterpret_cast(get_proc("vkDestroySurfaceKHR")); + const auto get_present_modes = reinterpret_cast( + get_proc("vkGetPhysicalDeviceSurfacePresentModesKHR")); + VkSurfaceKHR surface = VK_NULL_HANDLE; + SCOPE_EXIT { + if (surface != VK_NULL_HANDLE && destroy_surface) + destroy_surface(*instance, surface, nullptr); + }; + if (renderSurface && create_surface && destroy_surface) + { + VkMetalSurfaceCreateInfoEXT info{}; + info.sType = VK_STRUCTURE_TYPE_METAL_SURFACE_CREATE_INFO_EXT; + info.pLayer = static_cast(renderSurface); + const auto status = create_surface(*instance, &info, nullptr, &surface); + if (status != VK_SUCCESS) + LOG_WARNING(Render_Vulkan, "Device probe could not create a Metal surface: {}", static_cast(status)); + } + records.reserve(devices.size()); + for (const auto device : devices) + { + VkPhysicalDeviceProperties properties{}; + dispatch.vkGetPhysicalDeviceProperties(device, &properties); + std::vector present_modes; + if (surface != VK_NULL_HANDLE && get_present_modes) + { + u32 mode_count = 0; + std::vector modes; + VkResult status; + do + { + status = get_present_modes(device, surface, &mode_count, nullptr); + if (status != VK_SUCCESS) break; + modes.resize(mode_count); + status = get_present_modes(device, surface, &mode_count, modes.data()); + } while (status == VK_INCOMPLETE); + if (status == VK_SUCCESS) + present_modes.assign(modes.begin(), modes.begin() + mode_count); + } + records.emplace_back(properties.deviceName, present_modes, false); + LOG_INFO(Render_Vulkan, "Graphics device available: {}", properties.deviceName); + } + if (records.empty()) + LOG_WARNING(Render_Vulkan, "MoltenVK reported no physical devices"); + } + catch (const std::exception& e) + { + records.clear(); + LOG_ERROR(Render_Vulkan, "Failed to enumerate graphics devices: {}", e.what()); + } #else (void)renderSurface; records.clear(); diff --git a/src/nxemu/user_interface/about_dialog.cpp b/src/nxemu/user_interface/about_dialog.cpp index 62347eb7..0bfcb151 100644 --- a/src/nxemu/user_interface/about_dialog.cpp +++ b/src/nxemu/user_interface/about_dialog.cpp @@ -15,7 +15,14 @@ AboutDialog::AboutDialog(ISciterUI & sciterUI) : { } -AboutDialog::~AboutDialog() = default; +AboutDialog::~AboutDialog() +{ + if (m_window != nullptr) + { + m_window->OnDestroySinkRemove(this); + m_window->Destroy(); + } +} void AboutDialog::Display(void * parentWindow) { diff --git a/src/nxemu/user_interface/file_dialogs.cpp b/src/nxemu/user_interface/file_dialogs.cpp index 847de61f..cd1d5d33 100644 --- a/src/nxemu/user_interface/file_dialogs.cpp +++ b/src/nxemu/user_interface/file_dialogs.cpp @@ -21,6 +21,14 @@ #include #endif +#ifdef __APPLE__ +namespace MacOSFileDialogs +{ +bool FileSelect(const char * initialDir, const char * fileFilter, Path & selected); +Path BrowseForDirectory(const char * title); +} // namespace MacOSFileDialogs +#endif + bool FileSelect(void * hwndOwner, const char * initialDir, const char * fileFilter, bool fileMustExist, Path & selected) { #ifdef _WIN32 @@ -59,6 +67,10 @@ bool FileSelect(void * hwndOwner, const char * initialDir, const char * fileFilt } selected = Path(stdstr().FromUTF16(fileName.data()).c_str()); return true; +#elif defined(__APPLE__) + (void)hwndOwner; + (void)fileMustExist; + return MacOSFileDialogs::FileSelect(initialDir, fileFilter, selected); #else return false; #endif @@ -444,6 +456,9 @@ Path BrowseForDirectory(void * parentWindow, const char * title) { CoUninitialize(); } +#elif defined(__APPLE__) + (void)parentWindow; + selected = MacOSFileDialogs::BrowseForDirectory(title); #elif defined(__linux__) const std::string folder = PickDirectory(parentWindow, title); if (!folder.empty()) diff --git a/src/nxemu/user_interface/file_dialogs_macos.mm b/src/nxemu/user_interface/file_dialogs_macos.mm new file mode 100644 index 00000000..66e64af5 --- /dev/null +++ b/src/nxemu/user_interface/file_dialogs_macos.mm @@ -0,0 +1,167 @@ +#include "file_dialogs.h" + +#include + +#import +#import + +#include +#include + +namespace +{ + +NSString * ToNSString(const char * text) +{ + if (text == nullptr || *text == '\0') + { + return nil; + } + return [NSString stringWithUTF8String:text]; +} + +void SetInitialDirectory(NSOpenPanel * panel, const char * initialDir) +{ + NSString * path = ToNSString(initialDir); + if (path == nil) + { + return; + } + + BOOL isDirectory = NO; + if ([[NSFileManager defaultManager] fileExistsAtPath:path isDirectory:&isDirectory] && isDirectory) + { + panel.directoryURL = [NSURL fileURLWithPath:path isDirectory:YES]; + } +} + +NSArray * ContentTypesFromFilter(const char * fileFilter) +{ + if (fileFilter == nullptr || *fileFilter == '\0') + { + return nil; + } + + const char * pattern = fileFilter + std::strlen(fileFilter) + 1; + if (*pattern == '\0') + { + return nil; + } + + NSMutableArray * types = [NSMutableArray array]; + std::string patterns(pattern); + size_t start = 0; + + while (start <= patterns.size()) + { + const size_t end = patterns.find(';', start); + std::string entry = patterns.substr(start, end == std::string::npos ? std::string::npos : end - start); + + if (entry == "*" || entry == "*.*") + { + return nil; + } + + if (entry.rfind("*.", 0) == 0) + { + entry.erase(0, 2); + } + else if (!entry.empty() && entry.front() == '.') + { + entry.erase(0, 1); + } + + if (!entry.empty() && entry.find_first_of("*?") == std::string::npos) + { + NSString * extension = [NSString stringWithUTF8String:entry.c_str()]; + if (extension != nil) + { + UTType * type = [UTType typeWithFilenameExtension:extension]; + if (type != nil && ![types containsObject:type]) + { + [types addObject:type]; + } + } + } + + if (end == std::string::npos) + { + break; + } + start = end + 1; + } + + return types.count == 0 ? nil : types; +} + +} // namespace + +namespace MacOSFileDialogs +{ + +bool FileSelect(const char * initialDir, const char * fileFilter, Path & selected) +{ + @autoreleasepool + { + NSOpenPanel * panel = [NSOpenPanel openPanel]; + panel.canChooseFiles = YES; + panel.canChooseDirectories = NO; + panel.allowsMultipleSelection = NO; + panel.resolvesAliases = YES; + + SetInitialDirectory(panel, initialDir); + NSArray * contentTypes = ContentTypesFromFilter(fileFilter); + if (contentTypes != nil) + { + panel.allowedContentTypes = contentTypes; + } + + if ([panel runModal] != NSModalResponseOK) + { + return false; + } + + NSURL * url = panel.URL; + if (url == nil || !url.fileURL) + { + return false; + } + + selected = Path(url.path.fileSystemRepresentation); + return true; + } +} + +Path BrowseForDirectory(const char * title) +{ + @autoreleasepool + { + NSOpenPanel * panel = [NSOpenPanel openPanel]; + panel.canChooseFiles = NO; + panel.canChooseDirectories = YES; + panel.allowsMultipleSelection = NO; + panel.canCreateDirectories = YES; + panel.resolvesAliases = YES; + + NSString * message = ToNSString(title); + if (message != nil) + { + panel.message = message; + } + + if ([panel runModal] != NSModalResponseOK) + { + return {}; + } + + NSURL * url = panel.URL; + if (url == nil || !url.fileURL) + { + return {}; + } + + return Path(url.path.fileSystemRepresentation, ""); + } +} + +} // namespace MacOSFileDialogs diff --git a/src/nxemu/user_interface/render_window_macos.h b/src/nxemu/user_interface/render_window_macos.h new file mode 100644 index 00000000..f4c825e0 --- /dev/null +++ b/src/nxemu/user_interface/render_window_macos.h @@ -0,0 +1,9 @@ +#pragma once + +void NxEmuMacOSApplyWindowStyle(const void * nativeWindow); +void * NxEmuMacOSCreateRenderView(const void * nativeWindow); +void NxEmuMacOSDestroyRenderView(void * renderView); +void * NxEmuMacOSGetRenderSurface(void * renderView); +void NxEmuMacOSLayoutRenderView(void * renderView, int x, int y, int width, int height); +void NxEmuMacOSSetRenderViewVisible(void * renderView, bool visible); +float NxEmuMacOSWindowScale(const void * nativeWindow); diff --git a/src/nxemu/user_interface/render_window_macos.mm b/src/nxemu/user_interface/render_window_macos.mm new file mode 100644 index 00000000..6db1b8a8 --- /dev/null +++ b/src/nxemu/user_interface/render_window_macos.mm @@ -0,0 +1,171 @@ +#import +#import + +#include "render_window_macos.h" + +@interface NxEmuMetalRenderView : NSView +- (void)updateDrawableSize; +@end + +@implementation NxEmuMetalRenderView + +- (BOOL)wantsUpdateLayer +{ + return YES; +} + +- (CALayer *)makeBackingLayer +{ + return [CAMetalLayer layer]; +} + +- (void)updateDrawableSize +{ + CAMetalLayer * metalLayer = (CAMetalLayer *)self.layer; + if (metalLayer == nil) + { + return; + } + + const CGFloat scale = self.window != nil ? self.window.backingScaleFactor : 1.0; + // Retina sizes the presentation surface only. Guest render targets are + // scaled separately by the video resolution setting; do not multiply that + // setting by backingScaleFactor or force the layer to 1x. + metalLayer.contentsScale = scale; + metalLayer.drawableSize = [self convertRectToBacking:self.bounds].size; +} + +- (void)viewDidMoveToWindow +{ + [super viewDidMoveToWindow]; + [self updateDrawableSize]; +} + +- (void)viewDidChangeBackingProperties +{ + [super viewDidChangeBackingProperties]; + [self updateDrawableSize]; +} + +- (void)setFrameSize:(NSSize)newSize +{ + [super setFrameSize:newSize]; + [self updateDrawableSize]; +} + +- (NSView *)hitTest:(NSPoint)point +{ + (void)point; + return nil; +} + +@end + +static NSWindow * NxEmuGetSciterWindow(const void * nativeWindowHandle) +{ + return (NSWindow *)const_cast(nativeWindowHandle); +} + +void NxEmuMacOSApplyWindowStyle(const void * nativeWindowHandle) +{ + NSWindow * window = NxEmuGetSciterWindow(nativeWindowHandle); + if (window == nil) + { + return; + } + + window.titleVisibility = NSWindowTitleVisible; + window.titlebarAppearsTransparent = NO; + window.movableByWindowBackground = NO; + window.hasShadow = YES; + // A standard main window must have an opaque background, including the + // status bar. A clear background can retain per-pixel click-through regions. + window.opaque = YES; + window.backgroundColor = [NSColor windowBackgroundColor]; + window.tabbingMode = NSWindowTabbingModeDisallowed; + + window.appearance = [NSAppearance appearanceNamed:NSAppearanceNameDarkAqua]; + + if (@available(macOS 14.0, *)) + { + [NSApp activate]; + } + else + { + [NSApp activateIgnoringOtherApps:YES]; + } + [window makeKeyAndOrderFront:nil]; +} + +void * NxEmuMacOSCreateRenderView(const void * nativeWindowHandle) +{ + NSWindow * window = NxEmuGetSciterWindow(nativeWindowHandle); + NSView * parentView = window != nil ? window.contentView : nil; + if (parentView == nil) + { + return nullptr; + } + + NxEmuMetalRenderView * renderView = [[NxEmuMetalRenderView alloc] initWithFrame:NSZeroRect]; + renderView.wantsLayer = YES; + + CAMetalLayer * metalLayer = (CAMetalLayer *)renderView.layer; + if (metalLayer == nil) + { + [renderView release]; + return nullptr; + } + + metalLayer.opaque = YES; + renderView.hidden = YES; + [parentView addSubview:renderView positioned:NSWindowAbove relativeTo:nil]; + [renderView updateDrawableSize]; + return renderView; +} + +void NxEmuMacOSDestroyRenderView(void * renderViewHandle) +{ + NSView * renderView = (NSView *)renderViewHandle; + if (renderView == nil) + { + return; + } + + [renderView removeFromSuperview]; + [renderView release]; +} + +void * NxEmuMacOSGetRenderSurface(void * renderViewHandle) +{ + NSView * renderView = (NSView *)renderViewHandle; + return renderView != nil ? (void *)renderView.layer : nullptr; +} + +void NxEmuMacOSLayoutRenderView(void * renderViewHandle, int x, int y, int width, int height) +{ + NxEmuMetalRenderView * renderView = (NxEmuMetalRenderView *)renderViewHandle; + if (renderView == nil || renderView.superview == nil || width <= 0 || height <= 0) + { + return; + } + + const NSRect parentBounds = renderView.superview.bounds; + const CGFloat originY = renderView.superview.isFlipped ? y : parentBounds.size.height - y - height; + renderView.frame = NSMakeRect(x, originY, width, height); + [renderView updateDrawableSize]; +} + +void NxEmuMacOSSetRenderViewVisible(void * renderViewHandle, bool visible) +{ + NSView * renderView = (NSView *)renderViewHandle; + if (renderView != nil) + { + renderView.hidden = !visible; + } +} + +float NxEmuMacOSWindowScale(const void * nativeWindowHandle) +{ + NSWindow * window = NxEmuGetSciterWindow(nativeWindowHandle); + return window != nil ? (float)window.backingScaleFactor : 1.0f; +} diff --git a/src/nxemu/user_interface/sciter_main_window.cpp b/src/nxemu/user_interface/sciter_main_window.cpp index 5bae8b49..3b603262 100644 --- a/src/nxemu/user_interface/sciter_main_window.cpp +++ b/src/nxemu/user_interface/sciter_main_window.cpp @@ -10,6 +10,9 @@ #include "user_interface/html_utils.h" #include "user_interface/key_mappings.h" #include "user_interface/notification.h" +#ifdef __APPLE__ +#include "user_interface/render_window_macos.h" +#endif #include #include #include @@ -364,6 +367,16 @@ const std::string * SciterMainWindow::MenuIconSvg(GuiAction action) SciterMainWindow::~SciterMainWindow() { + if (m_window != nullptr) + { + m_window->OnCloseSinkRemove(this); + m_window->OnDestroySinkRemove(this); + if (m_menuBar) + { + m_menuBar->RemoveSink(this); + } + } + Notification::GetInstance().ClearSciterContext(); m_ProfileSelect.Detach(); m_WebBrowser.DetachWindow(); @@ -388,19 +401,45 @@ SciterMainWindow::~SciterMainWindow() settings.UnregisterCallback(NXUISetting::HideMouseOnInactivity, SciterMainWindow::SettingChanged, this); settings.UnregisterCallback(NXUISetting::EnableDiscordPresence, SciterMainWindow::SettingChanged, this); + if (m_rootElement.IsValid()) + { + m_rootElement.SetTimer(0, (uint32_t *)TIMER_UPDATE_INSTALL_FIRMWARE); + } if (m_firmwareInstallThread.joinable()) { m_firmwareInstallThread.join(); } m_systemConfig.reset(nullptr); m_inputConfig.reset(nullptr); + m_gameConfig.reset(nullptr); + m_aboutDialog.reset(nullptr); settings.SetBool(NXCoreSetting::ShuttingDown, true); if (m_romBrowser) + { + // Stop launches a fresh library scan. A native close has already + // cleared m_rootElement, but that worker must still be joined before + // its loader module is destroyed. + m_romBrowser->StopScan(); + m_romBrowser->SetMainWindow(nullptr, nullptr); + } + if (m_romBrowser && m_rootElement.IsValid()) { m_romBrowser->ClearItems(); } m_modules.ShutDown(); +#ifdef __APPLE__ + NxEmuMacOSDestroyRenderView(m_renderWindow); + m_renderWindow = nullptr; +#endif + if (m_window != nullptr) + { + // File -> Exit stops the loop without a native close notification. Close + // while this owner is alive, so shutdown cannot dispatch into dead sinks. + m_window->Destroy(); + m_window = nullptr; + m_rootElement = {}; + } } void SciterMainWindow::RegisterApplets() @@ -615,11 +654,20 @@ bool SciterMainWindow::Show() WINDOW_WIDTH = 760, }; - if (!m_sciterUI.WindowCreate(nullptr, "main_window.html", 0, 0, WINDOW_WIDTH, WINDOW_HEIGHT, SUIW_MAIN | SUIW_HIDDEN, m_window)) +#ifdef __APPLE__ + constexpr const char * mainDocument = "main_window_macos.html"; +#else + constexpr const char * mainDocument = "main_window.html"; +#endif + if (!m_sciterUI.WindowCreate(nullptr, mainDocument, 0, 0, WINDOW_WIDTH, WINDOW_HEIGHT, SUIW_MAIN | SUIW_HIDDEN, m_window)) { return false; } m_rootElement = m_window->GetRootElement(); +#ifdef __APPLE__ + NxEmuMacOSApplyWindowStyle(m_window->GetHandle()); + m_sciterUI.UpdateWindow(m_rootElement.GetElementHwnd(true)); +#endif m_sciterUI.AttachHandler(m_rootElement, IID_IKEYSINK, (IKeySink *)this); m_sciterUI.AttachHandler(m_rootElement, IID_EVENTSINK, (IEventSink *)this); m_window->OnCloseSinkAdd(this); @@ -654,7 +702,12 @@ bool SciterMainWindow::Show() UpdateDiscordPresence(); m_sciterUI.AttachHandler(m_rootElement.GetElementByID("dockedMode"), IID_ICLICKSINK, (IClickSink *)this); +#ifdef _WIN32 + // NXEmu's OpenGL context implementation uses WGL. Other platforms + // currently provide Vulkan contexts only. + SciterElement(m_rootElement.GetElementByID("renderer")).AddClassName("interactive"); m_sciterUI.AttachHandler(m_rootElement.GetElementByID("renderer"), IID_ICLICKSINK, (IClickSink *)this); +#endif m_sciterUI.AttachHandler(m_rootElement.GetElementByID("gpuCommandSynchronization"), IID_ICLICKSINK, (IClickSink *)this); m_sciterUI.AttachHandler(m_rootElement.GetElementByID("volume"), IID_ICLICKSINK, (IClickSink *)this); m_sciterUI.AttachHandler(m_rootElement.GetElementByID("volumePopupBtn"), IID_ICLICKSINK, (IClickSink *)this); @@ -734,6 +787,12 @@ void SciterMainWindow::LoadGame(const char * path, int32_t program_index, Applic m_romBrowser->SetMainWindow(this, nullptr); } + // Configuration pages retain controller and loader interfaces from the + // current modules. Retire them before Setup replaces those modules, even + // when a game is launched while a child window is still open. + m_inputConfig.reset(); + m_systemConfig.reset(); + m_gameConfig.reset(); m_modules.Setup(*this); if (!m_modules.IsValid()) { @@ -893,6 +952,9 @@ void SciterMainWindow::CreateRenderWindow() m_renderWindow = CreateWindowExW(0, L"Static", L"", WS_CHILD | WS_VISIBLE | WS_CLIPSIBLINGS, rect.left, rect.top, width, height, (HWND)m_window->GetHandle(), nullptr, GetModuleHandle(nullptr), nullptr); ShowWindow((HWND)m_renderWindow, SW_HIDE); +#elif defined(__APPLE__) + m_renderWindow = NxEmuMacOSCreateRenderView(m_window->GetHandle()); + NxEmuMacOSLayoutRenderView(m_renderWindow, rect.left, rect.top, width, height); #else (void)width; (void)height; @@ -1031,6 +1093,8 @@ void SciterMainWindow::EmulationRunning(const char * /*setting*/, void * userDat { #ifdef _WIN32 DestroyWindow((HWND)impl->m_renderWindow); +#elif defined(__APPLE__) + NxEmuMacOSDestroyRenderView(impl->m_renderWindow); #endif impl->m_renderWindow = nullptr; } @@ -1071,6 +1135,8 @@ void SciterMainWindow::ShowPanel(Panel panel) } #ifdef _WIN32 ShowWindow((HWND)m_renderWindow, panel == Panel::Renderer ? SW_SHOW : SW_HIDE); +#elif defined(__APPLE__) + NxEmuMacOSSetRenderViewVisible(m_renderWindow, panel == Panel::Renderer); #endif m_sciterUI.UpdateWindow(m_rootElement.GetElementHwnd(true)); } @@ -1849,12 +1915,13 @@ void SciterMainWindow::OnRecetGame(uint32_t fileIndex) void SciterMainWindow::OnWindowDestroy(HWINDOW /*hWnd*/) { - m_ProfileSelect.Detach(); + m_ProfileSelect.DetachAfterWindowDestroyed(); m_WebBrowser.DetachWindow(); if (m_rootElement.IsValid()) { m_rootElement.SetTimer(0, (uint32_t *)TIMER_UPDATE_INSTALL_FIRMWARE); } + m_window = nullptr; m_rootElement = {}; m_sciterUI.Stop(); } @@ -1961,7 +2028,11 @@ void SciterMainWindow::OnGuiAction(GuiAction action) void * SciterMainWindow::RenderSurface() const { +#ifdef __APPLE__ + return NxEmuMacOSGetRenderSurface(m_renderWindow); +#else return m_renderWindow; +#endif } float SciterMainWindow::PixelRatio() const @@ -2000,6 +2071,9 @@ float SciterMainWindow::PixelRatio() const return static_cast(dpi) / static_cast(USER_DEFAULT_SCREEN_DPI); } } +#endif +#ifdef __APPLE__ + return NxEmuMacOSWindowScale(m_window != nullptr ? m_window->GetHandle() : nullptr); #endif return 1.0f; } @@ -2118,6 +2192,8 @@ void SciterMainWindow::LayoutRenderWindow() uint32_t height = rect.bottom - rect.top; #ifdef _WIN32 MoveWindow((HWND)m_renderWindow, rect.left, rect.top, width, height, false); +#elif defined(__APPLE__) + NxEmuMacOSLayoutRenderView(m_renderWindow, rect.left, rect.top, width, height); #else (void)width; (void)height; @@ -2400,6 +2476,7 @@ bool SciterMainWindow::OnClick(SCITER_ELEMENT element, SCITER_ELEMENT source, ui } else if (element == rootElement.GetElementByID("renderer")) { +#ifdef _WIN32 SettingsStore & settings = SettingsStore::GetInstance(); RendererBackend graphicsAPI = (RendererBackend)settings.GetInt(NXVideoSetting::GraphicsAPI); if (graphicsAPI == RendererBackend::Vulkan) @@ -2417,6 +2494,7 @@ bool SciterMainWindow::OnClick(SCITER_ELEMENT element, SCITER_ELEMENT source, ui { m_modules.FlushSettings(); } +#endif } else if (source == rootElement.GetElementByID("gpuCommandSynchronization")) { diff --git a/src/nxemu/user_interface/settings/game_config.cpp b/src/nxemu/user_interface/settings/game_config.cpp index dbd97038..84207c39 100644 --- a/src/nxemu/user_interface/settings/game_config.cpp +++ b/src/nxemu/user_interface/settings/game_config.cpp @@ -58,7 +58,19 @@ GameConfig::GameConfig(ISciterUI & sciterUI, SystemModules & modules) : { } -GameConfig::~GameConfig() = default; +GameConfig::~GameConfig() +{ + if (m_window != nullptr) + { + // Replacing a dialog must retire its callbacks before its owner dies. + if (m_revertAddonsOnClose && m_gameConfigAddons) + { + m_gameConfigAddons->RevertSetting(); + } + m_window->OnDestroySinkRemove(this); + m_window->Destroy(); + } +} void GameConfig::Display(void * parentWindow, const char * gamePath) { @@ -273,7 +285,6 @@ bool GameConfig::OnClick(SCITER_ELEMENT element, SCITER_ELEMENT /*source*/, uint if (m_window != nullptr) { m_window->Destroy(); - m_window = nullptr; } } return true; diff --git a/src/nxemu/user_interface/settings/input_config.cpp b/src/nxemu/user_interface/settings/input_config.cpp index da15c9d9..ea7df9da 100644 --- a/src/nxemu/user_interface/settings/input_config.cpp +++ b/src/nxemu/user_interface/settings/input_config.cpp @@ -24,6 +24,17 @@ InputConfig::InputConfig(ISciterUI & SciterUI, SystemModules & modules) : InputConfig::~InputConfig() { + if (m_pageNav) + { + m_pageNav->RemoveSink(this); + } + if (m_window != nullptr) + { + m_window->OnCloseSinkRemove(this); + m_window->OnDestroySinkRemove(this); + m_window->Destroy(); + m_window = nullptr; + } ShutdownPlayers(); if (m_inputDeviceList != nullptr) { @@ -157,6 +168,11 @@ void InputConfig::OnWindowDestroy(HWINDOW hWnd) { return; } + if (m_pageNav) + { + m_pageNav->RemoveSink(this); + m_pageNav.reset(); + } ShutdownPlayers(); if (m_inputDeviceList != nullptr) { diff --git a/src/nxemu/user_interface/settings/system_config.cpp b/src/nxemu/user_interface/settings/system_config.cpp index 98df6006..c04d0c21 100644 --- a/src/nxemu/user_interface/settings/system_config.cpp +++ b/src/nxemu/user_interface/settings/system_config.cpp @@ -34,6 +34,35 @@ SystemConfig::SystemConfig(ISciterUI & SciterUI, SystemModules & modules, std::v SystemConfig::~SystemConfig() { + if (m_pageNav) + { + m_pageNav->RemoveSink(this); + } + if (m_window != nullptr) + { + m_window->OnDestroySinkRemove(this); + m_window->Destroy(); + } +} + +void SystemConfig::OnWindowDestroy(HWINDOW hWnd) +{ + if (m_window == nullptr || m_window->GetHandle() != hWnd) + { + return; + } + m_window = nullptr; + if (m_pageNav) + { + m_pageNav->RemoveSink(this); + m_pageNav.reset(); + } + m_systemConfigGraphics.reset(); + m_systemConfigAudio.reset(); + m_systemConfigGeneral.reset(); + m_systemConfigGameBrowser.reset(); + m_systemConfigProfiles.reset(); + m_systemConfigSystemTab.reset(); } void SystemConfig::Display(void * parentWindow, const char * startPage) @@ -57,6 +86,7 @@ void SystemConfig::Display(void * parentWindow, const char * startPage) { return; } + m_window->OnDestroySinkAdd(this); SciterElement root(m_window->GetRootElement()); if (root.IsValid()) { @@ -408,7 +438,9 @@ void SystemConfig::InitializeTranslations() } m_settingTranslations.insert({ TranslationType::RendererBackend, { +#ifdef _WIN32 {(uint32_t)RendererBackend::OpenGL, "OpenGL"}, +#endif {(uint32_t)RendererBackend::Vulkan, "Vulkan"}, {(uint32_t)RendererBackend::Null, "Null"}, }}); diff --git a/src/nxemu/user_interface/settings/system_config.h b/src/nxemu/user_interface/settings/system_config.h index fa403247..53c47b9b 100644 --- a/src/nxemu/user_interface/settings/system_config.h +++ b/src/nxemu/user_interface/settings/system_config.h @@ -21,7 +21,8 @@ class SystemModules; class SystemConfig : public IPagesSink, - public IClickSink + public IClickSink, + public IWindowDestroySink { public: enum class TranslationType : uint32_t @@ -70,6 +71,7 @@ public: //IClickSink bool OnClick(SCITER_ELEMENT element, SCITER_ELEMENT source, uint32_t reason) override; + void OnWindowDestroy(HWINDOW hWnd) override; private: SystemConfig() = delete; diff --git a/src/nxemu/user_interface/widgets/rom_browser.cpp b/src/nxemu/user_interface/widgets/rom_browser.cpp index c9ac37f8..a7a5b3c6 100644 --- a/src/nxemu/user_interface/widgets/rom_browser.cpp +++ b/src/nxemu/user_interface/widgets/rom_browser.cpp @@ -71,6 +71,7 @@ class WidgetRomBrowser : public ITimerSink, public IClickSink, public IDoubleClickSink, + public IContextMenuSink, public IRomBrowser { friend class RomListWorker; @@ -89,6 +90,8 @@ public: // IClickSink bool OnClick(SCITER_ELEMENT element, SCITER_ELEMENT source, uint32_t reason) override; + bool OnContextMenu(SCITER_ELEMENT element, SCITER_ELEMENT source, int32_t x, int32_t y) override; + // IDoubleClickSink bool OnDoubleClick(SCITER_ELEMENT element, SCITER_ELEMENT source) override; @@ -276,16 +279,32 @@ bool WidgetRomBrowser::OnEvent(SCITER_ELEMENT element, SCITER_ELEMENT source, ui if (event_code == (uint32_t)SciterBehaviorEvent::ContextMenuRequest) { SciterElement card = FindRomCard(source); + if (!card.IsValid()) card = FindRomCard(element); if (card.IsValid()) { - SelectRomCard(card); - m_contextMenuPath = card.GetAttribute("data-path"); + const auto bounds = card.GetLocation(SciterElement::VIEW_RELATIVE | SciterElement::BORDER_BOX); + return OnContextMenu(element, source, bounds.left, bounds.bottom); } return false; } return false; } +bool WidgetRomBrowser::OnContextMenu(SCITER_ELEMENT element, SCITER_ELEMENT source, int32_t x, int32_t y) +{ + SciterElement card = FindRomCard(source); + if (!card.IsValid()) card = FindRomCard(element); + if (!card.IsValid()) return false; + SciterElement menu(m_rootElement.GetRoot().GetElementByID("RomCardContextMenu")); + if (!menu.IsValid()) return false; + SelectRomCard(card); + m_contextMenuPath = card.GetAttribute("data-path"); + // Placement 7 anchors the top-left at a point in view coordinates. The + // macOS backend uses Cocoa's context menu and native cursor coordinates. + m_sciterUI.PopupShowAt(menu, {x, y}, 7); + return true; +} + bool WidgetRomBrowser::OnTimer(SCITER_ELEMENT /*element*/, uint32_t * timerId) { if (timerId == (uint32_t *)TIMER_UPDATE_UI) @@ -491,6 +510,7 @@ void WidgetRomBrowser::Attached(SCITER_ELEMENT element, IBaseElement * baseEleme m_rootElement = element; m_sciterUI.AttachHandler(m_rootElement, IID_EVENTSINK, (IEventSink *)this); m_sciterUI.AttachHandler(m_rootElement, IID_ITIMERSINK, (ITimerSink *)this); + m_sciterUI.AttachHandler(m_rootElement, IID_ICONTEXTMENUSINK, (IContextMenuSink *)this); AttachContextMenuHandlers(); SettingsStore & settings = SettingsStore::GetInstance(); @@ -713,4 +733,4 @@ bool RomListWorker::HasSupportedFileExtension(const std::string & file_name) bool Register_WidgetRomBrowser(ISciterUI & sciterUI) { return WidgetRomBrowser::Register(sciterUI); -} \ No newline at end of file +} diff --git a/src/nxemu/vulkan_probe.cpp b/src/nxemu/vulkan_probe.cpp index 2086de58..febd4003 100644 --- a/src/nxemu/vulkan_probe.cpp +++ b/src/nxemu/vulkan_probe.cpp @@ -5,8 +5,8 @@ // sandbox. Enough for CheckVulkan (headless OpenLibrary + CreateInstance). #include -#include #include +#include #include #include #include @@ -22,6 +22,20 @@ #include namespace Vulkan { +namespace { + +#ifdef __APPLE__ +std::filesystem::path AppleVulkanLibraryPath() { + const auto exe_dir = Common::FS::GetExeDirectory(); + if (exe_dir.filename() == "MacOS" && exe_dir.parent_path().filename() == "Contents") { + return exe_dir.parent_path() / "Frameworks/libMoltenVK.dylib"; + } + return exe_dir.empty() ? std::filesystem::path{} : exe_dir / "libMoltenVK.dylib"; +} +#endif + +} // namespace + namespace vk { namespace { @@ -174,6 +188,11 @@ namespace { switch (window_type) { case WindowSystemType::Headless: break; +#ifdef __APPLE__ + case WindowSystemType::Metal: + extensions.push_back(VK_EXT_METAL_SURFACE_EXTENSION_NAME); + break; +#endif } if (window_type != WindowSystemType::Headless) { extensions.push_back(VK_KHR_SURFACE_EXTENSION_NAME); @@ -223,15 +242,12 @@ std::shared_ptr OpenLibrary() { LOG_DEBUG(Render_Vulkan, "Looking for a Vulkan library"); auto library = std::make_shared(); #ifdef __APPLE__ - const auto libvulkan_filename = - Common::FS::GetBundleDirectory() / "Contents/Frameworks/libvulkan.1.dylib"; - const auto libmoltenvk_filename = - Common::FS::GetBundleDirectory() / "Contents/Frameworks/libMoltenVK.dylib"; - const char* library_paths[] = {std::getenv("LIBVULKAN_PATH"), libvulkan_filename.c_str(), - libmoltenvk_filename.c_str()}; - for (const auto& library_path : library_paths) { - if (library_path && library->Open(library_path)) { - break; + const auto library_path = AppleVulkanLibraryPath(); + if (!library_path.empty()) { + const std::string path = library_path.string(); + LOG_DEBUG(Render_Vulkan, "Trying Vulkan library: {}", path); + if (library->Open(path.c_str())) { + LOG_INFO(Render_Vulkan, "Loaded Vulkan library: {}", path); } } #else diff --git a/src/nxemu/vulkan_probe.h b/src/nxemu/vulkan_probe.h index be96d4f1..f41b7a99 100644 --- a/src/nxemu/vulkan_probe.h +++ b/src/nxemu/vulkan_probe.h @@ -38,6 +38,9 @@ namespace Vulkan { // matching CreateInstance's default in vulkan_instance.h. enum class WindowSystemType { Headless, +#ifdef __APPLE__ + Metal, +#endif }; namespace vk { diff --git a/src/script/build_macos.sh b/src/script/build_macos.sh new file mode 100644 index 00000000..87ccde45 --- /dev/null +++ b/src/script/build_macos.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu + +configuration="${1:-Release}" +build_directory="${2:-build}" +jobs="${NXEMU_BUILD_JOBS:-2}" + +cd "$(dirname "$0")/../.." + +cmake -S . -B "$build_directory" -G Ninja \ + -DCMAKE_BUILD_TYPE="$configuration" \ + -DCMAKE_OSX_ARCHITECTURES=arm64 \ + -DNXEMU_MACOS_BUNDLE=ON +cmake --build "$build_directory" --target nxemu_bundle --parallel "$jobs" diff --git a/src/script/package_dmg_macos.sh b/src/script/package_dmg_macos.sh new file mode 100644 index 00000000..b1453d86 --- /dev/null +++ b/src/script/package_dmg_macos.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -eu + +dmg_file="${1:-NxEmu-macos-arm64.dmg}" +configuration="${2:-Release}" + +cd "$(dirname "$0")/../.." + +app="bin/arm64/$configuration/NxEmu.app" +codesign --verify --deep --strict "$app" + +staging_directory="$(mktemp -d "${TMPDIR:-/tmp}/nxemu-dmg.XXXXXX")" +trap 'rm -rf "$staging_directory"' EXIT +trap 'exit 1' HUP INT TERM + +ditto "$app" "$staging_directory/NxEmu.app" +ln -s /Applications "$staging_directory/Applications" + +mkdir -p package +hdiutil create -volname NxEmu -srcfolder "$staging_directory" \ + -fs HFS+ -format UDZO -ov "package/$dmg_file" diff --git a/src/yuzu_audio_core/CMakeLists.txt b/src/yuzu_audio_core/CMakeLists.txt index 44166a04..35880211 100644 --- a/src/yuzu_audio_core/CMakeLists.txt +++ b/src/yuzu_audio_core/CMakeLists.txt @@ -18,6 +18,12 @@ list(FILTER YUZU_AUDIO_CORE_SOURCES EXCLUDE REGEX ".*/oboe_sink\\.cpp$") add_library(yuzu_audio_core STATIC ${YUZU_AUDIO_CORE_SOURCES}) +if(APPLE AND ENABLE_SDL2) + target_sources(yuzu_audio_core PRIVATE sink/sdl2_sink.cpp) + target_compile_definitions(yuzu_audio_core PRIVATE HAVE_SDL2) + target_link_libraries(yuzu_audio_core PRIVATE SDL2::SDL2) +endif() + target_include_directories(yuzu_audio_core PUBLIC ${CMAKE_SOURCE_DIR}/src ${CMAKE_SOURCE_DIR}/src/nxemu-os diff --git a/src/yuzu_common/fs/path_util.cpp b/src/yuzu_common/fs/path_util.cpp index f1d07ce1..d4206d6e 100644 --- a/src/yuzu_common/fs/path_util.cpp +++ b/src/yuzu_common/fs/path_util.cpp @@ -106,6 +106,13 @@ public: #else const fs::path exe_dir = GetExeDirectory(); yuzu_path = exe_dir.empty() ? fs::path{} : exe_dir / PORTABLE_DIR; +#ifdef __APPLE__ + // A portable user directory belongs beside the .app, outside its signed + // contents. This also preserves data from a previous raw executable. + if (exe_dir.filename() == "MacOS" && exe_dir.parent_path().filename() == "Contents") { + yuzu_path = exe_dir.parent_path().parent_path().parent_path() / PORTABLE_DIR; + } +#endif if (!IsDir(yuzu_path)) { yuzu_path = GetCurrentDir() / PORTABLE_DIR; } diff --git a/src/yuzu_common/host_memory.cpp b/src/yuzu_common/host_memory.cpp index d6f9a912..07a3708b 100644 --- a/src/yuzu_common/host_memory.cpp +++ b/src/yuzu_common/host_memory.cpp @@ -9,7 +9,7 @@ #include #include "yuzu_common/dynamic_library.h" -#elif defined(__linux__) || defined(__FreeBSD__) // ^^^ Windows ^^^ vvv Linux vvv +#elif defined(__linux__) || defined(__FreeBSD__) || defined(__APPLE__) // ^^^ Windows ^^^ vvv POSIX vvv #ifndef _GNU_SOURCE #define _GNU_SOURCE @@ -17,8 +17,13 @@ #include #include #include +#if defined(__linux__) #include +#endif #include +#if defined(__APPLE__) +#include +#endif #if defined(__ANDROID__) #include #endif @@ -27,9 +32,13 @@ #ifndef MAP_NORESERVE #define MAP_NORESERVE 0 #endif +#if defined(__APPLE__) && !defined(MAP_ANONYMOUS) +#define MAP_ANONYMOUS MAP_ANON +#endif -#endif // ^^^ Linux ^^^ +#endif // ^^^ POSIX ^^^ +#include #include #include @@ -41,9 +50,22 @@ namespace Common { -constexpr size_t PageAlignment = 0x1000; +constexpr size_t GuestPageSize = 0x1000; constexpr size_t HugePageSize = 0x200000; +static size_t GetHostPageSize() { +#if defined(__linux__) || defined(__FreeBSD__) || defined(__APPLE__) + static const size_t host_page_size = [] { + const long value = sysconf(_SC_PAGESIZE); + ASSERT_MSG(value > 0, "sysconf(_SC_PAGESIZE) failed"); + return static_cast(value); + }(); + return host_page_size; +#else + return GuestPageSize; +#endif +} + #ifdef _WIN32 // Manually imported for MinGW compatibility @@ -367,9 +389,16 @@ private: std::unordered_map placeholder_host_pointers; ///< Placeholder backing offset }; -#elif defined(__linux__) || defined(__FreeBSD__) // ^^^ Windows ^^^ vvv Linux vvv +#elif defined(__linux__) || defined(__FreeBSD__) || defined(__APPLE__) // ^^^ Windows ^^^ vvv POSIX vvv -#if defined(_M_ARM64) || defined(ARCHITECTURE_arm64) +#if defined(__APPLE__) + +static void* ChooseVirtualBase(size_t virtual_size) { + return mmap(nullptr, virtual_size, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0); +} + +#elif defined(_M_ARM64) || defined(ARCHITECTURE_arm64) static void* ChooseVirtualBase(size_t virtual_size) { constexpr uintptr_t Map39BitSize = (1ULL << 39); @@ -444,21 +473,39 @@ public: } }; - long page_size = sysconf(_SC_PAGESIZE); - if (page_size != 0x1000) { - LOG_CRITICAL(HW_Memory, "page size {:#x} is incompatible with 4K paging", page_size); +#if !defined(__APPLE__) + if (host_page_size != GuestPageSize) { + LOG_CRITICAL(HW_Memory, "page size {:#x} is incompatible with 4K paging", + host_page_size); throw std::bad_alloc{}; } +#endif // Backing memory initialization #if defined(__FreeBSD__) && __FreeBSD__ < 13 // XXX Drop after FreeBSD 12.* reaches EOL on 2024-06-30 fd = shm_open(SHM_ANON, O_RDWR, 0600); +#elif defined(__APPLE__) + // Keep a shared backing store for the page-table path without exposing + // a 4 KiB fastmem arena on 16 KiB hosts. + for (int attempt = 0; attempt < 16; ++attempt) { + char name[32]; + std::snprintf(name, sizeof(name), "/nxemu-%08x%08x", arc4random(), arc4random()); + fd = shm_open(name, O_RDWR | O_CREAT | O_EXCL, 0600); + if (fd >= 0) { + shm_unlink(name); + break; + } + if (errno != EEXIST) { + break; + } + } #else fd = memfd_create("HostMemory", 0); #endif if (fd < 0) { - LOG_CRITICAL(HW_Memory, "memfd_create failed: {}", strerror(errno)); + LOG_CRITICAL(HW_Memory, "Failed to create HostMemory backing file: {}", + strerror(errno)); throw std::bad_alloc{}; } @@ -477,7 +524,20 @@ public: throw std::bad_alloc{}; } - // Virtual memory initialization +#if defined(__APPLE__) + if (host_page_size != GuestPageSize) { + LOG_INFO(HW_Memory, + "Fastmem disabled: host page size {:#x} does not match guest page size {:#x}; " + "using the page-table memory path", + host_page_size, GuestPageSize); + virtual_base = nullptr; + good = true; + return; + } +#endif + + // Virtual memory initialization. This path is only used when the host can represent + // the guest's 4 KiB mappings directly. virtual_base = virtual_map_base = static_cast(ChooseVirtualBase(virtual_size)); if (virtual_base == MAP_FAILED) { LOG_CRITICAL(HW_Memory, "mmap failed: {}", strerror(errno)); @@ -498,6 +558,9 @@ public: void Map(size_t virtual_offset, size_t host_offset, size_t length, MemoryPermission perms) { // Intersect the range with our address space. AdjustMap(&virtual_offset, &length); + if (length == 0) { + return; + } // We are removing a placeholder. free_manager.AllocateBlock(virtual_base + virtual_offset, length); @@ -527,6 +590,9 @@ public: // Intersect the range with our address space. AdjustMap(&virtual_offset, &length); + if (length == 0) { + return; + } // Merge with any adjacent placeholder mappings. auto [merged_pointer, merged_size] = @@ -540,6 +606,9 @@ public: void Protect(size_t virtual_offset, size_t length, bool read, bool write, bool execute) { // Intersect the range with our address space. AdjustMap(&virtual_offset, &length); + if (length == 0) { + return; + } int flags = PROT_NONE; if (read) { @@ -553,6 +622,7 @@ public: flags |= PROT_EXEC; } #endif + int ret = mprotect(virtual_base + virtual_offset, length, flags); ASSERT_MSG(ret == 0, "mprotect failed: {}", strerror(errno)); } @@ -576,6 +646,7 @@ public: const size_t backing_size; ///< Size of the backing memory in bytes const size_t virtual_size; ///< Size of the virtual address placeholder in bytes + const size_t host_page_size{GetHostPageSize()}; u8* backing_base{reinterpret_cast(MAP_FAILED)}; u8* virtual_base{reinterpret_cast(MAP_FAILED)}; @@ -621,11 +692,11 @@ private: } } - int fd{-1}; // memfd file descriptor, -1 is the error value of memfd_create + int fd{-1}; // Shared backing file descriptor; -1 means no file. FreeRegionManager free_manager{}; }; -#else // ^^^ Linux ^^^ vvv Generic vvv +#else // ^^^ POSIX ^^^ vvv Generic vvv class HostMemory::Impl { public: @@ -659,8 +730,9 @@ HostMemory::HostMemory(size_t backing_size_, size_t virtual_size_) // Try to allocate a fastmem arena. // The implementation will fail with std::bad_alloc on errors. impl = - std::make_unique(AlignUp(backing_size, PageAlignment), - AlignUp(virtual_size, PageAlignment) + HugePageSize); + std::make_unique(AlignUp(backing_size, GetHostPageSize()), + AlignUp(virtual_size, GetHostPageSize()) + + HugePageSize); backing_base = impl->backing_base; virtual_base = impl->virtual_base; @@ -688,9 +760,9 @@ HostMemory& HostMemory::operator=(HostMemory&&) noexcept = default; void HostMemory::Map(size_t virtual_offset, size_t host_offset, size_t length, MemoryPermission perms, bool separate_heap) { - ASSERT(virtual_offset % PageAlignment == 0); - ASSERT(host_offset % PageAlignment == 0); - ASSERT(length % PageAlignment == 0); + ASSERT(virtual_offset % GuestPageSize == 0); + ASSERT(host_offset % GuestPageSize == 0); + ASSERT(length % GuestPageSize == 0); ASSERT(virtual_offset + length <= virtual_size); ASSERT(host_offset + length <= backing_size); if (length == 0 || !virtual_base || !impl) { @@ -700,8 +772,8 @@ void HostMemory::Map(size_t virtual_offset, size_t host_offset, size_t length, } void HostMemory::Unmap(size_t virtual_offset, size_t length, bool separate_heap) { - ASSERT(virtual_offset % PageAlignment == 0); - ASSERT(length % PageAlignment == 0); + ASSERT(virtual_offset % GuestPageSize == 0); + ASSERT(length % GuestPageSize == 0); ASSERT(virtual_offset + length <= virtual_size); if (length == 0 || !virtual_base || !impl) { return; @@ -710,8 +782,8 @@ void HostMemory::Unmap(size_t virtual_offset, size_t length, bool separate_heap) } void HostMemory::Protect(size_t virtual_offset, size_t length, MemoryPermission perm) { - ASSERT(virtual_offset % PageAlignment == 0); - ASSERT(length % PageAlignment == 0); + ASSERT(virtual_offset % GuestPageSize == 0); + ASSERT(length % GuestPageSize == 0); ASSERT(virtual_offset + length <= virtual_size); if (length == 0 || !virtual_base || !impl) { return; diff --git a/src/yuzu_common/host_memory.h b/src/yuzu_common/host_memory.h index 7af149ab..5465e0b1 100644 --- a/src/yuzu_common/host_memory.h +++ b/src/yuzu_common/host_memory.h @@ -66,7 +66,8 @@ public: } bool IsInVirtualRange(void* address) const noexcept { - return address >= virtual_base && address < virtual_base + virtual_size; + return virtual_base != nullptr && address >= virtual_base && + address < virtual_base + virtual_size; } private: diff --git a/src/yuzu_common/logging/formatter.h b/src/yuzu_common/logging/formatter.h index 88e55505..16bddde8 100644 --- a/src/yuzu_common/logging/formatter.h +++ b/src/yuzu_common/logging/formatter.h @@ -14,7 +14,7 @@ template struct fmt::formatter, char>> : formatter> { template - auto format(const T& value, FormatContext& ctx) -> decltype(ctx.out()) { + auto format(const T& value, FormatContext& ctx) const -> decltype(ctx.out()) { return fmt::formatter>::format( static_cast>(value), ctx); } diff --git a/src/yuzu_common/settings_setting.h b/src/yuzu_common/settings_setting.h index cc48f893..d33b8754 100644 --- a/src/yuzu_common/settings_setting.h +++ b/src/yuzu_common/settings_setting.h @@ -10,7 +10,7 @@ #include #include #include -#include +#include #include "yuzu_common/common_types.h" #include "yuzu_common/settings_common.h" diff --git a/src/yuzu_common/thread.cpp b/src/yuzu_common/thread.cpp index 9236caa7..f2747c3a 100644 --- a/src/yuzu_common/thread.cpp +++ b/src/yuzu_common/thread.cpp @@ -9,6 +9,7 @@ #include "yuzu_common/thread.h" #ifdef __APPLE__ #include +#include #elif defined(_WIN32) #include #include "yuzu_common/string_util.h" @@ -58,6 +59,31 @@ void SetCurrentThreadPriority(ThreadPriority new_priority) { SetThreadPriority(handle, windows_priority); } +#elif defined(__APPLE__) + +void SetCurrentThreadPriority(ThreadPriority new_priority) { + qos_class_t qos_class = QOS_CLASS_DEFAULT; + switch (new_priority) { + case ThreadPriority::Low: + qos_class = QOS_CLASS_UTILITY; + break; + case ThreadPriority::Normal: + qos_class = QOS_CLASS_DEFAULT; + break; + case ThreadPriority::High: + qos_class = QOS_CLASS_USER_INITIATED; + break; + case ThreadPriority::VeryHigh: + case ThreadPriority::Critical: + qos_class = QOS_CLASS_USER_INTERACTIVE; + break; + } + + if (const int result = pthread_set_qos_class_self_np(qos_class, 0); result != 0) { + LOG_ERROR(Common, "Failed to set thread QoS class: {}", result); + } +} + #else void SetCurrentThreadPriority(ThreadPriority new_priority) { diff --git a/src/yuzu_common/typed_address.h b/src/yuzu_common/typed_address.h index 60fb0a8c..2fab8de7 100644 --- a/src/yuzu_common/typed_address.h +++ b/src/yuzu_common/typed_address.h @@ -257,7 +257,7 @@ struct fmt::formatter { return ctx.begin(); } template - auto format(const Common::PhysicalAddress& addr, FormatContext& ctx) { + auto format(const Common::PhysicalAddress& addr, FormatContext& ctx) const { return fmt::format_to(ctx.out(), "{:#x}", static_cast(addr.GetValue())); } }; @@ -268,7 +268,7 @@ struct fmt::formatter { return ctx.begin(); } template - auto format(const Common::ProcessAddress& addr, FormatContext& ctx) { + auto format(const Common::ProcessAddress& addr, FormatContext& ctx) const { return fmt::format_to(ctx.out(), "{:#x}", static_cast(addr.GetValue())); } }; @@ -279,7 +279,7 @@ struct fmt::formatter { return ctx.begin(); } template - auto format(const Common::VirtualAddress& addr, FormatContext& ctx) { + auto format(const Common::VirtualAddress& addr, FormatContext& ctx) const { return fmt::format_to(ctx.out(), "{:#x}", static_cast(addr.GetValue())); } }; diff --git a/src/yuzu_input_common/drivers/sdl_driver.cpp b/src/yuzu_input_common/drivers/sdl_driver.cpp index 37b8185d..d98e9c6d 100644 --- a/src/yuzu_input_common/drivers/sdl_driver.cpp +++ b/src/yuzu_input_common/drivers/sdl_driver.cpp @@ -491,7 +491,16 @@ void SDLDriver::PumpEvents() const { if (initialized) { +#ifdef __APPLE__ + // Sciter owns the Cocoa event loop; this backend only needs controller + // events. Do not let SDL pump native window events in a host frontend. + SDL_JoystickUpdate(); +#else SDL_PumpEvents(); +#endif + // The event watcher already delivered these inputs. Keep motion events + // from accumulating in SDL's queue during long sessions. + SDL_FlushEvents(SDL_JOYAXISMOTION, SDL_CONTROLLERSENSORUPDATE); } } @@ -622,9 +631,11 @@ SDLDriver::SDLDriver(std::string input_engine_) : // Share the same button mapping with non-Nintendo controllers SDL_SetHint(SDL_HINT_GAMECONTROLLER_USE_BUTTON_LABELS, "0"); - // Disable hidapi driver for xbox. Already default on Windows, this causes conflict with native - // driver on Linux. + // Windows and Linux have native Xbox backends. macOS needs HIDAPI for + // supported USB Xbox controllers, so retain SDL's platform default there. +#ifndef __APPLE__ SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_XBOX, "0"); +#endif // If the frontend is going to manage the event loop, then we don't start one here start_thread = SDL_WasInit(SDL_INIT_JOYSTICK | SDL_INIT_GAMECONTROLLER) == 0; @@ -665,7 +676,10 @@ SDLDriver::~SDLDriver() initialized = false; if (start_thread) { - vibration_thread.join(); + if (vibration_thread.joinable()) + { + vibration_thread.join(); + } SDL_QuitSubSystem(SDL_INIT_JOYSTICK | SDL_INIT_GAMECONTROLLER); } } diff --git a/src/yuzu_shader_recompiler/backend/glasm/reg_alloc.h b/src/yuzu_shader_recompiler/backend/glasm/reg_alloc.h index 943b6ce2..7402ed4f 100644 --- a/src/yuzu_shader_recompiler/backend/glasm/reg_alloc.h +++ b/src/yuzu_shader_recompiler/backend/glasm/reg_alloc.h @@ -184,7 +184,7 @@ struct fmt::formatter { return ctx.begin(); } template - auto format(Shader::Backend::GLASM::Id id, FormatContext& ctx) { + auto format(Shader::Backend::GLASM::Id id, FormatContext& ctx) const { return Shader::Backend::GLASM::FormatTo(ctx, id); } }; @@ -195,7 +195,7 @@ struct fmt::formatter { return ctx.begin(); } template - auto format(const Shader::Backend::GLASM::Register& value, FormatContext& ctx) { + auto format(const Shader::Backend::GLASM::Register& value, FormatContext& ctx) const { if (value.type != Shader::Backend::GLASM::Type::Register) { throw Shader::InvalidArgument("Register value type is not register"); } @@ -209,7 +209,7 @@ struct fmt::formatter { return ctx.begin(); } template - auto format(const Shader::Backend::GLASM::ScalarRegister& value, FormatContext& ctx) { + auto format(const Shader::Backend::GLASM::ScalarRegister& value, FormatContext& ctx) const { if (value.type != Shader::Backend::GLASM::Type::Register) { throw Shader::InvalidArgument("Register value type is not register"); } @@ -223,7 +223,7 @@ struct fmt::formatter { return ctx.begin(); } template - auto format(const Shader::Backend::GLASM::ScalarU32& value, FormatContext& ctx) { + auto format(const Shader::Backend::GLASM::ScalarU32& value, FormatContext& ctx) const { switch (value.type) { case Shader::Backend::GLASM::Type::Void: break; @@ -244,7 +244,7 @@ struct fmt::formatter { return ctx.begin(); } template - auto format(const Shader::Backend::GLASM::ScalarS32& value, FormatContext& ctx) { + auto format(const Shader::Backend::GLASM::ScalarS32& value, FormatContext& ctx) const { switch (value.type) { case Shader::Backend::GLASM::Type::Void: break; @@ -265,7 +265,7 @@ struct fmt::formatter { return ctx.begin(); } template - auto format(const Shader::Backend::GLASM::ScalarF32& value, FormatContext& ctx) { + auto format(const Shader::Backend::GLASM::ScalarF32& value, FormatContext& ctx) const { switch (value.type) { case Shader::Backend::GLASM::Type::Void: break; @@ -286,7 +286,7 @@ struct fmt::formatter { return ctx.begin(); } template - auto format(const Shader::Backend::GLASM::ScalarF64& value, FormatContext& ctx) { + auto format(const Shader::Backend::GLASM::ScalarF64& value, FormatContext& ctx) const { switch (value.type) { case Shader::Backend::GLASM::Type::Void: break; diff --git a/src/yuzu_shader_recompiler/frontend/ir/attribute.h b/src/yuzu_shader_recompiler/frontend/ir/attribute.h index c8926c51..8e0e683c 100644 --- a/src/yuzu_shader_recompiler/frontend/ir/attribute.h +++ b/src/yuzu_shader_recompiler/frontend/ir/attribute.h @@ -250,7 +250,7 @@ struct fmt::formatter { return ctx.begin(); } template - auto format(const Shader::IR::Attribute& attribute, FormatContext& ctx) { + auto format(const Shader::IR::Attribute& attribute, FormatContext& ctx) const { return fmt::format_to(ctx.out(), "{}", Shader::IR::NameOf(attribute)); } }; diff --git a/src/yuzu_shader_recompiler/frontend/ir/condition.h b/src/yuzu_shader_recompiler/frontend/ir/condition.h index 888918ac..f3c6f89f 100644 --- a/src/yuzu_shader_recompiler/frontend/ir/condition.h +++ b/src/yuzu_shader_recompiler/frontend/ir/condition.h @@ -52,7 +52,7 @@ struct fmt::formatter { return ctx.begin(); } template - auto format(const Shader::IR::Condition& cond, FormatContext& ctx) { + auto format(const Shader::IR::Condition& cond, FormatContext& ctx) const { return fmt::format_to(ctx.out(), "{}", Shader::IR::NameOf(cond)); } }; diff --git a/src/yuzu_shader_recompiler/frontend/ir/flow_test.h b/src/yuzu_shader_recompiler/frontend/ir/flow_test.h index 7a84e51c..f6e4e33d 100644 --- a/src/yuzu_shader_recompiler/frontend/ir/flow_test.h +++ b/src/yuzu_shader_recompiler/frontend/ir/flow_test.h @@ -55,7 +55,7 @@ struct fmt::formatter { return ctx.begin(); } template - auto format(const Shader::IR::FlowTest& flow_test, FormatContext& ctx) { + auto format(const Shader::IR::FlowTest& flow_test, FormatContext& ctx) const { return fmt::format_to(ctx.out(), "{}", Shader::IR::NameOf(flow_test)); } }; diff --git a/src/yuzu_shader_recompiler/frontend/ir/opcodes.h b/src/yuzu_shader_recompiler/frontend/ir/opcodes.h index 5896f420..1a359a81 100644 --- a/src/yuzu_shader_recompiler/frontend/ir/opcodes.h +++ b/src/yuzu_shader_recompiler/frontend/ir/opcodes.h @@ -103,7 +103,7 @@ struct fmt::formatter { return ctx.begin(); } template - auto format(const Shader::IR::Opcode& op, FormatContext& ctx) { + auto format(const Shader::IR::Opcode& op, FormatContext& ctx) const { return fmt::format_to(ctx.out(), "{}", Shader::IR::NameOf(op)); } }; diff --git a/src/yuzu_shader_recompiler/frontend/ir/pred.h b/src/yuzu_shader_recompiler/frontend/ir/pred.h index a77c1e2a..f3f92b06 100644 --- a/src/yuzu_shader_recompiler/frontend/ir/pred.h +++ b/src/yuzu_shader_recompiler/frontend/ir/pred.h @@ -33,7 +33,7 @@ struct fmt::formatter { return ctx.begin(); } template - auto format(const Shader::IR::Pred& pred, FormatContext& ctx) { + auto format(const Shader::IR::Pred& pred, FormatContext& ctx) const { if (pred == Shader::IR::Pred::PT) { return fmt::format_to(ctx.out(), "PT"); } else { diff --git a/src/yuzu_shader_recompiler/frontend/ir/reg.h b/src/yuzu_shader_recompiler/frontend/ir/reg.h index 641824bd..4d63d4f2 100644 --- a/src/yuzu_shader_recompiler/frontend/ir/reg.h +++ b/src/yuzu_shader_recompiler/frontend/ir/reg.h @@ -319,7 +319,7 @@ struct fmt::formatter { return ctx.begin(); } template - auto format(const Shader::IR::Reg& reg, FormatContext& ctx) { + auto format(const Shader::IR::Reg& reg, FormatContext& ctx) const { if (reg == Shader::IR::Reg::RZ) { return fmt::format_to(ctx.out(), "RZ"); } else if (static_cast(reg) >= 0 && static_cast(reg) < 255) { diff --git a/src/yuzu_shader_recompiler/frontend/ir/type.h b/src/yuzu_shader_recompiler/frontend/ir/type.h index 50c6655b..476efe4f 100644 --- a/src/yuzu_shader_recompiler/frontend/ir/type.h +++ b/src/yuzu_shader_recompiler/frontend/ir/type.h @@ -54,7 +54,7 @@ struct fmt::formatter { return ctx.begin(); } template - auto format(const Shader::IR::Type& type, FormatContext& ctx) { + auto format(const Shader::IR::Type& type, FormatContext& ctx) const { return fmt::format_to(ctx.out(), "{}", NameOf(type)); } }; diff --git a/src/yuzu_shader_recompiler/frontend/maxwell/location.h b/src/yuzu_shader_recompiler/frontend/maxwell/location.h index 814df482..845ca02d 100644 --- a/src/yuzu_shader_recompiler/frontend/maxwell/location.h +++ b/src/yuzu_shader_recompiler/frontend/maxwell/location.h @@ -102,7 +102,7 @@ struct fmt::formatter { return ctx.begin(); } template - auto format(const Shader::Maxwell::Location& location, FormatContext& ctx) { + auto format(const Shader::Maxwell::Location& location, FormatContext& ctx) const { return fmt::format_to(ctx.out(), "{:04x}", location.Offset()); } }; diff --git a/src/yuzu_shader_recompiler/frontend/maxwell/opcodes.h b/src/yuzu_shader_recompiler/frontend/maxwell/opcodes.h index 72dd143c..b3a493ff 100644 --- a/src/yuzu_shader_recompiler/frontend/maxwell/opcodes.h +++ b/src/yuzu_shader_recompiler/frontend/maxwell/opcodes.h @@ -23,7 +23,7 @@ struct fmt::formatter { return ctx.begin(); } template - auto format(const Shader::Maxwell::Opcode& opcode, FormatContext& ctx) { + auto format(const Shader::Maxwell::Opcode& opcode, FormatContext& ctx) const { return fmt::format_to(ctx.out(), "{}", NameOf(opcode)); } }; diff --git a/src/yuzu_video_core/CMakeLists.txt b/src/yuzu_video_core/CMakeLists.txt index 5567661c..6004c1b3 100644 --- a/src/yuzu_video_core/CMakeLists.txt +++ b/src/yuzu_video_core/CMakeLists.txt @@ -334,6 +334,12 @@ if (YUZU_USE_BUNDLED_FFMPEG AND NOT (WIN32 OR ANDROID OR APPLE)) endif() if(APPLE AND NOT FFmpeg_LIBRARIES) + if(NOT ARCHITECTURE_arm64) + message(FATAL_ERROR + "Bundled macOS FFmpeg libraries are arm64-only. " + "Build with -DCMAKE_OSX_ARCHITECTURES=arm64, or provide " + "FFmpeg_INCLUDE_DIR and FFmpeg_LIBRARIES for the target architecture.") + endif() set(_ffmpeg_macos_root "${CMAKE_SOURCE_DIR}/external/ffmpeg/lib/macos-arm64") set(_ffmpeg_macos_libs "${_ffmpeg_macos_root}/libavfilter.a" @@ -389,6 +395,12 @@ endif() target_include_directories(video_core PRIVATE ${FFmpeg_INCLUDE_DIR}) target_link_libraries(video_core PRIVATE ${FFmpeg_LIBRARIES}) +if(APPLE) + foreach(framework CoreFoundation CoreMedia CoreVideo VideoToolbox) + find_library(NXEMU_${framework}_FRAMEWORK ${framework} REQUIRED) + target_link_libraries(video_core PRIVATE ${NXEMU_${framework}_FRAMEWORK}) + endforeach() +endif() target_link_options(video_core PRIVATE ${FFmpeg_LDFLAGS}) add_dependencies(video_core host_shaders) diff --git a/src/yuzu_video_core/host1x/ffmpeg/ffmpeg.cpp b/src/yuzu_video_core/host1x/ffmpeg/ffmpeg.cpp index b8493cff..d0e21c71 100644 --- a/src/yuzu_video_core/host1x/ffmpeg/ffmpeg.cpp +++ b/src/yuzu_video_core/host1x/ffmpeg/ffmpeg.cpp @@ -25,11 +25,14 @@ namespace constexpr AVPixelFormat PreferredGpuFormat = AV_PIX_FMT_NV12; constexpr AVPixelFormat PreferredCpuFormat = AV_PIX_FMT_YUV420P; constexpr std::array PreferredGpuDecoders = { +#ifdef __APPLE__ + AV_HWDEVICE_TYPE_VIDEOTOOLBOX, +#endif AV_HWDEVICE_TYPE_CUDA, #ifdef _WIN32 AV_HWDEVICE_TYPE_D3D11VA, AV_HWDEVICE_TYPE_DXVA2, -#elif defined(__unix__) +#elif defined(__unix__) && !defined(__APPLE__) AV_HWDEVICE_TYPE_VAAPI, AV_HWDEVICE_TYPE_VDPAU, #endif @@ -39,19 +42,28 @@ constexpr std::array PreferredGpuDecoders = { AVPixelFormat GetGpuFormat(AVCodecContext * codec_context, const AVPixelFormat * pix_fmts) { + // FFmpeg can change pix_fmt before invoking get_format; retain our selection separately. + auto & hw_pix_fmt = *static_cast(codec_context->opaque); for (const AVPixelFormat * p = pix_fmts; *p != AV_PIX_FMT_NONE; ++p) { - if (*p == codec_context->pix_fmt) + if (*p == hw_pix_fmt) { - return codec_context->pix_fmt; + return hw_pix_fmt; } } LOG_INFO(HW_GPU, "Could not find compatible GPU AV format, falling back to CPU"); av_buffer_unref(&codec_context->hw_device_ctx); + hw_pix_fmt = AV_PIX_FMT_NONE; - codec_context->pix_fmt = PreferredCpuFormat; - return codec_context->pix_fmt; + for (const AVPixelFormat * p = pix_fmts; *p != AV_PIX_FMT_NONE; ++p) + { + if (*p == PreferredCpuFormat) + { + return *p; + } + } + return AV_PIX_FMT_NONE; } std::string AVError(int errnum) @@ -227,6 +239,8 @@ DecoderContext::~DecoderContext() void DecoderContext::InitializeHardwareDecoder(const HardwareContext & context, AVPixelFormat hw_pix_fmt) { m_codec_context->hw_device_ctx = av_buffer_ref(context.GetBufferRef()); + m_hw_pix_fmt = hw_pix_fmt; + m_codec_context->opaque = &m_hw_pix_fmt; m_codec_context->get_format = GetGpuFormat; m_codec_context->pix_fmt = hw_pix_fmt; } @@ -278,29 +292,25 @@ std::unique_ptr DecoderContext::ReceiveFrame(bool * out_is_interlaced) return true; }; - if (m_codec_context->hw_device_ctx) + if (!ReceiveImpl(dst_frame->GetFrame())) { - // If we have a hardware context, make a separate frame here to receive the - // hardware result before sending it to the output. - Frame intermediate_frame; - - if (!ReceiveImpl(intermediate_frame.GetFrame())) - { - return {}; - } + return {}; + } + // get_format may have fallen back to software while receiving this frame. + // Transfer only frames that actually own a hardware surface. + if (dst_frame->GetFrame()->hw_frames_ctx) + { + auto hardware_frame = std::move(dst_frame); + dst_frame = std::make_unique(); dst_frame->SetFormat(PreferredGpuFormat); - const int ret = av_hwframe_transfer_data(dst_frame->GetFrame(), intermediate_frame.GetFrame(), 0); + const int ret = av_hwframe_transfer_data(dst_frame->GetFrame(), hardware_frame->GetFrame(), 0); if (ret < 0) { LOG_ERROR(HW_GPU, "av_hwframe_transfer_data error: {}", AVError(ret)); return {}; } - } - else - { - // Otherwise, decode the frame as normal. - if (!ReceiveImpl(dst_frame->GetFrame())) + if (av_frame_copy_props(dst_frame->GetFrame(), hardware_frame->GetFrame()) < 0) { return {}; } @@ -414,6 +424,12 @@ bool DecodeApi::Initialize(Tegra::Host1x::NvdecCommon::VideoCodec codec) { this->Reset(); m_decoder.emplace(codec); + if (!m_decoder->GetCodec()) + { + LOG_ERROR(HW_GPU, "FFmpeg build has no decoder for codec {}", codec); + this->Reset(); + return false; + } m_decoder_context.emplace(*m_decoder); // Enable GPU decoding if requested. diff --git a/src/yuzu_video_core/host1x/ffmpeg/ffmpeg.h b/src/yuzu_video_core/host1x/ffmpeg/ffmpeg.h index fac4b5a2..e82e02c3 100644 --- a/src/yuzu_video_core/host1x/ffmpeg/ffmpeg.h +++ b/src/yuzu_video_core/host1x/ffmpeg/ffmpeg.h @@ -172,6 +172,7 @@ public: private: AVCodecContext* m_codec_context{}; + AVPixelFormat m_hw_pix_fmt{AV_PIX_FMT_NONE}; }; // Wraps an AVFilterGraph. diff --git a/src/yuzu_video_core/renderer_vulkan/renderer_vulkan.cpp b/src/yuzu_video_core/renderer_vulkan/renderer_vulkan.cpp index 642b1206..3597e35f 100644 --- a/src/yuzu_video_core/renderer_vulkan/renderer_vulkan.cpp +++ b/src/yuzu_video_core/renderer_vulkan/renderer_vulkan.cpp @@ -10,6 +10,7 @@ #include #include +#include #include "frontend/graphics_context.h" #include "yuzu_common/logging/log.h" diff --git a/src/yuzu_video_core/renderer_vulkan/vk_scheduler.cpp b/src/yuzu_video_core/renderer_vulkan/vk_scheduler.cpp index 16eedbb3..a97dbc93 100644 --- a/src/yuzu_video_core/renderer_vulkan/vk_scheduler.cpp +++ b/src/yuzu_video_core/renderer_vulkan/vk_scheduler.cpp @@ -144,6 +144,9 @@ bool Scheduler::UpdateRescaling(bool is_rescaling) { void Scheduler::WorkerThread(std::stop_token stop_token) { Common::SetCurrentThreadName("VulkanWorker"); +#ifdef __APPLE__ + Common::SetCurrentThreadPriority(Common::ThreadPriority::Critical); +#endif const auto TryPopQueue{[this](auto& work) -> bool { if (work_queue.empty()) { diff --git a/src/yuzu_video_core/texture_cache/formatter.h b/src/yuzu_video_core/texture_cache/formatter.h index ad6acd7c..30ab57c2 100644 --- a/src/yuzu_video_core/texture_cache/formatter.h +++ b/src/yuzu_video_core/texture_cache/formatter.h @@ -13,7 +13,7 @@ template <> struct fmt::formatter : fmt::formatter { template - auto format(VideoCore::Surface::PixelFormat format, FormatContext& ctx) { + auto format(VideoCore::Surface::PixelFormat format, FormatContext& ctx) const { using VideoCore::Surface::PixelFormat; const string_view name = [format] { switch (format) { @@ -234,7 +234,7 @@ struct fmt::formatter : fmt::formatter struct fmt::formatter : fmt::formatter { template - auto format(VideoCommon::ImageType type, FormatContext& ctx) { + auto format(VideoCommon::ImageType type, FormatContext& ctx) const { const string_view name = [type] { using VideoCommon::ImageType; switch (type) { @@ -262,7 +262,7 @@ struct fmt::formatter { } template - auto format(const VideoCommon::Extent3D& extent, FormatContext& ctx) { + auto format(const VideoCommon::Extent3D& extent, FormatContext& ctx) const { return fmt::format_to(ctx.out(), "{{{}, {}, {}}}", extent.width, extent.height, extent.depth); } diff --git a/src/yuzu_video_core/vulkan_common/vulkan_library.cpp b/src/yuzu_video_core/vulkan_common/vulkan_library.cpp index 7075dd76..2ac8c3b1 100644 --- a/src/yuzu_video_core/vulkan_common/vulkan_library.cpp +++ b/src/yuzu_video_core/vulkan_common/vulkan_library.cpp @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2020 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include #include #include "yuzu_common/dynamic_library.h" @@ -11,6 +12,21 @@ namespace Vulkan { +#ifdef __APPLE__ +namespace { + +std::filesystem::path AppleVulkanLibraryPath() +{ + const auto exe_dir = Common::FS::GetExeDirectory(); + if (exe_dir.filename() == "MacOS" && exe_dir.parent_path().filename() == "Contents") { + return exe_dir.parent_path() / "Frameworks/libMoltenVK.dylib"; + } + return exe_dir.empty() ? std::filesystem::path{} : exe_dir / "libMoltenVK.dylib"; +} + +} // namespace +#endif + std::shared_ptr OpenLibrary([[maybe_unused]] Core::Frontend::GraphicsContext * context) { LOG_DEBUG(Render_Vulkan, "Looking for a Vulkan library"); @@ -30,15 +46,14 @@ std::shared_ptr OpenLibrary([[maybe_unused]] Core::Front #else std::shared_ptr library = std::make_shared(); #ifdef __APPLE__ - const std::filesystem::path libvulkan_filename = Common::FS::GetBundleDirectory() / "Contents/Frameworks/libvulkan.1.dylib"; - const std::filesystem::path libmoltenvk_filename = Common::FS::GetBundleDirectory() / "Contents/Frameworks/libMoltenVK.dylib"; - const char * library_paths[] = {std::getenv("LIBVULKAN_PATH"), libvulkan_filename.c_str(), libmoltenvk_filename.c_str()}; - // Check if a path to a specific Vulkan library has been specified. - for (const char * library_path : library_paths) + const auto library_path = AppleVulkanLibraryPath(); + if (!library_path.empty()) { - if (library_path && library->Open(library_path)) + const std::string path = library_path.string(); + LOG_DEBUG(Render_Vulkan, "Trying Vulkan library: {}", path); + if (library->Open(path.c_str())) { - break; + LOG_INFO(Render_Vulkan, "Loaded Vulkan library: {}", path); } } #else diff --git a/src/yuzu_video_core/vulkan_common/vulkan_surface.cpp b/src/yuzu_video_core/vulkan_common/vulkan_surface.cpp index 67260f3e..9ccf8a78 100644 --- a/src/yuzu_video_core/vulkan_common/vulkan_surface.cpp +++ b/src/yuzu_video_core/vulkan_common/vulkan_surface.cpp @@ -29,7 +29,14 @@ vk::SurfaceKHR CreateSurface( } #elif defined(__APPLE__) if (window_info.type == Core::Frontend::WindowSystemType::Cocoa) { + if (window_info.render_surface == nullptr) { + LOG_ERROR(Render_Vulkan, "No CAMetalLayer available for the render window"); + throw vk::Exception(VK_ERROR_INITIALIZATION_FAILED); + } const VkMetalSurfaceCreateInfoEXT macos_ci = { + .sType = VK_STRUCTURE_TYPE_METAL_SURFACE_CREATE_INFO_EXT, + .pNext = nullptr, + .flags = 0, .pLayer = static_cast(window_info.render_surface), }; const auto vkCreateMetalSurfaceEXT = reinterpret_cast( diff --git a/src/yuzu_video_core/vulkan_common/vulkan_wrapper.cpp b/src/yuzu_video_core/vulkan_common/vulkan_wrapper.cpp index 99558dd2..4e5f931e 100644 --- a/src/yuzu_video_core/vulkan_common/vulkan_wrapper.cpp +++ b/src/yuzu_video_core/vulkan_common/vulkan_wrapper.cpp @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include +#include #include #include #include @@ -423,7 +424,12 @@ VkResult Free(VkDevice device, VkCommandPool handle, Span buffe Instance Instance::Create(u32 version, Span layers, Span extensions, InstanceDispatch& dispatch) { #ifdef __APPLE__ - constexpr VkFlags ci_flags{VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR}; + const bool enumerate_portability = + std::find_if(extensions.begin(), extensions.end(), [](const char* extension) { + return std::strcmp(extension, VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME) == 0; + }) != extensions.end(); + const VkFlags ci_flags = + enumerate_portability ? VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR : 0; #else constexpr VkFlags ci_flags{}; #endif