From 0d04be19d6ed5cba7d6841c17bd7e9199e59871f Mon Sep 17 00:00:00 2001 From: Michael G <10155689+DarthMDev@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:07:15 -0400 Subject: [PATCH] macos: add x86_64 Intel support (#122) * macos: add x86_64 Intel support * aurora: construct texture replacement index entries * Switch back to LLVM 22 so build can succeed on at least some systems (#141) two upstream LLVM bugs currently prevent building on some of the newest distros. There is no current LLVM release that works on them so we are pending fixes from LLVM https://github.com/patchzyy/Wiicompiled/issues/136 * Fix GC Pocket+ rumble stop handling (#148) * macos: package universal setup tools * ci: build macOS input expression tests * runtime: Do not force 14.0 minimum anymore new minimum is 12.0 * Update network_socket.cpp * fix(macos): increase guest fiber stack size (#154) * v0.2.28 * Update settings_overlay.cpp * macos: support older libc++ algorithms * Load console identity from NAND setting.txt (#164) * Add synthetic recompilation CI workflow (#161) * version * macos: allow undefined MTLLogStateDescriptor for older SDKs * tests: deflake input expression timing window test * import setting.txt (#169) * import setting.txt * coderabbit ugh * Treat empty MKW save as missing rather than corrupt (#168) * treat empty mkw save as missing first-run format zero-fills rksys.dat before any real save; a quit before the first save left an all-zero file that read back as corrupt and trapped the user in a delete/recreate loop. read opens now treat an all-zero rksys.dat as absent (a real save always begins with the RKSD0006 header), so the game recreates it from scratch. also ignore native build output. * shorten * I dont really want to change this to be honest. * extra safety --------- Co-authored-by: patchzyy <64382339+patchzyy@users.noreply.github.com> * version 0.2.30 * csnum fix * update * build: Fix mac tests * translator: emit null statement after continuation labels for C++17 compatibility * review fixes --------- Co-authored-by: theofficialgman <28281419+theofficialgman@users.noreply.github.com> Co-authored-by: Cristian Boehm <75502884+GalaxisBeast@users.noreply.github.com> Co-authored-by: patchzyy <64382339+patchzyy@users.noreply.github.com> Co-authored-by: Wubbzee <41394708+JGM01@users.noreply.github.com> --- .github/workflows/build.yml | 10 +- .github/workflows/package.yml | 75 ++++-- .github/workflows/recomp-test.yml | 41 ++++ .gitignore | 2 + Launcher/Build-Installer.ps1 | 2 +- Launcher/Prepare-NativePrebuilt.sh | 2 +- Launcher/Test-Recompilation.ps1 | 146 ++++++++++++ .../WiiCompiled.Setup.Common.Cli.csproj | 2 +- .../WiiCompiled.Setup.Common.csproj | 2 +- Launcher/WiiCompiled.Setup.Linux/Models.cs | 2 +- .../WiiCompiled.Setup.Linux.csproj | 2 +- Launcher/WiiCompiled.Setup.Windows/Program.cs | 2 +- .../WiiCompiled.Setup.Windows.csproj | 2 +- Launcher/local-build-macos.command | 17 +- Launcher/macos/build-setup-pkg.command | 83 +++++-- Launcher/macos/macos-x86_64-toolchain.cmake | 11 + Launcher/macos/publish-app.command | 65 +++++- Launcher/macos/setup.command | 16 +- Launcher/prepare-portable-tools.sh | 38 +-- README.md | 4 +- aurora-main/cmake/aurora_core.cmake | 4 +- aurora-main/extern/CMakeLists.txt | 50 ++++ aurora-main/lib/dolphin/pad/pad.cpp | 49 ++-- aurora-main/lib/gfx/common.cpp | 5 +- aurora-main/lib/gfx/texture_replacement.cpp | 2 +- aurora-main/lib/input.cpp | 9 + aurora-main/lib/input.hpp | 1 + runtime/CMakeLists.txt | 122 +++++++--- runtime/cmake/PublicProducts.cmake | 10 +- runtime/include/console_identity.h | 109 ++------- runtime/include/host_context.h | 10 +- runtime/include/nand_path.h | 15 +- runtime/include/nand_save_probe.h | 59 +++++ runtime/include/nand_settings.h | 220 ++++++++++++++++++ runtime/include/sc_serial_contract.h | 26 +++ runtime/src/fiber_manager.cpp | 8 +- runtime/src/hle/net/network_socket.cpp | 2 +- runtime/src/hle/sc.cpp | 54 +++-- runtime/src/hle/storage/nand_api.cpp | 3 + runtime/src/hle/storage/nand_async.cpp | 2 + runtime/src/hle/storage/nand_fs.cpp | 20 ++ runtime/src/hle/storage/nand_internal.h | 7 + runtime/src/hle/storage/nand_isfs.cpp | 3 + runtime/src/host_context.cpp | 4 +- runtime/src/main.cpp | 13 +- runtime/src/music_attenuation.cpp | 22 +- runtime/src/settings_overlay.cpp | 5 - runtime/tests/nand_save_tests.cpp | 142 +++++++++++ runtime/tests/nand_settings_tests.cpp | 183 +++++++++++++++ runtime/tests/sc_serial_tests.cpp | 81 +++++++ runtime/tests/test_expr.cpp | 24 +- .../CodeGen/CxxLinearCodeGenerator.cs | 4 +- .../EmittedOutputShapeTests.cs | 33 ++- 53 files changed, 1526 insertions(+), 299 deletions(-) create mode 100644 .github/workflows/recomp-test.yml create mode 100644 Launcher/Test-Recompilation.ps1 create mode 100644 Launcher/macos/macos-x86_64-toolchain.cmake create mode 100644 runtime/include/nand_save_probe.h create mode 100644 runtime/include/nand_settings.h create mode 100644 runtime/include/sc_serial_contract.h create mode 100644 runtime/tests/nand_save_tests.cpp create mode 100644 runtime/tests/nand_settings_tests.cpp create mode 100644 runtime/tests/sc_serial_tests.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bf83009..fe8dc86 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,6 +14,10 @@ concurrency: cancel-in-progress: true jobs: + recompilation: + name: Recompilation test + uses: ./.github/workflows/recomp-test.yml + translator: name: Translator (build + test) runs-on: windows-latest @@ -49,7 +53,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 @@ -57,6 +61,10 @@ 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 \ mkw_macos_host_context_tests \ diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index e65abd6..63f40b5 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -25,6 +25,10 @@ concurrency: cancel-in-progress: true jobs: + recompilation: + name: Recompilation test + uses: ./.github/workflows/recomp-test.yml + linux-appimage: name: Linux (AppImage, ${{ matrix.arch }}) strategy: @@ -92,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 @@ -103,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 @@ -155,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 @@ -183,7 +218,7 @@ jobs: release: name: Publish GitHub Release if: startsWith(github.ref, 'refs/tags/v') - needs: [linux-appimage, windows-installer, macos-setup-package] + needs: [linux-appimage, windows-installer, macos-setup-package, recompilation] runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/recomp-test.yml b/.github/workflows/recomp-test.yml new file mode 100644 index 0000000..0908d15 --- /dev/null +++ b/.github/workflows/recomp-test.yml @@ -0,0 +1,41 @@ +name: Synthetic recompilation + +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +jobs: + windows: + name: Windows runtime (synthetic DOL) + runs-on: windows-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '8.0.x' + + # Cache downloads only. Preparation still validates pins, and every run + # compiles current Aurora, runtime, and generated sources from scratch. + - uses: actions/cache@v5 + with: + path: Launcher/artifacts/downloads + key: windows-recomp-downloads-${{ hashFiles('Launcher/Prepare-PortableTools.ps1', 'Launcher/Prepare-Dependencies.ps1') }} + + - name: Prepare the shipped Windows toolchain + shell: pwsh + run: ./Launcher/Prepare-PortableTools.ps1 + + - name: Prepare pinned native dependencies + shell: pwsh + run: ./Launcher/Prepare-Dependencies.ps1 + + - name: Translate, compile the full runtime, and link + shell: pwsh + run: ./Launcher/Test-Recompilation.ps1 -Parallel 3 diff --git a/.gitignore b/.gitignore index 855cfed..59542aa 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,8 @@ Code.pul /build/ /build-*/ /native-build/ +/native-build-macos/ +/local-products/ /dist/ /out/ [Bb]in/ diff --git a/Launcher/Build-Installer.ps1 b/Launcher/Build-Installer.ps1 index 27058cb..eb70930 100644 --- a/Launcher/Build-Installer.ps1 +++ b/Launcher/Build-Installer.ps1 @@ -281,7 +281,7 @@ foreach ($required in @('ToolkitFingerprint','TranslationFingerprint','NativeToo $manifest = [ordered]@{ SchemaVersion = 2 - ProductVersion = '0.2.27' + ProductVersion = '0.2.31' ExpectedGameId = $pins.GameId ExpectedDolSha256 = $pins.DolSha256 ExpectedRelSha256 = $pins.RelSha256 diff --git a/Launcher/Prepare-NativePrebuilt.sh b/Launcher/Prepare-NativePrebuilt.sh index df2e100..3d4e6ed 100755 --- a/Launcher/Prepare-NativePrebuilt.sh +++ b/Launcher/Prepare-NativePrebuilt.sh @@ -88,7 +88,7 @@ assert_file "$ninja_bin" "Portable Ninja" assert_file "$cc" "Portable C compiler" assert_file "$cxx" "Portable C++ compiler" assert_dir "$aurora_source" "aurora-main source tree" -clang_binary=$(normalize "$toolchain_dir/bin/clang-23") +clang_binary=$(normalize "$toolchain_dir/bin/clang-22") assert_file "$clang_binary" "Portable clang driver binary" (( parallel > 0 )) || parallel=$(nproc) diff --git a/Launcher/Test-Recompilation.ps1 b/Launcher/Test-Recompilation.ps1 new file mode 100644 index 0000000..9a8d8a2 --- /dev/null +++ b/Launcher/Test-Recompilation.ps1 @@ -0,0 +1,146 @@ +# Build the real Windows runtime with translated, entirely synthetic PowerPC code. +# No game dump, game symbol map, REL, mod download, or existing generated/ output is used. +[CmdletBinding()] +param( + [string]$PortableToolsDirectory = 'Launcher/artifacts/portable-tools', + [string]$DependencySourceDirectory = 'Launcher/artifacts/dependencies', + [string]$StageDirectory = 'build/recomp-test', + [ValidateRange(1, 64)] [int]$Parallel = 3 +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 3.0 +. (Join-Path $PSScriptRoot 'NativeBuildFlags.ps1') + +$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +function Full([string]$Path) { + if ([IO.Path]::IsPathRooted($Path)) { return [IO.Path]::GetFullPath($Path) } + return [IO.Path]::GetFullPath((Join-Path $repoRoot $Path)) +} +$portableTools = Full $PortableToolsDirectory +$dependencies = Full $DependencySourceDirectory +$stage = Full $StageDirectory +$dotnet = (Get-Command dotnet -CommandType Application).Source +$cmake = Join-Path $portableTools 'CMake/bin/cmake.exe' +$ninja = Join-Path $portableTools 'Ninja/ninja.exe' +$compilerBin = Join-Path $portableTools 'llvm-mingw/bin' +Assert-File $cmake 'Pinned CMake (run Prepare-PortableTools.ps1 first)' +Assert-File $ninja 'Pinned Ninja' +Assert-File (Join-Path $dependencies 'cppwinrt/winrt/base.h') 'Pinned dependencies (run Prepare-Dependencies.ps1 first)' + +# Refuse reuse so a developer's game translation or an earlier build cannot make +# the test pass. Keep the staging tree after the run for diagnostics. +if (Test-Path -LiteralPath $stage) { throw "Test stage already exists; choose a fresh -StageDirectory: $stage" } +[IO.Directory]::CreateDirectory($stage) | Out-Null +Write-Host "Synthetic recompilation workspace: $stage" + +# Copy current sources, including uncommitted edits, but no ignored build output. +# An isolated workspace preserves the developer's real generated/ directory. +$sourceFiles = & git -C $repoRoot -c core.quotepath=false ls-files --cached --others --exclude-standard -- runtime aurora-main +if ($LASTEXITCODE -ne 0) { throw 'Could not enumerate runtime and Aurora sources.' } +foreach ($relative in $sourceFiles | Sort-Object -Unique) { + $destination = Join-Path $stage $relative + [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null + Copy-Item -LiteralPath (Join-Path $repoRoot $relative) -Destination $destination +} + +# One synthetic text section: li r3,40; addi r3,r3,2; nop; blr. +# Native HLE wrappers also call these eight guest symbols directly. Give each +# its own generated blr function so the real product can link without game code. +# Keep this list explicit: a new unresolved guest dependency must fail the test. +[uint32]$entry = 0x80001000L +[uint32[]]$guestCallbacks = @( + 0x8012B830L, 0x801A0620L, 0x801A1ED8L, 0x801A961CL, + 0x801AADE0L, 0x801D8D30L, 0x801D9E94L, 0x8055531CL +) +$textSize = [int]($guestCallbacks[-1] - $entry + 4) +$dataOffset = 0x100 + $textSize +$dol = [byte[]]::new($dataOffset + 4) +function Write-BigEndian32([int]$Offset, [uint32]$Value) { + $dol[$Offset] = [byte](($Value -shr 24) -band 255) + $dol[$Offset + 1] = [byte](($Value -shr 16) -band 255) + $dol[$Offset + 2] = [byte](($Value -shr 8) -band 255) + $dol[$Offset + 3] = [byte]($Value -band 255) +} +Write-BigEndian32 0x00 0x100 # text[0] file offset +Write-BigEndian32 0x48 $entry # text[0] guest address +Write-BigEndian32 0x90 $textSize # text[0] length (unreachable gaps are zero) +Write-BigEndian32 0x1C $dataOffset # data[0] file offset +Write-BigEndian32 0x64 0x80600000L # data[0] guest address +Write-BigEndian32 0xAC 4 # data[0] length +Write-BigEndian32 0xD8 0x80601000L # BSS address +Write-BigEndian32 0xDC 32 # BSS length +Write-BigEndian32 0xE0 $entry # entry point +Write-BigEndian32 0x100 0x38600028 # li r3,40 +Write-BigEndian32 0x104 0x38630002 # addi r3,r3,2 +Write-BigEndian32 0x108 0x60000000 # nop +Write-BigEndian32 0x10C 0x4E800020 # blr +foreach ($address in $guestCallbacks) { + Write-BigEndian32 ([int](0x100 + $address - $entry)) 0x4E800020 +} +Write-BigEndian32 $dataOffset 0x12345678 +[IO.File]::WriteAllBytes((Join-Path $stage 'synthetic.dol'), $dol) +$entryPoints = (@($entry) + $guestCallbacks | ForEach-Object { '0x{0:X8}' -f $_ }) -join ', ' +$functionMap = (@($entry) + $guestCallbacks | ForEach-Object { '{0:X8} func_{0:X8}' -f $_ }) -join "`n" +[IO.File]::WriteAllText((Join-Path $stage 'synthetic-functions.txt'), $functionMap) +$manifest = Join-Path $stage 'recomp.yml' +[IO.File]::WriteAllText($manifest, @" +schema_version: 1 +workspace_root: . +project: + id: ci-synthetic-dol + display_name: CI Synthetic DOL +memory: + base: 0x80000000 + size: 0x01800000 + sda_base: 0x80600000 + sda2_base: 0x80600000 +inputs: + dol: + path: synthetic.dol +translation: + entry_points: [$entryPoints] + function_map: + path: synthetic-functions.txt + allow_unsupported_instructions: false +runtime: + native_abi_directories: [] + native_registration_root: runtime/src +output: + root: generated +"@) + +$translatorProject = Join-Path $repoRoot 'translator/src/Translator.Cli/Translator.Cli.csproj' +Invoke-Checked $dotnet @('build', $translatorProject, '-c', 'Release', '--disable-build-servers') 'Building the translator' +$translator = Join-Path $repoRoot 'translator/src/Translator.Cli/bin/Release/net8.0/Translator.Cli.dll' +$metadata = Join-Path $stage 'generated/base_translation_output.json' +Invoke-Checked $dotnet @($translator, 'translate-recursive', '0x80001000', '--project', $manifest, + '--output-metadata', $metadata, '--threads', "$Parallel") ` + 'Translating the synthetic DOL' +# Function-map seeds can be skipped by discovery; do not accept a partial fixture. +$translated = Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json +foreach ($address in @($entry) + $guestCallbacks) { + if ($address -notin $translated.functions.entryPoint) { + throw ('Synthetic function 0x{0:X8} was not translated.' -f $address) + } +} +Invoke-Checked $dotnet @($translator, 'generate-data-init', '--project', $manifest) 'Generating synthetic data and runtime configuration' +Invoke-Checked $dotnet @($translator, 'emit-build-shards', '--project', $manifest) 'Emitting the production build graph' + +$nativeBuild = Join-Path $stage 'native-build' +$oldPath = $env:PATH +try { + $env:PATH = Get-MkwToolchainPath $portableTools + $configure = Get-MkwNativeConfigureArguments -SourceDirectory (Join-Path $stage 'runtime') -BuildDirectory $nativeBuild ` + -Ninja $ninja -CCompiler (Join-Path $compilerBin 'x86_64-w64-mingw32-clang.exe') ` + -CxxCompiler (Join-Path $compilerBin 'x86_64-w64-mingw32-clang++.exe') ` + -ResourceCompiler (Join-Path $compilerBin 'x86_64-w64-mingw32-windres.exe') ` + -DependenciesDirectory $dependencies -AdditionalArguments @('-DMKW_BUILD_PRODUCTS=ON') + Invoke-Checked $cmake $configure 'Configuring the production Windows runtime' + Invoke-Checked $cmake @('--build', $nativeBuild, '--target', 'WiiCompiled', '--parallel', "$Parallel") ` + 'Compiling and linking the synthetic product with the full runtime' + Assert-File (Join-Path $nativeBuild 'WiiCompiled.exe') 'Linked synthetic product' +} finally { + $env:PATH = $oldPath +} +Write-Host 'Synthetic recompilation passed (translation, data generation, runtime compilation, and product link).' diff --git a/Launcher/WiiCompiled.Setup.Common.Cli/WiiCompiled.Setup.Common.Cli.csproj b/Launcher/WiiCompiled.Setup.Common.Cli/WiiCompiled.Setup.Common.Cli.csproj index b22d680..97e8ecb 100644 --- a/Launcher/WiiCompiled.Setup.Common.Cli/WiiCompiled.Setup.Common.Cli.csproj +++ b/Launcher/WiiCompiled.Setup.Common.Cli/WiiCompiled.Setup.Common.Cli.csproj @@ -6,7 +6,7 @@ enable WiiCompiled.Setup.Common.Cli WiiCompiled.Setup.Common.Cli - 0.2.22 + 0.2.31 patchzy WiiCompiled Packaging-time helper: resolves (downloading if needed) the nodtool binary bundled by build-appimage.sh and Build-Installer.ps1 diff --git a/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj b/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj index 91fbef7..aebdc1b 100644 --- a/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj +++ b/Launcher/WiiCompiled.Setup.Common/WiiCompiled.Setup.Common.csproj @@ -5,7 +5,7 @@ enable WiiCompiled.Setup.Common WiiCompiled.Setup.Common - 0.2.22 + 0.2.31 patchzy WiiCompiled Shared nodtool/Retro-WFC-payload logic used by both the Windows and Linux installers diff --git a/Launcher/WiiCompiled.Setup.Linux/Models.cs b/Launcher/WiiCompiled.Setup.Linux/Models.cs index 4c8a4b9..b8f8ea6 100644 --- a/Launcher/WiiCompiled.Setup.Linux/Models.cs +++ b/Launcher/WiiCompiled.Setup.Linux/Models.cs @@ -3,7 +3,7 @@ namespace WiiCompiled.Setup.Linux; internal static class ProductInfo { public const string Name = "WiiCompiled"; - public const string Version = "0.2.27"; + public const string Version = "0.2.31"; } /// One installed product's record inside install-state.json. diff --git a/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj b/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj index 7424382..84d3269 100644 --- a/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj +++ b/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj @@ -6,7 +6,7 @@ enable WiiCompiled.Setup.Linux WiiCompiled.Setup.Linux - 0.2.22 + 0.2.31 patchzy WiiCompiled Command-line installer and launcher for WiiCompiled on Linux diff --git a/Launcher/WiiCompiled.Setup.Windows/Program.cs b/Launcher/WiiCompiled.Setup.Windows/Program.cs index 4f59f7a..7995f17 100644 --- a/Launcher/WiiCompiled.Setup.Windows/Program.cs +++ b/Launcher/WiiCompiled.Setup.Windows/Program.cs @@ -121,7 +121,7 @@ internal static class PlatformChecks internal static class ProductInfo { public const string Name = "WiiCompiled"; - public const string Version = "0.2.27"; + public const string Version = "0.2.31"; /// /// The setup executable is copied into the installation under this name. It is the launcher and diff --git a/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj b/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj index 48fb916..0efaa73 100644 --- a/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj +++ b/Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj @@ -7,7 +7,7 @@ WiiCompiled.Setup WiiCompiled.Setup.Windows app.manifest - 0.2.27 + 0.2.31 patchzy WiiCompiled Command-line installer and launcher for WiiCompiled diff --git a/Launcher/local-build-macos.command b/Launcher/local-build-macos.command index 2b2ec91..e86e58a 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,11 +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' -# 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" -DMKW_TRANSLATED_COMPILE_JOBS="$translated_jobs" -DAURORA_SDL3_PROVIDER=vendor +"$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" -DAURORA_SDL3_PROVIDER=vendor -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 22efb2b..6cbe610 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,14 +145,17 @@ 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 +# setup.command uses this marker to refresh 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" -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 0000000..f367498 --- /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 b384a55..a284ed0 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 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,25 +65,62 @@ for asset in dsp_coef.bin initial_pipeline_cache.db 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 && /^[[:space:]]*path / { + sub(/^[[:space:]]*path /, "") + sub(/ \(offset [0-9]+\)$/, "") + print + 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") + # Resolve transitive @loader_path dependencies relative to the + # original dylib, not its copied Frameworks location. + queue+=("$dependency_path") fi done < <(otool -L "$current" | tail -n +2 | awk '{print $1}') 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 5694a57..8a68342 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" source_bundle_version="$workspace_source/.bundle-version" @@ -84,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/Launcher/prepare-portable-tools.sh b/Launcher/prepare-portable-tools.sh index fe7b42a..69ea00d 100755 --- a/Launcher/prepare-portable-tools.sh +++ b/Launcher/prepare-portable-tools.sh @@ -25,7 +25,7 @@ set -euo pipefail script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) workspace=$(cd "$script_dir/.." && pwd) -llvm_version=23.1.0 +llvm_version=22.1.8 cmake_version=4.3.3 ninja_version=1.13.2 destination="$script_dir/artifacts/portable-tools" @@ -52,13 +52,13 @@ done case "$arch" in x86_64) llvm_release_arch=X64; target_triple=x86_64-unknown-linux-gnu - llvm_release_sha256=18da30f77f475688a18f7704d23f9f155ae007ed9922dbed6850a9419d9fec8c + llvm_release_sha256=df0e1ecf16caf3489a272a5eea4eec9b0d82878f6477fa309504f918a0006384 cmake_release_arch=x86_64 cmake_sha256=927b2368a946c37269c3a66225ab00544e756459cdd0b5d0da438694fb9ff802 ninja_asset=ninja-linux.zip ninja_sha256=5749cbc4e668273514150a80e387a957f933c6ed3f5f11e03fb30955e2bbead6 ;; aarch64) llvm_release_arch=ARM64; target_triple=aarch64-unknown-linux-gnu - llvm_release_sha256=cfb31bfc713ef453248bf5bd026312f838ad6c52c25623e987cb6a340f3050d4 + llvm_release_sha256=805efad2bb91cb4967fa569e0881d10c0f69c04461cf671cccbae19f547acc34 cmake_release_arch=aarch64 cmake_sha256=9ea38356dbd3e32e51029a3e09a0f2f8e117ef4fbcaad7a21ffb36409bbd5cb4 ninja_asset=ninja-linux-aarch64.zip @@ -120,9 +120,9 @@ echo "prepare-portable-tools.sh: pruning to the minimal compile+link toolchain.. # clang: the real driver executable plus the clang/clang++ symlinks CMake/local-build.sh invoke. # Stripped: debug symbols are dead weight for a bundled compiler nobody will debug. -cp -a "$src/bin/clang-23" "$work/bin/" -strip "$work/bin/clang-23" -ln -s clang-23 "$work/bin/clang" +cp -a "$src/bin/clang-22" "$work/bin/" +strip "$work/bin/clang-22" +ln -s clang-22 "$work/bin/clang" ln -s clang "$work/bin/clang++" # lld: linked via -fuse-ld=lld, which clang resolves by looking for ld.lld next to itself first - @@ -201,10 +201,10 @@ Ninja $ninja_version Apache License 2.0 EOF -echo "prepare-portable-tools.sh: smoke-testing the toolchain..." -smoke_dir=$(mktemp -d) -trap 'rm -rf "$smoke_dir"' EXIT -cat > "$smoke_dir/t.cpp" <<'EOF' +echo "prepare-portable-tools.sh: testing the toolchain..." +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT +cat > "$test_dir/t.cpp" <<'EOF' #include #include int main() { @@ -214,23 +214,23 @@ int main() { return sum == 6 ? 0 : 1; } EOF -"$work/bin/clang++" -std=c++20 -fuse-ld=lld "$smoke_dir/t.cpp" -o "$smoke_dir/t" -"$smoke_dir/t" +"$work/bin/clang++" -std=c++20 -fuse-ld=lld "$test_dir/t.cpp" -o "$test_dir/t" +"$test_dir/t" # Also exercised together through CMake+Ninja, exactly how local-build.sh drives them - a plain # clang++ invocation above would not catch a broken CMAKE_ROOT (Modules/Templates) or a Ninja that # can't find the compiler. -cat > "$smoke_dir/CMakeLists.txt" <<'EOF' +cat > "$test_dir/CMakeLists.txt" <<'EOF' cmake_minimum_required(VERSION 3.16) -project(smoke CXX) -add_executable(smoke t.cpp) +project(test CXX) +add_executable(test t.cpp) EOF -"$work/bin/cmake" -S "$smoke_dir" -B "$smoke_dir/build" -G Ninja \ +"$work/bin/cmake" -S "$test_dir" -B "$test_dir/build" -G Ninja \ -DCMAKE_MAKE_PROGRAM="$work/bin/ninja" -DCMAKE_CXX_COMPILER="$work/bin/clang++" >/dev/null -"$work/bin/cmake" --build "$smoke_dir/build" >/dev/null -"$smoke_dir/build/smoke" +"$work/bin/cmake" --build "$test_dir/build" >/dev/null +"$test_dir/build/test" -rm -rf "$smoke_dir" +rm -rf "$test_dir" trap - EXIT mv "$work" "$toolchain_dir" diff --git a/README.md b/README.md index 4a7be5f..8a6403f 100644 --- a/README.md +++ b/README.md @@ -89,8 +89,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/cmake/aurora_core.cmake b/aurora-main/cmake/aurora_core.cmake index 3e53a62..b17cb42 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/aurora-main/extern/CMakeLists.txt b/aurora-main/extern/CMakeLists.txt index 9d77bbb..1612b4f 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/aurora-main/lib/dolphin/pad/pad.cpp b/aurora-main/lib/dolphin/pad/pad.cpp index b06c489..0ba55f1 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; @@ -342,7 +342,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; }); @@ -634,8 +634,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; } @@ -725,8 +725,8 @@ u32 PADRead(PADStatus* status) { status[i].err = PAD_ERR_NONE; if (g_keyboardBindings[i].m_mappingsSet) { - std::ranges::for_each( - g_keyboardBindings[i].m_buttonMapping, [&kbState, &i, &status](const PADKeyButtonBinding& mapping) { + std::for_each(g_keyboardBindings[i].m_buttonMapping.begin(), g_keyboardBindings[i].m_buttonMapping.end(), + [&kbState, &i, &status](const PADKeyButtonBinding& mapping) { if (mapping.scancode > PAD_KEY_INVALID && kbState[mapping.scancode]) { status[i].button |= mapping.padButton; } else if (is_mouse_scancode(mapping.scancode) && is_mouse_button_pressed(mapping.scancode)) { @@ -833,8 +833,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 (SDL_GetGamepadButton(controller->m_controller, static_cast(mapping.nativeButton))) { status[i].button |= mapping.padButton; } @@ -847,8 +847,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; } @@ -1165,8 +1165,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; } @@ -1199,8 +1199,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; } @@ -1226,8 +1226,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; } @@ -1396,9 +1396,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; } @@ -1543,8 +1544,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(); } @@ -1557,7 +1558,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(); } @@ -1566,7 +1568,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 1bb1bb4..9f4ee8b 100644 --- a/aurora-main/lib/gfx/common.cpp +++ b/aurora-main/lib/gfx/common.cpp @@ -22,7 +22,6 @@ #include #include #include -#include #include #include @@ -1328,8 +1327,8 @@ static void render_impl(std::vector& renderPasses, wgpu::CommandEnco #if defined(AURORA_GFX_DEBUG_GROUPS) if (finalize && !g_debugGroupStack.empty()) { - for (auto& it : std::ranges::reverse_view(g_debugGroupStack)) { - Log.warn("Debug group was not popped at end of frame: {}", it); + for (auto it = g_debugGroupStack.rbegin(); it != g_debugGroupStack.rend(); ++it) { + Log.warn("Debug group was not popped at end of frame: {}", *it); } g_debugGroupStack.clear(); } diff --git a/aurora-main/lib/gfx/texture_replacement.cpp b/aurora-main/lib/gfx/texture_replacement.cpp index 6791381..e8d0fbf 100644 --- a/aurora-main/lib/gfx/texture_replacement.cpp +++ b/aurora-main/lib/gfx/texture_replacement.cpp @@ -553,7 +553,7 @@ void build_index() noexcept { continue; } - s_replacementIndex.try_emplace(*parsed, path); + s_replacementIndex.try_emplace(*parsed, ReplacementIndexEntry{path}); } Log.info("Indexed {} texture replacements", s_replacementIndex.size()); diff --git a/aurora-main/lib/input.cpp b/aurora-main/lib/input.cpp index 8884f1a..6c9418b 100644 --- a/aurora-main/lib/input.cpp +++ b/aurora-main/lib/input.cpp @@ -401,6 +401,8 @@ SDL_JoystickID add_controller(SDL_JoystickID which) noexcept { return -1; } controller.m_isGameCube = controller.m_vid == 0x057E && controller.m_pid == 0x0337; + const char* serial = SDL_GetGamepadSerial(ctrl); + controller.m_gameCubeUseOrdinaryStop = controller.m_isGameCube && serial && "GCP+"sv == serial; if (controller.m_isGameCube || (SDL_GetGamepadType(ctrl) == SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO && controller.m_pid == 0x2073)) { controller.m_deadZones.emulateTriggers = false; @@ -481,6 +483,13 @@ bool controller_has_rumble(Uint32 instance) noexcept { void controller_rumble(uint32_t instance, uint16_t low_freq_intensity, uint16_t high_freq_intensity, uint16_t duration_ms) noexcept { if (auto it = g_GameControllers.find(instance); it != g_GameControllers.end()) { + // GC Pocket+ has been observed continuing to vibrate after a hard stop; + // an ordinary stop cleared it. With GAMECUBE_RUMBLE_BRAKE enabled, SDL + // encodes (0, 1) as adapter command 0, whereas (0, 0) sends command 2. + // Apply the workaround here so shutdown uses the same stop as PAD calls. + if (it->second.m_gameCubeUseOrdinaryStop && low_freq_intensity == 0 && high_freq_intensity == 0) { + high_freq_intensity = 1; + } SDL_RumbleGamepad(it->second.m_controller, low_freq_intensity, high_freq_intensity, duration_ms); } } diff --git a/aurora-main/lib/input.hpp b/aurora-main/lib/input.hpp index 1c6f24e..ae083b1 100644 --- a/aurora-main/lib/input.hpp +++ b/aurora-main/lib/input.hpp @@ -17,6 +17,7 @@ extern Module Log; struct GameController { SDL_Gamepad* m_controller = nullptr; bool m_isGameCube = false; + bool m_gameCubeUseOrdinaryStop = false; Sint32 m_index = -1; Sint32 m_playerIndex = -1; bool m_hasRumble = false; diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index a6d383b..45e442c 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1,30 +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") -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) + "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") @@ -69,14 +81,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) @@ -253,12 +266,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() @@ -287,6 +302,23 @@ 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_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) +add_test(NAME mkw_nand_save_tests COMMAND mkw_nand_save_tests) + +add_executable(mkw_nand_settings_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_settings_tests.cpp") +find_package(Threads REQUIRED) +target_link_libraries(mkw_nand_settings_tests PRIVATE Threads::Threads) +target_include_directories(mkw_nand_settings_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") +target_compile_features(mkw_nand_settings_tests PRIVATE cxx_std_17) +add_test(NAME mkw_nand_settings_tests COMMAND mkw_nand_settings_tests) + +add_executable(mkw_sc_serial_tests "${CMAKE_CURRENT_LIST_DIR}/tests/sc_serial_tests.cpp") +target_include_directories(mkw_sc_serial_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") +target_compile_features(mkw_sc_serial_tests PRIVATE cxx_std_17) +add_test(NAME mkw_sc_serial_tests COMMAND mkw_sc_serial_tests) + # The input expression engine is self-contained, so it can be exercised without # linking the runtime or SDL. add_executable(mkw_input_expr_tests @@ -312,20 +344,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) + # 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") + 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) @@ -384,6 +427,13 @@ else() target_link_libraries(mkw_macos_native_compile PRIVATE aurora::gx aurora::pad aurora::si aurora::vi aurora::mtx mkw::pugixml mkw::toml11 mkw::cryptopp) + 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 ad2a535..98ce39a 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 @@ -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) 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) @@ -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") @@ -226,7 +228,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/console_identity.h b/runtime/include/console_identity.h index ef46679..c07fc30 100644 --- a/runtime/include/console_identity.h +++ b/runtime/include/console_identity.h @@ -1,38 +1,28 @@ #pragma once -#include "runtime_config.h" +#include "nand_path.h" +#include "nand_settings.h" -#include #include -#include #include #include -#include -#include #include -#include -#include #include #include -#include #include namespace RuntimeConsoleIdentity { struct Identity { std::string serial; + std::string productCode; + std::string area; + std::string gameRegion; std::array mac; }; -inline bool IsValidSerial(const std::string& serial) { - return serial.size() == 9 && - serial != "000000000" && - std::all_of(serial.begin(), serial.end(), - [](unsigned char value) { return std::isdigit(value) != 0; }); -} - inline Identity FromSerial(std::string serial) { - // Keep Nintendo's Wii OUI. The suffix is derived from the persisted serial + // Keep Nintendo's Wii OUI. The suffix is derived from the NAND serial // so every API exposes one coherent, stable virtual-console identity. uint32_t hash = 2166136261u; for (const unsigned char value : serial) { @@ -46,6 +36,7 @@ inline Identity FromSerial(std::string serial) { return { std::move(serial), + {}, {}, {}, { 0x00, 0x09, @@ -57,83 +48,23 @@ inline Identity FromSerial(std::string serial) { }; } -inline std::optional ReadSerial(const std::filesystem::path& path) { - std::ifstream input(path); - std::string line; - if (!input || !std::getline(input, line)) { - return std::nullopt; +inline Identity LoadFromNand() { + const auto root = RuntimeNandPath::DiscoverNandRootPath(); + const auto settings = RuntimeNandSettings::Read(root); + if (!settings || !RuntimeNandSettings::HasIdentity(*settings)) { + RuntimeNandPath::FailNandRoot( + "NAND setting.txt is missing or has invalid console identity fields (SERNO, CODE, AREA, GAME)", + root / "title/00000001/00000002/data/setting.txt"); } - constexpr std::string_view prefix = "serial="; - if (line.rfind(prefix, 0) != 0) { - return std::nullopt; - } - std::string serial = line.substr(prefix.size()); - if (!IsValidSerial(serial)) { - return std::nullopt; - } - return serial; -} - -inline bool WriteSerial(const std::filesystem::path& path, const std::string& serial) { - std::error_code ec; - std::filesystem::create_directories(path.parent_path(), ec); - if (ec) { - return false; - } - - std::filesystem::path temporary = path; - temporary += ".tmp"; - { - std::ofstream output(temporary, std::ios::trunc); - if (!output) { - return false; - } - output << "serial=" << serial << '\n'; - output.close(); - if (!output) { - return false; - } - } - - std::filesystem::rename(temporary, path, ec); - if (!ec) { - return true; - } - std::filesystem::remove(temporary, ec); - return false; -} - -inline std::string GenerateSerial() { - std::random_device entropy; - std::seed_seq seed{ - entropy(), - entropy(), - entropy(), - entropy(), - }; - std::mt19937 generator(seed); - std::uniform_int_distribution distribution(100000000u, 999999999u); - return std::to_string(distribution(generator)); -} - -inline Identity LoadOrCreate(const std::filesystem::path& path) { - if (const auto serial = ReadSerial(path)) { - return FromSerial(*serial); - } - - const std::string generated = GenerateSerial(); - if (WriteSerial(path, generated)) { - return FromSerial(generated); - } - - // Remain operational in a read-only environment. This fallback matches - // Dolphin's deterministic serial while keeping the same valid identity shape. - return FromSerial("123456789"); + Identity identity = FromSerial(settings->at("SERNO")); + identity.productCode = settings->at("CODE"); + identity.area = settings->at("AREA"); + identity.gameRegion = settings->at("GAME"); + return identity; } inline const Identity& Current() { - static const Identity identity = - LoadOrCreate(RuntimeConfigFile::ApplicationDataDirectory() / "ConsoleIdentity.txt"); + static const Identity identity = LoadFromNand(); return identity; } diff --git a/runtime/include/host_context.h b/runtime/include/host_context.h index 4d7355f..5ea6bfb 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/include/nand_path.h b/runtime/include/nand_path.h index 709e4af..eb0fda8 100644 --- a/runtime/include/nand_path.h +++ b/runtime/include/nand_path.h @@ -1,6 +1,7 @@ #pragma once #include "runtime_config.h" +#include "nand_settings.h" #include "runtime_log.h" #include "system_bridge.h" @@ -163,7 +164,7 @@ inline std::filesystem::path CreateManagedNandRoot() { return root; } -inline std::filesystem::path DiscoverNandRootPath() { +inline std::filesystem::path ResolveNandRootPath() { const std::string configPath = RuntimeConfigFile::NandRoot(); if (!configPath.empty()) { const auto path = ResolveConfiguredPath(configPath); @@ -179,4 +180,16 @@ inline std::filesystem::path DiscoverNandRootPath() { return CreateManagedNandRoot(); } +inline std::filesystem::path DiscoverNandRootPath() { + static const auto root = [] { + const auto resolved = ResolveNandRootPath(); + std::string error; + if (!RuntimeNandSettings::Ensure(resolved, error)) { + FailNandRoot(error.c_str(), RuntimeNandSettings::FilePath(resolved)); + } + return resolved; + }(); + return root; +} + } // namespace RuntimeNandPath diff --git a/runtime/include/nand_save_probe.h b/runtime/include/nand_save_probe.h new file mode 100644 index 0000000..b00b7d8 --- /dev/null +++ b/runtime/include/nand_save_probe.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include + +namespace RuntimeNandSave { + +enum class Contents { Missing, Blank, Nonzero, Error }; +enum class ReadAction { Proceed, Missing, Error, RecoveryNeeded }; + +// A failed read is not evidence that a save is blank. Check badbit before EOF: +// an I/O failure may set both, whereas a successful short final read sets EOF. +inline Contents InspectStream(std::istream& input) { + if (!input) return Contents::Error; + char block[4096]; + for (;;) { + input.read(block, sizeof(block)); + if (input.bad() || (input.fail() && !input.eof())) return Contents::Error; + for (std::streamsize i = 0; i < input.gcount(); ++i) { + if (block[i] != 0) return Contents::Nonzero; + } + if (input.eof()) return Contents::Blank; + } +} + +inline Contents InspectFile(const std::filesystem::path& path) { + std::error_code ec; + const auto status = std::filesystem::symlink_status(path, ec); + if (ec && ec != std::errc::no_such_file_or_directory) return Contents::Error; + if (!std::filesystem::exists(status)) return Contents::Missing; + if (!std::filesystem::is_regular_file(path, ec) || ec) return Contents::Error; + std::ifstream input(path, std::ios::binary); + return InspectStream(input); +} + +// Probe only read-only opens of the actual save and its exact write shadow. +// No probe writes, removes, or repairs data, and backups are not save aliases. +inline ReadAction CheckRead(const std::filesystem::path& path, int mode) { + const auto name = path.filename(); + const bool isMain = name == "rksys.dat"; + if (mode != 1 || (!isMain && name != "rksys.dat.nandsafe.tmp")) return ReadAction::Proceed; + const auto contents = InspectFile(path); + if (contents == Contents::Error) return ReadAction::Error; + if (contents == Contents::Nonzero) return ReadAction::Proceed; + if (isMain) { + auto shadow = path; + shadow += ".nandsafe.tmp"; + const auto shadowContents = InspectFile(shadow); + if (shadowContents == Contents::Error) return ReadAction::Error; + // The next write normally discards an old shadow. Preserve a possible + // recovery source when there is no usable original, without promoting + // an uncommitted (and potentially incomplete) shadow to the real save. + if (shadowContents == Contents::Nonzero) return ReadAction::RecoveryNeeded; + } + return contents == Contents::Blank ? ReadAction::Missing : ReadAction::Proceed; +} + +} // namespace RuntimeNandSave diff --git a/runtime/include/nand_settings.h b/runtime/include/nand_settings.h new file mode 100644 index 0000000..055f4c2 --- /dev/null +++ b/runtime/include/nand_settings.h @@ -0,0 +1,220 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#endif + +namespace RuntimeNandSettings { + +using Settings = std::map; + +inline std::filesystem::path FilePath(const std::filesystem::path& root) { + return root / "title/00000001/00000002/data/setting.txt"; +} + +// Wii setting.txt is a 256-byte buffer encrypted with a rotating XOR key. +inline std::optional Read(const std::filesystem::path& nandRoot) { + std::ifstream input(FilePath(nandRoot), std::ios::binary); + std::array bytes{}; + if (!input.read(reinterpret_cast(bytes.data()), bytes.size())) { + return std::nullopt; + } + uint32_t key = 0x73B5DBFAu; + std::string decoded; + for (const uint8_t byte : bytes) { + const char value = static_cast(byte ^ static_cast(key)); + key = (key << 1) | (key >> 31); + if (value == '\0') { + break; + } + if (value != '\r') { + decoded += value; + } + } + Settings settings; + for (size_t start = 0; start < decoded.size();) { + const size_t end = decoded.find('\n', start); + const std::string line = decoded.substr(start, end - start); + const size_t equals = line.find('='); + if (equals != std::string::npos && equals != 0) { + settings.emplace(line.substr(0, equals), line.substr(equals + 1)); + } + if (end == std::string::npos) { + break; + } + start = end + 1; + } + return settings; +} + +inline bool HasIdentity(const Settings& settings) { + const auto serial = settings.find("SERNO"); + if (serial == settings.end() || serial->second.empty() || serial->second.size() > 9 || + serial->second.find_first_not_of("0123456789") != std::string::npos || + serial->second.find_first_not_of('0') == std::string::npos) { + return false; + } + for (const auto& field : {std::pair{"CODE", 5u}, {"AREA", 3u}, {"GAME", 2u}}) { + const auto value = settings.find(field.first); + if (value == settings.end() || value->second.empty() || + value->second.size() > field.second) { + return false; + } + } + return true; +} + +// Dolphin's normal (non-deterministic) first-boot algorithm. It is independent +// of the ES device ID. Matching another NAND requires that NAND's saved serial. +inline std::string GenerateSerial(std::time_t now) { + if (now < 0) { + return {}; + } + const auto digits = std::to_string(now % 1000000000); + return std::string(9 - digits.size(), '0') + digits; +} + +// This recompilation targets the European disc. These are Dolphin's PAL boot +// defaults; an existing setting.txt always takes precedence, in every region. +inline std::optional> EncodeNew(const std::string& serial) { + const Settings identity{{"SERNO", serial}, {"CODE", "LEH"}, {"AREA", "EUR"}, {"GAME", "EU"}}; + if (!HasIdentity(identity)) { + return std::nullopt; + } + std::array bytes{}; + size_t position = 0; + uint32_t key = 0x73B5DBFAu; + const auto writeByte = [&](char value) { + bytes[position++] = static_cast(value) ^ static_cast(key); + key = (key << 1) | (key >> 31); + }; + for (const std::string& line : {std::string("AREA=EUR\r\n"), std::string("MODEL=RVL-001(EUR)\r\n"), + std::string("DVD=0\r\n"), std::string("MPCH=0x7FFE\r\n"), std::string("CODE=LEH\r\n"), + "SERNO=" + serial + "\r\n", std::string("VIDEO=PAL\r\n"), std::string("GAME=EU\r\n")}) { + for (;;) { + if (position + line.size() > bytes.size()) { + return std::nullopt; + } + const auto start = position; + const auto savedKey = key; + bool hasNull = false; + for (const char value : line) { + writeByte(value); + hasNull |= bytes[position - 1] == 0; + } + if (!hasNull) { + break; + } + // Nintendo stops at an encoded NUL. Dolphin inserts an extra LF + // before this line and retries with the shifted encryption key. + position = start; + key = savedKey; + writeByte('\n'); + } + } + return bytes; // The unused tail stays raw zero, as in Dolphin. +} + +// Atomically claim our own scratch directory. A collision belongs to another +// launch (or a previous crashed launch); leave it untouched and try another name. +inline std::optional CreateScratchDirectory( + const std::filesystem::path& parent, const std::string& token, std::error_code& ec) { + for (unsigned attempt = 0; attempt < 128; ++attempt) { + const auto candidate = parent / (".setting-init-" + token + "-" + std::to_string(attempt)); + ec.clear(); + if (std::filesystem::create_directory(candidate, ec)) return candidate; + if (ec && ec != std::errc::file_exists) return std::nullopt; + } + ec = std::make_error_code(std::errc::file_exists); + return std::nullopt; +} + +// Never replace an existing file, including an unreadable or damaged one. +// Publish a complete file atomically so simultaneous launches use one identity. +inline bool Ensure(const std::filesystem::path& root, std::string& error, + std::time_t now = std::time(nullptr)) { + const auto path = FilePath(root); + std::error_code ec; + const auto status = std::filesystem::symlink_status(path, ec); + if (ec && ec != std::errc::no_such_file_or_directory) { + error = "Cannot inspect NAND setting.txt: " + ec.message(); + return false; + } + if (std::filesystem::exists(status)) { + const auto existing = Read(root); + if (existing && HasIdentity(*existing)) { + return true; + } + error = "Existing NAND setting.txt is unreadable or invalid; restore it from this console's backup"; + return false; + } + + const auto bytes = EncodeNew(GenerateSerial(now)); + if (!bytes) { + error = "Cannot initialize NAND settings: invalid system clock"; + return false; + } + ec.clear(); + std::filesystem::create_directories(path.parent_path(), ec); + if (ec) { + error = "Cannot create NAND settings directory: " + ec.message(); + return false; + } + static std::atomic sequence{0}; +#ifdef _WIN32 + const auto processId = GetCurrentProcessId(); +#else + const auto processId = getpid(); +#endif + const auto scratch = CreateScratchDirectory(path.parent_path(), + std::to_string(processId) + "-" + std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count()) + "-" + + std::to_string(sequence++), ec); + if (!scratch) { + error = "Cannot create temporary NAND settings directory: " + ec.message(); + return false; + } + const auto temporary = *scratch / "setting.txt"; + bool written = false; + { + std::ofstream output(temporary, std::ios::binary); + output.write(reinterpret_cast(bytes->data()), bytes->size()); + output.close(); + written = static_cast(output); + } + bool published = false; + if (written) { +#ifdef _WIN32 + published = MoveFileExW(temporary.c_str(), path.c_str(), MOVEFILE_WRITE_THROUGH) != 0; +#else + published = ::link(temporary.c_str(), path.c_str()) == 0; +#endif + } + std::filesystem::remove(temporary, ec); + std::filesystem::remove(*scratch, ec); + // A competing launcher may have published its settings first. Always read + // the winner from NAND rather than using our unpersisted candidate serial. + const auto persisted = Read(root); + if (persisted && HasIdentity(*persisted)) { + return true; + } + error = published ? "Cannot read newly initialized NAND setting.txt" : + "Cannot persist NAND setting.txt; check NAND directory permissions"; + return false; +} + +} // namespace RuntimeNandSettings diff --git a/runtime/include/sc_serial_contract.h b/runtime/include/sc_serial_contract.h new file mode 100644 index 0000000..32e3600 --- /dev/null +++ b/runtime/include/sc_serial_contract.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace RuntimeScSerial { + +// SCGetProductSN's output is a u32, not a character buffer. DWC loads +// that word and formats it with the product code to construct csnum. +template +uint32_t Write(std::string_view serial, uint32_t address, + RangeValidator&& contains, WordWriter&& write32) { + if (serial.empty() || serial.size() > 9 || + serial.find_first_not_of("0123456789") != std::string_view::npos) return 0; + uint32_t number = 0; + const auto parsed = std::from_chars(serial.data(), serial.data() + serial.size(), number); + if (parsed.ec != std::errc{} || parsed.ptr != serial.data() + serial.size() || + !address || !contains(address, sizeof(uint32_t))) return 0; + write32(address, number); + return 1; +} + +} // namespace RuntimeScSerial diff --git a/runtime/src/fiber_manager.cpp b/runtime/src/fiber_manager.cpp index fb4575d..9eee67c 100644 --- a/runtime/src/fiber_manager.cpp +++ b/runtime/src/fiber_manager.cpp @@ -256,8 +256,12 @@ bool GuestFiberManager::CreateGuestFiber(uint32_t guestThreadAddr, uint32_t entr gf.cpuContext.srr0 = entryPoint; // The host stack models only translated host calls; the guest stack starts - // at stackBase in the CPU context above. - constexpr size_t kHostStackSize = 64 * 1024; + // at stackBase in the CPU context above. 64 KiB is too small for deep + // translated/HLE call chains (notably NW4R's sound worker), and on macOS + // it can exhaust the guarded coroutine stack as unrelated host work (such + // as a window resize) adds a little more nesting. Keep enough headroom for + // those chains while the guest stack remains separately bounded. + constexpr size_t kHostStackSize = 1024 * 1024; gf.fiber = HostContext::Create(kHostStackSize, FiberProc, reinterpret_cast(static_cast(guestThreadAddr))); diff --git a/runtime/src/hle/net/network_socket.cpp b/runtime/src/hle/net/network_socket.cpp index 898942f..95c6634 100644 --- a/runtime/src/hle/net/network_socket.cpp +++ b/runtime/src/hle/net/network_socket.cpp @@ -499,7 +499,7 @@ int32_t HandleIpTopIoctlv(uint32_t cmd, const std::vector& in, const s // Nonblocking sockets get -SO_EAGAIN immediately (Dolphin's retry predicate // short-circuits on nonBlock/forceNonBlock, IOS/Network/Socket.cpp:715-718); // waiting here anyway stalled the whole emulation thread on every empty read. - constexpr int kStreamRecvWaitMs = 250; + constexpr int kStreamRecvWaitMs = 1000; const int streamWaitMs = (forceNonBlock || s->nonblocking) ? 0 : kStreamRecvWaitMs; const bool waited = ret < 0 && !fromPtr && s->type == SOCK_STREAM && IsWouldBlockError(nativeErr) && WaitForReadable(s->native, streamWaitMs); diff --git a/runtime/src/hle/sc.cpp b/runtime/src/hle/sc.cpp index 62c4411..8e50ed1 100644 --- a/runtime/src/hle/sc.cpp +++ b/runtime/src/hle/sc.cpp @@ -1,6 +1,7 @@ #include "hle_stubs.h" #include "console_identity.h" +#include "sc_serial_contract.h" #include #include #include @@ -12,7 +13,25 @@ namespace { -constexpr uint32_t kPalProductRegion = 2; +// Use the SDK's own value tables, including its unknown-region result. +uint32_t LookupProductRegion(uint32_t table, uint32_t stride, uint32_t count, + const std::string& value) { + for (uint32_t index = 0; index < count; ++index) { + const uint32_t entry = table + index * stride; + if (!Memory::Contains(entry, stride)) { + break; + } + const auto* bytes = static_cast(Memory::GetPointer(entry, stride)); + if (bytes[0] == 0xFF) { + break; + } + if (value.size() < stride - 1 && + std::memcmp(bytes + 1, value.c_str(), value.size() + 1) == 0) { + return bytes[0]; + } + } + return 0xFFFFFFFFu; +} } // namespace @@ -50,16 +69,12 @@ extern "C" uint32_t SCGetEuRgb60Mode_HLE() PPC_NATIVE_OVERRIDE(801B1CAC, SCGetEuRgb60Mode_HLE, uint32_t, (), ()); -// The managed NAND intentionally starts without a console-owned setting.txt. -// DWC nevertheless requires the Wii product code and serial number so it can -// include csnum in NAS authentication. Expose one stable virtual-console -// identity without requiring or mutating a user's real NAND. +// Expose the selected emulated NAND identity through the SDK SC APIs. extern "C" uint32_t SCGetProductArea_HLE() { - // The PAL setting.txt AREA value is "EUR". The SDK's lookup table at - // 0x8029CEB0 maps JPN=0, USA=1, EUR=2. - return kPalProductRegion; + return LookupProductRegion(0x8029CEB0u, 5, 13, + RuntimeConsoleIdentity::Current().area); } PPC_NATIVE_OVERRIDE(801B23A0, SCGetProductArea_HLE, uint32_t, (), ()); @@ -68,12 +83,13 @@ extern "C" uint32_t SCGetProductCode_HLE() { // Original PAL SC storage for the six-byte CODE value. constexpr uint32_t kProductCodeAddress = 0x803869E0u; - static constexpr char kProductCode[] = "LEH"; - if (!Memory::Contains(kProductCodeAddress, sizeof(kProductCode))) { + const std::string& productCode = RuntimeConsoleIdentity::Current().productCode; + const size_t size = productCode.size() + 1; + if (!Memory::Contains(kProductCodeAddress, size)) { return 0; } - std::memcpy(Memory::GetPointer(kProductCodeAddress, sizeof(kProductCode)), - kProductCode, sizeof(kProductCode)); + std::memcpy(Memory::GetPointer(kProductCodeAddress, size), + productCode.c_str(), size); return kProductCodeAddress; } @@ -82,21 +98,17 @@ PPC_NATIVE_OVERRIDE(801B2424, SCGetProductCode_HLE, uint32_t, (), ()); extern "C" uint32_t SCGetProductSN_HLE(uint32_t serialAddress) { const std::string& serial = RuntimeConsoleIdentity::Current().serial; - if (!serialAddress || !Memory::Contains(serialAddress, serial.size() + 1)) { - return 0; - } - std::memcpy(Memory::GetPointer(serialAddress, serial.size() + 1), - serial.c_str(), serial.size() + 1); - return 1; + return RuntimeScSerial::Write(serial, serialAddress, + [](uint32_t address, size_t size) { return Memory::Contains(address, size); }, + [](uint32_t address, uint32_t value) { Memory::Write32(address, value); }); } PPC_NATIVE_OVERRIDE(801B2460, SCGetProductSN_HLE, uint32_t, (uint32_t serialAddress), (serialAddress)); extern "C" uint32_t SCGetProductGameRegion_HLE() { - // The PAL setting.txt GAME value is "EU". The SDK's own lookup table at - // 0x8029CEF8 maps JP=0, US=1, EU=2. - return kPalProductRegion; + return LookupProductRegion(0x8029CEF8u, 4, 4, + RuntimeConsoleIdentity::Current().gameRegion); } PPC_NATIVE_OVERRIDE(801B24C8, SCGetProductGameRegion_HLE, uint32_t, (), ()); diff --git a/runtime/src/hle/storage/nand_api.cpp b/runtime/src/hle/storage/nand_api.cpp index 723569c..6796b5b 100644 --- a/runtime/src/hle/storage/nand_api.cpp +++ b/runtime/src/hle/storage/nand_api.cpp @@ -93,6 +93,9 @@ extern "C" int32_t NANDOpen_HLE(uint32_t pathPtr, uint32_t fileInfoPtr, uint32_t const std::filesystem::path hostPath = TranslateNandPath(path); + if (const auto result = NandCheckSystemSaveRead("NANDOpen", hostPath, mode)) + return *result; + // Existing-file write opens go through a shadow copy seeded from the original, so a // crash between NANDWrite and NANDClose cannot leave a torn file (the game patches // sub-ranges, e.g. ghost saves at a non-zero offset). New files still create in place. diff --git a/runtime/src/hle/storage/nand_async.cpp b/runtime/src/hle/storage/nand_async.cpp index ae9f3d9..aeb1962 100644 --- a/runtime/src/hle/storage/nand_async.cpp +++ b/runtime/src/hle/storage/nand_async.cpp @@ -411,6 +411,8 @@ extern "C" int32_t NANDSafeOpen_HLE(uint32_t pathPtr, uint32_t fileInfoPtr, uint if (mode == 1) { // Read-only safe open reads the original in place; the library builds no scratch // copy for this case. + if (const auto result = NandCheckSystemSaveRead("NANDSafeOpen", hostPath, mode)) + return *result; FILE* file = NandFopen(hostPath, "rb"); if (!file && IsFaceLibResourcePath(path) && SeedFaceLibResource(hostPath)) { file = NandFopen(hostPath, "rb"); diff --git a/runtime/src/hle/storage/nand_fs.cpp b/runtime/src/hle/storage/nand_fs.cpp index 5b35e16..4dba4f0 100644 --- a/runtime/src/hle/storage/nand_fs.cpp +++ b/runtime/src/hle/storage/nand_fs.cpp @@ -411,6 +411,26 @@ bool IsFaceLibResourcePath(const char* path) { return std::strcmp(path, "/shared2/menu/FaceLib/RFL_Res.dat") == 0; } +std::optional NandCheckSystemSaveRead(const char* who, + const std::filesystem::path& hostPath, int mode, bool ios) { + const auto action = RuntimeNandSave::CheckRead(hostPath, mode); + if (action == RuntimeNandSave::ReadAction::Proceed) return std::nullopt; + if (action == RuntimeNandSave::ReadAction::Missing) { + LogNandWarning(who, "treating empty or zero-filled system save '%s' as missing", + HostPathText(hostPath).c_str()); + return ios ? ISFS_ENOENT : NAND_RESULT_NOEXISTS; + } + if (action == RuntimeNandSave::ReadAction::RecoveryNeeded) { + LogNandError(who, "system save '%s' is missing or blank but its .nandsafe.tmp contains data; " + "back up both files before attempting recovery", + HostPathText(hostPath).c_str()); + } else { + LogNandError(who, "could not inspect system save '%s' or its write shadow; leaving data untouched", + HostPathText(hostPath).c_str()); + } + return ios ? ISFS_EIO : NAND_RESULT_UNKNOWN; +} + // Create directories recursively bool CreateDirectoryPath(const std::filesystem::path& path) { if (path.empty()) { diff --git a/runtime/src/hle/storage/nand_internal.h b/runtime/src/hle/storage/nand_internal.h index f45c617..346be4f 100644 --- a/runtime/src/hle/storage/nand_internal.h +++ b/runtime/src/hle/storage/nand_internal.h @@ -9,6 +9,7 @@ #include "hle/runtime_parse_helpers.h" #include "memory.h" #include "nand_path.h" +#include "nand_save_probe.h" #include "hle/net/network.h" #include "recomp_mod_loader.h" #include "runtime_config.h" @@ -26,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -56,6 +58,11 @@ constexpr uint32_t kNandTitleIdLo = 0x524D4350; // "RMCP" fallback void LogNandError(const char* func, const char* fmt, ...); void LogNandWarning(const char* func, const char* fmt, ...); +// An empty optional means continue opening normally; otherwise return the +// supplied NAND/IOS error without exposing a failed scan as a missing save. +std::optional NandCheckSystemSaveRead(const char* who, + const std::filesystem::path& hostPath, int mode, bool ios = false); + // ============================================================================ // File Descriptor Management // ============================================================================ diff --git a/runtime/src/hle/storage/nand_isfs.cpp b/runtime/src/hle/storage/nand_isfs.cpp index 6ea0ad4..f084226 100644 --- a/runtime/src/hle/storage/nand_isfs.cpp +++ b/runtime/src/hle/storage/nand_isfs.cpp @@ -391,6 +391,9 @@ extern "C" int32_t NAND_IOS_Open_HLE(uint32_t pathPtr, uint32_t mode) { // It's a NAND file path const std::filesystem::path hostPath = TranslateNandPath(path); + + if (const auto result = NandCheckSystemSaveRead("IOS_Open", hostPath, mode, true)) + return *result; // Seed FaceLib resources before the existence check so every open mode can // still find them on a fresh managed NAND. diff --git a/runtime/src/host_context.cpp b/runtime/src/host_context.cpp index e23c1c0..502f72c 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 d06a0ce..fbf817f 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 diff --git a/runtime/src/music_attenuation.cpp b/runtime/src/music_attenuation.cpp index 3ce2b82..3d0f123 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 diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 0429be6..8856ba5 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -483,11 +483,6 @@ void DrawRumbleSettings() { PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, }; PADControlAllMotors(stopAll.data()); -#if defined(_WIN32) - for (uint32_t port = 0; port < PAD_MAX_CONTROLLERS; ++port) { - Wup028Adapter::SetRumble(port, false); - } -#endif } } if (ImGui::IsItemHovered()) { diff --git a/runtime/tests/nand_save_tests.cpp b/runtime/tests/nand_save_tests.cpp new file mode 100644 index 0000000..a9bbb60 --- /dev/null +++ b/runtime/tests/nand_save_tests.cpp @@ -0,0 +1,142 @@ +#include "nand_save_probe.h" + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#endif + +namespace fs = std::filesystem; +using RuntimeNandSave::ReadAction; +using RuntimeNandSave::Contents; + +static void Require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +static void Write(const fs::path& path, const std::string& bytes) { + fs::create_directories(path.parent_path()); + std::ofstream output(path, std::ios::binary); + output.write(bytes.data(), bytes.size()); + output.close(); + Require(static_cast(output), "Fixture write failed"); +} + +static std::string Read(const fs::path& path) { + std::ifstream input(path, std::ios::binary); + Require(static_cast(input), "Fixture read failed"); + return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; +} + +// A disk error after zero-filled blocks must not look like a blank file's EOF. +class FailingDisk : public std::streambuf { + int blocks; +public: + explicit FailingDisk(int zeroBlocks) : blocks(zeroBlocks) {} + std::streamsize xsgetn(char* buffer, std::streamsize length) override { + if (blocks-- <= 0) throw std::runtime_error("injected read failure"); + std::fill(buffer, buffer + length, '\0'); + return length; + } +}; + +int main() { + const auto root = fs::temp_directory_path() / ("wiicomp-save-scenarios-" + + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + try { + const auto save = root / "title/00010004/524d4350/data/rksys.dat"; + const auto shadow = fs::path(save.native() + fs::path(".nandsafe.tmp").native()); + // Save inspection must leave unrelated NAND data alone. Settings + // initialization is covered separately by nand_settings_tests. + const auto settingsPath = root / "title/00000001/00000002/data/setting.txt"; + const std::string identity(256, '\x5a'); + Write(settingsPath, identity); + Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Proceed, "Fresh profile follows normal missing-file handling"); + Require(!fs::exists(save), "Probing fresh profile must not create a save"); + + // First launch interrupted before save initialization, including block + // boundaries and a full-sized synthetic zero-filled allocation. + for (const size_t size : {size_t(0), size_t(1), size_t(4095), size_t(4096), size_t(4097), size_t(3 * 1024 * 1024)}) { + const std::string bytes(size, '\0'); + Write(save, bytes); + Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Missing, "Blank save should be offered first-save recovery"); + Require(Read(save) == bytes, "Blank-save detection must not modify the file"); + for (int mode : {2, 3}) { + Require(RuntimeNandSave::CheckRead(save, mode) == ReadAction::Proceed, "Write opens must remain available for initialization"); + } + } + + // Existing saves, imported saves, partial/corrupt saves, and a zero + // prefix with data only in the final byte are all left to the game. + std::string existing(3 * 1024 * 1024, '\0'); + existing.replace(0, 8, "RKSD0006"); + existing[10000] = 42; + for (const std::string& bytes : {existing, std::string("RKSD"), std::string("damaged-header"), + std::string(8192, '\0') + "x", std::string(8191, '\0') + "x"}) { + Write(save, bytes); + Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Proceed, "Never hide a save containing any data"); + Require(Read(save) == bytes, "Existing/partial save must be byte-identical after inspection"); + } + + // Interrupted replacement: retain a committed original regardless of + // whether the shadow is blank, partial, or contains a complete header. + Write(save, existing); + for (const std::string& bytes : {std::string(), std::string(4096, '\0'), std::string("RKSD"), existing}) { + Write(shadow, bytes); + Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Proceed, "Committed original takes precedence over write shadow"); + Require(Read(save) == existing && Read(shadow) == bytes, "Probe must preserve both sides of an interrupted write"); + } + // No usable original: do not let missing-save recovery discard the + // only possible recovery source, and do not auto-promote that shadow. + for (const bool mainExists : {false, true}) { + fs::remove(save); + if (mainExists) Write(save, std::string(4096, '\0')); + Write(shadow, existing); + Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::RecoveryNeeded, "Preserve recovery candidate when original is missing or blank"); + Require(Read(shadow) == existing, "Recovery candidate must remain unchanged"); + Require(fs::exists(save) == mainExists, "Do not promote shadow automatically"); + } + Write(shadow, std::string(4096, '\0')); + Require(RuntimeNandSave::CheckRead(save, 1) == ReadAction::Missing, "Two blank files may use first-save recovery"); + fs::remove(shadow); + + for (const char* name : {"rksys.dat.bak", "rksys.dat.backup", "rksys.dat2", "banner.bin", "setting.txt"}) { + const auto unrelated = save.parent_path() / name; + Write(unrelated, std::string(4096, '\0')); + Require(RuntimeNandSave::CheckRead(unrelated, 1) == ReadAction::Proceed, "Do not classify backups or unrelated files as missing saves"); + } + for (int blocks : {0, 1, 2}) { + FailingDisk disk(blocks); + std::istream input(&disk); + Require(RuntimeNandSave::InspectStream(input) == Contents::Error, "Read failure must remain an error, including after zero-filled blocks"); + } + std::istringstream badEof; + badEof.setstate(std::ios::badbit | std::ios::eofbit); + Require(RuntimeNandSave::InspectStream(badEof) == Contents::Error, "Badbit plus EOF must not imply a blank save"); + +#ifdef _WIN32 + Write(save, existing); + const HANDLE locked = CreateFileW(save.c_str(), GENERIC_READ, 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + Require(locked != INVALID_HANDLE_VALUE, "Could not lock fixture"); + const auto lockedResult = RuntimeNandSave::CheckRead(save, 1); + CloseHandle(locked); + Require(lockedResult == ReadAction::Error, "Sharing/access failure must not report a missing save"); + Require(Read(save) == existing, "Locked save must survive inspection unchanged"); + Require(SetFileAttributesW(save.c_str(), FILE_ATTRIBUTE_READONLY) != 0, "Set fixture read-only"); + const auto readOnlyResult = RuntimeNandSave::CheckRead(save, 1); + SetFileAttributesW(save.c_str(), FILE_ATTRIBUTE_NORMAL); + Require(readOnlyResult == ReadAction::Proceed && Read(save) == existing, "Readable read-only save remains available"); +#endif + Require(Read(settingsPath) == identity, "Save inspection must not change NAND settings"); + fs::remove_all(root); + std::cout << "NAND save startup, preservation, interrupted-write and I/O failure scenarios passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << " (fixtures retained at " << root << ")\n"; + return 1; + } +} diff --git a/runtime/tests/nand_settings_tests.cpp b/runtime/tests/nand_settings_tests.cpp new file mode 100644 index 0000000..3231911 --- /dev/null +++ b/runtime/tests/nand_settings_tests.cpp @@ -0,0 +1,183 @@ +#include "nand_settings.h" + +#include +#include +#include +#include +#include + +static void Require(bool condition, const char* message = "NAND settings check failed") { + if (!condition) { + throw std::runtime_error(message); + } +} + +static std::string ReadBytes(const std::filesystem::path& path) { + std::ifstream input(path, std::ios::binary); + return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; +} + +int main() { + const auto root = std::filesystem::temp_directory_path() / + ("wiicomp-nand-settings-" + std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count())); + const auto path = root / "title/00000001/00000002/data/setting.txt"; + try { + using namespace RuntimeNandSettings; + Require(GenerateSerial(1800000123) == "800000123", "Dolphin timestamp modulo"); + Require(GenerateSerial(1000000001) == "000000001", "Dolphin leading zero padding"); + Require(GenerateSerial(-1).empty(), "Invalid clock must not supply an identity"); + // Golden bytes generated by Dolphin's unmodified SettingsHandler.cpp + // (upstream 2026-09-06), PAL boot fields and synthetic serial 000000001. + // Everything after this prefix is raw zero padding to 256 bytes. + const std::string goldenHex = + "bba6ac929a0bc96b7eed83d27f33a1e7e73d9b836d8b47c59ee23df6b275baab" + "bec9d9dead03cc7a3bdafee50c30ab9fb86194e119fe4ba19eff62d5ec3aacb3" + "b5c9d9e3977eac0943d7ff903120a49ef024eafe1cf77be79cf6229a823aabf0f0"; + std::array golden{}; + for (size_t i = 0; i < goldenHex.size() / 2; ++i) { + golden[i] = static_cast(std::stoul(goldenHex.substr(i * 2, 2), nullptr, 16)); + } + Require(EncodeNew("000000001") == golden, "Exact Dolphin writer golden fixture"); + std::string error; + Require(!RuntimeNandSettings::Read(root)); + Require(!std::filesystem::exists(root)); + std::filesystem::create_directories(path.parent_path()); + const auto scratchParent = root / "scratch-collisions"; + std::filesystem::create_directories(scratchParent / ".setting-init-fixed-0"); + const auto sentinel = scratchParent / ".setting-init-fixed-0" / "setting.txt"; + { std::ofstream output(sentinel); output << "another launch owns this"; } + const auto occupiedFile = scratchParent / ".setting-init-fixed-1"; + { std::ofstream output(occupiedFile); output << "leave this file alone"; } + std::error_code scratchError; + const auto claimed = CreateScratchDirectory(scratchParent, "fixed", scratchError); + Require(claimed && *claimed == scratchParent / ".setting-init-fixed-2" && !scratchError, + "Retry collisions with both existing directories and files"); + Require(ReadBytes(sentinel) == "another launch owns this" && + ReadBytes(occupiedFile) == "leave this file alone", "Never modify another launch's scratch data"); + Require(!CreateScratchDirectory(occupiedFile / "not-a-directory", "fixed", scratchError) && scratchError, + "Real filesystem errors must fail rather than retry indefinitely"); + + // Force all claimants to use the same token; this deterministically + // exercises the collision path even when host clock precision is high. + std::array, 16> claims; + std::vector claimants; + for (size_t i = 0; i < claims.size(); ++i) { + claimants.emplace_back([&, i] { + std::error_code ec; + claims[i] = CreateScratchDirectory(scratchParent, "shared", ec); + }); + } + for (auto& claimant : claimants) claimant.join(); + for (size_t i = 0; i < claims.size(); ++i) { + Require(claims[i].has_value(), "Every concurrent claimant must acquire a scratch directory"); + for (size_t j = 0; j < i; ++j) { + Require(claims[i] != claims[j], "Concurrent claimants must own different scratch directories"); + } + } + const std::string plain = "AREA=USA\r\n\nCODE=LU\r\nSERNO=987654321\r\nGAME=US\r\n"; + std::array fixture{}; + for (size_t i = 0; i < fixture.size(); ++i) { + const unsigned shift = i % 32; + const uint32_t key = shift == 0 ? 0x73B5DBFAu : + (0x73B5DBFAu << shift) | (0x73B5DBFAu >> (32 - shift)); + fixture[i] = static_cast(key) ^ (i < plain.size() ? plain[i] : 0); + } + { + std::ofstream output(path, std::ios::binary); + output.write(reinterpret_cast(fixture.data()), fixture.size()); + } + auto settings = RuntimeNandSettings::Read(root); + Require(settings && RuntimeNandSettings::HasIdentity(*settings)); + Require(settings->at("SERNO") == "987654321" && settings->at("CODE") == "LU"); + Require(settings->at("AREA") == "USA" && settings->at("GAME") == "US"); + Require(Ensure(root, error, 1800000123), "Existing imported NAND must work"); + std::array after{}; + { + std::ifstream input(path, std::ios::binary); + input.read(reinterpret_cast(after.data()), after.size()); + } + Require(after == fixture); + for (const auto serial : {"", "000000000", "1234567890", "123ABC789"}) { + (*settings)["SERNO"] = serial; + Require(!RuntimeNandSettings::HasIdentity(*settings)); + } + (*settings)["SERNO"] = "012345678"; + Require(RuntimeNandSettings::HasIdentity(*settings)); + (*settings)["CODE"] = "TOOLONG"; + Require(!RuntimeNandSettings::HasIdentity(*settings)); + (*settings)["CODE"] = "LEH"; + settings->erase("GAME"); + Require(!RuntimeNandSettings::HasIdentity(*settings)); + std::filesystem::resize_file(path, 128); + Require(!RuntimeNandSettings::Read(root)); + const auto damaged = ReadBytes(path); + Require(!Ensure(root, error, 1800000123), "Do not replace a truncated identity"); + Require(ReadBytes(path) == damaged, "Damaged file must remain untouched"); + + const auto fresh = root / "fresh"; + Require(Ensure(fresh, error, 1800000123), "Missing setting.txt must initialize"); + const auto generated = Read(fresh); + Require(generated && HasIdentity(*generated), "Generated file must be readable"); + Require(generated->at("SERNO") == "800000123", "Persist Dolphin-generated serial"); + Require(generated->at("CODE") == "LEH" && generated->at("AREA") == "EUR" && + generated->at("GAME") == "EU", "PAL first-boot fields"); + Require(generated->at("MODEL") == "RVL-001(EUR)" && generated->at("VIDEO") == "PAL" && + generated->at("DVD") == "0" && generated->at("MPCH") == "0x7FFE", + "Complete Dolphin boot settings"); + const auto firstBoot = ReadBytes(FilePath(fresh)); + Require(firstBoot.size() == 256 && firstBoot.back() == 0, "Dolphin buffer size and raw zero padding"); + Require(Ensure(fresh, error, 1900000999), "Second boot"); + Require(ReadBytes(FilePath(fresh)) == firstBoot, "Second boot must not change any bytes"); + + const auto blocked = root / "blocked"; + { std::ofstream output(blocked); output << "file obstructing NAND directory"; } + Require(!Ensure(blocked, error, 1800000123), "Write failure must not return an ephemeral identity"); + Require(!Ensure(root / "bad-clock", error, -1), "Clock failure must not initialize"); + + const auto concurrent = root / "concurrent"; + std::array results{}; + std::vector workers; + for (size_t i = 0; i < results.size(); ++i) { + workers.emplace_back([&, i] { + std::string detail; + results[i] = Ensure(concurrent, detail, 1800000001 + i); + }); + } + for (auto& worker : workers) worker.join(); + for (const bool result : results) Require(result, "Concurrent boot must read the persisted winner"); + const auto winner = ReadBytes(FilePath(concurrent)); + Require(Read(concurrent) && HasIdentity(*Read(concurrent)), "Concurrent boot must persist valid settings"); + Require(Ensure(concurrent, error, 1900000999), "Boot after concurrent initialization"); + Require(ReadBytes(FilePath(concurrent)) == winner, "Concurrent winner must remain stable"); + + // Independently decode as Nintendo does: stop at the first encoded NUL. + // Exercise serials that force Dolphin's extra-LF escaping, not only + // values that happen to work with a plain rotating-XOR encoder. + bool sawExtraLf = false; + for (int serial = 1; serial <= 10000; ++serial) { + const auto number = GenerateSerial(1000000000 + serial); + const auto encoded = EncodeNew(number); + Require(encoded.has_value(), "Serial encoding must fit"); + std::string decoded; + for (size_t i = 0; i < encoded->size() && (*encoded)[i] != 0; ++i) { + const unsigned shift = i % 32; + const uint32_t key = shift == 0 ? 0x73B5DBFAu : + (0x73B5DBFAu << shift) | (0x73B5DBFAu >> (32 - shift)); + decoded += static_cast((*encoded)[i] ^ static_cast(key)); + } + Require(decoded.find("SERNO=" + number + "\r\n") != std::string::npos && + decoded.find("GAME=EU\r\n") != std::string::npos, + "Encoded NUL must not truncate settings"); + sawExtraLf |= decoded.find("\r\n\n") != std::string::npos; + } + Require(sawExtraLf, "Exercise Dolphin LF escape path"); + std::filesystem::remove_all(root); + std::cout << "NAND settings checks passed\n"; + return 0; + } catch (const std::exception& error) { + std::filesystem::remove_all(root); + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/runtime/tests/sc_serial_tests.cpp b/runtime/tests/sc_serial_tests.cpp new file mode 100644 index 0000000..c63257f --- /dev/null +++ b/runtime/tests/sc_serial_tests.cpp @@ -0,0 +1,81 @@ +#include "sc_serial_contract.h" +#include "nand_settings.h" + +#include +#include +#include +#include +#include +#include + +static void Require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +static uint32_t ReadWord(const unsigned char* bytes) { + return (uint32_t(bytes[0]) << 24) | (uint32_t(bytes[1]) << 16) | + (uint32_t(bytes[2]) << 8) | uint32_t(bytes[3]); +} + +int main(int argc, char** argv) { + try { + // Feed real generator + SC ABI outputs to the upstream bot decoder. + // Usage: mkw_sc_serial_tests --timestamp-vectors ... + if (argc > 1 && std::string(argv[1]) == "--timestamp-vectors") { + for (int i = 2; i < argc; ++i) { + const auto timestamp = std::stoll(argv[i]); + const auto serial = RuntimeNandSettings::GenerateSerial(static_cast(timestamp)); + std::array output{}; + Require(RuntimeScSerial::Write(serial, 4, + [](uint32_t address, size_t size) { return address == 4 && size == 4; }, + [&](uint32_t, uint32_t value) { + for (unsigned j = 0; j < 4; ++j) + output[j] = static_cast(value >> (24 - 8 * j)); + }) == 1, "Generated serial must pass SC ABI"); + std::cout << timestamp << '\t' << serial << "\tLEH" + << std::setfill('0') << std::setw(9) << ReadWord(output.data()) << '\n'; + } + return 0; + } + // Reproduce the reported csnums from the old string-writing override. + const unsigned char old7886[] = {'7', '8', '8', '6'}; + const unsigned char old7618[] = {'7', '6', '1', '8'}; + Require(ReadWord(old7886) == 926431286, "Reproduce shared LEH926431286"); + Require(ReadWord(old7618) == 926298424, "Reproduce shared LEH926298424"); + + std::array memory; + size_t available = 4; + unsigned writes = 0; + const auto contains = [&](uint32_t address, size_t size) { + return address == 4 && size <= available; + }; + const auto write32 = [&](uint32_t address, uint32_t value) { + ++writes; + for (unsigned i = 0; i < 4; ++i) + memory[address + i] = static_cast(value >> (24 - 8 * i)); + }; + for (const auto& pair : {std::pair{"788600001", 788600001u}, {"788699999", 788699999u}, + {"761800001", 761800001u}, {"761899999", 761899999u}, + {"012345678", 12345678u}, {"000000001", 1u}, {"999999999", 999999999u}}) { + memory.fill(0xa5); + writes = 0; + Require(RuntimeScSerial::Write(pair.first, 4, contains, write32) == 1, "Accept an exactly four-byte output buffer"); + Require(writes == 1 && ReadWord(memory.data() + 4) == pair.second, "Return full numeric serial, including digits after common prefix"); + for (size_t i = 0; i < memory.size(); ++i) + if (i < 4 || i >= 8) Require(memory[i] == 0xa5, "Do not overwrite adjacent guest stack data"); + } + for (const char* serial : {"", "1234567890", "7886x1234", "-12345678", "+12345678"}) { + writes = 0; + Require(RuntimeScSerial::Write(serial, 4, contains, write32) == 0 && writes == 0, "Reject malformed serial without a write"); + } + writes = 0; + Require(RuntimeScSerial::Write("788600001", 0, contains, write32) == 0 && writes == 0, "Reject null output"); + available = 3; + Require(RuntimeScSerial::Write("788600001", 4, contains, write32) == 0 && writes == 0, "Reject undersized output"); + std::cout << "SC serial collision reproduction, numeric output and memory-boundary tests passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/runtime/tests/test_expr.cpp b/runtime/tests/test_expr.cpp index c770a3c..8c593a4 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)"); diff --git a/translator/src/Translator.Core/CodeGen/CxxLinearCodeGenerator.cs b/translator/src/Translator.Core/CodeGen/CxxLinearCodeGenerator.cs index 17b7fe4..5c20a57 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; @@ -378,7 +378,7 @@ public sealed partial class CxxLinearCodeGenerator 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 6fbb0c2..44367ad 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 class EmittedOutputShapeTests 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); + } } +