From 9e0234fcd15cc865d52144305782cc0ca4728f18 Mon Sep 17 00:00:00 2001 From: DarthM Date: Tue, 1 Sep 2026 17:40:19 -0400 Subject: [PATCH 01/14] macos: add x86_64 Intel support --- Launcher/local-build-macos.command | 15 +-- Launcher/macos/build-setup-pkg.command | 81 ++++++++++++---- Launcher/macos/macos-x86_64-toolchain.cmake | 11 +++ Launcher/macos/publish-app.command | 53 +++++++++-- Launcher/macos/setup.command | 14 ++- README.md | 4 +- aurora-main/extern/CMakeLists.txt | 50 ++++++++++ runtime/CMakeLists.txt | 100 ++++++++++++++------ runtime/cmake/PublicProducts.cmake | 6 +- runtime/include/host_context.h | 10 +- runtime/src/host_context.cpp | 4 +- runtime/src/main.cpp | 13 ++- 12 files changed, 282 insertions(+), 79 deletions(-) create mode 100644 Launcher/macos/macos-x86_64-toolchain.cmake diff --git a/Launcher/local-build-macos.command b/Launcher/local-build-macos.command index 1f91b0ce8..71523065e 100755 --- a/Launcher/local-build-macos.command +++ b/Launcher/local-build-macos.command @@ -22,7 +22,7 @@ Usage: local-build-macos.command --output-dir DIR [options] --retro-rewind-package-dir DIR RetroRewind6 directory (required for Retro Rewind) --retro-wfc-offline-dir DIR Directory containing binary/payload.RMCPD00.bin --skip-retro-wfc-payload Build Retro Rewind without the shared Retro-WFC payload - --force-clean-build Delete local generated and native-build-macos caches + --force-clean-build Delete local generated and current-architecture native build caches --parallel N Pin translation and build parallelism --cmake PATH --ninja PATH Override build tools --dotnet PATH Override dotnet @@ -56,7 +56,9 @@ while (($#)); do done [[ $(uname -s) == Darwin ]] || fail 'this build script is for macOS only' -[[ $(uname -m) == arm64 ]] || fail 'the current macOS product target is Apple Silicon only' +macos_arch=$(uname -m) +case "$macos_arch" in arm64|x86_64) ;; *) fail "unsupported macOS architecture: $macos_arch" ;; esac +macos_deployment_target=12.0 workspace=$(cd "$workspace" && pwd) [[ -n "$output_dir" ]] || fail '--output-dir is required' case "$profile" in base|retro-rewind|both) ;; *) fail '--profile must be base, retro-rewind, or both' ;; esac @@ -73,7 +75,8 @@ for tool in "$cmake_bin" "$ninja_bin" clang clang++ shasum; do command -v "$tool project="$workspace/projects/mkwii/recomp.yml"; assets="$workspace/Assets"; generated="$workspace/generated" functions="$generated/functions"; metadata="$generated/base_translation_output.json"; manifest_dir="$workspace/build/base" -manifest="$manifest_dir/mkwii_base_manifest.json"; shards="$generated/build_shards"; native_build="$workspace/native-build-macos" +manifest="$manifest_dir/mkwii_base_manifest.json"; shards="$generated/build_shards" +native_build="$workspace/native-build-macos-$macos_arch" assert_file "$project" 'translation project' if [[ -n "$game" ]]; then "$script_dir/macos/extract-disc.command" --game "$game" --assets-dir "$assets" --nodtool "$nodtool"; fi assert_file "$assets/main.dol" 'extracted main.dol'; assert_file "$assets/StaticR.rel" 'extracted StaticR.rel' @@ -131,9 +134,9 @@ if (( builds_retro )); then args+=(--resolved-profile "$mod_out/resolved_dispatc step emit-build-shards 'Preparing native build shards'; translator "${args[@]}" step configure-native 'Configuring the native toolchain' -"$cmake_bin" -S "$workspace/runtime" -B "$native_build" -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_MAKE_PROGRAM="$ninja_bin" -DMKW_TRANSLATED_COMPILE_JOBS="$translated_jobs" +"$cmake_bin" -S "$workspace/runtime" -B "$native_build" -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_MAKE_PROGRAM="$ninja_bin" -DCMAKE_OSX_ARCHITECTURES="$macos_arch" -DCMAKE_OSX_DEPLOYMENT_TARGET="$macos_deployment_target" -DMKW_TRANSLATED_COMPILE_JOBS="$translated_jobs" targets=(); [[ "$profile" != retro-rewind ]] && targets+=(WiiCompiled); [[ "$profile" != base ]] && targets+=(RetroRewind) step compile "Compiling ${targets[*]} locally"; "$cmake_bin" --build "$native_build" --target "${targets[@]}" --parallel "$global_jobs" -if [[ "$profile" != retro-rewind ]]; then "$script_dir/macos/publish-app.command" --build-dir "$native_build" --product WiiCompiled --output-dir "${base_output_dir:-$output_dir}"; fi -if (( builds_retro )); then "$script_dir/macos/publish-app.command" --build-dir "$native_build" --product RetroRewind --output-dir "$output_dir"; fi +if [[ "$profile" != retro-rewind ]]; then "$script_dir/macos/publish-app.command" --build-dir "$native_build" --product WiiCompiled --output-dir "${base_output_dir:-$output_dir}" --architecture "$macos_arch" --minimum-system-version "$macos_deployment_target"; fi +if (( builds_retro )); then "$script_dir/macos/publish-app.command" --build-dir "$native_build" --product RetroRewind --output-dir "$output_dir" --architecture "$macos_arch" --minimum-system-version "$macos_deployment_target"; fi printf 'MKWCBUILD:OUTPUT=%s\n' "$output_dir" diff --git a/Launcher/macos/build-setup-pkg.command b/Launcher/macos/build-setup-pkg.command index 50082c965..ffd465d5a 100755 --- a/Launcher/macos/build-setup-pkg.command +++ b/Launcher/macos/build-setup-pkg.command @@ -9,12 +9,15 @@ fail() { printf 'build-setup-pkg.command: error: %s\n' "$*" >&2; exit 1; } copy_clean() { DITTONORSRC=1 ditto --norsrc --noqtn "$@"; } usage() { cat <<'EOF' -Usage: build-setup-pkg.command --nodtool PATH --translator PATH --cmake-root DIR --ninja PATH --output PKG [options] +Usage: build-setup-pkg.command --nodtool-arm64 PATH --nodtool-x86_64 PATH --translator-arm64 PATH --translator-x86_64 PATH --cmake-root DIR --ninja-arm64 PATH --ninja-x86_64 PATH --output PKG [options] Creates a game-code-free WiiCompiled Setup.pkg. The supplied tools must be -maintainer-verified, redistributable macOS arm64 artifacts. The resulting pkg -is unsigned unless --installer-identity is supplied; releases should sign and -notarize it with a Developer ID Installer certificate. +maintainer-verified, redistributable macOS artifacts for both arm64 and +x86_64. The setup package selects native tools for its host while the game is +compiled locally for that host architecture. CMake must be universal2. The +resulting pkg is unsigned unless +--installer-identity is supplied; releases should sign and notarize it with a +Developer ID Installer certificate. --workspace DIR Repository root (default: script's grandparent) --version VERSION Bundle/package version (default: 0.1.0) @@ -23,14 +26,17 @@ EOF } script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -workspace=$(cd "$script_dir/../.." && pwd); nodtool=""; translator=""; cmake_root=""; ninja=""; output=""; version=0.1.0; identity="" +workspace=$(cd "$script_dir/../.." && pwd); nodtool_arm64=""; nodtool_x86_64=""; translator_arm64=""; translator_x86_64=""; cmake_root=""; ninja_arm64=""; ninja_x86_64=""; output=""; version=0.1.0; identity="" while (($#)); do case "$1" in --workspace) workspace=${2:-}; shift 2 ;; - --nodtool) nodtool=${2:-}; shift 2 ;; - --translator) translator=${2:-}; shift 2 ;; + --nodtool-arm64) nodtool_arm64=${2:-}; shift 2 ;; + --nodtool-x86_64) nodtool_x86_64=${2:-}; shift 2 ;; + --translator-arm64) translator_arm64=${2:-}; shift 2 ;; + --translator-x86_64) translator_x86_64=${2:-}; shift 2 ;; --cmake-root) cmake_root=${2:-}; shift 2 ;; - --ninja) ninja=${2:-}; shift 2 ;; + --ninja-arm64) ninja_arm64=${2:-}; shift 2 ;; + --ninja-x86_64) ninja_x86_64=${2:-}; shift 2 ;; --output) output=${2:-}; shift 2 ;; --version) version=${2:-}; shift 2 ;; --installer-identity) identity=${2:-}; shift 2 ;; @@ -42,12 +48,48 @@ version=${version#v} [[ "$version" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]] || fail '--version must contain one to three period-separated integers' IFS=. read -r version_major version_minor version_patch <<< "$version" short_version="$version_major.${version_minor:-0}.${version_patch:-0}" -for tool in pkgbuild productbuild ditto codesign; do command -v "$tool" >/dev/null || fail "required macOS tool unavailable: $tool"; done -[[ -x "$nodtool" ]] || fail '--nodtool must name an executable' -[[ -x "$translator" ]] || fail '--translator must name an executable' +for tool in pkgbuild productbuild ditto codesign lipo; do command -v "$tool" >/dev/null || fail "required macOS tool unavailable: $tool"; done +for tool_path in "$nodtool_arm64" "$nodtool_x86_64" "$translator_arm64" "$translator_x86_64" "$ninja_arm64" "$ninja_x86_64"; do [[ -x "$tool_path" ]] || fail 'each architecture-specific tool must name an executable'; done [[ -x "$cmake_root/bin/cmake" ]] || fail '--cmake-root must contain bin/cmake' -[[ -x "$ninja" ]] || fail '--ninja must name an executable' -"$nodtool" --version >/dev/null || fail '--nodtool did not run successfully' +require_arch() { + local artifact=$1 arch=$2 label=$3 + lipo "$artifact" -verify_arch "$arch" >/dev/null 2>&1 || fail "$label must contain a $arch slice: $artifact" +} +require_arch "$nodtool_arm64" arm64 '--nodtool-arm64'; require_arch "$nodtool_x86_64" x86_64 '--nodtool-x86_64' +require_arch "$translator_arm64" arm64 '--translator-arm64'; require_arch "$translator_x86_64" x86_64 '--translator-x86_64' +require_arch "$ninja_arm64" arm64 '--ninja-arm64'; require_arch "$ninja_x86_64" x86_64 '--ninja-x86_64' +lipo "$cmake_root/bin/cmake" -verify_arch arm64 x86_64 >/dev/null 2>&1 || fail '--cmake-root/bin/cmake must be universal2' + +# Slice checks above prevent accidental cross-architecture packaging. Exercise +# each supplied executable as well: an incorrectly bundled runtime can have a +# valid Mach-O header but still fail before the setup app can use it. Apple +# Silicon maintainers validate Intel tools through Rosetta when it is present. +host_arch=$(uname -m) +run_for_arch() { + local arch=$1 label=$2 + shift 2 + if [[ "$arch" == "$host_arch" ]]; then + "$@" >/dev/null || fail "$label did not run successfully" + elif [[ "$host_arch" == arm64 && "$arch" == x86_64 ]] && /usr/bin/arch -x86_64 /usr/bin/true >/dev/null 2>&1; then + /usr/bin/arch -x86_64 "$@" >/dev/null || fail "$label did not run successfully under Rosetta" + else + # Intel hosts cannot execute arm64 binaries. The slice remains checked + # above; CI or an Apple Silicon maintainer must execute that tool set. + printf 'build-setup-pkg.command: warning: unable to execute %s on %s; architecture slice was verified, but run it in %s CI before release\n' \ + "$label" "$host_arch" "$arch" >&2 + fi +} +for arch in arm64 x86_64; do + if [[ "$arch" == arm64 ]]; then + nodtool=$nodtool_arm64; translator=$translator_arm64; ninja=$ninja_arm64 + else + nodtool=$nodtool_x86_64; translator=$translator_x86_64; ninja=$ninja_x86_64 + fi + run_for_arch "$arch" "--nodtool-$arch" "$nodtool" --version + run_for_arch "$arch" "--translator-$arch" "$translator" --help + run_for_arch "$arch" "--ninja-$arch" "$ninja" --version +done +run_for_arch "$host_arch" '--cmake-root/bin/cmake' "$cmake_root/bin/cmake" --version workspace=$(cd "$workspace" && pwd); output=$(cd "$(dirname "$output")" && pwd)/$(basename "$output") stage=$(mktemp -d "${TMPDIR:-/tmp}/wiicompiled-pkg.XXXXXX") trap 'rm -rf "$stage"' EXIT @@ -64,7 +106,7 @@ cat > "$app/Contents/Info.plist" <CFBundlePackageTypeAPPL CFBundleShortVersionString$short_version CFBundleVersion$version -LSMinimumSystemVersion14.0 +LSMinimumSystemVersion12.0 EOF cat > "$app/Contents/MacOS/WiiCompiledSetup" <<'EOF' @@ -103,11 +145,14 @@ copy_clean "$workspace/Launcher/local-build-macos.command" "$resources/workspace copy_clean "$workspace/Launcher/macos/extract-disc.command" "$resources/workspace/Launcher/macos/extract-disc.command" copy_clean "$workspace/Launcher/macos/publish-app.command" "$resources/workspace/Launcher/macos/publish-app.command" chmod +x "$resources/workspace/Launcher/local-build-macos.command" "$resources/workspace/Launcher/macos/"*.command -mkdir -p "$resources/tools/cmake" -copy_clean "$nodtool" "$resources/tools/nodtool"; chmod +x "$resources/tools/nodtool" -copy_clean "$translator" "$resources/tools/Translator.Cli"; chmod +x "$resources/tools/Translator.Cli" +mkdir -p "$resources/tools/cmake" "$resources/tools/arm64" "$resources/tools/x86_64" +copy_clean "$nodtool_arm64" "$resources/tools/arm64/nodtool"; chmod +x "$resources/tools/arm64/nodtool" +copy_clean "$nodtool_x86_64" "$resources/tools/x86_64/nodtool"; chmod +x "$resources/tools/x86_64/nodtool" +copy_clean "$translator_arm64" "$resources/tools/arm64/Translator.Cli"; chmod +x "$resources/tools/arm64/Translator.Cli" +copy_clean "$translator_x86_64" "$resources/tools/x86_64/Translator.Cli"; chmod +x "$resources/tools/x86_64/Translator.Cli" copy_clean "$cmake_root" "$resources/tools/cmake" -copy_clean "$ninja" "$resources/tools/ninja"; chmod +x "$resources/tools/ninja" +copy_clean "$ninja_arm64" "$resources/tools/arm64/ninja"; chmod +x "$resources/tools/arm64/ninja" +copy_clean "$ninja_x86_64" "$resources/tools/x86_64/ninja"; chmod +x "$resources/tools/x86_64/ninja" copy_clean "$workspace/LICENSE" "$resources/LICENSE" copy_clean "$workspace/THIRD-PARTY-NOTICES.md" "$resources/THIRD-PARTY-NOTICES.md" codesign --force --deep --sign - "$app" diff --git a/Launcher/macos/macos-x86_64-toolchain.cmake b/Launcher/macos/macos-x86_64-toolchain.cmake new file mode 100644 index 000000000..f36749827 --- /dev/null +++ b/Launcher/macos/macos-x86_64-toolchain.cmake @@ -0,0 +1,11 @@ +# Cross-compile a thin x86_64 macOS build from an Apple Silicon Mac. +# Pass this file on the first configure with: +# -DCMAKE_TOOLCHAIN_FILE=/absolute/path/to/macos-x86_64-toolchain.cmake +# +# CMAKE_SYSTEM_PROCESSOR is deliberately declared here rather than inferred +# from CMAKE_OSX_ARCHITECTURES, so target-aware CMake dependencies select their +# x86_64 artifacts. +set(CMAKE_SYSTEM_NAME Darwin) +set(CMAKE_SYSTEM_PROCESSOR x86_64) +set(CMAKE_OSX_ARCHITECTURES x86_64 CACHE STRING + "Target macOS architectures" FORCE) diff --git a/Launcher/macos/publish-app.command b/Launcher/macos/publish-app.command index d7ce3a19f..36b2c3ea1 100755 --- a/Launcher/macos/publish-app.command +++ b/Launcher/macos/publish-app.command @@ -5,28 +5,36 @@ set -euo pipefail fail() { printf 'publish-app.command: error: %s\n' "$*" >&2; exit 1; } usage() { cat <<'EOF' -Usage: publish-app.command --build-dir DIR --product {WiiCompiled|RetroRewind} --output-dir DIR +Usage: publish-app.command --build-dir DIR --product {WiiCompiled|RetroRewind} --output-dir DIR [options] Copies a locally built product and its runtime assets into OUTPUT-DIR/.app. It bundles non-system dylibs, rewrites their install names, and ad-hoc signs the result. This is suitable for local use; a release must replace ad-hoc signing with the project's Developer ID signing and notarization process. + + --architecture {arm64|x86_64} Required architecture of the compiled product (default: host) + --minimum-system-version VERSION App bundle minimum macOS version (default: 12.0) EOF } -build_dir=""; product=""; output_dir="" +build_dir=""; product=""; output_dir=""; architecture=$(uname -m); minimum_system_version=12.0 while (($#)); do case "$1" in --build-dir) build_dir=${2:-}; shift 2 ;; --product) product=${2:-}; shift 2 ;; --output-dir) output_dir=${2:-}; shift 2 ;; + --architecture) architecture=${2:-}; shift 2 ;; + --minimum-system-version) minimum_system_version=${2:-}; shift 2 ;; -h|--help) usage; exit 0 ;; *) fail "unknown option: $1" ;; esac done [[ "$product" == WiiCompiled || "$product" == RetroRewind ]] || fail '--product must be WiiCompiled or RetroRewind' -for tool in codesign ditto install_name_tool otool; do command -v "$tool" >/dev/null || fail "required macOS tool is unavailable: $tool"; done +[[ "$architecture" == arm64 || "$architecture" == x86_64 ]] || fail '--architecture must be arm64 or x86_64' +[[ "$minimum_system_version" =~ ^[0-9]+(\.[0-9]+){1,2}$ ]] || fail '--minimum-system-version must contain two or three period-separated integers' +for tool in codesign ditto install_name_tool lipo otool; do command -v "$tool" >/dev/null || fail "required macOS tool is unavailable: $tool"; done [[ -x "$build_dir/$product" ]] || fail "missing compiled product: $build_dir/$product" +lipo "$build_dir/$product" -verify_arch "$architecture" || fail "compiled product is not $architecture: $build_dir/$product" for asset in dsp_coef.bin initial_pipeline_cache.db cacert.pem wii_bootstrap; do [[ -e "$build_dir/$asset" ]] || fail "missing runtime asset: $build_dir/$asset"; done app="$output_dir/$product.app" @@ -47,7 +55,7 @@ cat > "$app/Contents/Info.plist" <CFBundlePackageTypeAPPL CFBundleShortVersionString0.1.0 CFBundleVersion1 - LSMinimumSystemVersion14.0 + LSMinimumSystemVersion$minimum_system_version NSHighResolutionCapable EOF @@ -57,17 +65,45 @@ for asset in dsp_coef.bin initial_pipeline_cache.db cacert.pem wii_bootstrap; do ln -s "../Resources/$asset" "$macos/$asset" done -# Build a closure of Homebrew dylibs. System libraries remain system references. +# Resolve a non-system dependency from the build product's rpaths. This covers +# both traditional Homebrew dylibs and the vendored dylibs CMake emits under +# the local build directory for a cross-architecture build. +dependency_path() { + local current=$1 dependency=$2 name rpath candidate + case "$dependency" in + /opt/homebrew/*|/usr/local/*) + [[ -f "$dependency" ]] && { printf '%s\n' "$dependency"; return 0; } + ;; + @rpath/*) + name=${dependency##*/} + while IFS= read -r rpath; do + case "$rpath" in + @loader_path/*) rpath="$(dirname "$current")/${rpath#@loader_path/}" ;; + @executable_path/*) rpath="$macos/${rpath#@executable_path/}" ;; + esac + candidate="$rpath/$name" + [[ -f "$candidate" ]] && { printf '%s\n' "$candidate"; return 0; } + done < <(otool -l "$current" | awk '/LC_RPATH/{rpath = 1; next} rpath && /path / { print $2; rpath = 0 }') + ;; + @loader_path/*) + candidate="$(dirname "$current")/${dependency#@loader_path/}" + [[ -f "$candidate" ]] && { printf '%s\n' "$candidate"; return 0; } + ;; + esac + return 1 +} + +# Build a closure of non-system dylibs. System libraries remain system +# references, while every resolved dependency is copied beside the executable. queue=("$macos/$product") while ((${#queue[@]})); do current=${queue[0]} queue=("${queue[@]:1}") while IFS= read -r dependency; do - [[ "$dependency" == /opt/homebrew/* || "$dependency" == /usr/local/* ]] || continue - [[ -f "$dependency" ]] || continue + dependency_path=$(dependency_path "$current" "$dependency") || continue name=$(basename "$dependency") if [[ ! -f "$frameworks/$name" ]]; then - ditto "$dependency" "$frameworks/$name" + ditto "$dependency_path" "$frameworks/$name" install_name_tool -id "@rpath/$name" "$frameworks/$name" queue+=("$frameworks/$name") fi @@ -75,7 +111,6 @@ while ((${#queue[@]})); do done while IFS= read -r binary; do while IFS= read -r old; do - [[ "$old" == /opt/homebrew/* || "$old" == /usr/local/* ]] || continue name=$(basename "$old") [[ -f "$frameworks/$name" ]] || continue if [[ "$binary" == "$macos/$product" ]]; then diff --git a/Launcher/macos/setup.command b/Launcher/macos/setup.command index 09c291534..85b91e7bf 100755 --- a/Launcher/macos/setup.command +++ b/Launcher/macos/setup.command @@ -5,10 +5,7 @@ set -euo pipefail resources=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) workspace_source="$resources/workspace" -nodtool="$resources/tools/nodtool" -translator="$resources/tools/Translator.Cli" cmake_bin="$resources/tools/cmake/bin/cmake" -ninja_bin="$resources/tools/ninja" support_root="$HOME/Library/Application Support/WiiCompiled" workspace="$support_root/BuildWorkspace" products="$support_root/Products" @@ -36,6 +33,13 @@ while (($#)); do done [[ "$install_location" == user || "$install_location" == applications ]] || fail '--install-location must be user or applications' +host_arch=$(uname -m) +case "$host_arch" in arm64|x86_64) ;; *) fail "unsupported macOS architecture: $host_arch" ;; esac +host_tools="$resources/tools/$host_arch" +nodtool="$host_tools/nodtool" +translator="$host_tools/Translator.Cli" +ninja_bin="$host_tools/ninja" + if [[ -z "$game" ]]; then game=$(/usr/bin/osascript <<'APPLESCRIPT' set selectedFile to choose file with prompt "Choose your clean Mario Kart Wii PAL (RMCP01) disc image" @@ -60,6 +64,10 @@ if ! /usr/bin/xcode-select -p >/dev/null 2>&1; then /usr/bin/xcode-select --install || true exit 1 fi +for tool in "$nodtool" "$translator" "$cmake_bin" "$ninja_bin"; do + /usr/bin/lipo "$tool" -verify_arch "$host_arch" >/dev/null 2>&1 || \ + fail "the packaged $(basename "$tool") does not support $host_arch" +done mkdir -p "$support_root" "$products" if [[ ! -d "$workspace/.git" && ! -f "$workspace/projects/mkwii/recomp.yml" ]]; then diff --git a/README.md b/README.md index aceca104f..48e1c75c2 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,8 @@ Known limitations of the Wii Remote path: - GPU: GTX 1650 / RX 6400 / Arc A310 or higher - CPU: Intel Core i5-8400 / AMD Ryzen 5 2600 (4c/6c, ~3.5GHz+) or higher - About 20 GB of free disk space during installation (Final game size ~5 GB) -- macOS 14 (Sonoma) or later on Apple Silicon -- On macOS, Apple Xcode Command Line Tools (Setup opens Apple's installer when they are missing) +- macOS 12 (Monterey) or later on Apple Silicon (`arm64`) or Intel (`x86_64-v3`); pre-Haswell Intel CPUs are unsupported +- On macOS, a Metal-capable GPU and Apple Xcode Command Line Tools (Setup opens Apple's installer when they are missing) - A clean, unmodified **PAL `RMCP01`** disc image of Mario Kart Wii, dumped by you. ISO, GCM, GCZ, CISO, WBFS, WIA and RVZ are accepted. diff --git a/aurora-main/extern/CMakeLists.txt b/aurora-main/extern/CMakeLists.txt index 9d77bbbc3..1612b4fe1 100644 --- a/aurora-main/extern/CMakeLists.txt +++ b/aurora-main/extern/CMakeLists.txt @@ -16,10 +16,60 @@ function(aurora_find_package_global) set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL ${_PREV_FIND_PACKAGE_TARGETS_GLOBAL}) endfunction() +# A macOS build for another architecture must not discover Homebrew packages +# built for its physical host. CMAKE_CROSSCOMPILING is not sufficient here: +# Apple Clang can target another architecture through CMAKE_OSX_ARCHITECTURES +# without CMake considering the configure a cross-build. +set(_AURORA_EXCLUDE_HOST_HOMEBREW FALSE) +if (APPLE AND CMAKE_OSX_ARCHITECTURES) + list(LENGTH CMAKE_OSX_ARCHITECTURES _AURORA_OSX_ARCH_COUNT) + if (_AURORA_OSX_ARCH_COUNT EQUAL 1) + list(GET CMAKE_OSX_ARCHITECTURES 0 _AURORA_TARGET_ARCH) + string(TOLOWER "${_AURORA_TARGET_ARCH}" _AURORA_TARGET_ARCH) + # hw.optional.arm64 identifies Apple Silicon even when CMake itself runs + # through Rosetta, where CMAKE_HOST_SYSTEM_PROCESSOR reports x86_64. + execute_process( + COMMAND /usr/sbin/sysctl -n hw.optional.arm64 + RESULT_VARIABLE _AURORA_ARM64_PROBE_RESULT + OUTPUT_VARIABLE _AURORA_ARM64_PROBE + ERROR_QUIET + OUTPUT_STRIP_TRAILING_WHITESPACE) + if (_AURORA_ARM64_PROBE_RESULT EQUAL 0 AND _AURORA_ARM64_PROBE STREQUAL "1") + set(_AURORA_HOST_ARCH arm64) + else () + execute_process( + COMMAND /usr/bin/uname -m + OUTPUT_VARIABLE _AURORA_HOST_ARCH + OUTPUT_STRIP_TRAILING_WHITESPACE) + string(TOLOWER "${_AURORA_HOST_ARCH}" _AURORA_HOST_ARCH) + endif () + if (_AURORA_TARGET_ARCH STREQUAL "x86_64" AND _AURORA_HOST_ARCH MATCHES "^(arm64|aarch64)$") + list(APPEND CMAKE_IGNORE_PREFIX_PATH "/opt/homebrew") + set(_AURORA_EXCLUDE_HOST_HOMEBREW TRUE) + elseif (_AURORA_TARGET_ARCH MATCHES "^(arm64|aarch64)$" AND _AURORA_HOST_ARCH MATCHES "^(x86_64|amd64)$") + list(APPEND CMAKE_IGNORE_PREFIX_PATH "/usr/local") + set(_AURORA_EXCLUDE_HOST_HOMEBREW TRUE) + endif () + endif () +endif () + +if (_AURORA_EXCLUDE_HOST_HOMEBREW) + list(REMOVE_DUPLICATES CMAKE_IGNORE_PREFIX_PATH) + message(STATUS "aurora: cross-architecture macOS build; ignoring host Homebrew prefixes") +endif () + if (AURORA_ENABLE_GX) include(${CMAKE_CURRENT_SOURCE_DIR}/../cmake/AuroraDawnProvider.cmake) endif () +# SDL's pkg-config probe can bypass CMake's prefix exclusion. macOS does not +# need libusb for the supported SDL input paths, so keep that host-only library +# out of a cross-architecture configure. +if (_AURORA_EXCLUDE_HOST_HOMEBREW) + set(SDL_HIDAPI_LIBUSB OFF CACHE BOOL "" FORCE) + set(SDL_HIDAPI_LIBUSB_SHARED OFF CACHE BOOL "" FORCE) +endif () + # Abseil is needed for core libraries. It normally comes via Dawn's vendor build. # Otherwise prefer a system package and only fetch it as a last resort. if (NOT TARGET absl::flat_hash_map OR NOT TARGET absl::btree) diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 336370bbd..ece9373ab 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1,21 +1,42 @@ cmake_minimum_required(VERSION 3.16) + +# Dawn's pinned macOS artifacts target 12.0. Set the same floor before project() +# initializes the Apple toolchain so direct developer CMake invocations cannot +# accidentally inherit the running SDK's deployment version. This cache entry +# is harmless on non-Apple platforms and remains overridable by a caller. +if(NOT CMAKE_OSX_DEPLOYMENT_TARGET) + set(CMAKE_OSX_DEPLOYMENT_TARGET "12.0" CACHE STRING + "Minimum macOS version supported by WiiCompiled" FORCE) +endif() project(mkw_recompiled) if(NOT CMAKE_CXX_COMPILER_ID MATCHES "^(Clang|AppleClang)$" OR NOT CMAKE_SIZEOF_VOID_P EQUAL 8) message(FATAL_ERROR "WiiCompiled requires a 64-bit Clang toolchain") endif() +if(APPLE AND CMAKE_OSX_ARCHITECTURES) + list(LENGTH CMAKE_OSX_ARCHITECTURES MKW_OSX_ARCHITECTURE_COUNT) + if(MKW_OSX_ARCHITECTURE_COUNT GREATER 1) + message(FATAL_ERROR + "WiiCompiled supports one macOS architecture per build directory; " + "configure separate arm64 and x86_64 build directories") + endif() +endif() + if(WIN32 AND MINGW AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64)$") set(MKW_PLATFORM_WINDOWS TRUE) -elseif(APPLE AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm64|ARM64)$") - # The first native macOS target is Apple Silicon. Intel and universal - # binaries remain future compatibility work; do not silently claim them. +elseif(APPLE AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64|arm64|ARM64)$") set(MKW_PLATFORM_MACOS TRUE) + if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64)$") + set(MKW_PLATFORM_MACOS_X86_64 TRUE) + else() + set(MKW_PLATFORM_MACOS_ARM64 TRUE) + endif() elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64|aarch64|arm64|ARM64)$") set(MKW_PLATFORM_LINUX TRUE) else() message(FATAL_ERROR - "WiiCompiled supports 64-bit LLVM-MinGW Clang on Windows, native Linux x86_64/aarch64, or Apple Clang on macOS arm64") + "WiiCompiled supports 64-bit LLVM-MinGW Clang on Windows, native Linux x86_64/aarch64, or Apple Clang on macOS x86_64/arm64") endif() if(NOT CMAKE_BUILD_TYPE STREQUAL "Release") message(FATAL_ERROR "WiiCompiled only supports Release builds") @@ -59,14 +80,15 @@ target_include_directories(mkw_pugixml PUBLIC third_party/pugixml) target_compile_features(mkw_pugixml PUBLIC cxx_std_17) set_target_properties(mkw_pugixml PROPERTIES UNITY_BUILD OFF) -# Linux guest-fiber scheduling (runtime/src/host_context.cpp) needs a symmetric +# POSIX x86-64 guest-fiber scheduling (runtime/src/host_context.cpp) needs a symmetric # stackful-coroutine primitive to stand in for Win32 Fibers. libco's co_switch() transfers # directly to any other created coroutine, matching SwitchToFiber's semantics exactly (unlike # asymmetric resume/yield coroutine libraries, which would need every call site restructured). # Vendored from upstream (higan-emu/libco @ e18e09d, 2019-10-16, ISC license; valgrind.h is -# separately BSD-style licensed, see third_party/libco/LICENSE). Windows keeps native Fibers -# and macOS uses the project's x18-safe AArch64 assembly backend, so this target is Linux-only. -if(MKW_PLATFORM_LINUX) +# separately BSD-style licensed, see third_party/libco/LICENSE). Windows keeps native Fibers; +# Apple Silicon uses the project's x18-safe AArch64 assembly backend, while Intel macOS uses +# libco's existing System V AMD64 backend. +if(MKW_PLATFORM_LINUX OR MKW_PLATFORM_MACOS_X86_64) add_library(mkw_libco STATIC third_party/libco/libco.c) add_library(mkw::libco ALIAS mkw_libco) target_include_directories(mkw_libco PUBLIC third_party/libco) @@ -280,12 +302,14 @@ endif() file(GLOB_RECURSE SOURCES CONFIGURE_DEPENDS "src/*.cpp") if(MKW_PLATFORM_MACOS) list(REMOVE_ITEM SOURCES "${CMAKE_CURRENT_LIST_DIR}/src/guest_flat_memory.cpp") - # HostContext's Apple Silicon backend is implemented in a small assembly - # companion. It must be part of the product runtime as well as the - # standalone context test; otherwise the final executable is missing - # mkw_co_init/mkw_co_switch at link time. - enable_language(ASM) - list(APPEND SOURCES "${CMAKE_CURRENT_LIST_DIR}/src/platform/macos/co_switch.S") + if(MKW_PLATFORM_MACOS_ARM64) + # HostContext's Apple Silicon backend is implemented in a small assembly + # companion. It must be part of the product runtime as well as the + # standalone context test; otherwise the final executable is missing + # mkw_co_init/mkw_co_switch at link time. + enable_language(ASM) + list(APPEND SOURCES "${CMAKE_CURRENT_LIST_DIR}/src/platform/macos/co_switch.S") + endif() else() list(REMOVE_ITEM SOURCES "${CMAKE_CURRENT_LIST_DIR}/src/guest_flat_memory_macos.cpp") endif() @@ -356,20 +380,31 @@ if(MKW_PLATFORM_LINUX) endif() if(MKW_PLATFORM_MACOS) - # Exercise the Apple Silicon context ABI and the public host-memory - # contracts separately from translated products. - enable_language(ASM) - add_executable(mkw_macos_context_abi_tests - "${CMAKE_CURRENT_LIST_DIR}/tests/macos_context_abi_tests.cpp" - "${CMAKE_CURRENT_LIST_DIR}/src/platform/macos/co_switch.S") - target_compile_features(mkw_macos_context_abi_tests PRIVATE cxx_std_17) - add_test(NAME mkw_macos_context_abi_tests COMMAND mkw_macos_context_abi_tests) - - add_executable(mkw_macos_host_context_tests - "${CMAKE_CURRENT_LIST_DIR}/tests/host_context_tests.cpp" - "${CMAKE_CURRENT_LIST_DIR}/src/host_context.cpp" - "${CMAKE_CURRENT_LIST_DIR}/src/platform/macos/co_switch.S") - target_include_directories(mkw_macos_host_context_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") + # Exercise the public host-memory contracts separately from translated products. + if(MKW_PLATFORM_MACOS_ARM64) + # Apple Silicon's context ABI is implemented by the local assembly backend. + enable_language(ASM) + add_executable(mkw_macos_context_abi_tests + "${CMAKE_CURRENT_LIST_DIR}/tests/macos_context_abi_tests.cpp" + "${CMAKE_CURRENT_LIST_DIR}/src/platform/macos/co_switch.S") + target_compile_features(mkw_macos_context_abi_tests PRIVATE cxx_std_17) + add_test(NAME mkw_macos_context_abi_tests COMMAND mkw_macos_context_abi_tests) + + add_executable(mkw_macos_host_context_tests + "${CMAKE_CURRENT_LIST_DIR}/tests/host_context_tests.cpp" + "${CMAKE_CURRENT_LIST_DIR}/src/host_context.cpp" + "${CMAKE_CURRENT_LIST_DIR}/src/platform/macos/co_switch.S") + target_include_directories(mkw_macos_host_context_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") + else() + # Intel macOS follows the same System V AMD64 libco path as Linux. + add_executable(mkw_macos_host_context_tests + "${CMAKE_CURRENT_LIST_DIR}/tests/host_context_tests.cpp" + "${CMAKE_CURRENT_LIST_DIR}/src/host_context.cpp") + target_include_directories(mkw_macos_host_context_tests PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/include" + "${CMAKE_CURRENT_LIST_DIR}/third_party/libco") + target_link_libraries(mkw_macos_host_context_tests PRIVATE mkw::libco) + endif() target_compile_features(mkw_macos_host_context_tests PRIVATE cxx_std_17) add_test(NAME mkw_macos_host_context_tests COMMAND mkw_macos_host_context_tests) @@ -427,7 +462,14 @@ else() target_compile_definitions(mkw_macos_native_compile PRIVATE SDL_MAIN_HANDLED TARGET_PC) target_link_libraries(mkw_macos_native_compile PRIVATE aurora::gx aurora::pad aurora::si aurora::vi aurora::mtx - mkw::pugixml mkw::toml11 mkw::cryptopp) + mkw::pugixml mkw::toml11 mkw::cryptopp mkw::mbedtls) + if(MKW_PLATFORM_MACOS_X86_64) + target_link_libraries(mkw_macos_native_compile PRIVATE mkw::libco) + # Keep this compile-only audit on the same Haswell-era x86-64-v3 + # baseline as the translated product. PPC paired FMA helpers use + # FMA intrinsics and intentionally cannot compile for plain x86-64. + target_compile_options(mkw_macos_native_compile PRIVATE -march=x86-64-v3) + endif() set_target_properties(mkw_macos_native_compile PROPERTIES UNITY_BUILD OFF) endif() add_custom_target(mkw_platform_paths_check DEPENDS mkw_platform) diff --git a/runtime/cmake/PublicProducts.cmake b/runtime/cmake/PublicProducts.cmake index e76e75a5e..6c7038699 100644 --- a/runtime/cmake/PublicProducts.cmake +++ b/runtime/cmake/PublicProducts.cmake @@ -84,9 +84,9 @@ target_link_libraries(mkw_runtime_common PRIVATE target_link_libraries(mkw_runtime_common PRIVATE mkw_platform mkw::pugixml mkw::toml11 mkw::cryptopp mkw::mbedtls) if(MKW_PLATFORM_WINDOWS) target_link_libraries(mkw_runtime_common PRIVATE shell32 windowsapp) -elseif(MKW_PLATFORM_LINUX) +elseif(MKW_PLATFORM_LINUX OR MKW_PLATFORM_MACOS_X86_64) # ${CMAKE_DL_LIBS} for music_attenuation.cpp's dlopen of libdbus-1 (MPRIS - # media monitoring). Empty string on glibc >= 2.34 where dl* is in libc. + # media monitoring). Empty on platforms where dl* is already in libc/libSystem. target_link_libraries(mkw_runtime_common PRIVATE mkw::libco ${CMAKE_DL_LIBS}) endif() if(MKW_CPPWINRT_INCLUDE_DIR) @@ -226,7 +226,7 @@ function(mkw_configure_product target) dbghelp user32 winmm ws2_32 iphlpapi secur32 crypt32 windowsapp) set_target_properties(${target} PROPERTIES WIN32_EXECUTABLE TRUE) - elseif(MKW_PLATFORM_LINUX) + elseif(MKW_PLATFORM_LINUX OR MKW_PLATFORM_MACOS_X86_64) # mkw_runtime_common is an OBJECT library: WiiCompiled/RetroRewind only pull in its .o # files via $, which does not propagate mkw_runtime_common's own # target_link_libraries (object libraries don't carry usage requirements to a consumer diff --git a/runtime/include/host_context.h b/runtime/include/host_context.h index 4d7355f7c..5ea6bfb5b 100644 --- a/runtime/include/host_context.h +++ b/runtime/include/host_context.h @@ -3,11 +3,11 @@ #include // HostContext is the deliberately small boundary between the guest scheduler -// and the host's cooperative-context facility. Windows uses native Fibers and -// Linux uses libco; macOS AArch64 uses the local assembly backend because it -// must preserve Darwin's platform-reserved x18 register, which libco's AArch64 -// backend does not save. Its handles are only valid on the thread that -// initialized the scheduler. +// and the host's cooperative-context facility. Windows uses native Fibers; +// Linux and Intel macOS use libco's System V x86-64 backend. macOS AArch64 uses +// the local assembly backend because it must preserve Darwin's platform-reserved +// x18 register, which libco's AArch64 backend does not save. Its handles are +// only valid on the thread that initialized the scheduler. namespace HostContext { using Handle = void*; diff --git a/runtime/src/host_context.cpp b/runtime/src/host_context.cpp index e23c1c036..502f72c49 100644 --- a/runtime/src/host_context.cpp +++ b/runtime/src/host_context.cpp @@ -14,7 +14,7 @@ extern "C" void mkw_co_switch(void** targetSp, void** sourceSp); extern "C" void* mkw_co_init(void* stackTop, void (*entry)(void*), void* argument); -#elif defined(__linux__) +#elif defined(__linux__) || (defined(__APPLE__) && defined(__x86_64__)) #include #include @@ -158,7 +158,7 @@ void Switch(Handle target) g_current = source; } -#elif defined(__linux__) +#elif defined(__linux__) || (defined(__APPLE__) && defined(__x86_64__)) namespace { struct Context { diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 780539eeb..1597d208f 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -44,10 +44,15 @@ #include #if defined(__x86_64__) // Only the x86 POSIX fault path inspects ucontext_t to recover the page-fault -// write bit. macOS deprecates ucontext and requires _XOPEN_SOURCE just to -// include the header, while the arm64 handler does not use it at all. +// write bit. macOS exposes the signal-handler context through sys/ucontext.h; +// avoid ucontext.h itself because its deprecated user-context APIs require +// _XOPEN_SOURCE. The arm64 handler does not inspect a host context at all. +#if defined(__APPLE__) +#include +#else #include #endif +#endif #include #endif @@ -1128,7 +1133,11 @@ void PosixMemoryFaultHandler(int sig, siginfo_t* info, void* ucontextVoid) { // error code x86 pushes on a page fault records whether it was a write. if (ucontextVoid != nullptr) { auto* uc = static_cast(ucontextVoid); +#if defined(__APPLE__) + isWrite = uc->uc_mcontext != nullptr && (uc->uc_mcontext->__es.__err & 0x2) != 0; +#else isWrite = (uc->uc_mcontext.gregs[REG_ERR] & 0x2) != 0; +#endif } #endif From bb841154ed723b504e90f1e6a6eb873a96727f62 Mon Sep 17 00:00:00 2001 From: Michael G <10155689+DarthMDev@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:13:55 -0400 Subject: [PATCH 02/14] (feat) Apple Silicon macOS CI building and Setup.pkg documentation (#118) * docs(macos): document Setup.pkg installation Add Apple Silicon macOS CI coverage for runtime configuration, substrate tests, and Setup.pkg packaging alongside the macOS installation instructions. * macos: pin Apple Silicon deployment target * fix(macos): restore Retro Rewind local builds --- .github/workflows/build.yml | 31 +++++++ .github/workflows/package.yml | 102 +++++++++++++++++++-- Launcher/local-build-macos.command | 4 +- Launcher/macos/build-setup-pkg.command | 3 + Launcher/macos/setup.command | 26 +++++- README.md | 15 +++ aurora-main/cmake/AuroraSDL3Provider.cmake | 7 +- aurora-main/lib/dolphin/pad/pad.cpp | 5 +- aurora-main/lib/system_info.cpp | 2 +- runtime/CMakeLists.txt | 10 ++ 10 files changed, 191 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a156710a5..795128006 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -46,3 +46,34 @@ jobs: - name: Test run: dotnet test translator/Translator.sln -c Release --no-build --verbosity normal + + macos_substrate: + name: macOS arm64 (configure + substrate tests) + runs-on: macos-14 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Configure native runtime + shell: bash + run: | + test "$(uname -m)" = arm64 + cmake -S runtime -B build-macos -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DMKW_BUILD_PRODUCTS=OFF + grep -qx 'CMAKE_OSX_DEPLOYMENT_TARGET:STRING=14.0' \ + build-macos/CMakeCache.txt + + - name: Build macOS portability targets + shell: bash + run: | + cmake --build build-macos --target \ + mkw_platform_paths_tests \ + mkw_macos_native_compile \ + mkw_macos_context_abi_tests \ + mkw_macos_host_context_tests \ + mkw_macos_guest_flat_memory_tests + + - name: Test execution substrate + shell: bash + run: ctest --test-dir build-macos --output-on-failure diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 2022fbedb..1753b70f8 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -1,8 +1,8 @@ name: Package installers # Builds the per-platform installer/setup tool (WiiCompiled-Setup.exe / -# WiiCompiled-Setup-x86_64.AppImage) via Launcher/Build-Installer.ps1 and -# Launcher/build-appimage.sh respectively - the same scripts a maintainer runs by hand today to +# WiiCompiled-Setup-x86_64.AppImage / WiiCompiled-Setup.pkg) via the platform packaging scripts - +# the same scripts a maintainer runs by hand today to # produce a GitHub Release asset. This does NOT build the actual translated game executable: # that step requires the end user's own Mario Kart Wii dump (Assets/main.dol, Assets/StaticR.rel), # which is proprietary and not present in this repository or in CI. @@ -11,6 +11,11 @@ on: tags: - '*' workflow_dispatch: + inputs: + version: + description: Package version + required: true + type: string permissions: contents: read @@ -90,14 +95,99 @@ jobs: if-no-files-found: error archive: false + macos-setup-package: + name: macOS (Setup.pkg, Apple Silicon) + runs-on: macos-14 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '8.0.x' + + - name: Verify Apple Silicon runner tools + shell: bash + run: | + test "$(uname -m)" = arm64 + xcode-select -p + command -v ninja + file "$(command -v ninja)" | grep -q arm64 + + - name: Download pinned nodtool release + shell: bash + run: | + mkdir -p Launcher/artifacts/macos + nodtool_version=v2.0.0-alpha.10 + nodtool_asset=nodtool-macos-arm64 + nodtool_sha256=e23ca466999b720c55e6d29c9683fce8cc74451ba64ead2e543d50129f24528a + curl -fsSL --retry 3 \ + "https://github.com/encounter/nod/releases/download/${nodtool_version}/${nodtool_asset}" \ + -o Launcher/artifacts/macos/nodtool + printf '%s %s\n' "$nodtool_sha256" Launcher/artifacts/macos/nodtool | shasum -a 256 -c - + chmod +x Launcher/artifacts/macos/nodtool + Launcher/artifacts/macos/nodtool --version + + - name: Publish self-contained translator + shell: bash + run: | + dotnet publish translator/src/Translator.Cli/Translator.Cli.csproj \ + -c Release -r osx-arm64 --self-contained true \ + -p:PublishSingleFile=true \ + -o Launcher/artifacts/macos/translator + + - name: Download pinned portable CMake + shell: bash + run: | + cmake_version=4.4.3 + archive="cmake-${cmake_version}-macos-universal.tar.gz" + base_url="https://github.com/Kitware/CMake/releases/download/v${cmake_version}" + expected_sha256=0c5d65251c14cc884bfa16bdbed3c263ce5bffe2e21c0d0d00962cb0610464fa + curl -fsSL --retry 3 "$base_url/$archive" -o "$archive" + printf '%s %s\n' "$expected_sha256" "$archive" | shasum -a 256 -c - + tar -xzf "$archive" + mv "cmake-${cmake_version}-macos-universal/CMake.app/Contents" Launcher/artifacts/macos/cmake + + - name: Build Setup.pkg + env: + PACKAGE_VERSION: ${{ inputs.version }} + TAG_VERSION: ${{ github.ref_name }} + shell: bash + run: | + package_version="$PACKAGE_VERSION" + if [[ -z "$package_version" ]]; then package_version="${TAG_VERSION#v}"; fi + mkdir -p Launcher/dist + Launcher/macos/build-setup-pkg.command \ + --nodtool Launcher/artifacts/macos/nodtool \ + --translator Launcher/artifacts/macos/translator/Translator.Cli \ + --cmake-root Launcher/artifacts/macos/cmake \ + --ninja "$(command -v ninja)" \ + --output Launcher/dist/WiiCompiled-Setup.pkg \ + --version "$package_version" + + - name: Verify package boundary + shell: bash + run: | + pkgutil --check-signature Launcher/dist/WiiCompiled-Setup.pkg + ! pkgutil --payload-files Launcher/dist/WiiCompiled-Setup.pkg | \ + grep -E '/(Assets|generated|PulsarPacks|WiiCompiled.app|RetroRewind.app)(/|$)' + + - uses: actions/upload-artifact@v7 + with: + name: WiiCompiled-Setup-macos-arm64 + path: Launcher/dist/WiiCompiled-Setup.pkg + if-no-files-found: error + archive: false + # Publishes the packaged installers as a GitHub Release whenever a v* tag is pushed. Wheel Wizard # discovers updates from these releases, so the contract it relies on is enforced here: a full - # (non-prerelease) release whose tag is v, carrying an asset named exactly - # WiiCompiled-Setup.exe, produced by a setup host that reports that same version. + # (non-prerelease) release whose tag is v, carrying the expected platform assets, + # produced by setup hosts that report that same version. release: name: Publish GitHub Release if: startsWith(github.ref, 'refs/tags/v') - needs: [linux-appimage, windows-installer, recompilation] + needs: [linux-appimage, windows-installer, macos-setup-package, recompilation] runs-on: ubuntu-latest permissions: contents: write @@ -141,7 +231,7 @@ jobs: set -euo pipefail ls -lR artifacts assets=() - for name in WiiCompiled-Setup.exe WiiCompiled-Setup-x86_64.AppImage WiiCompiled-Setup-aarch64.AppImage; do + for name in WiiCompiled-Setup.exe WiiCompiled-Setup-x86_64.AppImage WiiCompiled-Setup-aarch64.AppImage WiiCompiled-Setup.pkg; do found="$(find artifacts -type f -name "$name" | head -n 1)" [ -n "$found" ] && [ -s "$found" ] || { echo "::error::missing release asset $name"; exit 1; } assets+=("$found") diff --git a/Launcher/local-build-macos.command b/Launcher/local-build-macos.command index 71523065e..4c238fd2d 100755 --- a/Launcher/local-build-macos.command +++ b/Launcher/local-build-macos.command @@ -134,7 +134,9 @@ if (( builds_retro )); then args+=(--resolved-profile "$mod_out/resolved_dispatc step emit-build-shards 'Preparing native build shards'; translator "${args[@]}" step configure-native 'Configuring the native toolchain' -"$cmake_bin" -S "$workspace/runtime" -B "$native_build" -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_MAKE_PROGRAM="$ninja_bin" -DCMAKE_OSX_ARCHITECTURES="$macos_arch" -DCMAKE_OSX_DEPLOYMENT_TARGET="$macos_deployment_target" -DMKW_TRANSLATED_COMPILE_JOBS="$translated_jobs" +# Use Aurora's pinned SDL3 source on macOS. A system SDL3 can be older than +# Aurora's required API even when find_package() succeeds. +"$cmake_bin" -S "$workspace/runtime" -B "$native_build" -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_MAKE_PROGRAM="$ninja_bin" -DCMAKE_OSX_ARCHITECTURES="$macos_arch" -DCMAKE_OSX_DEPLOYMENT_TARGET="$macos_deployment_target" -DMKW_TRANSLATED_COMPILE_JOBS="$translated_jobs" -DAURORA_SDL3_PROVIDER=vendor targets=(); [[ "$profile" != retro-rewind ]] && targets+=(WiiCompiled); [[ "$profile" != base ]] && targets+=(RetroRewind) step compile "Compiling ${targets[*]} locally"; "$cmake_bin" --build "$native_build" --target "${targets[@]}" --parallel "$global_jobs" if [[ "$profile" != retro-rewind ]]; then "$script_dir/macos/publish-app.command" --build-dir "$native_build" --product WiiCompiled --output-dir "${base_output_dir:-$output_dir}" --architecture "$macos_arch" --minimum-system-version "$macos_deployment_target"; fi diff --git a/Launcher/macos/build-setup-pkg.command b/Launcher/macos/build-setup-pkg.command index ffd465d5a..67c323ffa 100755 --- a/Launcher/macos/build-setup-pkg.command +++ b/Launcher/macos/build-setup-pkg.command @@ -145,6 +145,9 @@ copy_clean "$workspace/Launcher/local-build-macos.command" "$resources/workspace copy_clean "$workspace/Launcher/macos/extract-disc.command" "$resources/workspace/Launcher/macos/extract-disc.command" copy_clean "$workspace/Launcher/macos/publish-app.command" "$resources/workspace/Launcher/macos/publish-app.command" chmod +x "$resources/workspace/Launcher/local-build-macos.command" "$resources/workspace/Launcher/macos/"*.command +# setup.command uses this marker to update source inputs in an existing user +# workspace without replacing extracted game assets or Retro Rewind files. +printf '%s\n' "$version" > "$resources/workspace/.bundle-version" mkdir -p "$resources/tools/cmake" "$resources/tools/arm64" "$resources/tools/x86_64" copy_clean "$nodtool_arm64" "$resources/tools/arm64/nodtool"; chmod +x "$resources/tools/arm64/nodtool" copy_clean "$nodtool_x86_64" "$resources/tools/x86_64/nodtool"; chmod +x "$resources/tools/x86_64/nodtool" diff --git a/Launcher/macos/setup.command b/Launcher/macos/setup.command index 85b91e7bf..58edbda37 100755 --- a/Launcher/macos/setup.command +++ b/Launcher/macos/setup.command @@ -70,10 +70,30 @@ for tool in "$nodtool" "$translator" "$cmake_bin" "$ninja_bin"; do done mkdir -p "$support_root" "$products" -if [[ ! -d "$workspace/.git" && ! -f "$workspace/projects/mkwii/recomp.yml" ]]; then +source_bundle_version="$workspace_source/.bundle-version" +workspace_bundle_version="$workspace/.bundle-version" +needs_workspace_refresh=0 +if [[ ! -f "$workspace/projects/mkwii/recomp.yml" ]]; then + needs_workspace_refresh=1 +elif [[ -f "$source_bundle_version" ]] && [[ ! -f "$workspace_bundle_version" || "$(<"$source_bundle_version")" != "$(<"$workspace_bundle_version")" ]]; then + needs_workspace_refresh=1 +fi + +if (( needs_workspace_refresh )); then printf 'Preparing the local build workspace...\n' - rm -rf "$workspace" - /usr/bin/ditto "$workspace_source" "$workspace" + if [[ ! -d "$workspace" ]]; then + /usr/bin/ditto "$workspace_source" "$workspace" + else + # Refresh only packaged source inputs. Assets and the staged Retro + # Rewind package belong to the user and stay in place. + for source in aurora-main projects runtime translator Launcher; do + /usr/bin/ditto "$workspace_source/$source" "$workspace/$source" + done + /usr/bin/ditto "$source_bundle_version" "$workspace_bundle_version" + # A dependency provider can be cached in this directory, so make the + # refreshed sources configure from a clean native build tree. + rm -rf "$workspace/native-build-macos" + fi fi profile=base diff --git a/README.md b/README.md index 48e1c75c2..745b598ee 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,21 @@ image under Settings, turn on **WiiCompiled (beta)**, and hit install from the H Wheel Wizard downloads the setup tool from this repo and walks you through install, updates and launching. The backend itself is deliberately command-line only, Wheel Wizard is a wrapper around it. +### macOS + +Download `WiiCompiled-Setup.pkg` from this repository's Releases page and open it. It requires an +Apple Silicon Mac because its bundled nodtool and Translator.Cli executables are arm64. It installs +**WiiCompiled Setup** in Applications; open that app, choose your clean PAL `RMCP01` disc image, +and select either the base game or Retro Rewind. For Retro Rewind, choose the `RetroRewind6` folder +or its parent folder. + +Setup verifies and extracts the image locally, then translates and compiles the native app on your +Mac. On a first run it may ask macOS to install Xcode Command Line Tools; complete Apple's installer, +then open Setup again. When the build completes, Setup asks for administrator approval once to install +`WiiCompiled.app` (and, if selected, `RetroRewind.app`) in `/Applications`. + +Setup opens Terminal while it works, so the extraction and build progress—and any error that needs +reporting—remain visible. > [!CAUTION] > Only take builds from this repository's diff --git a/aurora-main/cmake/AuroraSDL3Provider.cmake b/aurora-main/cmake/AuroraSDL3Provider.cmake index 16ce9336a..7f3de510f 100644 --- a/aurora-main/cmake/AuroraSDL3Provider.cmake +++ b/aurora-main/cmake/AuroraSDL3Provider.cmake @@ -41,7 +41,10 @@ if (_aurora_sdl3_provider STREQUAL "auto") set(_aurora_sdl3_provider "package") else () set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL ON) - find_package(SDL3 QUIET) + # Aurora uses APIs from the SDL version pinned by AURORA_SDL3_VERSION. + # Do not silently select an older system package and fail later while + # compiling its headers. + find_package(SDL3 ${AURORA_SDL3_VERSION} QUIET) set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL OFF) if (SDL3_FOUND) set(_aurora_sdl3_provider "system") @@ -58,7 +61,7 @@ if (_aurora_sdl3_provider STREQUAL "system") message(STATUS "aurora: Using system SDL3 (provider=system)") if (NOT SDL3_FOUND) set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL ON) - find_package(SDL3 REQUIRED) + find_package(SDL3 ${AURORA_SDL3_VERSION} REQUIRED) set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL OFF) endif () _aurora_sdl3_select_target() diff --git a/aurora-main/lib/dolphin/pad/pad.cpp b/aurora-main/lib/dolphin/pad/pad.cpp index 1b315ca3a..2b0e48825 100644 --- a/aurora-main/lib/dolphin/pad/pad.cpp +++ b/aurora-main/lib/dolphin/pad/pad.cpp @@ -1468,7 +1468,10 @@ void __PADWriteDeadZones(SDL_IOStream* file, // NOLINT(*-reserved-identifier) void PADSerializeMappings() { const std::filesystem::path basePath = fs_path_from_string(aurora::g_config.userPath); - for (auto& controller : aurora::input::g_GameControllers | std::views::values) { + // Avoid std::views::values here: older Apple libc++ releases implement the + // C++20 ranges algorithms we use but not this adaptor. + for (auto& entry : aurora::input::g_GameControllers) { + auto& controller = entry.second; EnsureMappingLoaded(&controller); const auto filePath = basePath / fmt::format("{}_{:04X}_{:04X}.controller", aurora::input::controller_name(controller.m_index), diff --git a/aurora-main/lib/system_info.cpp b/aurora-main/lib/system_info.cpp index 15a58d41c..9197cd0e2 100644 --- a/aurora-main/lib/system_info.cpp +++ b/aurora-main/lib/system_info.cpp @@ -235,7 +235,7 @@ std::string GetOSVersion() { constexpr auto name = "iOS"; #elif TARGET_OS_TV constexpr auto name = "tvOS"; -#elif +#else constexpr auto name = Unknown; #endif diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index ece9373ab..5212929a0 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -38,6 +38,16 @@ else() message(FATAL_ERROR "WiiCompiled supports 64-bit LLVM-MinGW Clang on Windows, native Linux x86_64/aarch64, or Apple Clang on macOS x86_64/arm64") endif() + +# Do not inherit the host SDK's deployment target (macOS 26 on current +# toolchains). The supported Apple Silicon release is macOS 14 and the app +# bundle advertises that same minimum. This remains arm64-only until the Intel +# support work is merged. +if(MKW_PLATFORM_MACOS) + set(CMAKE_OSX_DEPLOYMENT_TARGET "14.0" CACHE STRING + "Minimum macOS version for the Apple Silicon build" FORCE) +endif() + if(NOT CMAKE_BUILD_TYPE STREQUAL "Release") message(FATAL_ERROR "WiiCompiled only supports Release builds") endif() From e00f49e9bad7e49cc185288abe2fcd6801f80764 Mon Sep 17 00:00:00 2001 From: DarthM Date: Sat, 5 Sep 2026 07:01:52 -0400 Subject: [PATCH 03/14] macos: package universal setup tools --- .github/workflows/package.yml | 69 ++++++++++++++++++++++--------- Launcher/macos/setup.command | 2 +- runtime/src/music_attenuation.cpp | 22 ++++++++-- 3 files changed, 69 insertions(+), 24 deletions(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 1753b70f8..817821be6 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -96,7 +96,7 @@ jobs: archive: false macos-setup-package: - name: macOS (Setup.pkg, Apple Silicon) + name: macOS (universal Setup.pkg) runs-on: macos-14 steps: - uses: actions/checkout@v7 @@ -107,35 +107,52 @@ jobs: with: dotnet-version: '8.0.x' - - name: Verify Apple Silicon runner tools + - name: Verify Apple Silicon runner shell: bash run: | test "$(uname -m)" = arm64 xcode-select -p - command -v ninja - file "$(command -v ninja)" | grep -q arm64 - - name: Download pinned nodtool release + - name: Download pinned Nod tools shell: bash run: | mkdir -p Launcher/artifacts/macos nodtool_version=v2.0.0-alpha.10 - nodtool_asset=nodtool-macos-arm64 - nodtool_sha256=e23ca466999b720c55e6d29c9683fce8cc74451ba64ead2e543d50129f24528a + nodtool_arm64_asset=nodtool-macos-arm64 + nodtool_arm64_sha256=e23ca466999b720c55e6d29c9683fce8cc74451ba64ead2e543d50129f24528a + nodtool_x86_64_asset=nodtool-macos-x86_64 + nodtool_x86_64_sha256=f68f504dc2b72694b468ca78b6a24142c7aa5c8800f77564297f4143682e6575 curl -fsSL --retry 3 \ - "https://github.com/encounter/nod/releases/download/${nodtool_version}/${nodtool_asset}" \ - -o Launcher/artifacts/macos/nodtool - printf '%s %s\n' "$nodtool_sha256" Launcher/artifacts/macos/nodtool | shasum -a 256 -c - - chmod +x Launcher/artifacts/macos/nodtool - Launcher/artifacts/macos/nodtool --version + "https://github.com/encounter/nod/releases/download/${nodtool_version}/${nodtool_arm64_asset}" \ + -o Launcher/artifacts/macos/nodtool-arm64 + curl -fsSL --retry 3 \ + "https://github.com/encounter/nod/releases/download/${nodtool_version}/${nodtool_x86_64_asset}" \ + -o Launcher/artifacts/macos/nodtool-x86_64 + printf '%s %s\n' "$nodtool_arm64_sha256" Launcher/artifacts/macos/nodtool-arm64 | shasum -a 256 -c - + printf '%s %s\n' "$nodtool_x86_64_sha256" Launcher/artifacts/macos/nodtool-x86_64 | shasum -a 256 -c - + chmod +x Launcher/artifacts/macos/nodtool-arm64 Launcher/artifacts/macos/nodtool-x86_64 - - name: Publish self-contained translator + - name: Publish self-contained Translator tools shell: bash run: | dotnet publish translator/src/Translator.Cli/Translator.Cli.csproj \ -c Release -r osx-arm64 --self-contained true \ -p:PublishSingleFile=true \ - -o Launcher/artifacts/macos/translator + -o Launcher/artifacts/macos/translator-arm64 + dotnet publish translator/src/Translator.Cli/Translator.Cli.csproj \ + -c Release -r osx-x64 --self-contained true \ + -p:PublishSingleFile=true \ + -o Launcher/artifacts/macos/translator-x86_64 + + - name: Download pinned universal Ninja + shell: bash + run: | + ninja_version=1.13.2 + ninja_sha256=c99048673aa765960a99cf10c6ddb9f1fad506099ff0a0e137ad8960a88f321b + curl -fsSL --retry 3 "https://github.com/ninja-build/ninja/releases/download/v${ninja_version}/ninja-mac.zip" -o ninja-mac.zip + printf '%s %s\n' "$ninja_sha256" ninja-mac.zip | shasum -a 256 -c - + unzip -q ninja-mac.zip -d Launcher/artifacts/macos/ninja + chmod +x Launcher/artifacts/macos/ninja/ninja - name: Download pinned portable CMake shell: bash @@ -159,23 +176,37 @@ jobs: if [[ -z "$package_version" ]]; then package_version="${TAG_VERSION#v}"; fi mkdir -p Launcher/dist Launcher/macos/build-setup-pkg.command \ - --nodtool Launcher/artifacts/macos/nodtool \ - --translator Launcher/artifacts/macos/translator/Translator.Cli \ + --nodtool-arm64 Launcher/artifacts/macos/nodtool-arm64 \ + --nodtool-x86_64 Launcher/artifacts/macos/nodtool-x86_64 \ + --translator-arm64 Launcher/artifacts/macos/translator-arm64/Translator.Cli \ + --translator-x86_64 Launcher/artifacts/macos/translator-x86_64/Translator.Cli \ --cmake-root Launcher/artifacts/macos/cmake \ - --ninja "$(command -v ninja)" \ + --ninja-arm64 Launcher/artifacts/macos/ninja/ninja \ + --ninja-x86_64 Launcher/artifacts/macos/ninja/ninja \ --output Launcher/dist/WiiCompiled-Setup.pkg \ --version "$package_version" - - name: Verify package boundary + - name: Verify package layout and architecture-specific tools shell: bash run: | pkgutil --check-signature Launcher/dist/WiiCompiled-Setup.pkg ! pkgutil --payload-files Launcher/dist/WiiCompiled-Setup.pkg | \ grep -E '/(Assets|generated|PulsarPacks|WiiCompiled.app|RetroRewind.app)(/|$)' + expanded="$RUNNER_TEMP/wiicompiled-setup-expanded" + pkgutil --expand-full Launcher/dist/WiiCompiled-Setup.pkg "$expanded" + resources="$expanded/Payload/Applications/WiiCompiled Setup.app/Contents/Resources" + for arch in arm64 x86_64; do + for tool in nodtool Translator.Cli ninja; do + lipo "$resources/tools/$arch/$tool" -verify_arch "$arch" + done + done + lipo "$resources/tools/cmake/bin/cmake" -verify_arch arm64 x86_64 + bash "$resources/setup.command" --help + /usr/bin/arch -x86_64 /bin/bash "$resources/setup.command" --help - uses: actions/upload-artifact@v7 with: - name: WiiCompiled-Setup-macos-arm64 + name: WiiCompiled-Setup-macos-universal path: Launcher/dist/WiiCompiled-Setup.pkg if-no-files-found: error archive: false diff --git a/Launcher/macos/setup.command b/Launcher/macos/setup.command index 58edbda37..315c8211a 100755 --- a/Launcher/macos/setup.command +++ b/Launcher/macos/setup.command @@ -92,7 +92,7 @@ if (( needs_workspace_refresh )); then /usr/bin/ditto "$source_bundle_version" "$workspace_bundle_version" # A dependency provider can be cached in this directory, so make the # refreshed sources configure from a clean native build tree. - rm -rf "$workspace/native-build-macos" + rm -rf "$workspace/native-build-macos-arm64" "$workspace/native-build-macos-x86_64" fi fi diff --git a/runtime/src/music_attenuation.cpp b/runtime/src/music_attenuation.cpp index 3ce2b82c9..3d0f123d9 100644 --- a/runtime/src/music_attenuation.cpp +++ b/runtime/src/music_attenuation.cpp @@ -4,9 +4,9 @@ #include #include -#include #include #include +#include #include #include #include @@ -52,6 +52,20 @@ std::array g_requestedSoundPlayerVolumes{}; std::array g_lastAppliedSoundPlayerVolumes{}; std::array g_haveSoundPlayerVolumes{}; +uint32_t FloatBits(float value) noexcept { + static_assert(sizeof(float) == sizeof(uint32_t)); + uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + return bits; +} + +float BitsFloat(uint32_t bits) noexcept { + static_assert(sizeof(float) == sizeof(uint32_t)); + float value = 0.0f; + std::memcpy(&value, &bits, sizeof(value)); + return value; +} + float ClampSoundPlayerVolume(float volume) noexcept { // Match nw4r::snd::SoundPlayer::SetVolume at 0x800A35E0 exactly, // including its NaN behavior (unordered compares select the upper bound). @@ -63,7 +77,7 @@ float ClampSoundPlayerVolume(float volume) noexcept { bool WriteGuestFloat(uint32_t address, float value) noexcept { try { - Memory::Write32(address, std::bit_cast(value)); + Memory::Write32(address, FloatBits(value)); return true; } catch (const Memory::AccessViolation&) { return false; @@ -75,7 +89,7 @@ bool ReadGuestFloat(uint32_t address, float& value) noexcept { if (!Memory::TryRead32(address, bits)) { return false; } - value = std::bit_cast(bits); + value = BitsFloat(bits); return true; } @@ -558,7 +572,7 @@ void SetSoundPlayerVolume(uint32_t soundPlayer, float requestedVolume) { } // Preserve the original function's access semantics. An invalid player is // a guest bug and must not be converted into a silent successful call. - Memory::Write32(soundPlayer + kSoundPlayerVolumeOffset, std::bit_cast(applied)); + Memory::Write32(soundPlayer + kSoundPlayerVolumeOffset, FloatBits(applied)); } } // namespace MusicAttenuation From b1236587c2248660ecb1960075f0eb95dd40d533 Mon Sep 17 00:00:00 2001 From: DarthM Date: Sat, 5 Sep 2026 07:10:05 -0400 Subject: [PATCH 04/14] ci: build macOS input expression tests --- .github/workflows/build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 795128006..efc4e269e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -61,7 +61,7 @@ jobs: test "$(uname -m)" = arm64 cmake -S runtime -B build-macos -G Ninja \ -DCMAKE_BUILD_TYPE=Release -DMKW_BUILD_PRODUCTS=OFF - grep -qx 'CMAKE_OSX_DEPLOYMENT_TARGET:STRING=14.0' \ + grep -qx 'CMAKE_OSX_DEPLOYMENT_TARGET:STRING=12.0' \ build-macos/CMakeCache.txt - name: Build macOS portability targets @@ -69,6 +69,7 @@ jobs: run: | cmake --build build-macos --target \ mkw_platform_paths_tests \ + mkw_input_expr_tests \ mkw_macos_native_compile \ mkw_macos_context_abi_tests \ mkw_macos_host_context_tests \ From c81a130a6990416b7e85efb8dbc31c397f93f507 Mon Sep 17 00:00:00 2001 From: DarthM Date: Sat, 5 Sep 2026 07:14:36 -0400 Subject: [PATCH 05/14] runtime: Do not force 14.0 minimum anymore new minimum is 12.0 --- runtime/CMakeLists.txt | 9 --------- 1 file changed, 9 deletions(-) diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 5212929a0..d7e2a990a 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -39,15 +39,6 @@ else() "WiiCompiled supports 64-bit LLVM-MinGW Clang on Windows, native Linux x86_64/aarch64, or Apple Clang on macOS x86_64/arm64") endif() -# Do not inherit the host SDK's deployment target (macOS 26 on current -# toolchains). The supported Apple Silicon release is macOS 14 and the app -# bundle advertises that same minimum. This remains arm64-only until the Intel -# support work is merged. -if(MKW_PLATFORM_MACOS) - set(CMAKE_OSX_DEPLOYMENT_TARGET "14.0" CACHE STRING - "Minimum macOS version for the Apple Silicon build" FORCE) -endif() - if(NOT CMAKE_BUILD_TYPE STREQUAL "Release") message(FATAL_ERROR "WiiCompiled only supports Release builds") endif() From a7c560dd8c10568bfd377db8b9668347e467d8e0 Mon Sep 17 00:00:00 2001 From: DarthM Date: Sat, 5 Sep 2026 15:10:25 -0400 Subject: [PATCH 06/14] macos: support older libc++ algorithms --- aurora-main/lib/dolphin/pad/pad.cpp | 49 +++++++++++++++-------------- aurora-main/lib/gfx/common.cpp | 5 ++- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/aurora-main/lib/dolphin/pad/pad.cpp b/aurora-main/lib/dolphin/pad/pad.cpp index 2b0e48825..7ae292fa7 100644 --- a/aurora-main/lib/dolphin/pad/pad.cpp +++ b/aurora-main/lib/dolphin/pad/pad.cpp @@ -6,10 +6,10 @@ #include #include +#include #include #include #include -#include namespace { constexpr int32_t k_mappingsFileVersion = 3; @@ -354,7 +354,7 @@ BOOL PADInit() { } g_initialized = true; - std::ranges::for_each(g_keyboardBindings, [](auto& state) { + std::for_each(g_keyboardBindings.begin(), g_keyboardBindings.end(), [](auto& state) { state.m_buttonMapping = g_defaultKeys; state.m_axisMapping = g_defaultKeyAxis; }); @@ -647,8 +647,8 @@ static void EnsureMappingLoaded(aurora::input::GameController* controller) { static Sint16 _get_axis_value(const aurora::input::GameController* controller, // NOLINT(*-reserved-identifier) PADAxis axis) { - const auto iter = - std::ranges::find_if(controller->m_axisMapping, [axis](const auto& pair) { return pair.padAxis == axis; }); + const auto iter = std::find_if(controller->m_axisMapping.begin(), controller->m_axisMapping.end(), + [axis](const auto& pair) { return pair.padAxis == axis; }); if (iter == controller->m_axisMapping.end()) { return 0; } @@ -738,8 +738,8 @@ u32 PADRead(PADStatus* status) { status[i].err = PAD_ERR_NONE; if (g_keyboardBindings[i].m_mappingsSet && SDL_GetKeyboardFocus() != nullptr) { - std::ranges::for_each( - g_keyboardBindings[i].m_buttonMapping, [&kbState, &numKeys, &i, &status](const PADKeyButtonBinding& mapping) { + std::for_each(g_keyboardBindings[i].m_buttonMapping.begin(), g_keyboardBindings[i].m_buttonMapping.end(), + [&kbState, &numKeys, &i, &status](const PADKeyButtonBinding& mapping) { if (mapping.scancode > PAD_KEY_INVALID && mapping.scancode < numKeys && kbState[mapping.scancode]) { status[i].button |= mapping.padButton; } else if (is_mouse_scancode(mapping.scancode) && is_mouse_button_pressed(mapping.scancode)) { @@ -846,8 +846,8 @@ u32 PADRead(PADStatus* status) { bool leftTriggerSet = false; bool rightTriggerSet = false; - std::ranges::for_each(controller->m_buttonMapping, [&controller, &i, &status, &leftTriggerSet, - &rightTriggerSet](const auto& mapping) { + std::for_each(controller->m_buttonMapping.begin(), controller->m_buttonMapping.end(), + [&controller, &i, &status, &leftTriggerSet, &rightTriggerSet](const auto& mapping) { if (is_native_binding_pressed(controller->m_controller, mapping.nativeButton)) { status[i].button |= mapping.padButton; } @@ -860,8 +860,8 @@ u32 PADRead(PADStatus* status) { } }); - std::ranges::for_each(controller->m_altButtonMapping, [&controller, &i, &status, &leftTriggerSet, - &rightTriggerSet](const auto& mapping) { + std::for_each(controller->m_altButtonMapping.begin(), controller->m_altButtonMapping.end(), + [&controller, &i, &status, &leftTriggerSet, &rightTriggerSet](const auto& mapping) { if (mapping.nativeButton == PAD_NATIVE_BUTTON_INVALID) { return; } @@ -1190,8 +1190,8 @@ void PADSetButtonMapping(const u32 port, const PADButtonMapping mapping) { return; } - const auto iter = std::ranges::find_if(controller->m_buttonMapping, - [mapping](const auto& pair) { return mapping.padButton == pair.padButton; }); + const auto iter = std::find_if(controller->m_buttonMapping.begin(), controller->m_buttonMapping.end(), + [mapping](const auto& pair) { return mapping.padButton == pair.padButton; }); if (iter == controller->m_buttonMapping.end()) { return; } @@ -1224,8 +1224,8 @@ void PADSetAltButtonMapping(const u32 port, const PADButtonMapping mapping) { return; } - const auto iter = std::ranges::find_if(controller->m_altButtonMapping, - [mapping](const auto& pair) { return mapping.padButton == pair.padButton; }); + const auto iter = std::find_if(controller->m_altButtonMapping.begin(), controller->m_altButtonMapping.end(), + [mapping](const auto& pair) { return mapping.padButton == pair.padButton; }); if (iter == controller->m_altButtonMapping.end()) { return; } @@ -1251,8 +1251,8 @@ void PADSetAxisMapping(const u32 port, const PADAxisMapping mapping) { return; } - const auto iter = std::ranges::find_if(controller->m_axisMapping, - [mapping](const auto& pair) { return mapping.padAxis == pair.padAxis; }); + const auto iter = std::find_if(controller->m_axisMapping.begin(), controller->m_axisMapping.end(), + [mapping](const auto& pair) { return mapping.padAxis == pair.padAxis; }); if (iter == controller->m_axisMapping.end()) { return; } @@ -1426,9 +1426,10 @@ static void load_keyboard_bindings() { if (mappingsSet) { const bool anyBound = - std::ranges::any_of(buttonMapping, - [](const PADKeyButtonBinding& b) { return b.scancode != PAD_KEY_INVALID; }) || - std::ranges::any_of(axisMapping, [](const PADKeyAxisBinding& b) { return b.scancode != PAD_KEY_INVALID; }); + std::any_of(buttonMapping.begin(), buttonMapping.end(), + [](const PADKeyButtonBinding& b) { return b.scancode != PAD_KEY_INVALID; }) || + std::any_of(axisMapping.begin(), axisMapping.end(), + [](const PADKeyAxisBinding& b) { return b.scancode != PAD_KEY_INVALID; }); if (!anyBound) { mappingsSet = false; } @@ -1573,8 +1574,8 @@ static constexpr std::array, PAD_AXIS_COU const char* PADGetButtonName(const PADButton button) { - if (const auto iter = - std::ranges::find_if(skButtonNames, [&button](const auto& pair) { return button == pair.first; }); + if (const auto iter = std::find_if(skButtonNames.begin(), skButtonNames.end(), + [&button](const auto& pair) { return button == pair.first; }); iter != skButtonNames.end()) { return iter->second.data(); } @@ -1587,7 +1588,8 @@ const char* PADGetNativeButtonName(u32 button) { } const char* PADGetAxisName(const PADAxis axis) { - if (const auto it = std::ranges::find_if(skAxisNames, [&axis](const auto& pair) { return axis == pair.first; }); + if (const auto it = std::find_if(skAxisNames.begin(), skAxisNames.end(), + [&axis](const auto& pair) { return axis == pair.first; }); it != skAxisNames.end()) { return it->second.data(); } @@ -1596,7 +1598,8 @@ const char* PADGetAxisName(const PADAxis axis) { } const char* PADGetAxisDirectionLabel(const PADAxis axis) { - if (const auto it = std::ranges::find_if(skAxisDirLabels, [&axis](const auto& pair) { return axis == pair.first; }); + if (const auto it = std::find_if(skAxisDirLabels.begin(), skAxisDirLabels.end(), + [&axis](const auto& pair) { return axis == pair.first; }); it != skAxisDirLabels.end()) { return it->second.data(); } diff --git a/aurora-main/lib/gfx/common.cpp b/aurora-main/lib/gfx/common.cpp index dd19d8875..e0939018f 100644 --- a/aurora-main/lib/gfx/common.cpp +++ b/aurora-main/lib/gfx/common.cpp @@ -23,7 +23,6 @@ #include #include #include -#include #include #include @@ -1448,8 +1447,8 @@ static void render_impl(std::vector& renderPasses, wgpu::CommandEnco #if defined(AURORA_GFX_DEBUG_GROUPS) if (finalize && !debugFrame.groups.empty()) { - for (auto& it : std::ranges::reverse_view(debugFrame.groups)) { - Log.warn("Debug group was not popped at end of frame: {}", it); + for (auto it = debugFrame.groups.rbegin(); it != debugFrame.groups.rend(); ++it) { + Log.warn("Debug group was not popped at end of frame: {}", *it); } debugFrame.groups.clear(); } From efdeb3398eb93829596a6bfecd15bd33af7acc9a Mon Sep 17 00:00:00 2001 From: DarthM Date: Sat, 5 Sep 2026 20:26:38 -0400 Subject: [PATCH 07/14] macos: allow undefined MTLLogStateDescriptor for older SDKs --- aurora-main/cmake/aurora_core.cmake | 4 +++- runtime/cmake/PublicProducts.cmake | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/aurora-main/cmake/aurora_core.cmake b/aurora-main/cmake/aurora_core.cmake index 3e53a62c3..b17cb4249 100644 --- a/aurora-main/cmake/aurora_core.cmake +++ b/aurora-main/cmake/aurora_core.cmake @@ -41,7 +41,9 @@ if (AURORA_ENABLE_GX) target_compile_definitions(aurora_core PRIVATE DAWN_ENABLE_BACKEND_METAL) target_sources(aurora_core PRIVATE lib/dawn/MetalBinding.mm) set_source_files_properties(lib/dawn/MetalBinding.mm PROPERTIES COMPILE_FLAGS -fobjc-arc) - target_link_options(aurora_core PUBLIC "LINKER:-weak_framework,Metal") + target_link_options(aurora_core PUBLIC + "LINKER:-weak_framework,Metal" + "LINKER:-U,_OBJC_CLASS_$_MTLLogStateDescriptor") endif () if (DAWN_ENABLE_D3D11) target_compile_definitions(aurora_core PRIVATE DAWN_ENABLE_BACKEND_D3D11) diff --git a/runtime/cmake/PublicProducts.cmake b/runtime/cmake/PublicProducts.cmake index 6c7038699..f77d8de04 100644 --- a/runtime/cmake/PublicProducts.cmake +++ b/runtime/cmake/PublicProducts.cmake @@ -1,4 +1,4 @@ -# Public WiiCompiled product graph. +# Public WiiCompiled product graph. # # The translator owns the translated build graph. Mario Kart's profile-neutral # functions are compiled once into mkw_base_shared; only callers whose direct @@ -206,6 +206,8 @@ function(mkw_configure_product target) if(MKW_PLATFORM_MACOS) target_link_libraries(${target} PRIVATE "${MKW_IOKIT_FRAMEWORK}" "${MKW_COREFOUNDATION_FRAMEWORK}") + target_link_options(${target} PRIVATE + "LINKER:-U,_OBJC_CLASS_$_MTLLogStateDescriptor") endif() if(EXISTS "${MKW_AURORA_DIR}/cmake/AuroraCopyRuntimeDLLs.cmake") include("${MKW_AURORA_DIR}/cmake/AuroraCopyRuntimeDLLs.cmake") From ab3eca964ac780694c9a9bed6b36d2a98572ebf7 Mon Sep 17 00:00:00 2001 From: DarthM Date: Sat, 5 Sep 2026 20:45:44 -0400 Subject: [PATCH 08/14] tests: deflake input expression timing window test --- runtime/tests/test_expr.cpp | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/runtime/tests/test_expr.cpp b/runtime/tests/test_expr.cpp index c770a3c32..8c593a4ff 100644 --- a/runtime/tests/test_expr.cpp +++ b/runtime/tests/test_expr.cpp @@ -97,15 +97,29 @@ int main() { Check(!Pressed(pulse), "pulse expires"); // The timing-window idiom seen in shared Dolphin configs. - auto window = Compile("!pulse(`W`, 0.05) & pulse(`W`, 0.15)"); + auto window = Compile("!pulse(`W`, 0.05) & pulse(`W`, 0.35)"); g_inputs["W"] = 0.0; window.Evaluate(Source()); g_inputs["W"] = 1.0; Check(!Pressed(window), "window closed before its start"); - Sleep(90); - Check(Pressed(window), "window open between the two pulses"); - Sleep(90); - Check(!Pressed(window), "window closed after its end"); + bool windowOpened = false; + for (int i = 0; i < 40; ++i) { + Sleep(10); + if (Pressed(window)) { + windowOpened = true; + break; + } + } + Check(windowOpened, "window open between the two pulses"); + bool windowClosed = false; + for (int i = 0; i < 50; ++i) { + Sleep(10); + if (!Pressed(window)) { + windowClosed = true; + break; + } + } + Check(windowClosed, "window closed after its end"); // timer ramps 0..1 and wraps, so a threshold turns it into a square wave. auto timer = Compile("`X` & timer(0.1)"); From 4af64a2bbd235f2673e15e06052f6f183c028330 Mon Sep 17 00:00:00 2001 From: DarthM Date: Sun, 6 Sep 2026 14:31:30 -0400 Subject: [PATCH 09/14] build: Fix mac tests --- .github/workflows/build.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index efc4e269e..0badf041e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -69,6 +69,9 @@ jobs: run: | cmake --build build-macos --target \ mkw_platform_paths_tests \ + mkw_nand_save_tests \ + mkw_nand_settings_tests \ + mkw_sc_serial_tests \ mkw_input_expr_tests \ mkw_macos_native_compile \ mkw_macos_context_abi_tests \ From fd4ecb8e04cfccc2853040a2248bca8a21c895a2 Mon Sep 17 00:00:00 2001 From: DarthM Date: Sun, 6 Sep 2026 15:02:07 -0400 Subject: [PATCH 10/14] translator: emit null statement after continuation labels for C++17 compatibility --- .../CodeGen/CxxLinearCodeGenerator.cs | 4 +-- .../EmittedOutputShapeTests.cs | 33 ++++++++++++++++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/translator/src/Translator.Core/CodeGen/CxxLinearCodeGenerator.cs b/translator/src/Translator.Core/CodeGen/CxxLinearCodeGenerator.cs index 5dabbb112..1af76dd4b 100644 --- a/translator/src/Translator.Core/CodeGen/CxxLinearCodeGenerator.cs +++ b/translator/src/Translator.Core/CodeGen/CxxLinearCodeGenerator.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -380,7 +380,7 @@ string EmitFunctionBody(RegisterResidency? residency, int bufferBaseLength, bool instructionContinuationLabels.TryGetValue(trace.Address, out var continuationLabel)) { RecordEmittedLocalLabel(continuationLabel); - body.AppendLine($"{continuationLabel}:"); + body.AppendLine($"{continuationLabel}: ;"); } var localFallthroughLr = TryGetLocalFallthroughLr(block.Instructions, i, nonReturningCallTargets, lrContinuationCallTargets); // State-free bodies are cloned after register caching and lose their diff --git a/translator/tests/Translator.Tests/EmittedOutputShapeTests.cs b/translator/tests/Translator.Tests/EmittedOutputShapeTests.cs index 6fbb0c286..44367ad1d 100644 --- a/translator/tests/Translator.Tests/EmittedOutputShapeTests.cs +++ b/translator/tests/Translator.Tests/EmittedOutputShapeTests.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Translator.Core.Analysis.Representation; using Translator.Core.Analysis.Ssa; using Translator.Core.CodeGen; @@ -153,4 +153,35 @@ public void FloatFlatHelpersPreserveTheWidthMapping() Assert.Contains("f3.d = MemoryInline::FlatReadFloat32((r4 + 16));", code, StringComparison.Ordinal); Assert.Contains("f4.d = MemoryInline::FlatReadFloat64((r4 + 24));", code, StringComparison.Ordinal); } + + [Fact] + public void ContinuationLabelAtBlockEndEmitsValidCxx17Statement() + { + var function = new IrFunction("continuation_at_block_end", "0x800E7798", new[] + { + new IrBasicBlock("0x800E7798", new IrInstruction[] + { + new IrCall(string.Empty, "0x8179B000", System.Array.Empty()), + new IrTracePpc(0x800E77A0u, "nop", "0x60000000"), + new IrJump("0x800E77A4") + }), + new IrBasicBlock("0x800E77A4", new IrInstruction[] + { + new IrReturn(null) + }) + }); + + var types = new RepresentationEnvironment(new Dictionary()); + var code = new CxxLinearCodeGenerator().Emit( + 0x800E7798, + new SsaTransformer().Convert(function), + new FunctionAbiClassification(function.Name, ValueRepresentation.Void), + types, + lrContinuationCallTargets: new HashSet { 0x8179B000u }); + + // In C++17, a label before a closing brace is invalid without an intervening statement. + Assert.Contains("loc_800E77A0: ;", code, StringComparison.Ordinal); + Assert.DoesNotContain("loc_800E77A0:\n}", code.Replace("\r\n", "\n"), StringComparison.Ordinal); + } } + From 461e14ab0611c7a74f547d54ffd9ece72b90992b Mon Sep 17 00:00:00 2001 From: DarthM Date: Sun, 13 Sep 2026 21:58:21 -0400 Subject: [PATCH 11/14] feat(macos): add MetalFX spatial upscaling --- .github/workflows/build.yml | 1 + README.md | 1 + aurora-main/CMakeLists.txt | 8 + aurora-main/cmake/aurora_core.cmake | 11 + aurora-main/include/aurora/aurora.h | 15 + aurora-main/lib/aurora.cpp | 159 ++++++++- aurora-main/lib/webgpu/gpu.cpp | 8 + aurora-main/lib/webgpu/metalfx.hpp | 41 +++ aurora-main/lib/webgpu/metalfx.mm | 326 ++++++++++++++++++ aurora-main/lib/webgpu/metalfx_stub.cpp | 11 + .../tests/metalfx_interop/CMakeLists.txt | 28 ++ aurora-main/tests/metalfx_interop/README.md | 124 +++++++ aurora-main/tests/metalfx_interop/main.mm | 269 +++++++++++++++ .../metalfx_interop/presentation_test.cpp | 124 +++++++ .../tests/metalfx_interop/stub_test.cpp | 9 + runtime/CMakeLists.txt | 7 + runtime/include/runtime_config.h | 12 + runtime/src/settings_overlay.cpp | 36 ++ runtime/tests/runtime_config_tests.cpp | 35 ++ 19 files changed, 1216 insertions(+), 9 deletions(-) create mode 100644 aurora-main/lib/webgpu/metalfx.hpp create mode 100644 aurora-main/lib/webgpu/metalfx.mm create mode 100644 aurora-main/lib/webgpu/metalfx_stub.cpp create mode 100644 aurora-main/tests/metalfx_interop/CMakeLists.txt create mode 100644 aurora-main/tests/metalfx_interop/README.md create mode 100644 aurora-main/tests/metalfx_interop/main.mm create mode 100644 aurora-main/tests/metalfx_interop/presentation_test.cpp create mode 100644 aurora-main/tests/metalfx_interop/stub_test.cpp create mode 100644 runtime/tests/runtime_config_tests.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0badf041e..880fe093c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -69,6 +69,7 @@ jobs: run: | cmake --build build-macos --target \ mkw_platform_paths_tests \ + mkw_runtime_config_tests \ mkw_nand_save_tests \ mkw_nand_settings_tests \ mkw_sc_serial_tests \ diff --git a/README.md b/README.md index 745b598ee..71e6d6c61 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ rather it didn't. All audio that shows in your display media controls on your wi Press **F10** while the game window has focus: - Internal resolution - FPS counter +- MetalFX spatial upscaling on supported macOS GPUs - Controller assignment for all four ports - Full per-controller button mapping, including the bumpers - Dolphin-syntax input expressions and GCPadNew.ini import diff --git a/aurora-main/CMakeLists.txt b/aurora-main/CMakeLists.txt index 7035769b7..6a0ef53ed 100644 --- a/aurora-main/CMakeLists.txt +++ b/aurora-main/CMakeLists.txt @@ -2,6 +2,7 @@ cmake_minimum_required(VERSION 3.25) project(aurora LANGUAGES C CXX) if (APPLE) enable_language(OBJC) + enable_language(OBJCXX) endif() set(CMAKE_C_STANDARD 11) set(CMAKE_CXX_STANDARD 20) @@ -83,3 +84,10 @@ if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR AND NOT CMAKE_CROSSCOMPIL enable_testing() add_subdirectory(tests) endif () + +option(AURORA_BUILD_METALFX_PRESENTATION_TEST "Build the macOS MetalFX presentation test" OFF) +if (AURORA_BUILD_METALFX_PRESENTATION_TEST AND APPLE AND AURORA_ENABLE_GX AND DAWN_ENABLE_METAL AND AURORA_METALFX_FRAMEWORK) + add_executable(metalfx_presentation_test tests/metalfx_interop/presentation_test.cpp) + target_include_directories(metalfx_presentation_test PRIVATE lib) + target_link_libraries(metalfx_presentation_test PRIVATE aurora::core aurora::gx aurora::vi dawn::webgpu_dawn) +endif () diff --git a/aurora-main/cmake/aurora_core.cmake b/aurora-main/cmake/aurora_core.cmake index b17cb4249..65ffaea62 100644 --- a/aurora-main/cmake/aurora_core.cmake +++ b/aurora-main/cmake/aurora_core.cmake @@ -34,6 +34,17 @@ if (AURORA_ENABLE_GX) target_compile_definitions(aurora_core PUBLIC AURORA_ENABLE_GX WEBGPU_DAWN) target_sources(aurora_core PRIVATE lib/webgpu/gpu.cpp lib/webgpu/gpu_cache.cpp lib/dawn/BackendBinding.cpp) target_link_libraries(aurora_core PRIVATE dawn::webgpu_dawn) + if (APPLE AND DAWN_ENABLE_METAL) + find_library(AURORA_METALFX_FRAMEWORK MetalFX) + endif () + if (APPLE AND DAWN_ENABLE_METAL AND AURORA_METALFX_FRAMEWORK) + target_sources(aurora_core PRIVATE lib/webgpu/metalfx.mm) + set_source_files_properties(lib/webgpu/metalfx.mm PROPERTIES COMPILE_FLAGS -fobjc-arc) + target_link_options(aurora_core PUBLIC "LINKER:-weak_framework,MetalFX") + target_link_libraries(aurora_core PRIVATE "-framework IOSurface") + else () + target_sources(aurora_core PRIVATE lib/webgpu/metalfx_stub.cpp) + endif () if (DAWN_ENABLE_VULKAN) target_compile_definitions(aurora_core PRIVATE DAWN_ENABLE_BACKEND_VULKAN) endif () diff --git a/aurora-main/include/aurora/aurora.h b/aurora-main/include/aurora/aurora.h index a55c9ab2b..00c478dce 100644 --- a/aurora-main/include/aurora/aurora.h +++ b/aurora-main/include/aurora/aurora.h @@ -170,6 +170,21 @@ void aurora_set_background_input(bool value); void aurora_set_display_mode(AuroraDisplayMode mode); AuroraDisplayMode aurora_get_display_mode(); +typedef enum { + AURORA_METALFX_DISABLED, + AURORA_METALFX_UNSUPPORTED, + AURORA_METALFX_NOT_UPSCALING, + AURORA_METALFX_ACTIVE, + AURORA_METALFX_ERROR, +} AuroraMetalFXStatus; + +// Changes are consumed at the next sealed frame boundary. MetalFX only applies +// when both source dimensions are smaller than the aspect-fitted output. +void aurora_set_metalfx_spatial(bool enabled); +bool aurora_get_metalfx_spatial(); +bool aurora_is_metalfx_spatial_supported(); +AuroraMetalFXStatus aurora_get_metalfx_status(); + AuroraBackend aurora_get_backend(); const AuroraBackend* aurora_get_available_backends(size_t* count); diff --git a/aurora-main/lib/aurora.cpp b/aurora-main/lib/aurora.cpp index 1b9c7116a..aede66a11 100644 --- a/aurora-main/lib/aurora.cpp +++ b/aurora-main/lib/aurora.cpp @@ -7,6 +7,7 @@ #include "gx/shader_info.hpp" #include "imgui.hpp" #include "webgpu/gpu.hpp" +#include "webgpu/metalfx.hpp" #include #endif @@ -60,6 +61,9 @@ std::atomic g_frameWorkerWaitCallback{nullptr}; // deadlines derived from it, so the presenter cannot drift. Zero means present when ready. std::atomic g_presentScheduleBaseNanos{0}; std::atomic g_presentScheduleIntervalNanos{0}; +std::atomic g_metalfxRequested{false}; +std::atomic g_metalfxSupported{false}; +std::atomic g_metalfxStatus{AURORA_METALFX_DISABLED}; namespace { Module Log("aurora"); @@ -765,6 +769,9 @@ AuroraInfo initialize(int argc, char* argv[], const AuroraConfig& config) noexce #ifdef AURORA_ENABLE_GX gfx::initialize(); + g_metalfxSupported.store(webgpu::metalfx::supported(g_device, webgpu::g_backendType)); + g_metalfxStatus.store(AURORA_METALFX_DISABLED); + imgui::create_context(); #endif const auto size = window::get_window_size(); @@ -1187,12 +1194,114 @@ void stop_presenter() noexcept { g_presenterStarted.store(false, std::memory_order_release); } +struct MetalFXSlot { + webgpu::metalfx::Size size{}; + std::unique_ptr scaler; + wgpu::BindGroup bindGroup; +}; +std::array g_metalfxSlots; +size_t g_metalfxNextSlot = 0; +webgpu::metalfx::SpatialScaler* g_metalfxPendingOutput = nullptr; +bool g_metalfxFailed = false; + +void metalfx_failed(const std::string& reason) { + Log.warn("MetalFX spatial upscaling disabled: {}; using normal presentation", reason); + g_metalfxFailed = true; + g_metalfxStatus.store(AURORA_METALFX_ERROR); + g_metalfxPendingOutput = nullptr; + g_metalfxSlots = {}; +} + +wgpu::BindGroup upscale_presentation(wgpu::CommandEncoder& encoder, + const webgpu::PresentSource& source, + const webgpu::Viewport& viewport, bool enabled) { + if (!enabled) { + g_metalfxSlots = {}; + g_metalfxFailed = false; + g_metalfxStatus.store(AURORA_METALFX_DISABLED); + return {}; + } + if (!g_metalfxSupported.load()) { + g_metalfxStatus.store(AURORA_METALFX_UNSUPPORTED); + return {}; + } + if (g_metalfxFailed) return {}; + const webgpu::metalfx::Size size{ + source.size.width, source.size.height, + static_cast(viewport.width), static_cast(viewport.height), + webgpu::g_graphicsConfig.surfaceConfiguration.format, + }; + // The existing copy path samples perceptual values from unorm game images. + // Do not introduce implicit sRGB decoding or downscaling into MetalFX. + if (!size.inputWidth || !size.inputHeight || size.inputWidth >= size.outputWidth || + size.inputHeight >= size.outputHeight || + (source.format != wgpu::TextureFormat::RGBA8Unorm && source.format != wgpu::TextureFormat::BGRA8Unorm)) { + g_metalfxStatus.store(AURORA_METALFX_NOT_UPSCALING); + return {}; + } + auto& slot = g_metalfxSlots[g_metalfxNextSlot++ % g_metalfxSlots.size()]; + if (!slot.scaler || !(slot.size == size)) { + slot = {}; + std::string error; + slot.scaler = webgpu::metalfx::create(g_instance, g_device, size, error); + if (!slot.scaler) { + if (error.empty()) g_metalfxStatus.store(AURORA_METALFX_NOT_UPSCALING); + else metalfx_failed(error); + return {}; + } + slot.size = size; + wgpu::SamplerDescriptor samplerDescriptor{}; + samplerDescriptor.magFilter = wgpu::FilterMode::Linear; + samplerDescriptor.minFilter = wgpu::FilterMode::Linear; + slot.bindGroup = webgpu::create_copy_bind_group(slot.scaler->output_view(), + g_device.CreateSampler(&samplerDescriptor)); + Log.info("MetalFX spatial slot: {}x{} -> {}x{}", size.inputWidth, size.inputHeight, + size.outputWidth, size.outputHeight); + } + if (!slot.scaler->begin_input()) { + metalfx_failed(slot.scaler->error()); + return {}; + } + const wgpu::RenderPassColorAttachment attachment{ + .view = slot.scaler->input_view(), + .loadOp = wgpu::LoadOp::Clear, + .storeOp = wgpu::StoreOp::Store, + }; + const wgpu::RenderPassDescriptor descriptor{ + .label = "MetalFX input copy", + .colorAttachmentCount = 1, + .colorAttachments = &attachment, + }; + auto pass = encoder.BeginRenderPass(&descriptor); + pass.SetPipeline(webgpu::g_CopyPipeline); + pass.SetBindGroup(0, source.bindGroup); + pass.SetViewport(0, 0, static_cast(size.inputWidth), static_cast(size.inputHeight), 0, 1); + pass.Draw(3); + pass.End(); + // Submit the sealed scene and input copy before crossing to the native queue. + // The replacement encoder composites the upscaled image and ImGui normally. + auto buffer = encoder.Finish(); + { + std::lock_guard submitLock(g_queueSubmitMutex); + g_queue.Submit(1, &buffer); + } + encoder = g_device.CreateCommandEncoder(); + if (!slot.scaler->upscale()) { + metalfx_failed(slot.scaler->error()); + return {}; + } + g_metalfxPendingOutput = slot.scaler.get(); + g_metalfxStatus.store(AURORA_METALFX_ACTIVE); + return slot.bindGroup; +} + // `presentSource` is latched in the seal prologue: by the time this encodes, the producer's next // gfx::begin_frame() may already have cleared the display-copy override. -void encode_presentation_snapshot(const wgpu::CommandEncoder& encoder, - const webgpu::PresentSource& presentSource, - const PresentationImage& image, - bool includeImGui) { +wgpu::BindGroup encode_presentation_snapshot(wgpu::CommandEncoder& encoder, + const webgpu::PresentSource& presentSource, + const PresentationImage& image, + bool includeImGui, bool metalfxEnabled, + const wgpu::BindGroup* cachedMetalFXOutput = nullptr) { ZoneScoped; auto viewport = webgpu::calculate_present_viewport( image.texture.size.width, image.texture.size.height, presentSource.size.width, @@ -1203,6 +1312,13 @@ void encode_presentation_snapshot(const wgpu::CommandEncoder& encoder, image.texture.size.width, image.texture.size.height, presentAspect); } wgpu::BindGroup presentBindGroup = presentSource.bindGroup; + wgpu::BindGroup newMetalFXOutput; + if (cachedMetalFXOutput && *cachedMetalFXOutput) { + presentBindGroup = *cachedMetalFXOutput; + } else if (auto upscaled = upscale_presentation(encoder, presentSource, viewport, metalfxEnabled)) { + presentBindGroup = std::move(upscaled); + newMetalFXOutput = presentBindGroup; + } { const std::array attachments{ wgpu::RenderPassColorAttachment{ @@ -1243,6 +1359,7 @@ void encode_presentation_snapshot(const wgpu::CommandEncoder& encoder, imgui::render(pass); pass.End(); } + return newMetalFXOutput; } #endif @@ -1251,6 +1368,13 @@ void shutdown() noexcept { #ifdef AURORA_ENABLE_GX stop_presenter(); g_presentationImagePools = {}; + g_metalfxSlots = {}; + g_metalfxPendingOutput = nullptr; + g_metalfxNextSlot = 0; + g_metalfxFailed = false; + g_metalfxRequested.store(false); + g_metalfxSupported.store(false); + g_metalfxStatus.store(AURORA_METALFX_DISABLED); imgui::shutdown(); gfx::shutdown(); webgpu::shutdown(); @@ -1363,6 +1487,7 @@ struct SealedFrameContext { uint32_t logicalFrame = 0; bool interpolationActive = false; bool replayInterpolatedFrames = false; + bool metalfxEnabled = false; }; // Phase 1: everything that touches producer-shared renderer state. Needs g_rendererGpuMutex and @@ -1390,6 +1515,7 @@ void seal_frame_locked(gfx::SealedFrame& sealedFrame, SealedFrameContext& ctx) { ctx.snapshotWidth = (std::max)(windowSize.native_fb_width, 1u); ctx.snapshotHeight = (std::max)(windowSize.native_fb_height, 1u); ctx.logicalFrame = gfx::current_frame(); + ctx.metalfxEnabled = g_metalfxRequested.load(); // Latched before webgpu::clear_present_source_override() in the producer's // next gfx::begin_frame(). ctx.presentSource = webgpu::current_present_source(); @@ -1437,10 +1563,14 @@ std::vector encode_sealed_frame(gfx::SealedFrame& sealedFrame, const wgpu::CommandBufferDescriptor cmdBufDescriptor{ .label = "Presentation slot command buffer", }; - const auto submitEncodedSlot = [&](wgpu::CommandEncoder& target) { + const auto submitEncodedSlot = [&](wgpu::CommandEncoder& target, bool releaseMetalFXOutput = true) { const auto buffer = target.Finish(&cmdBufDescriptor); std::lock_guard submitLock(g_queueSubmitMutex); g_queue.Submit(1, &buffer); + if (releaseMetalFXOutput && g_metalfxPendingOutput) { + if (!g_metalfxPendingOutput->end_output()) metalfx_failed(g_metalfxPendingOutput->error()); + g_metalfxPendingOutput = nullptr; + } }; if (ctx.replayInterpolatedFrames) { @@ -1449,7 +1579,7 @@ std::vector encode_sealed_frame(gfx::SealedFrame& sealedFrame, gfx::render(sealedFrame, encoder, static_cast(interpolatedFrame), false); auto image = acquire_presentation_image(interpolatedFrame, ctx.snapshotWidth, ctx.snapshotHeight); - encode_presentation_snapshot(encoder, ctx.presentSource, *image, true); + encode_presentation_snapshot(encoder, ctx.presentSource, *image, true, ctx.metalfxEnabled); presentationJobs.push_back({ .image = std::move(image), .logicalFrame = ctx.logicalFrame, @@ -1467,12 +1597,18 @@ std::vector encode_sealed_frame(gfx::SealedFrame& sealedFrame, // The copy targets now hold this frame's resolves, so queue their readbacks on the same encoder; // completion is harvested in gfx::after_submit, never waited on here. gfx::efb_ram::encode_async_downloads(encoder); + wgpu::BindGroup duplicatedMetalFXOutput; if (!ctx.replayInterpolatedFrames) { for (uint32_t interpolatedFrame = 0; interpolatedFrame < ctx.interpolatedFrameCount; ++interpolatedFrame) { auto image = acquire_presentation_image(interpolatedFrame, ctx.snapshotWidth, ctx.snapshotHeight); - encode_presentation_snapshot(encoder, ctx.presentSource, *image, true); + const auto newMetalFXOutput = encode_presentation_snapshot( + encoder, ctx.presentSource, *image, true, ctx.metalfxEnabled, + duplicatedMetalFXOutput ? &duplicatedMetalFXOutput : nullptr); + if (!duplicatedMetalFXOutput && newMetalFXOutput) { + duplicatedMetalFXOutput = newMetalFXOutput; + } presentationJobs.push_back({ .image = std::move(image), .logicalFrame = ctx.logicalFrame, @@ -1480,13 +1616,14 @@ std::vector encode_sealed_frame(gfx::SealedFrame& sealedFrame, .interpolated = true, .duplicated = true, }); - submitEncodedSlot(encoder); + submitEncodedSlot(encoder, false); encoder = g_device.CreateCommandEncoder(&encoderDescriptor); } } auto finalImage = acquire_presentation_image(ctx.interpolatedFrameCount, ctx.snapshotWidth, ctx.snapshotHeight); - encode_presentation_snapshot(encoder, ctx.presentSource, *finalImage, true); + encode_presentation_snapshot(encoder, ctx.presentSource, *finalImage, true, ctx.metalfxEnabled, + duplicatedMetalFXOutput ? &duplicatedMetalFXOutput : nullptr); auto pendingFrameCapture = encode_frame_capture(encoder, ctx.presentSource); presentationJobs.push_back({ .image = std::move(finalImage), @@ -1962,3 +2099,7 @@ void aurora_set_background_input(bool value) { } void aurora_set_display_mode(AuroraDisplayMode mode) { aurora::window::set_display_mode(mode); } AuroraDisplayMode aurora_get_display_mode() { return aurora::window::get_display_mode(); } +void aurora_set_metalfx_spatial(bool enabled) { aurora::g_metalfxRequested.store(enabled); } +bool aurora_get_metalfx_spatial() { return aurora::g_metalfxRequested.load(); } +bool aurora_is_metalfx_spatial_supported() { return aurora::g_metalfxSupported.load(); } +AuroraMetalFXStatus aurora_get_metalfx_status() { return aurora::g_metalfxStatus.load(); } diff --git a/aurora-main/lib/webgpu/gpu.cpp b/aurora-main/lib/webgpu/gpu.cpp index 97248936c..37515f277 100644 --- a/aurora-main/lib/webgpu/gpu.cpp +++ b/aurora-main/lib/webgpu/gpu.cpp @@ -642,6 +642,14 @@ bool initialize(AuroraBackend auroraBackend) { requiredLimits.maxDynamicStorageBuffersPerPipelineLayout, requiredLimits.maxStorageBuffersPerShaderStage, requiredLimits.minUniformBufferOffsetAlignment, requiredLimits.minStorageBufferOffsetAlignment); std::vector requiredFeatures; + // Optional native sharing for MetalFX. Devices without either feature keep + // the normal renderer; the upscaler checks the enabled pair at runtime. + if (backend == wgpu::BackendType::Metal && + g_adapter.HasFeature(wgpu::FeatureName::SharedTextureMemoryIOSurface) && + g_adapter.HasFeature(wgpu::FeatureName::SharedFenceMTLSharedEvent)) { + requiredFeatures.push_back(wgpu::FeatureName::SharedTextureMemoryIOSurface); + requiredFeatures.push_back(wgpu::FeatureName::SharedFenceMTLSharedEvent); + } bool implicitDeviceSynchronizationSupported = false; wgpu::SupportedFeatures supportedFeatures; g_adapter.GetFeatures(&supportedFeatures); diff --git a/aurora-main/lib/webgpu/metalfx.hpp b/aurora-main/lib/webgpu/metalfx.hpp new file mode 100644 index 000000000..6a9d48541 --- /dev/null +++ b/aurora-main/lib/webgpu/metalfx.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include + +#include +#include + +namespace aurora::webgpu::metalfx { +struct Size { + uint32_t inputWidth; + uint32_t inputHeight; + uint32_t outputWidth; + uint32_t outputHeight; + wgpu::TextureFormat format; + + bool operator==(const Size&) const = default; +}; + +// All methods except supported() belong to the serialized frame encoder. +// GPU ownership is explicit: begin_input -> submit input -> upscale -> +// submit output consumption -> end_output. Neither texture may be used by +// Dawn outside its access interval. Destruction retires in-flight resources. +class SpatialScaler { +public: + virtual ~SpatialScaler() = default; + virtual const wgpu::TextureView& input_view() const = 0; + virtual const wgpu::TextureView& output_view() const = 0; + virtual const wgpu::Texture& output_texture() const = 0; + virtual bool begin_input() = 0; + virtual bool upscale() = 0; + virtual bool end_output() = 0; + virtual const std::string& error() const = 0; +}; + +bool supported(const wgpu::Device& device, wgpu::BackendType backend); +// A null result with no error means the bounded retirement pool is busy; +// skip upscaling for this frame and retry at a later frame boundary. +std::unique_ptr create(const wgpu::Instance& instance, + const wgpu::Device& device, const Size& size, + std::string& error); +} // namespace aurora::webgpu::metalfx diff --git a/aurora-main/lib/webgpu/metalfx.mm b/aurora-main/lib/webgpu/metalfx.mm new file mode 100644 index 000000000..f66a5fe07 --- /dev/null +++ b/aurora-main/lib/webgpu/metalfx.mm @@ -0,0 +1,326 @@ +#include "metalfx.hpp" + +#import +#import +#import +#import + +#include + +#include +#include + +namespace aurora::webgpu::metalfx { +namespace { +constexpr uint64_t kScheduleTimeoutNs = 1'000'000'000; +// Four current slots plus at most four retiring slots during resize. A busy +// GPU must not allow resize events to allocate unbounded full-resolution images. +constexpr unsigned kMaxLiveResources = 8; +std::atomic g_liveResources{0}; + +struct SharedImage { + IOSurfaceRef surface = nullptr; + id metal; + wgpu::SharedTextureMemory memory; + wgpu::Texture texture; + wgpu::TextureView view; + + ~SharedImage() { if (surface) CFRelease(surface); } + + bool create(const wgpu::Device& device, id native, uint32_t width, + uint32_t height, wgpu::TextureFormat format, MTLTextureUsage nativeUsage, + wgpu::TextureUsage usage) { + const bool bgra = format == wgpu::TextureFormat::BGRA8Unorm; + const size_t rowBytes = IOSurfaceAlignProperty(kIOSurfaceBytesPerRow, size_t(width) * 4); + NSDictionary* properties = @{ + (id)kIOSurfaceWidth: @(width), (id)kIOSurfaceHeight: @(height), + (id)kIOSurfaceBytesPerElement: @4, (id)kIOSurfaceBytesPerRow: @(rowBytes), + (id)kIOSurfaceAllocSize: @(rowBytes * height), + (id)kIOSurfacePixelFormat: @(bgra ? 0x42475241u : 0x52474241u) + }; + surface = IOSurfaceCreate((__bridge CFDictionaryRef)properties); + if (!surface) return false; + auto descriptor = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat: + bgra ? MTLPixelFormatBGRA8Unorm : MTLPixelFormatRGBA8Unorm + width:width height:height mipmapped:NO]; + descriptor.storageMode = MTLStorageModeShared; + descriptor.usage = nativeUsage; + metal = [native newTextureWithDescriptor:descriptor iosurface:surface plane:0]; + if (!metal) return false; + + wgpu::SharedTextureMemoryIOSurfaceDescriptor io{}; + io.ioSurface = surface; + io.allowStorageBinding = false; + wgpu::SharedTextureMemoryDescriptor importDescriptor{}; + importDescriptor.nextInChain = &io; + memory = device.ImportSharedTextureMemory(&importDescriptor); + wgpu::SharedTextureMemoryProperties actual{}; + if (!memory || memory.GetProperties(&actual) != wgpu::Status::Success || + actual.format != format || actual.size.width != width || actual.size.height != height || + (actual.usage & usage) != usage) return false; + wgpu::TextureDescriptor textureDescriptor{}; + textureDescriptor.label = "MetalFX shared texture"; + textureDescriptor.size = {width, height, 1}; + textureDescriptor.format = format; + textureDescriptor.usage = usage; + texture = memory.CreateTexture(&textureDescriptor); + if (!texture) return false; + view = texture.CreateView(); + return view != nullptr; + } +}; + +struct API_AVAILABLE(macos(13.0)) Resources { + SharedImage input, output; + id scaler; + id privateOutput; + id nativeQueue; + id event; + wgpu::SharedFence fence; + std::atomic failed{false}; + + Resources() { ++g_liveResources; } + ~Resources() { --g_liveResources; } +}; + +class API_AVAILABLE(macos(13.0)) MetalSpatialScaler final : public SpatialScaler { + wgpu::Instance m_instance; + wgpu::Queue m_queue; + std::shared_ptr m_resources; + wgpu::SharedTextureMemoryEndAccessState m_outputReleased{}; + wgpu::Future m_outputScheduled{}; + uint64_t m_value = 0; + std::string m_error; + + bool fail(const char* reason) { + m_error = reason; + m_resources->failed = true; + return false; + } + + bool wait_scheduled(wgpu::Future future) { + return m_instance.WaitAny(future, kScheduleTimeoutNs) == wgpu::WaitStatus::Success || + fail("Timed out scheduling MetalFX GPU work"); + } + + bool end_access(SharedImage& image, wgpu::SharedTextureMemoryEndAccessState& state, + wgpu::Future& scheduled) { + wgpu::SharedTextureMemoryMetalEndAccessState metal{}; + state.nextInChain = &metal; + const auto status = image.memory.EndAccess(image.texture, &state); + state.nextInChain = nullptr; + scheduled = metal.commandsScheduledFuture; + return status == wgpu::Status::Success || fail("MetalFX Dawn EndAccess failed"); + } + + bool begin_access(SharedImage& image, bool initialized, uint64_t value) { + wgpu::SharedTextureMemoryBeginAccessDescriptor access{}; + access.initialized = initialized; + if (value) { + access.fenceCount = 1; + access.fences = &m_resources->fence; + access.signaledValueCount = 1; + access.signaledValues = &value; + } + return image.memory.BeginAccess(image.texture, &access) == wgpu::Status::Success || + fail("MetalFX Dawn BeginAccess failed"); + } + + bool encode_waits(id commands, + const wgpu::SharedTextureMemoryEndAccessState& state) { + for (size_t i = 0; i < state.fenceCount; ++i) { + wgpu::SharedFenceMTLSharedEventExportInfo metal{}; + wgpu::SharedFenceExportInfo info{}; + info.nextInChain = &metal; + state.fences[i].ExportInfo(&info); + if (info.type != wgpu::SharedFenceType::MTLSharedEvent || !metal.sharedEvent) + return fail("Dawn did not export a MetalFX shared-event dependency"); + [commands encodeWaitForEvent:(__bridge id)metal.sharedEvent + value:state.signaledValues[i]]; + } + return true; + } + + void retain_until_dawn_done() { + // A resize/toggle can destroy this wrapper immediately. The last submitted + // Dawn consumer keeps the IOSurfaces/scaler alive independently of the cache. + m_queue.OnSubmittedWorkDone(wgpu::CallbackMode::AllowSpontaneous, + [resources = m_resources](wgpu::QueueWorkDoneStatus status, wgpu::StringView) { + if (status != wgpu::QueueWorkDoneStatus::Success) resources->failed = true; + }); + } + +public: + MetalSpatialScaler(const wgpu::Instance& instance, const wgpu::Device& device, + std::shared_ptr resources) + : m_instance(instance), m_queue(device.GetQueue()), m_resources(std::move(resources)) {} + + const wgpu::TextureView& input_view() const override { return m_resources->input.view; } + const wgpu::TextureView& output_view() const override { return m_resources->output.view; } + const wgpu::Texture& output_texture() const override { return m_resources->output.texture; } + const std::string& error() const override { return m_error; } + + bool begin_input() override { + if (m_resources->failed) return fail("Previous MetalFX GPU work failed"); + return begin_access(m_resources->input, m_value != 0, m_value); + } + + bool upscale() override { + @autoreleasepool { + // Input has already been submitted. Retain it even if an export or native + // allocation fails and the caller immediately falls back to normal copy. + retain_until_dawn_done(); + wgpu::SharedTextureMemoryEndAccessState inputReleased{}; + wgpu::Future inputScheduled{}; + if (!end_access(m_resources->input, inputReleased, inputScheduled) || + !wait_scheduled(inputScheduled)) return false; + if (m_value && !wait_scheduled(m_outputScheduled)) return false; + id commands = [m_resources->nativeQueue commandBuffer]; + if (!commands) return fail("Could not allocate a MetalFX command buffer"); + commands.label = @"MetalFX spatial upscale and return to Dawn"; + if (!encode_waits(commands, inputReleased) || !encode_waits(commands, m_outputReleased)) + return false; + [m_resources->scaler encodeToCommandBuffer:commands]; + id blit = [commands blitCommandEncoder]; + if (!blit) return fail("Could not allocate the MetalFX output blit"); + [blit copyFromTexture:m_resources->privateOutput sourceSlice:0 sourceLevel:0 + sourceOrigin:MTLOriginMake(0, 0, 0) + sourceSize:MTLSizeMake(m_resources->privateOutput.width, m_resources->privateOutput.height, 1) + toTexture:m_resources->output.metal destinationSlice:0 destinationLevel:0 + destinationOrigin:MTLOriginMake(0, 0, 0)]; + [blit endEncoding]; + ++m_value; + [commands encodeSignalEvent:m_resources->event value:m_value]; + const auto resources = m_resources; + [commands addCompletedHandler:^(id completed) { + if (completed.status == MTLCommandBufferStatusError) resources->failed = true; + }]; + [commands commit]; + // Scheduling is required to order independent Metal queues. Completion + // remains asynchronous; resource reuse is guarded by shared GPU events. + [commands waitUntilScheduled]; + if (commands.status == MTLCommandBufferStatusError) + return fail("MetalFX command buffer failed"); + return begin_access(m_resources->output, true, m_value); + } + } + + bool end_output() override { + retain_until_dawn_done(); + m_outputReleased = {}; + return end_access(m_resources->output, m_outputReleased, m_outputScheduled); + } +}; + +// Allocation failures must be caught here rather than reaching Aurora's fatal +// uncaptured-error callback. Scope callbacks own their strings even on timeout. +bool pop_scope(const wgpu::Instance& instance, const wgpu::Device& device, std::string& error) { + auto message = std::make_shared(); + auto future = device.PopErrorScope(wgpu::CallbackMode::WaitAnyOnly, + [message](wgpu::PopErrorScopeStatus status, wgpu::ErrorType type, wgpu::StringView text) { + if (status != wgpu::PopErrorScopeStatus::Success || type != wgpu::ErrorType::NoError) { + const std::string_view detail{text}; + *message = detail.empty() ? "MetalFX texture allocation failed" : std::string(detail); + } + }); + if (instance.WaitAny(future, kScheduleTimeoutNs) != wgpu::WaitStatus::Success) { + error = "Timed out checking MetalFX texture allocation"; + return false; + } + if (!message->empty()) { error = *message; return false; } + return true; +} +} // namespace + +bool supported(const wgpu::Device& device, wgpu::BackendType backend) { + if (@available(macOS 13.0, *)) { + if (!device || backend != wgpu::BackendType::Metal || + !device.HasFeature(wgpu::FeatureName::SharedTextureMemoryIOSurface) || + !device.HasFeature(wgpu::FeatureName::SharedFenceMTLSharedEvent)) return false; + auto native = dawn::native::metal::GetMTLDevice(device.Get()); + return native && [MTLFXSpatialScalerDescriptor supportsDevice:native]; + } + return false; +} + +std::unique_ptr create(const wgpu::Instance& instance, + const wgpu::Device& device, const Size& size, + std::string& error) { + error.clear(); + if (@available(macOS 13.0, *)) { + @autoreleasepool { + if (!supported(device, wgpu::BackendType::Metal)) { + error = "MetalFX spatial scaling is unsupported"; + return {}; + } + wgpu::Limits limits{}; + device.GetLimits(&limits); + if (!size.inputWidth || !size.inputHeight || size.inputWidth >= size.outputWidth || + size.inputHeight >= size.outputHeight || size.outputWidth > limits.maxTextureDimension2D || + size.outputHeight > limits.maxTextureDimension2D || + (size.format != wgpu::TextureFormat::RGBA8Unorm && size.format != wgpu::TextureFormat::BGRA8Unorm)) { + error = "MetalFX requires smaller input dimensions and an RGBA8/BGRA8 unorm target"; + return {}; + } + if (g_liveResources.load() >= kMaxLiveResources) { + return {}; + } + auto native = dawn::native::metal::GetMTLDevice(device.Get()); + auto resources = std::make_shared(); + auto descriptor = [MTLFXSpatialScalerDescriptor new]; + descriptor.inputWidth = size.inputWidth; + descriptor.inputHeight = size.inputHeight; + descriptor.outputWidth = size.outputWidth; + descriptor.outputHeight = size.outputHeight; + descriptor.colorTextureFormat = size.format == wgpu::TextureFormat::BGRA8Unorm + ? MTLPixelFormatBGRA8Unorm : MTLPixelFormatRGBA8Unorm; + descriptor.outputTextureFormat = descriptor.colorTextureFormat; + descriptor.colorProcessingMode = MTLFXSpatialScalerColorProcessingModePerceptual; + resources->scaler = [descriptor newSpatialScalerWithDevice:native]; + resources->nativeQueue = [native newCommandQueue]; + resources->event = [native newSharedEvent]; + if (!resources->scaler || !resources->nativeQueue || !resources->event) { + error = "Could not create MetalFX spatial resources"; + return {}; + } + auto outputDescriptor = [MTLTextureDescriptor + texture2DDescriptorWithPixelFormat:descriptor.outputTextureFormat + width:size.outputWidth height:size.outputHeight mipmapped:NO]; + outputDescriptor.storageMode = MTLStorageModePrivate; + outputDescriptor.usage = resources->scaler.outputTextureUsage; + resources->privateOutput = [native newTextureWithDescriptor:outputDescriptor]; + if (!resources->privateOutput) { error = "Could not allocate MetalFX private output"; return {}; } + + device.PushErrorScope(wgpu::ErrorFilter::Validation); + device.PushErrorScope(wgpu::ErrorFilter::OutOfMemory); + device.PushErrorScope(wgpu::ErrorFilter::Internal); + bool allocated = resources->input.create(device, native, size.inputWidth, size.inputHeight, + size.format, resources->scaler.colorTextureUsage, wgpu::TextureUsage::RenderAttachment); + allocated = allocated && resources->output.create(device, native, size.outputWidth, size.outputHeight, + size.format, MTLTextureUsageShaderRead, wgpu::TextureUsage::TextureBinding | wgpu::TextureUsage::CopySrc); + if (allocated) { + wgpu::SharedFenceMTLSharedEventDescriptor event{}; + event.sharedEvent = (__bridge void*)resources->event; + wgpu::SharedFenceDescriptor fence{}; + fence.nextInChain = &event; + resources->fence = device.ImportSharedFence(&fence); + allocated = resources->fence != nullptr; + } + for (int i = 0; i < 3; ++i) { + if (!pop_scope(instance, device, error)) allocated = false; + } + if (!allocated) { + if (error.empty()) error = "Could not import MetalFX IOSurface textures into Dawn"; + return {}; + } + resources->scaler.colorTexture = resources->input.metal; + resources->scaler.outputTexture = resources->privateOutput; + resources->scaler.inputContentWidth = size.inputWidth; + resources->scaler.inputContentHeight = size.inputHeight; + return std::make_unique(instance, device, std::move(resources)); + } + } + error = "MetalFX requires macOS 13 or newer"; + return {}; +} +} // namespace aurora::webgpu::metalfx diff --git a/aurora-main/lib/webgpu/metalfx_stub.cpp b/aurora-main/lib/webgpu/metalfx_stub.cpp new file mode 100644 index 000000000..7279443cd --- /dev/null +++ b/aurora-main/lib/webgpu/metalfx_stub.cpp @@ -0,0 +1,11 @@ +#include "metalfx.hpp" + +namespace aurora::webgpu::metalfx { +bool supported(const wgpu::Device&, wgpu::BackendType) { return false; } + +std::unique_ptr create(const wgpu::Instance&, const wgpu::Device&, + const Size&, std::string& error) { + error = "MetalFX is not available in this build"; + return {}; +} +} // namespace aurora::webgpu::metalfx diff --git a/aurora-main/tests/metalfx_interop/CMakeLists.txt b/aurora-main/tests/metalfx_interop/CMakeLists.txt new file mode 100644 index 000000000..4f5330dbc --- /dev/null +++ b/aurora-main/tests/metalfx_interop/CMakeLists.txt @@ -0,0 +1,28 @@ +cmake_minimum_required(VERSION 3.25) +project(metalfx_interop LANGUAGES CXX) + +if(NOT APPLE) + message(FATAL_ERROR "The MetalFX interoperability probe requires macOS") +endif() +enable_language(OBJCXX) +set(CMAKE_OBJCXX_STANDARD 20) +set(CMAKE_OBJCXX_STANDARD_REQUIRED ON) +set(CMAKE_OSX_DEPLOYMENT_TARGET 13.0) + +# Use the same Dawn package as Aurora; no separate download or renderer build. +find_package(Threads REQUIRED) +find_package(Dawn CONFIG REQUIRED) +add_executable(metalfx_interop main.mm ../../lib/webgpu/metalfx.mm) +target_include_directories(metalfx_interop PRIVATE ../../lib) +target_compile_options(metalfx_interop PRIVATE -fobjc-arc -Wall -Wextra) +target_link_libraries(metalfx_interop PRIVATE dawn::webgpu_dawn + "-framework MetalFX" "-framework Metal" "-framework IOSurface" "-framework Foundation") +enable_testing() +add_test(NAME metalfx_interop COMMAND metalfx_interop) +set_tests_properties(metalfx_interop PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 60) + +add_executable(metalfx_stub_test stub_test.cpp ../../lib/webgpu/metalfx_stub.cpp) +target_include_directories(metalfx_stub_test PRIVATE ../../lib) +target_compile_features(metalfx_stub_test PRIVATE cxx_std_20) +target_link_libraries(metalfx_stub_test PRIVATE dawn::webgpu_dawn) +add_test(NAME metalfx_stub COMMAND metalfx_stub_test) diff --git a/aurora-main/tests/metalfx_interop/README.md b/aurora-main/tests/metalfx_interop/README.md new file mode 100644 index 000000000..7908a6841 --- /dev/null +++ b/aurora-main/tests/metalfx_interop/README.md @@ -0,0 +1,124 @@ +# MetalFX spatial upscaling: renderer integration and tests + +Aurora can now upscale the completed game image with MetalFX before aspect-fit +presentation and ImGui composition. It is opt-in and requires macOS 13+, a +supported Metal device, and Dawn IOSurface/shared-event support. Other backends +and builds without the MetalFX SDK use a stub and the existing presentation path. +The MetalFX framework is weak-linked; the game's deployment target is unchanged. + +## Trying the renderer integration + +Use F10 → Graphics → MetalFX spatial upscaling. Then use the existing +Resolution control to render below the output viewport's size. +Both source dimensions must be smaller than the output dimensions. Equal-size +rendering, supersampling, and unsupported source formats bypass MetalFX. In +particular, Auto (window size) generally offers no upscaling opportunity. + +The F10 toggle is saved in `Config.toml` as +`video.metalfx_spatial_upscaling`. It uses these thread-safe Aurora entry points: + +- `aurora_set_metalfx_spatial(bool)` requests a change at the next sealed frame. +- `aurora_get_metalfx_spatial()` returns the requested setting. +- `aurora_is_metalfx_spatial_supported()` reports device/build support. +- `aurora_get_metalfx_status()` distinguishes Disabled, Unsupported, + Not Upscaling, Active, and Error. A busy resize-retirement pool temporarily + bypasses upscaling and retries on a later frame. Other upscaler errors log a + reason and use normal presentation until a disabled frame resets the error. + +The game's HUD is part of the source image and is upscaled. ImGui/F10/FPS overlays +are composed afterward at output resolution. Existing source-frame captures +still capture the original source image. The interpolation snapshot call sites +all use the same upscaling hook; game-specific interpolation remains untested. + +## GPU path and ownership + +1. Request Dawn's `SharedTextureMemoryIOSurface` and `SharedFenceMTLSharedEvent` + features when the Metal adapter supports both. Use that Dawn device's native + `MTLDevice`, not a separately selected default device. +2. Cache three upscaling slots with IOSurface-backed input and output textures, + a spatial scaler, a private MetalFX output, and shared-event dependencies. + Check texture formats, dimensions, usages, and device size limits on creation. +3. Begin Dawn input access, copy the completed game image at its source size, + and submit the scene plus copy. End input access and wait for Dawn's + `commandsScheduledFuture` before submitting dependent native Metal work. +4. On the native queue, wait for input rendering and any prior Dawn consumption + of the shared output. Encode MetalFX into its required **private** output + texture, then GPU-blit the result into the output IOSurface and signal an event. +5. After native scheduling, begin Dawn output access with that event/value. + Composite the upscaled image into the existing content viewport, retaining + letterboxing, then draw ImGui. Submit and end output access. Reuse observes + both Dawn-to-Metal and Metal-to-Dawn event dependencies. +6. GPU completion callbacks retain resources after a cache entry is replaced, + disabled, or shut down. At most eight resource sets may exist (four current + plus four retiring); rapid resizing cannot allocate an unbounded queue. + +CPU scheduling waits remain, but there are no CPU image transfers or per-frame +GPU-completion waits in the upscaling path. There is one source-size GPU copy +and one full-output GPU blit. Their cost must be measured before promising a +performance gain. The input copy follows the existing perceptual/unorm sampling +path. sRGB texture formats bypass MetalFX to avoid implicit color conversion. + +## Standalone GPU regression test + +The test builds the actual `lib/webgpu/metalfx.mm` implementation. Point +`Dawn_DIR` at the package used by an existing Aurora build: + +```sh +cmake -S aurora-main/tests/metalfx_interop -B build-metalfx-interop \ + -DDawn_DIR="/absolute/path/to/dawn_prebuilt-src/lib/cmake/Dawn" \ + -DCMAKE_BUILD_TYPE=Release +cmake --build build-metalfx-interop +MTL_DEBUG_LAYER=1 MTL_SHADER_VALIDATION=1 \ + ctest --test-dir build-metalfx-interop --output-on-failure -V +``` + +The GPU test returns 77 (CTest **Skipped**) when no Metal adapter, required +sharing features, or spatial scaler is available. A skip is not evidence of +interoperability. Sandboxed processes may need GPU access. CTest imposes a +60-second timeout. The separate stub test needs no GPU. + +Tested on Apple M3, macOS 26.5.1, using Aurora's existing Dawn package +(`v20260603.191052`). Metal API and GPU validation were enabled: + +| Formats | Input | Output | Frames per format | +| --- | --- | --- | --- | +| RGBA8Unorm, BGRA8Unorm | 64 × 48 | 128 × 96 | 24 | +| RGBA8Unorm, BGRA8Unorm | 320 × 180 | 480 × 270 | 24 | +| RGBA8Unorm, BGRA8Unorm | 960 × 540 | 1920 × 1080 | 24 | + +All 144 frames and 2,304 interior pixel samples passed. Red changes per frame; +green and blue distinguish left/right and top/bottom. All channels are checked +within five 8-bit levels, catching stale images, orientation/channel mistakes, +and missing output. Tests cover 1.5× and 2× scaling, padded readback rows, slot +reuse, dropping wrappers before readback completion, invalid dimensions/sRGB +formats, the eight-set allocation bound, and the unavailable-backend stub. + +Readback is only the test oracle and is absent from the game upscaling path. +These samples do not measure reconstruction quality at edges or race performance. + +## Windowed presentation test + +This optional target exercises Aurora's actual frame submission and presentation +with a synthetic source and an ImGui overlay. It requires no Wii game data and +creates an automatically closing test window. Add the option to an existing +from-source runtime build (the normal dependency/provider options still apply): + +```sh +cmake -S runtime -B build-macos -DCMAKE_BUILD_TYPE=Release \ + -DAURORA_BUILD_METALFX_PRESENTATION_TEST=ON +cmake --build build-macos --target metalfx_presentation_test +MTL_DEBUG_LAYER=1 MTL_SHADER_VALIDATION=1 \ + ./build-macos/aurora-build/metalfx_presentation_test +``` + +On the same M3, all 84 frames passed with Metal API/GPU validation: disabled, +enabled, window resize, 4:3/16:9 aspect changes, native-size bypass, disable, and +re-enable. Assertions check renderer status and errors; this is not a pixel-level +verification of the window image. The core build and macOS 12 deployment-target +availability compilation also passed. Full Mario Kart gameplay, race performance, +visual quality, Intel Macs, other Apple GPUs, older macOS runtime versions, and +non-macOS full builds remain untested. + +References: [Apple MetalFX](https://developer.apple.com/documentation/metalfx), +[spatial scaler requirements](https://developer.apple.com/documentation/metalfx/mtlfxspatialscaler), +and the installed Dawn `MetalBackend.h` / `webgpu_cpp.h` APIs. diff --git a/aurora-main/tests/metalfx_interop/main.mm b/aurora-main/tests/metalfx_interop/main.mm new file mode 100644 index 000000000..5e62e25fa --- /dev/null +++ b/aurora-main/tests/metalfx_interop/main.mm @@ -0,0 +1,269 @@ +#import +#import +#import +#include "webgpu/metalfx.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr uint64_t kTimeoutNs = 10'000'000'000; +constexpr unsigned kFrames = 24; +std::atomic g_errors{0}; + +void require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +void wait(const wgpu::Instance& instance, wgpu::Future future) { + require(instance.WaitAny(future, kTimeoutNs) == wgpu::WaitStatus::Success, + "Dawn operation timed out or failed"); +} + +void runCase(const wgpu::Instance& instance, const wgpu::Device& device, + bool bgra, uint32_t width, uint32_t height, + uint32_t outWidth, uint32_t outHeight) { + const auto format = bgra ? wgpu::TextureFormat::BGRA8Unorm : wgpu::TextureFormat::RGBA8Unorm; + using namespace aurora::webgpu::metalfx; + std::array, 3> slots; + for (auto& slot : slots) { + std::string error; + slot = create(instance, device, {width, height, outWidth, outHeight, format}, error); + if (!slot) { + throw std::runtime_error(error.empty() + ? "MetalFX resource pool was still busy retiring earlier slots" + : error); + } + } + + // Asymmetric quadrants expose channel swaps, vertical flips, and stale frames. + wgpu::ShaderSourceWGSL source{}; + source.code = R"( + @group(0) @binding(0) var params: vec4f; + @vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { + let p = array(vec2f(-1, -1), vec2f(3, -1), vec2f(-1, 3)); + return vec4f(p[i], 0, 1); + } + @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f { + return vec4f(params.x, select(0.2, 0.8, p.x >= params.y / 2), + select(0.3, 0.7, p.y >= params.z / 2), 1); + } + )"; + wgpu::ShaderModuleDescriptor shaderDescriptor{}; + shaderDescriptor.nextInChain = &source; + auto shader = device.CreateShaderModule(&shaderDescriptor); + wgpu::ColorTargetState target{}; + target.format = format; + wgpu::FragmentState fragment{}; + fragment.module = shader; + fragment.entryPoint = "fs"; + fragment.targetCount = 1; + fragment.targets = ⌖ + wgpu::RenderPipelineDescriptor pipelineDescriptor{}; + pipelineDescriptor.vertex.module = shader; + pipelineDescriptor.vertex.entryPoint = "vs"; + pipelineDescriptor.fragment = &fragment; + auto pipeline = device.CreateRenderPipeline(&pipelineDescriptor); + auto dawnQueue = device.GetQueue(); + const uint32_t bytesPerRow = (outWidth * 4 + 255) & ~255u; + const uint64_t readbackSize = uint64_t(bytesPerRow) * outHeight; + std::vector readbacks; + + for (unsigned frame = 0; frame < kFrames; ++frame) { + auto& slot = slots[frame % slots.size()]; + require(slot->begin_input(), "Production MetalFX begin_input failed"); + const std::array params{0.2f + float(frame % 5) * 0.1f, + float(width), float(height), 0}; + wgpu::BufferDescriptor uniformDescriptor{}; + uniformDescriptor.size = sizeof(params); + uniformDescriptor.usage = wgpu::BufferUsage::Uniform | wgpu::BufferUsage::CopyDst; + auto uniform = device.CreateBuffer(&uniformDescriptor); + dawnQueue.WriteBuffer(uniform, 0, params.data(), sizeof(params)); + wgpu::BindGroupEntry entry{}; + entry.binding = 0; + entry.buffer = uniform; + entry.size = sizeof(params); + wgpu::BindGroupDescriptor bindDescriptor{}; + bindDescriptor.layout = pipeline.GetBindGroupLayout(0); + bindDescriptor.entryCount = 1; + bindDescriptor.entries = &entry; + auto bindGroup = device.CreateBindGroup(&bindDescriptor); + auto encoder = device.CreateCommandEncoder(); + wgpu::RenderPassColorAttachment attachment{}; + attachment.view = slot->input_view(); + attachment.loadOp = wgpu::LoadOp::Clear; + attachment.storeOp = wgpu::StoreOp::Store; + wgpu::RenderPassDescriptor passDescriptor{}; + passDescriptor.colorAttachmentCount = 1; + passDescriptor.colorAttachments = &attachment; + auto pass = encoder.BeginRenderPass(&passDescriptor); + pass.SetPipeline(pipeline); + pass.SetBindGroup(0, bindGroup); + pass.Draw(3); + pass.End(); + auto render = encoder.Finish(); + dawnQueue.Submit(1, &render); + if (!slot->upscale()) throw std::runtime_error(slot->error()); + + wgpu::BufferDescriptor readbackDescriptor{}; + readbackDescriptor.size = readbackSize; + readbackDescriptor.usage = wgpu::BufferUsage::CopyDst | wgpu::BufferUsage::MapRead; + auto readback = device.CreateBuffer(&readbackDescriptor); + encoder = device.CreateCommandEncoder(); + wgpu::TexelCopyTextureInfo copySource{}; + copySource.texture = slot->output_texture(); + wgpu::TexelCopyBufferInfo destination{}; + destination.buffer = readback; + destination.layout.bytesPerRow = bytesPerRow; + destination.layout.rowsPerImage = outHeight; + const wgpu::Extent3D extent{outWidth, outHeight, 1}; + encoder.CopyTextureToBuffer(©Source, &destination, &extent); + auto copy = encoder.Finish(); + dawnQueue.Submit(1, ©); + if (!slot->end_output()) throw std::runtime_error(slot->error()); + readbacks.push_back(std::move(readback)); + } + + // Model toggle/resize immediately after submission, while either queue may + // still be consuming these textures. Production completion callbacks must + // keep the resources alive after the cache drops its wrappers. + slots = {}; + + // Readback is only the test oracle. No CPU image transfer or GPU completion + // wait occurs between Dawn rendering, MetalFX, and Dawn consumption above. + for (unsigned frame = 0; frame < kFrames; ++frame) { + bool mapped = false; + auto& readback = readbacks[frame]; + wait(instance, readback.MapAsync(wgpu::MapMode::Read, 0, readbackSize, + wgpu::CallbackMode::WaitAnyOnly, [&mapped](wgpu::MapAsyncStatus status, wgpu::StringView) { + mapped = status == wgpu::MapAsyncStatus::Success; + })); + require(mapped, "Output readback mapping failed"); + const auto* bytes = static_cast(readback.GetConstMappedRange()); + require(bytes != nullptr, "Output readback pointer is null"); + for (unsigned y = 0; y < 4; ++y) { + for (unsigned x = 0; x < 4; ++x) { + const unsigned px = (2 * x + 1) * outWidth / 8; + const unsigned py = (2 * y + 1) * outHeight / 8; + const auto* pixel = bytes + py * bytesPerRow + px * 4; + const std::array expected{ + 0.2f + float(frame % 5) * 0.1f, x >= 2 ? 0.8f : 0.2f, + y >= 2 ? 0.7f : 0.3f, 1}; + for (unsigned c = 0; c < 4; ++c) { + const unsigned channel = bgra && c != 1 && c != 3 ? 2 - c : c; + if (std::abs(int(pixel[channel]) - int(std::lround(expected[c] * 255))) > 5) { + std::cerr << "Pixel mismatch: frame=" << frame << " x=" << px << " y=" << py + << " channel=" << c << " actual=" << int(pixel[channel]) + << " expected=" << std::lround(expected[c] * 255) << '\n'; + throw std::runtime_error("MetalFX output failed image validation"); + } + } + } + } + readback.Unmap(); + } + require(g_errors.load() == 0, "Dawn reported validation errors or device loss"); + std::cout << "PASS " << (bgra ? "BGRA8" : "RGBA8") << ' ' << width << 'x' << height + << " -> " << outWidth << 'x' << outHeight << ": " << kFrames + << " frames, 3 reused slots, 16 pixel samples/frame\n"; +} + +int run() { + const wgpu::InstanceFeatureName timedWait = wgpu::InstanceFeatureName::TimedWaitAny; + wgpu::InstanceDescriptor instanceDescriptor{}; + instanceDescriptor.requiredFeatureCount = 1; + instanceDescriptor.requiredFeatures = &timedWait; + auto instance = wgpu::CreateInstance(&instanceDescriptor); + require(instance != nullptr, "Dawn instance creation failed"); + wgpu::Adapter adapter; + wgpu::RequestAdapterOptions options{}; + options.backendType = wgpu::BackendType::Metal; + wait(instance, instance.RequestAdapter(&options, wgpu::CallbackMode::WaitAnyOnly, + [&adapter](wgpu::RequestAdapterStatus status, wgpu::Adapter result, wgpu::StringView message) { + if (status == wgpu::RequestAdapterStatus::Success) adapter = std::move(result); + else std::cerr << "Adapter: " << std::string_view(message) << '\n'; + })); + if (!adapter) { std::cout << "SKIP: no Dawn Metal adapter\n"; return 77; } + const std::array features{wgpu::FeatureName::SharedTextureMemoryIOSurface, + wgpu::FeatureName::SharedFenceMTLSharedEvent}; + for (auto feature : features) { + if (!adapter.HasFeature(feature)) { + std::cout << "SKIP: Dawn adapter lacks IOSurface/shared-event interoperability\n"; + return 77; + } + } + wgpu::DeviceDescriptor descriptor{}; + descriptor.requiredFeatureCount = features.size(); + descriptor.requiredFeatures = features.data(); + descriptor.SetUncapturedErrorCallback( + [](const wgpu::Device&, wgpu::ErrorType, wgpu::StringView message) { + ++g_errors; + std::cerr << "Dawn error: " << std::string_view(message) << '\n'; + }); + descriptor.SetDeviceLostCallback(wgpu::CallbackMode::AllowSpontaneous, + [](const wgpu::Device&, wgpu::DeviceLostReason reason, wgpu::StringView message) { + if (reason != wgpu::DeviceLostReason::Destroyed) { + ++g_errors; + std::cerr << "Device lost: " << std::string_view(message) << '\n'; + } + }); + wgpu::Device device; + wait(instance, adapter.RequestDevice(&descriptor, wgpu::CallbackMode::WaitAnyOnly, + [&device](wgpu::RequestDeviceStatus status, wgpu::Device result, wgpu::StringView message) { + if (status == wgpu::RequestDeviceStatus::Success) device = std::move(result); + else std::cerr << "Device: " << std::string_view(message) << '\n'; + })); + require(device != nullptr, "Dawn device creation failed"); + id native = dawn::native::metal::GetMTLDevice(device.Get()); + require(native != nil, "Dawn native Metal device is unavailable"); + std::cout << "GPU: " << native.name.UTF8String << '\n'; + if (!aurora::webgpu::metalfx::supported(device, wgpu::BackendType::Metal)) { + std::cout << "SKIP: GPU does not support MetalFX spatial scaling\n"; + return 77; + } + require(!aurora::webgpu::metalfx::supported(device, wgpu::BackendType::Vulkan), + "MetalFX must reject non-Metal backends"); + std::string error; + require(!aurora::webgpu::metalfx::create(instance, device, + {128, 96, 128, 96, wgpu::TextureFormat::RGBA8Unorm}, error) && !error.empty(), + "MetalFX must reject equal-size input/output"); + require(!aurora::webgpu::metalfx::create(instance, device, + {128, 96, 256, 192, wgpu::TextureFormat::RGBA8UnormSrgb}, error), + "MetalFX must reject implicit sRGB conversion"); + { + using namespace aurora::webgpu::metalfx; + std::array, 8> resources; + for (auto& scaler : resources) { + scaler = create(instance, device, {64, 48, 128, 96, wgpu::TextureFormat::RGBA8Unorm}, error); + require(scaler != nullptr, "Could not fill the MetalFX resource pool"); + } + require(!create(instance, device, {64, 48, 128, 96, wgpu::TextureFormat::RGBA8Unorm}, error) + && error.empty(), "A full retirement pool must defer allocation without a fatal error"); + } + for (bool bgra : {false, true}) { + runCase(instance, device, bgra, 64, 48, 128, 96); + runCase(instance, device, bgra, 320, 180, 480, 270); + runCase(instance, device, bgra, 960, 540, 1920, 1080); + } + return 0; +} +} // namespace + +int main() { + @autoreleasepool { + try { return run(); } + catch (const std::exception& error) { + std::cerr << "FAIL: " << error.what() << '\n'; + return 1; + } + } +} diff --git a/aurora-main/tests/metalfx_interop/presentation_test.cpp b/aurora-main/tests/metalfx_interop/presentation_test.cpp new file mode 100644 index 000000000..244f746c8 --- /dev/null +++ b/aurora-main/tests/metalfx_interop/presentation_test.cpp @@ -0,0 +1,124 @@ +// Explicitly opted-in windowed test of Aurora's real frame/presentation path. +// No Wii game data is needed; the source override supplies a synthetic image. +#include +#include +#include + +#include "webgpu/gpu.hpp" +#include "window.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +std::atomic g_errors{0}; + +void log_message(AuroraLogLevel level, const char* module, const char* message, unsigned len) { + if (level >= LOG_ERROR) ++g_errors; + if (level >= LOG_WARNING || std::string_view(message, len).find("MetalFX") != std::string_view::npos) + std::fprintf(stderr, "[%s] %.*s\n", module, static_cast(len), message); + if (level == LOG_FATAL) std::abort(); +} + +void require(bool value, const char* message) { + if (!value) throw std::runtime_error(message); +} + +void draw_frames(uint32_t width, uint32_t height, AuroraMetalFXStatus expected) { + using namespace aurora::webgpu; + auto source = create_render_texture(width, height, false); + auto bindGroup = create_copy_bind_group(source); + std::vector pixels(size_t(width) * height); + for (uint32_t y = 0; y < height; ++y) { + for (uint32_t x = 0; x < width; ++x) { + pixels[size_t(y) * width + x] = 0xff000000u | ((x / 16 % 2) ? 0x00bb55u : 0xbb5500u); + } + } + wgpu::TexelCopyTextureInfo target{}; + target.texture = source.texture; + wgpu::TexelCopyBufferLayout layout{}; + layout.bytesPerRow = width * 4; + layout.rowsPerImage = height; + g_queue.WriteTexture(&target, pixels.data(), pixels.size() * sizeof(uint32_t), &layout, &source.size); + + unsigned rendered = 0; + unsigned matchingStatus = 0; + for (unsigned attempt = 0; attempt < 300 && rendered < 12; ++attempt) { + aurora_update(); + if (!aurora_begin_frame()) { SDL_Delay(5); continue; } + set_present_source_override(bindGroup, source.texture, source.size, source.format); + ImGui::SetNextWindowPos(ImVec2(12, 12), ImGuiCond_Always); + ImGui::Begin("MetalFX presentation test", nullptr, ImGuiWindowFlags_AlwaysAutoResize); + ImGui::TextUnformatted("Output-resolution overlay after game upscaling"); + ImGui::Text("Source: %u x %u", width, height); + ImGui::End(); + aurora_end_frame(); + aurora_wait_for_frame_worker(); + const auto status = aurora_get_metalfx_status(); + require(status != AURORA_METALFX_ERROR, "MetalFX reported a presentation error"); + if (status == expected) ++matchingStatus; + ++rendered; + } + require(rendered == 12 && matchingStatus >= 9, "Presentation did not reach the expected MetalFX state"); + require(g_errors.load() == 0, "Aurora reported an error"); + std::printf("PASS presentation source=%ux%u status=%d frames=%u\n", width, height, expected, rendered); +} +} // namespace + +int main(int argc, char** argv) { + const auto cache = std::filesystem::temp_directory_path() / + ("aurora-metalfx-presentation-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + std::filesystem::create_directories(cache); + const auto path = cache.string(); + AuroraConfig config{}; + config.appName = "MetalFX presentation test"; + config.userPath = path.c_str(); + config.cachePath = path.c_str(); + config.resourcesPath = path.c_str(); + config.desiredBackend = BACKEND_METAL; + config.windowWidth = 640; + config.windowHeight = 480; + config.msaa = 1; + config.maxTextureAnisotropy = 1; + config.logCallback = log_message; + config.logLevel = LOG_INFO; + aurora_initialize(argc, argv, &config); + int result = 0; + try { + if (!aurora_is_metalfx_spatial_supported()) { + std::puts("SKIP: MetalFX spatial scaling is unavailable"); + result = 77; + } else { + aurora::window::lock_present_aspect_ratio(4, 3); + aurora_set_metalfx_spatial(false); + require(!aurora_get_metalfx_spatial(), "Disable request was not retained"); + draw_frames(320, 240, AURORA_METALFX_DISABLED); + aurora_set_metalfx_spatial(true); + require(aurora_get_metalfx_spatial(), "Enable request was not retained"); + draw_frames(320, 240, AURORA_METALFX_ACTIVE); + aurora::window::set_window_size(800, 500); + draw_frames(320, 240, AURORA_METALFX_ACTIVE); + aurora::window::lock_present_aspect_ratio(16, 9); + draw_frames(320, 240, AURORA_METALFX_ACTIVE); + const auto output = aurora::window::get_window_size(); + draw_frames(output.native_fb_width, output.native_fb_height, AURORA_METALFX_NOT_UPSCALING); + aurora_set_metalfx_spatial(false); + draw_frames(320, 240, AURORA_METALFX_DISABLED); + aurora_set_metalfx_spatial(true); + draw_frames(320, 240, AURORA_METALFX_ACTIVE); + } + } catch (const std::exception& error) { + std::fprintf(stderr, "FAIL: %s\n", error.what()); + result = 1; + } + aurora_shutdown(); + std::error_code cleanupError; + std::filesystem::remove_all(cache, cleanupError); + return result; +} diff --git a/aurora-main/tests/metalfx_interop/stub_test.cpp b/aurora-main/tests/metalfx_interop/stub_test.cpp new file mode 100644 index 000000000..fc4bc8dc4 --- /dev/null +++ b/aurora-main/tests/metalfx_interop/stub_test.cpp @@ -0,0 +1,9 @@ +#include "webgpu/metalfx.hpp" + +int main() { + using namespace aurora::webgpu::metalfx; + if (supported({}, wgpu::BackendType::Vulkan) || supported({}, wgpu::BackendType::Metal)) return 1; + std::string error; + if (create({}, {}, {640, 480, 1280, 960, wgpu::TextureFormat::RGBA8Unorm}, error)) return 1; + return error.empty() ? 1 : 0; +} diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index d7e2a990a..7946d3a82 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -339,6 +339,13 @@ target_link_libraries(mkw_platform_paths_tests PRIVATE mkw_platform) target_compile_features(mkw_platform_paths_tests PRIVATE cxx_std_17) add_test(NAME mkw_platform_paths_tests COMMAND mkw_platform_paths_tests) +add_executable(mkw_runtime_config_tests "${CMAKE_CURRENT_LIST_DIR}/tests/runtime_config_tests.cpp") +target_include_directories(mkw_runtime_config_tests PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/include" + "${CMAKE_CURRENT_LIST_DIR}/third_party/toml11") +target_compile_features(mkw_runtime_config_tests PRIVATE cxx_std_20) +add_test(NAME mkw_runtime_config_tests COMMAND mkw_runtime_config_tests) + add_executable(mkw_nand_save_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_save_tests.cpp") target_include_directories(mkw_nand_save_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") target_compile_features(mkw_nand_save_tests PRIVATE cxx_std_17) diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h index 601cf818e..2204f70de 100644 --- a/runtime/include/runtime_config.h +++ b/runtime/include/runtime_config.h @@ -42,6 +42,7 @@ struct RuntimeUserConfig { std::optional graphicsApi; std::optional displayMode; std::optional frameInterpolationFps; + std::optional metalFxSpatialUpscaling; std::optional skipUnreadyPipelines; std::optional disableCopyFilter; std::optional textureReplacements; @@ -459,6 +460,8 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) { config.frameInterpolationFps = migrated; } } + config.metalFxSpatialUpscaling = + FindConfigValue(document, "video", "metalfx_spatial_upscaling"); config.skipUnreadyPipelines = FindConfigValue(document, "video", "skip_unready_pipelines"); config.disableCopyFilter = FindConfigValue(document, "video", "disable_copy_filter"); config.showFps = FindConfigValue(document, "video", "show_fps"); @@ -660,6 +663,11 @@ inline bool SetFrameInterpolationFps(uint32_t value) { return WriteSetting("video", "frame_interpolation_fps", std::to_string(value)); } +inline bool SetMetalFxSpatialUpscaling(bool value) { + Mutable().metalFxSpatialUpscaling = value; + return WriteSetting("video", "metalfx_spatial_upscaling", value ? "true" : "false"); +} + inline bool SetDisplayMode(std::string value) { if (!IsSupportedDisplayMode(value)) { return false; @@ -815,6 +823,10 @@ inline float ResolutionMultiplier(float fallback = 1.0f) { return std::max(0.0f, Get().resolutionMultiplier.value_or(fallback)); } +inline bool MetalFxSpatialUpscaling(bool fallback = false) { + return Get().metalFxSpatialUpscaling.value_or(fallback); +} + inline float AudioVolume(float fallback = 1.0f) { return std::clamp(Get().audioVolume.value_or(fallback), 0.0f, 1.0f); } diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 4f62f325a..5c8b2d77b 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -111,6 +111,9 @@ bool g_skipUnreadyPipelines = RuntimeConfigFile::SkipUnreadyPipelines(true); bool g_disableCopyFilter = RuntimeConfigFile::DisableCopyFilter(true); bool g_showFps = RuntimeConfigFile::ShowFps(true); bool g_forceAspect169 = RuntimeConfigFile::ForceAspect169Enabled(); +#if defined(__APPLE__) +bool g_metalFxSpatialUpscaling = RuntimeConfigFile::MetalFxSpatialUpscaling(false); +#endif uint32_t g_disabledPostProcessingPaths = RuntimeConfigFile::DisabledPostProcessingPaths(0); std::array g_configuredControllerIndices = [] { std::array indices{}; @@ -1093,6 +1096,36 @@ void DrawGraphicsSettings() { ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f); ImGui::TextDisabled("Frame interpolation is experimental, you might find visual artifacts"); ImGui::PopTextWrapPos(); +#if defined(__APPLE__) + const bool metalFxSupported = aurora_is_metalfx_spatial_supported(); + ImGui::BeginDisabled(!metalFxSupported); + if (ImGui::Checkbox("MetalFX spatial upscaling", &g_metalFxSpatialUpscaling)) { + aurora_set_metalfx_spatial(g_metalFxSpatialUpscaling); + RuntimeConfigFile::SetMetalFxSpatialUpscaling(g_metalFxSpatialUpscaling); + } + ImGui::EndDisabled(); + switch (aurora_get_metalfx_status()) { + case AURORA_METALFX_ACTIVE: + ImGui::TextDisabled("Active: upscaling the game image before the overlay."); + break; + case AURORA_METALFX_NOT_UPSCALING: + ImGui::TextDisabled("Choose a lower internal resolution to use MetalFX."); + break; + case AURORA_METALFX_UNSUPPORTED: + ImGui::TextDisabled("Requires macOS 13+, Metal, and a MetalFX-capable GPU."); + break; + case AURORA_METALFX_ERROR: + ImGui::TextDisabled("Unavailable after a renderer error; toggle off and on to retry."); + break; + case AURORA_METALFX_DISABLED: + if (metalFxSupported) { + ImGui::TextDisabled("Render below output resolution for sharper lower-cost output."); + } else { + ImGui::TextDisabled("MetalFX spatial upscaling is unavailable on this device."); + } + break; + } +#endif if (ImGui::Checkbox("Disable copy filter", &g_disableCopyFilter)) { aurora_set_disable_copy_filter(g_disableCopyFilter); RuntimeConfigFile::SetDisableCopyFilter(g_disableCopyFilter); @@ -1397,6 +1430,9 @@ void InitializeRuntimeSettings() noexcept { MusicAttenuation::SetVoicesVolume(static_cast(g_voicesVolumePercent) / 100.0f); MusicAttenuation::SetEnabled(g_attenuateMusicWhenMediaPlays); RuntimeGameGraphicsOptions::SetDisabledPostProcessingPaths(g_disabledPostProcessingPaths); +#if defined(__APPLE__) + aurora_set_metalfx_spatial(g_metalFxSpatialUpscaling); +#endif const uint32_t targetFps = kFrameInterpolationTargetFps[static_cast(g_frameInterpolationMode)]; LimitResolutionForFrameRate(); aurora_set_frame_interpolation_fps(targetFps); diff --git a/runtime/tests/runtime_config_tests.cpp b/runtime/tests/runtime_config_tests.cpp new file mode 100644 index 000000000..337d40a95 --- /dev/null +++ b/runtime/tests/runtime_config_tests.cpp @@ -0,0 +1,35 @@ +#include "runtime_config.h" + +#include +#include + +namespace { +bool Require(bool value, const char* message) { + if (!value) { + std::cerr << message << '\n'; + return false; + } + return true; +} +} // namespace + +int main() { + { + std::istringstream input("[video]\nmetalfx_spatial_upscaling = true\n"); + const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "enabled.toml"); + if (!Require(config.metalFxSpatialUpscaling == std::optional{true}, + "valid MetalFX setting was not loaded")) return 1; + } + { + std::istringstream input("[video]\nmetalfx_spatial_upscaling = false\n"); + const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "disabled.toml"); + if (!Require(config.metalFxSpatialUpscaling == std::optional{false}, + "false MetalFX setting was not loaded")) return 1; + } + { + std::istringstream input("[video]\nmetalfx_spatial_upscaling = \"yes\"\n"); + const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "invalid.toml"); + if (!Require(!config.metalFxSpatialUpscaling, "invalid MetalFX setting was accepted")) return 1; + } + return 0; +} From d3d4d49f61073479110a575ecd6e6a7250c3d346 Mon Sep 17 00:00:00 2001 From: DarthM Date: Mon, 14 Sep 2026 16:39:38 -0400 Subject: [PATCH 12/14] Fix automatic music muting on macOS Co-Authored-By: Michael G <10155689+DarthMDev@users.noreply.github.com> Co-Authored-By: Daan Vervacke <23398694+DaanVervacke@users.noreply.github.com> --- .github/workflows/build.yml | 3 +- README.md | 5 +- runtime/CMakeLists.txt | 7 ++ runtime/cmake/PublicProducts.cmake | 18 ++- runtime/include/external_audio_macos.h | 14 +++ runtime/include/music_attenuation.h | 3 +- runtime/src/external_audio_macos.cpp | 87 +++++++++++++++ runtime/src/music_attenuation.cpp | 17 +++ runtime/src/settings_overlay.cpp | 13 ++- runtime/tests/macos_external_audio_tests.cpp | 109 +++++++++++++++++++ 10 files changed, 269 insertions(+), 7 deletions(-) create mode 100644 runtime/include/external_audio_macos.h create mode 100644 runtime/src/external_audio_macos.cpp create mode 100644 runtime/tests/macos_external_audio_tests.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 880fe093c..24d97697c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -77,7 +77,8 @@ jobs: mkw_macos_native_compile \ mkw_macos_context_abi_tests \ mkw_macos_host_context_tests \ - mkw_macos_guest_flat_memory_tests + mkw_macos_guest_flat_memory_tests \ + mkw_macos_external_audio_tests - name: Test execution substrate shell: bash diff --git a/README.md b/README.md index 71e6d6c61..3c72c2b9e 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,10 @@ Play at several times the console's resolution. **Music ducking.** Start playing something else, Spotify, a YouTube video, and the game automatically mutes its own music until the other audio stops. Optional, if you'd -rather it didn't. All audio that shows in your display media controls on your windows pc fall under this. +rather it didn't. Windows uses system media controls and Linux uses MPRIS players. +On macOS 14.2 or later, this detects other apps with active audio output and excludes +the game's own audio. Apps that keep an output stream running silently can keep +game music muted even when nothing is audible. **An in-game settings bar.** Press **F10** while the game window has focus: diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 7946d3a82..8279bd60a 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -422,6 +422,13 @@ if(MKW_PLATFORM_MACOS) target_include_directories(mkw_macos_guest_flat_memory_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") target_compile_features(mkw_macos_guest_flat_memory_tests PRIVATE cxx_std_17) add_test(NAME mkw_macos_guest_flat_memory_tests COMMAND mkw_macos_guest_flat_memory_tests) + + add_executable(mkw_macos_external_audio_tests + "${CMAKE_CURRENT_LIST_DIR}/tests/macos_external_audio_tests.cpp" + "${CMAKE_CURRENT_LIST_DIR}/src/external_audio_macos.cpp") + target_include_directories(mkw_macos_external_audio_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") + target_compile_features(mkw_macos_external_audio_tests PRIVATE cxx_std_17) + add_test(NAME mkw_macos_external_audio_tests COMMAND mkw_macos_external_audio_tests) endif() # The translator emits the complete, content-addressed source graph. Consuming diff --git a/runtime/cmake/PublicProducts.cmake b/runtime/cmake/PublicProducts.cmake index f77d8de04..390736fe4 100644 --- a/runtime/cmake/PublicProducts.cmake +++ b/runtime/cmake/PublicProducts.cmake @@ -28,6 +28,7 @@ list(REMOVE_DUPLICATES SOURCES) if(MKW_PLATFORM_MACOS) find_library(MKW_IOKIT_FRAMEWORK IOKit REQUIRED) find_library(MKW_COREFOUNDATION_FRAMEWORK CoreFoundation REQUIRED) + find_library(MKW_COREAUDIO_FRAMEWORK CoreAudio REQUIRED) endif() function(mkw_apply_common_compile_options target) @@ -84,11 +85,22 @@ target_link_libraries(mkw_runtime_common PRIVATE target_link_libraries(mkw_runtime_common PRIVATE mkw_platform mkw::pugixml mkw::toml11 mkw::cryptopp mkw::mbedtls) if(MKW_PLATFORM_WINDOWS) target_link_libraries(mkw_runtime_common PRIVATE shell32 windowsapp) -elseif(MKW_PLATFORM_LINUX OR MKW_PLATFORM_MACOS_X86_64) +elseif(MKW_PLATFORM_LINUX) # ${CMAKE_DL_LIBS} for music_attenuation.cpp's dlopen of libdbus-1 (MPRIS - # media monitoring). Empty on platforms where dl* is already in libc/libSystem. + # media monitoring). target_link_libraries(mkw_runtime_common PRIVATE mkw::libco ${CMAKE_DL_LIBS}) endif() + +if(MKW_PLATFORM_MACOS) + # CoreAudio framework is required for automatic music muting on macOS. + target_link_libraries(mkw_runtime_common PRIVATE "${MKW_COREAUDIO_FRAMEWORK}") + if(MKW_PLATFORM_MACOS_X86_64) + # libco is used by Intel macOS. Apple Silicon uses the local + # x18-safe assembly backend and therefore does not define mkw::libco. + target_link_libraries(mkw_runtime_common PRIVATE mkw::libco) + endif() +endif() + if(MKW_CPPWINRT_INCLUDE_DIR) if(NOT EXISTS "${MKW_CPPWINRT_INCLUDE_DIR}/winrt/base.h") message(FATAL_ERROR @@ -205,7 +217,7 @@ function(mkw_configure_product target) aurora::gx aurora::pad aurora::si aurora::vi aurora::mtx) if(MKW_PLATFORM_MACOS) target_link_libraries(${target} PRIVATE - "${MKW_IOKIT_FRAMEWORK}" "${MKW_COREFOUNDATION_FRAMEWORK}") + "${MKW_IOKIT_FRAMEWORK}" "${MKW_COREFOUNDATION_FRAMEWORK}" "${MKW_COREAUDIO_FRAMEWORK}") target_link_options(${target} PRIVATE "LINKER:-U,_OBJC_CLASS_$_MTLLogStateDescriptor") endif() diff --git a/runtime/include/external_audio_macos.h b/runtime/include/external_audio_macos.h new file mode 100644 index 000000000..de8612c4a --- /dev/null +++ b/runtime/include/external_audio_macos.h @@ -0,0 +1,14 @@ +#pragma once + +namespace MusicAttenuation { + +struct MacOSAudioStatus { + bool available = false; + bool playing = false; +}; + +// Queries output activity without capturing audio. Requires Core Audio process +// objects (macOS 14.2+); older systems return an unavailable sample. +MacOSAudioStatus QueryMacOSExternalAudio() noexcept; + +} // namespace MusicAttenuation diff --git a/runtime/include/music_attenuation.h b/runtime/include/music_attenuation.h index f4a8b9ab4..e2f11dbe6 100644 --- a/runtime/include/music_attenuation.h +++ b/runtime/include/music_attenuation.h @@ -5,7 +5,8 @@ namespace MusicAttenuation { // Enables the optional external-media integration (Windows media sessions, -// or MPRIS over D-Bus on Linux). The monitor is started lazily the first time +// Core Audio output activity on macOS, or MPRIS over D-Bus on Linux). +// The monitor is started lazily the first time // this is enabled. void SetEnabled(bool enabled) noexcept; void SetMusicVolume(float volume) noexcept; diff --git a/runtime/src/external_audio_macos.cpp b/runtime/src/external_audio_macos.cpp new file mode 100644 index 000000000..0b38a924f --- /dev/null +++ b/runtime/src/external_audio_macos.cpp @@ -0,0 +1,87 @@ +#if defined(__APPLE__) + +#include "external_audio_macos.h" + +#include +#include +#include + +namespace MusicAttenuation { +namespace { + +#if __MAC_OS_X_VERSION_MAX_ALLOWED >= 140200 +constexpr auto kProcessObjectList = kAudioHardwarePropertyProcessObjectList; +constexpr auto kProcessPid = kAudioProcessPropertyPID; +constexpr auto kProcessRunningOutput = kAudioProcessPropertyIsRunningOutput; +#else +// Public Core Audio selector ABI values, for SDKs predating process objects. +// The runtime HasProperty check still determines whether the OS supports them. +constexpr AudioObjectPropertySelector kProcessObjectList = 'prs#'; +constexpr AudioObjectPropertySelector kProcessPid = 'ppid'; +constexpr AudioObjectPropertySelector kProcessRunningOutput = 'piro'; +#endif + +template +bool ReadAudioProcessProperty(AudioObjectID object, AudioObjectPropertySelector selector, + T& value) noexcept { + const AudioObjectPropertyAddress address{ + selector, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain}; + UInt32 size = sizeof(value); + return AudioObjectGetPropertyData(object, &address, 0, nullptr, &size, &value) == noErr && + size == sizeof(value); +} + +} // namespace + +MacOSAudioStatus QueryMacOSExternalAudio() noexcept { + const AudioObjectPropertyAddress address{ + kProcessObjectList, + kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain}; + // Probe the property instead of raising the game's minimum macOS version. + if (!AudioObjectHasProperty(kAudioObjectSystemObject, &address)) { + return {}; + } + + // Processes may start between the size and data queries. Retry a changed + // list a bounded number of times; subsequent monitor polls also retry. + for (int attempt = 0; attempt < 3; ++attempt) { + UInt32 size = 0; + if (AudioObjectGetPropertyDataSize(kAudioObjectSystemObject, &address, + 0, nullptr, &size) != noErr || + size % sizeof(AudioObjectID) != 0) { + return {}; + } + if (size == 0) { + return {true, false}; + } + std::vector processes(size / sizeof(AudioObjectID)); + const auto result = AudioObjectGetPropertyData(kAudioObjectSystemObject, &address, + 0, nullptr, &size, processes.data()); + if (result == kAudioHardwareBadPropertySizeError) { + continue; + } + if (result != noErr || size % sizeof(AudioObjectID) != 0 || + size / sizeof(AudioObjectID) > processes.size()) { + return {}; + } + const pid_t ownPid = getpid(); + for (size_t index = 0; index < size / sizeof(AudioObjectID); ++index) { + pid_t pid = 0; + UInt32 runningOutput = 0; + // An exiting process may disappear mid-query. Never count an + // unknown PID or our own SDL output as external playback. + if (ReadAudioProcessProperty(processes[index], kProcessPid, pid) && + pid > 0 && pid != ownPid && + ReadAudioProcessProperty(processes[index], kProcessRunningOutput, + runningOutput) && runningOutput != 0) { + return {true, true}; + } + } + return {true, false}; + } + return {}; +} + +} // namespace MusicAttenuation + +#endif diff --git a/runtime/src/music_attenuation.cpp b/runtime/src/music_attenuation.cpp index 3d0f123d9..d03c3cfce 100644 --- a/runtime/src/music_attenuation.cpp +++ b/runtime/src/music_attenuation.cpp @@ -16,6 +16,8 @@ #include #include #include +#elif defined(__APPLE__) +#include "external_audio_macos.h" #elif defined(__linux__) #include @@ -462,6 +464,19 @@ void MonitorLinuxMprisSessions() noexcept { } #endif +#if defined(__APPLE__) +void MonitorMacOSAudio() noexcept { + using namespace std::chrono_literals; + for (;;) { + const auto status = QueryMacOSExternalAudio(); + g_externalMediaPlaying.store(status.playing, std::memory_order_release); + g_mediaControlAvailable.store(status.available, std::memory_order_release); + g_mediaControlInitializationComplete.store(true, std::memory_order_release); + std::this_thread::sleep_for(250ms); + } +} +#endif + void StartMonitor() noexcept { #if defined(_WIN32) // The process owns this monitor for its remaining lifetime. Keeping it @@ -470,6 +485,8 @@ void StartMonitor() noexcept { #elif defined(__linux__) // Detached so there is no shutdown ordering to manage against static audio state. std::thread(MonitorLinuxMprisSessions).detach(); +#elif defined(__APPLE__) + std::thread(MonitorMacOSAudio).detach(); #else g_mediaControlAvailable.store(false, std::memory_order_release); g_mediaControlInitializationComplete.store(true, std::memory_order_release); diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 5c8b2d77b..78c32bf8c 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -1001,13 +1001,24 @@ void DrawAudioSettings() { MusicAttenuation::SetEnabled(g_attenuateMusicWhenMediaPlays); RuntimeConfigFile::SetAttenuateMusicWhenMediaPlays(g_attenuateMusicWhenMediaPlays); } +#if defined(__APPLE__) + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip( + "Detects other apps with active audio output on macOS 14.2 or later. " + "Apps that keep an output stream running silently may keep game music muted."); + } +#endif if (g_attenuateMusicWhenMediaPlays) { if (MusicAttenuation::IsExternalMediaPlaying()) { ImGui::TextDisabled("External media is playing; game music is muted."); } else if (!MusicAttenuation::IsMediaControlInitializationComplete()) { - ImGui::TextDisabled("Waiting for media controls..."); + ImGui::TextDisabled("Checking external audio..."); } else if (!MusicAttenuation::IsMediaControlAvailable()) { +#if defined(__APPLE__) + ImGui::TextDisabled("External audio detection unavailable (requires macOS 14.2 or later)."); +#else ImGui::TextDisabled("Media controls are unavailable."); +#endif } else { ImGui::TextDisabled("No external media is currently playing."); } diff --git a/runtime/tests/macos_external_audio_tests.cpp b/runtime/tests/macos_external_audio_tests.cpp new file mode 100644 index 000000000..2672affd4 --- /dev/null +++ b/runtime/tests/macos_external_audio_tests.cpp @@ -0,0 +1,109 @@ +#include "external_audio_macos.h" + +#include +#include +#include +#include +#include +#include + +namespace { +struct Process { + pid_t pid; + UInt32 output; + bool disappeared = false; +}; +std::vector processes; +bool supported = true; +bool queryFailed = false; +int sizeRaces = 0; + +void Require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +void Expect(bool available, bool playing, const char* message) { + const auto status = MusicAttenuation::QueryMacOSExternalAudio(); + Require(status.available == available && status.playing == playing, message); +} +} // namespace + +// Fake only the Core Audio boundary so the production enumeration and process +// filtering run unchanged, without depending on other apps on the test machine. +// Use the public selector ABI values so these mocks also build with older SDKs. +extern "C" Boolean AudioObjectHasProperty(AudioObjectID object, + const AudioObjectPropertyAddress* address) { + Require(object == kAudioObjectSystemObject && + address->mSelector == 'prs#', + "Must query the system process list"); + return supported; +} + +extern "C" OSStatus AudioObjectGetPropertyDataSize( + AudioObjectID, const AudioObjectPropertyAddress*, UInt32, const void*, UInt32* size) { + if (queryFailed) return kAudioHardwareUnspecifiedError; + *size = static_cast(processes.size() * sizeof(AudioObjectID)); + return noErr; +} + +extern "C" OSStatus AudioObjectGetPropertyData( + AudioObjectID object, const AudioObjectPropertyAddress* address, UInt32, + const void*, UInt32* size, void* data) { + Require(address->mScope == kAudioObjectPropertyScopeGlobal, "Must use global scope"); + if (object == kAudioObjectSystemObject) { + if (sizeRaces > 0) { + --sizeRaces; + return kAudioHardwareBadPropertySizeError; + } + for (size_t i = 0; i < processes.size(); ++i) { + static_cast(data)[i] = static_cast(i + 100); + } + *size = static_cast(processes.size() * sizeof(AudioObjectID)); + return noErr; + } + const auto& process = processes.at(object - 100); + if (process.disappeared) return kAudioHardwareBadObjectError; + if (address->mSelector == 'ppid') { + std::memcpy(data, &process.pid, sizeof(process.pid)); + *size = sizeof(process.pid); + } else { + Require(address->mSelector == 'piro', + "Input-only activity must not count as playback"); + Require(process.pid != getpid(), "Must exclude the game's own output"); + std::memcpy(data, &process.output, sizeof(process.output)); + *size = sizeof(process.output); + } + return noErr; +} + +int main() { + supported = false; + Expect(false, false, "Older macOS must report unavailable"); + supported = true; + Expect(true, false, "An empty process list is available but inactive"); + processes = {{getpid(), 1}}; + Expect(true, false, "Game audio alone must not trigger muting"); + processes.push_back({getpid() + 1, 0}); + Expect(true, false, "An idle or input-only app must not trigger muting"); + processes.back().output = 1; + Expect(true, true, "External playback must trigger muting"); + processes.back().output = 0; + Expect(true, false, "Stopping playback must restore music"); + processes.back().output = 1; + processes.back().disappeared = true; + Expect(true, false, "Exiting apps must not leave music muted"); + processes.push_back({getpid() + 2, 1}); + Expect(true, true, "A disappearing app must not hide another active player"); + sizeRaces = 1; + Expect(true, true, "A growing process list must be retried"); + sizeRaces = 3; + Expect(false, false, "List retries must be bounded"); + Expect(true, true, "Later polls must recover from list races"); + queryFailed = true; + Expect(false, false, "Query failure must clear playing state"); + queryFailed = false; + Expect(true, true, "Monitoring must recover from query failure"); + processes = {{0, 1}}; + Expect(true, false, "Unknown PIDs must not count as external playback"); + std::cout << "macOS external audio tests passed\n"; +} From 094f48e8121a85dc3985c8524f9e2f96aaf69730 Mon Sep 17 00:00:00 2001 From: DarthM Date: Sat, 26 Sep 2026 18:53:14 -0400 Subject: [PATCH 13/14] Address CodeRabbit review comments and integrate upstream TLS cmake --- .github/workflows/package.yml | 7 +++++-- Launcher/macos/setup.command | 1 + README.md | 8 ++++---- aurora-main/tests/metalfx_interop/README.md | 2 +- runtime/CMakeLists.txt | 12 +++++++++--- 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 817821be6..45aaf24ea 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -190,8 +190,11 @@ jobs: shell: bash run: | pkgutil --check-signature Launcher/dist/WiiCompiled-Setup.pkg - ! pkgutil --payload-files Launcher/dist/WiiCompiled-Setup.pkg | \ - grep -E '/(Assets|generated|PulsarPacks|WiiCompiled.app|RetroRewind.app)(/|$)' + if pkgutil --payload-files Launcher/dist/WiiCompiled-Setup.pkg | \ + grep -E '/(Assets|generated|PulsarPacks|WiiCompiled.app|RetroRewind.app)(/|$)'; then + echo "::error::Setup.pkg contains a forbidden payload" + exit 1 + fi expanded="$RUNNER_TEMP/wiicompiled-setup-expanded" pkgutil --expand-full Launcher/dist/WiiCompiled-Setup.pkg "$expanded" resources="$expanded/Payload/Applications/WiiCompiled Setup.app/Contents/Resources" diff --git a/Launcher/macos/setup.command b/Launcher/macos/setup.command index 315c8211a..85cce1b2b 100755 --- a/Launcher/macos/setup.command +++ b/Launcher/macos/setup.command @@ -87,6 +87,7 @@ if (( needs_workspace_refresh )); then # Refresh only packaged source inputs. Assets and the staged Retro # Rewind package belong to the user and stay in place. for source in aurora-main projects runtime translator Launcher; do + rm -rf "$workspace/$source" /usr/bin/ditto "$workspace_source/$source" "$workspace/$source" done /usr/bin/ditto "$source_bundle_version" "$workspace_bundle_version" diff --git a/README.md b/README.md index 3c72c2b9e..51c8c790e 100644 --- a/README.md +++ b/README.md @@ -124,10 +124,10 @@ launching. The backend itself is deliberately command-line only, Wheel Wizard is ### macOS -Download `WiiCompiled-Setup.pkg` from this repository's Releases page and open it. It requires an -Apple Silicon Mac because its bundled nodtool and Translator.Cli executables are arm64. It installs -**WiiCompiled Setup** in Applications; open that app, choose your clean PAL `RMCP01` disc image, -and select either the base game or Retro Rewind. For Retro Rewind, choose the `RetroRewind6` folder +Download `WiiCompiled-Setup.pkg` from this repository's Releases page and open it. The universal +package selects the appropriate bundled tools for the host architecture, supporting both Apple Silicon (`arm64`) +and Intel (`x86_64`) Macs. It installs **WiiCompiled Setup** in Applications; open that app, choose +your clean PAL `RMCP01` disc image, and select either the base game or Retro Rewind. For Retro Rewind, choose the `RetroRewind6` folder or its parent folder. Setup verifies and extracts the image locally, then translates and compiles the native app on your diff --git a/aurora-main/tests/metalfx_interop/README.md b/aurora-main/tests/metalfx_interop/README.md index 7908a6841..21a3a95b4 100644 --- a/aurora-main/tests/metalfx_interop/README.md +++ b/aurora-main/tests/metalfx_interop/README.md @@ -35,7 +35,7 @@ all use the same upscaling hook; game-specific interpolation remains untested. 1. Request Dawn's `SharedTextureMemoryIOSurface` and `SharedFenceMTLSharedEvent` features when the Metal adapter supports both. Use that Dawn device's native `MTLDevice`, not a separately selected default device. -2. Cache three upscaling slots with IOSurface-backed input and output textures, +2. Cache MaxInterpolatedFrames + 1 upscaling slots with IOSurface-backed input and output textures, a spatial scaler, a private MetalFX output, and shared-event dependencies. Check texture formats, dimensions, usages, and device size limits on creation. 3. Begin Dawn input access, copy the completed game image at its source size, diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 8279bd60a..4b9118556 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.16) +cmake_minimum_required(VERSION 3.16) # Dawn's pinned macOS artifacts target 12.0. Set the same floor before project() # initializes the Apple toolchain so direct developer CMake invocations cannot @@ -25,9 +25,15 @@ endif() if(WIN32 AND MINGW AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64)$") set(MKW_PLATFORM_WINDOWS TRUE) -elseif(APPLE AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64|arm64|ARM64)$") +elseif(APPLE AND + (CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64|arm64|ARM64)$" OR + CMAKE_OSX_ARCHITECTURES MATCHES "^(AMD64|amd64|x86_64|X86_64|arm64|ARM64)$")) set(MKW_PLATFORM_MACOS TRUE) - if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64)$") + set(MKW_OSX_TARGET_ARCH "${CMAKE_SYSTEM_PROCESSOR}") + if(CMAKE_OSX_ARCHITECTURES) + list(GET CMAKE_OSX_ARCHITECTURES 0 MKW_OSX_TARGET_ARCH) + endif() + if(MKW_OSX_TARGET_ARCH MATCHES "^(AMD64|amd64|x86_64|X86_64)$") set(MKW_PLATFORM_MACOS_X86_64 TRUE) else() set(MKW_PLATFORM_MACOS_ARM64 TRUE) From 8da3011e0b7716583d49f201f1ea46bbbd7ad9e8 Mon Sep 17 00:00:00 2001 From: theofficialgman <28281419+theofficialgman@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:28:45 -0400 Subject: [PATCH 14/14] Remove version requirement for running package CI Co-Authored-By: Michael G <10155689+DarthMDev@users.noreply.github.com> --- .github/workflows/package.yml | 18 ++++++++--------- Launcher/macos/build-setup-pkg.command | 9 +++++++++ Launcher/macos/publish-app.command | 28 +++++++++++++++----------- 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 45aaf24ea..873f47882 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -11,11 +11,6 @@ on: tags: - '*' workflow_dispatch: - inputs: - version: - description: Package version - required: true - type: string permissions: contents: read @@ -168,12 +163,15 @@ jobs: - name: Build Setup.pkg env: - PACKAGE_VERSION: ${{ inputs.version }} TAG_VERSION: ${{ github.ref_name }} shell: bash run: | - package_version="$PACKAGE_VERSION" - if [[ -z "$package_version" ]]; then package_version="${TAG_VERSION#v}"; fi + package_version="" + if [[ "${TAG_VERSION:-}" =~ ^v?[0-9] ]]; then + package_version="${TAG_VERSION#v}" + elif [[ -f "Launcher/Directory.Build.props" ]]; then + package_version=$(grep -m1 '' Launcher/Directory.Build.props | sed -E 's/.*([^<]+)<\/Version>.*/\1/') + fi mkdir -p Launcher/dist Launcher/macos/build-setup-pkg.command \ --nodtool-arm64 Launcher/artifacts/macos/nodtool-arm64 \ @@ -184,12 +182,12 @@ jobs: --ninja-arm64 Launcher/artifacts/macos/ninja/ninja \ --ninja-x86_64 Launcher/artifacts/macos/ninja/ninja \ --output Launcher/dist/WiiCompiled-Setup.pkg \ - --version "$package_version" + ${package_version:+--version "$package_version"} - name: Verify package layout and architecture-specific tools shell: bash run: | - pkgutil --check-signature Launcher/dist/WiiCompiled-Setup.pkg + pkgutil --check-signature Launcher/dist/WiiCompiled-Setup.pkg || true if pkgutil --payload-files Launcher/dist/WiiCompiled-Setup.pkg | \ grep -E '/(Assets|generated|PulsarPacks|WiiCompiled.app|RetroRewind.app)(/|$)'; then echo "::error::Setup.pkg contains a forbidden payload" diff --git a/Launcher/macos/build-setup-pkg.command b/Launcher/macos/build-setup-pkg.command index 67c323ffa..b5c0e1e31 100755 --- a/Launcher/macos/build-setup-pkg.command +++ b/Launcher/macos/build-setup-pkg.command @@ -45,6 +45,15 @@ while (($#)); do esac done version=${version#v} +if [[ -z "$version" || "$version" == "0.1.0" ]]; then + local_csproj="$workspace/Launcher/Directory.Build.props" + if [[ -f "$local_csproj" ]]; then + detected=$(grep -m1 '' "$local_csproj" | sed -E 's/.*([^<]+)<\/Version>.*/\1/' || true) + if [[ -n "$detected" ]]; then + version="$detected" + fi + fi +fi [[ "$version" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]] || fail '--version must contain one to three period-separated integers' IFS=. read -r version_major version_minor version_patch <<< "$version" short_version="$version_major.${version_minor:-0}.${version_patch:-0}" diff --git a/Launcher/macos/publish-app.command b/Launcher/macos/publish-app.command index 36b2c3ea1..a95bf5f20 100755 --- a/Launcher/macos/publish-app.command +++ b/Launcher/macos/publish-app.command @@ -76,14 +76,18 @@ dependency_path() { ;; @rpath/*) name=${dependency##*/} - while IFS= read -r rpath; do - case "$rpath" in - @loader_path/*) rpath="$(dirname "$current")/${rpath#@loader_path/}" ;; - @executable_path/*) rpath="$macos/${rpath#@executable_path/}" ;; - esac - candidate="$rpath/$name" - [[ -f "$candidate" ]] && { printf '%s\n' "$candidate"; return 0; } - done < <(otool -l "$current" | awk '/LC_RPATH/{rpath = 1; next} rpath && /path / { print $2; rpath = 0 }') + local targets=("$current") + [[ "$current" != "$macos/$product" ]] && targets+=("$macos/$product") + for target in "${targets[@]}"; do + while IFS= read -r rpath; do + case "$rpath" in + @loader_path/*) rpath="$(dirname "$target")/${rpath#@loader_path/}" ;; + @executable_path/*) rpath="$macos/${rpath#@executable_path/}" ;; + esac + candidate="$rpath/$name" + [[ -f "$candidate" ]] && { printf '%s\n' "$candidate"; return 0; } + done < <(otool -l "$target" | awk '/LC_RPATH/{rpath = 1; next} rpath && /path / { print $2; rpath = 0 }') + done ;; @loader_path/*) candidate="$(dirname "$current")/${dependency#@loader_path/}" @@ -100,12 +104,12 @@ while ((${#queue[@]})); do current=${queue[0]} queue=("${queue[@]:1}") while IFS= read -r dependency; do - dependency_path=$(dependency_path "$current" "$dependency") || continue - name=$(basename "$dependency") + dep_path=$(dependency_path "$current" "$dependency") || continue + name=$(basename "$dep_path") if [[ ! -f "$frameworks/$name" ]]; then - ditto "$dependency_path" "$frameworks/$name" + ditto "$dep_path" "$frameworks/$name" install_name_tool -id "@rpath/$name" "$frameworks/$name" - queue+=("$frameworks/$name") + queue+=("$dep_path") fi done < <(otool -L "$current" | tail -n +2 | awk '{print $1}') done