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); + } } +