diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ad2796d..f258e14 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,11 +18,11 @@ jobs: name: Translator (build + test) runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: persist-credentials: false - - uses: actions/setup-dotnet@v4 + - uses: actions/setup-dotnet@v6 with: dotnet-version: '8.0.x' diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 0000000..6df88da --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,158 @@ +name: Package installers + +# Builds the per-platform installer/setup tool (WiiCompiled-Setup.exe / +# WiiCompiled-Setup-x86_64.AppImage) via Launcher/Build-Installer.ps1 and +# Launcher/build-appimage.sh respectively - the same scripts a maintainer runs by hand today to +# produce a GitHub Release asset. This does NOT build the actual translated game executable: +# that step requires the end user's own Mario Kart Wii dump (Assets/main.dol, Assets/StaticR.rel), +# which is proprietary and not present in this repository or in CI. +on: + push: + tags: + - '*' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: package-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + linux-appimage: + name: Linux (AppImage, ${{ matrix.arch }}) + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-22.04 + arch: x86_64 + - runner: ubuntu-22.04-arm + arch: aarch64 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '8.0.x' + + # appimagetool ships as an AppImage itself and needs to self-mount via FUSE to run directly; + # GitHub's ubuntu-latest runners don't have libfuse2 preinstalled. + # patchelf: prepare-portable-tools.sh bundles the host libraries the llvm.org binaries link + # against and rewrites their RPATH to find them. + - name: Install dependencies (required by appimagetool, SDL3 build and toolchain bundling) + run: | + sudo apt-get update + sudo apt-get install -y libfuse2 patchelf build-essential git make \ + pkg-config cmake ninja-build gnome-desktop-testing libasound2-dev libpulse-dev \ + libaudio-dev libfribidi-dev libjack-dev libsndio-dev libx11-dev libxext-dev \ + libxrandr-dev libxcursor-dev libxfixes-dev libxi-dev libxss-dev libxtst-dev \ + libxkbcommon-dev libdrm-dev libgbm-dev libgl1-mesa-dev libgles2-mesa-dev \ + libegl1-mesa-dev libdbus-1-dev libibus-1.0-dev libudev-dev libthai-dev libusb-1.0-0-dev \ + libpipewire-0.3-dev libwayland-dev libdecor-0-dev liburing-dev + + - name: Build AppImage + run: bash Launcher/build-appimage.sh + + - uses: actions/upload-artifact@v7 + with: + name: WiiCompiled-Setup-linux-${{ matrix.arch }} + path: Launcher/dist/WiiCompiled-Setup-${{ matrix.arch }}.AppImage + if-no-files-found: error + archive: false + + windows-installer: + name: Windows (installer exe) + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '8.0.x' + + - name: Build installer + shell: pwsh + run: ./Launcher/Build-Installer.ps1 + + - uses: actions/upload-artifact@v7 + with: + name: WiiCompiled-Setup-windows-x64 + path: Launcher/dist/WiiCompiled-Setup.exe + if-no-files-found: error + archive: false + + # Publishes the packaged installers as a GitHub Release whenever a v* tag is pushed. Wheel Wizard + # discovers updates from these releases, so the contract it relies on is enforced here: a full + # (non-prerelease) release whose tag is v, carrying an asset named exactly + # WiiCompiled-Setup.exe, produced by a setup host that reports that same version. + release: + name: Publish GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + needs: [linux-appimage, windows-installer] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + # Wheel Wizard runs the downloaded setup with --version and refuses it when the reported + # version differs from the release tag, so a tag that was pushed without bumping every pinned + # version would ship an update nobody can install. Catch that before anything is published. + - name: Verify the tag matches the pinned setup version + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + version="${TAG#v}" + status=0 + check() { + if ! grep -Fq "$2" "$1"; then + echo "::error file=$1::expected '$2' for tag $TAG" + status=1 + fi + } + check Launcher/WiiCompiled.Setup.Windows/Program.cs "public const string Version = \"$version\";" + check Launcher/WiiCompiled.Setup.Windows/WiiCompiled.Setup.Windows.csproj "$version" + check Launcher/Build-Installer.ps1 "ProductVersion = '$version'" + exit $status + + # The build jobs upload with `archive: false`, which stores each installer as a raw file + # rather than a zip (and names the artifact after the file). Only download-artifact v8 and + # later understand such direct uploads; v7 tries to unzip everything and fails on them. + - uses: actions/download-artifact@v8 + with: + path: artifacts + merge-multiple: true + + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + ls -lR artifacts + assets=() + for name in WiiCompiled-Setup.exe WiiCompiled-Setup-x86_64.AppImage WiiCompiled-Setup-aarch64.AppImage; do + found="$(find artifacts -type f -name "$name" | head -n 1)" + [ -n "$found" ] && [ -s "$found" ] || { echo "::error::missing release asset $name"; exit 1; } + assets+=("$found") + done + # A re-run of a tag whose release already exists only refreshes the assets. + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release upload "$TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber + else + gh release create "$TAG" "${assets[@]}" \ + --repo "$GITHUB_REPOSITORY" \ + --title "$TAG" \ + --verify-tag \ + --generate-notes + fi diff --git a/.gitignore b/.gitignore index 10356c5..a7bb2a2 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ Code.pul # Build output /build/ /build-*/ +/native-build/ /dist/ /out/ [Bb]in/ diff --git a/Launcher/Build-Installer.ps1 b/Launcher/Build-Installer.ps1 index f547d4c..112018c 100644 --- a/Launcher/Build-Installer.ps1 +++ b/Launcher/Build-Installer.ps1 @@ -88,17 +88,23 @@ Assert-File $setupProject '.NET setup project' Assert-File $translatorProject 'Translator CLI project' if (-not (Test-Path -LiteralPath (Join-Path $portableTools 'llvm-mingw\bin\x86_64-w64-mingw32-clang++.exe'))) { + # A PowerShell script, not a native executable - it never touches $LASTEXITCODE, and its own + # $ErrorActionPreference = 'Stop' + throw already aborts this run on failure. & (Join-Path $PSScriptRoot 'Prepare-PortableTools.ps1') -Destination $portableTools - if ($LASTEXITCODE -ne 0) { throw 'Portable tool preparation failed.' } } Assert-File (Join-Path $portableTools 'CMake\bin\cmake.exe') 'Portable CMake' Assert-File (Join-Path $portableTools 'Ninja\ninja.exe') 'Portable Ninja' -Assert-Directory $dependencySources 'Pinned offline dependency sources' # native_prebuilt carries the aurora/third-party archives the user no longer has # to compile (launcher/Prepare-NativePrebuilt.ps1). # Kept in step with InstalledLayout.DependencyNames by Test-PinnedFacts.ps1: the installed host # refuses to call a toolkit complete unless every one of these directories is present. $requiredDependencies = @('abseil-cpp','cppwinrt','dawn_prebuilt','fmt','freetype','imgui','libusb','native_prebuilt','openxr','png','SDL','sqlite3','tracy','xxhash','zlib','zstd') +$missingSources = @($requiredDependencies | Where-Object { $_ -ne 'native_prebuilt' } | + Where-Object { -not (Test-Path -LiteralPath (Join-Path $dependencySources $_) -PathType Container) }) +if ($missingSources.Count -gt 0) { + & (Join-Path $PSScriptRoot 'Prepare-Dependencies.ps1') -Destination $dependencySources +} +Assert-Directory $dependencySources 'Pinned offline dependency sources' # The precompiled archives are only interchangeable with what the user's machine # compiles if both came from this toolchain and this flag set, so a stale package @@ -165,8 +171,7 @@ Assert-File $translator 'Self-contained translator' # Resolved via the shared WiiCompiled.Setup.Common.Cli helper (also used by build-appimage.sh on # Linux) rather than a separate download/version-pin copy here: it downloads and caches the same way -# NodToolProvider.cs always does (Launcher/artifacts/nodtool.exe), replacing the old manual -# -DolphinToolPath handoff with an automated, pinned acquisition step. +# NodToolProvider.cs always does (Launcher/artifacts/nodtool.exe) $nodToolCliProject = Join-Path $PSScriptRoot 'WiiCompiled.Setup.Common.Cli' $nodTool = (& dotnet run --project $nodToolCliProject -c Release -- --workspace $repoRoot | Select-Object -Last 1) if ($LASTEXITCODE -ne 0) { throw "nodtool resolution failed with exit code $LASTEXITCODE." } @@ -277,7 +282,7 @@ foreach ($required in @('ToolkitFingerprint','TranslationFingerprint','NativeToo $manifest = [ordered]@{ SchemaVersion = 2 - ProductVersion = '0.2.25' + ProductVersion = '0.2.26' ExpectedGameId = $pins.GameId ExpectedDolSha256 = $pins.DolSha256 ExpectedRelSha256 = $pins.RelSha256 @@ -293,11 +298,12 @@ $manifest = [ordered]@{ $manifest | ConvertTo-Json | Set-Content (Join-Path $payloadRoot 'payload-manifest.json') -Encoding UTF8 Write-Host '[4/6] Enforcing the copyright and generated-code boundary...' +# Both audits are PowerShell scripts that throw directly on failure (Test-PayloadBoundary.ps1 +# never invokes a native command at all, so it never touches $LASTEXITCODE); there is no exit +# code to check here, and doing so risks reading a stale value from an unrelated earlier command. & (Join-Path $PSScriptRoot 'Test-PayloadBoundary.ps1') -PayloadRoot $payloadRoot -if ($LASTEXITCODE -ne 0) { throw 'Payload boundary audit failed.' } & (Join-Path $PSScriptRoot 'Test-NativeDependencies.ps1') -PayloadRoot $payloadRoot -SetupHost $setupHost ` -LlvmReadobjPath (Join-Path $portableTools 'llvm-mingw\bin\llvm-readobj.exe') -if ($LASTEXITCODE -ne 0) { throw 'Native dependency audit failed.' } Write-Host '[5/6] Creating the canonical installer payload...' $payloadZip = Join-Path $workRoot 'payload.zip' diff --git a/Launcher/Prepare-Dependencies.ps1 b/Launcher/Prepare-Dependencies.ps1 new file mode 100644 index 0000000..fece1f8 --- /dev/null +++ b/Launcher/Prepare-Dependencies.ps1 @@ -0,0 +1,203 @@ +# Populates Launcher/artifacts/dependencies: the pinned, unpatched upstream source trees (plus the +# prebuilt Dawn package and generated C++/WinRT headers) that Build-Installer.ps1 ships inside the +# installer so the user's build runs with FETCHCONTENT_FULLY_DISCONNECTED=ON. Every pin is the one +# its owning CMake file declares; the Pin fields below are asserted against those files so the two +# cannot drift apart silently. native_prebuilt is not fetched here - Prepare-NativePrebuilt.ps1 +# compiles it from these trees. +[CmdletBinding()] +param( + [string]$Destination, + # Windows metadata source for cppwinrt.exe: 'local' (this machine's WinMetadata), 'sdk', or an + # installed SDK version such as 10.0.26100.0. + [string]$CppWinRtInput = 'local' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 3.0 + +$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$auroraCMake = Join-Path $repoRoot 'aurora-main\CMakeLists.txt' +$auroraExtern = Join-Path $repoRoot 'aurora-main\extern\CMakeLists.txt' +$auroraDawn = Join-Path $repoRoot 'aurora-main\cmake\AuroraDawnProvider.cmake' +$auroraLibUsb = Join-Path $repoRoot 'aurora-main\cmake\AuroraLibUSB.cmake' +$auroraSdl = Join-Path $repoRoot 'aurora-main\cmake\AuroraSDL3Provider.cmake' +$runtimeCMake = Join-Path $repoRoot 'runtime\CMakeLists.txt' + +# Name = directory under the destination; FetchContent maps it back through +# FETCHCONTENT_SOURCE_DIR_ (NativeBuildFlags.ps1), so the names are the declared +# FetchContent names, not the upstream project names. +$packages = @( + [pscustomobject]@{ + Name = 'SDL'; File = 'SDL3-3.4.4.tar.gz' + Uris = @('https://github.com/libsdl-org/SDL/releases/download/release-3.4.4/SDL3-3.4.4.tar.gz') + Pins = @(@{ File = $auroraCMake; Text = 'set(AURORA_SDL3_VERSION "3.4.4"' }, + @{ File = $auroraSdl; Text = 'releases/download/release-${AURORA_SDL3_VERSION}/SDL3-${AURORA_SDL3_VERSION}.tar.gz' }) + }, + [pscustomobject]@{ + Name = 'abseil-cpp'; File = 'abseil-cpp-20240722.0.tar.gz' + Uris = @('https://github.com/abseil/abseil-cpp/archive/refs/tags/20240722.0.tar.gz') + Pins = @(@{ File = $auroraExtern; Text = 'https://github.com/abseil/abseil-cpp/archive/refs/tags/20240722.0.tar.gz' }) + }, + [pscustomobject]@{ + Name = 'dawn_prebuilt'; File = 'dawn-v20260603.191052-windows-amd64.tar.gz' + Uris = @('https://github.com/encounter/dawn-build/releases/download/v20260603.191052/dawn-windows-amd64.tar.gz') + Pins = @(@{ File = $auroraCMake; Text = 'set(AURORA_DAWN_VERSION "v20260603.191052"' }, + @{ File = $auroraDawn; Text = 'SHA256=7785373d569b3b0237918ec9c523239f7d0667857c5ea8242e3cdfde95e6aeab' }) + }, + [pscustomobject]@{ + Name = 'fmt'; File = 'fmt-11.1.4.tar.gz' + Uris = @('https://github.com/fmtlib/fmt/archive/refs/tags/11.1.4.tar.gz') + Pins = @(@{ File = $auroraExtern; Text = 'https://github.com/fmtlib/fmt/archive/refs/tags/11.1.4.tar.gz' }) + }, + [pscustomobject]@{ + Name = 'freetype'; File = 'freetype-2.14.3.tar.gz' + Uris = @('https://files.twilitrealm.dev/freetype-2.14.3.tar.gz', + 'https://download.savannah.gnu.org/releases/freetype/freetype-2.14.3.tar.gz', + 'https://downloads.sourceforge.net/project/freetype/freetype2/2.14.3/freetype-2.14.3.tar.gz') + Pins = @(@{ File = $auroraExtern; Text = 'https://files.twilitrealm.dev/freetype-2.14.3.tar.gz' }) + }, + [pscustomobject]@{ + Name = 'imgui'; File = 'imgui-1.91.9b-docking.tar.gz' + Uris = @('https://github.com/ocornut/imgui/archive/refs/tags/v1.91.9b-docking.tar.gz') + Pins = @(@{ File = $auroraExtern; Text = 'https://github.com/ocornut/imgui/archive/refs/tags/v1.91.9b-docking.tar.gz' }) + }, + [pscustomobject]@{ + Name = 'libusb'; File = 'libusb-1.0.30.tar.bz2' + Uris = @('https://github.com/libusb/libusb/releases/download/v1.0.30/libusb-1.0.30.tar.bz2') + Pins = @(@{ File = $auroraCMake; Text = 'set(AURORA_LIBUSB_VERSION "1.0.30"' }, + @{ File = $auroraLibUsb; Text = 'releases/download/v${AURORA_LIBUSB_VERSION}/libusb-${AURORA_LIBUSB_VERSION}.tar.bz2' }) + }, + [pscustomobject]@{ + Name = 'openxr'; File = 'OpenXR-SDK-release-1.1.61.tar.gz' + Uris = @('https://github.com/KhronosGroup/OpenXR-SDK/archive/refs/tags/release-1.1.61.tar.gz') + Pins = @(@{ File = $runtimeCMake; Text = 'OpenXR-SDK/archive/refs/tags/release-1.1.61.tar.gz' }, + @{ File = $runtimeCMake; Text = 'SHA256=d5e2773d282642e6e250bd10266f316fc3ff3b8602c83db009a1f434205998e6' }) + }, + [pscustomobject]@{ + Name = 'png'; File = 'libpng-1.6.58.tar.gz' + Uris = @('https://github.com/pnggroup/libpng/archive/refs/tags/v1.6.58.tar.gz') + Pins = @(@{ File = $auroraExtern; Text = 'https://github.com/pnggroup/libpng/archive/refs/tags/v1.6.58.tar.gz' }) + }, + [pscustomobject]@{ + Name = 'sqlite3'; File = 'sqlite-amalgamation-3510300.zip' + Uris = @('https://sqlite.org/2026/sqlite-amalgamation-3510300.zip') + Pins = @(@{ File = $auroraExtern; Text = 'https://sqlite.org/2026/sqlite-amalgamation-3510300.zip' }) + }, + [pscustomobject]@{ + Name = 'tracy'; File = 'tracy-a64b9a20294d59421a2f57aeca3c6383d8c48169.tar.gz' + Uris = @('https://github.com/wolfpld/tracy/archive/a64b9a20294d59421a2f57aeca3c6383d8c48169.tar.gz') + Pins = @(@{ File = $auroraExtern; Text = 'https://github.com/wolfpld/tracy/archive/a64b9a20294d59421a2f57aeca3c6383d8c48169.tar.gz' }) + }, + [pscustomobject]@{ + Name = 'xxhash'; File = 'xxHash-0.8.3.tar.gz' + Uris = @('https://github.com/Cyan4973/xxHash/archive/refs/tags/v0.8.3.tar.gz') + Pins = @(@{ File = $auroraExtern; Text = 'https://github.com/Cyan4973/xxHash/archive/refs/tags/v0.8.3.tar.gz' }) + }, + [pscustomobject]@{ + Name = 'zlib'; File = 'zlib-1.3.2.tar.gz' + Uris = @('https://github.com/madler/zlib/releases/download/v1.3.2/zlib-1.3.2.tar.gz') + Pins = @(@{ File = $auroraExtern; Text = 'https://github.com/madler/zlib/releases/download/v1.3.2/zlib-1.3.2.tar.gz' }) + }, + [pscustomobject]@{ + Name = 'zstd'; File = 'zstd-1.5.7.tar.gz' + Uris = @('https://github.com/facebook/zstd/releases/download/v1.5.7/zstd-1.5.7.tar.gz') + Pins = @(@{ File = $auroraExtern; Text = 'https://github.com/facebook/zstd/releases/download/v1.5.7/zstd-1.5.7.tar.gz' }) + } +) + +# The C++/WinRT compiler (NuGet package = zip) that generates the projection headers. +$cppWinRtTool = [pscustomobject]@{ + Name = 'cppwinrt-tool'; File = 'Microsoft.Windows.CppWinRT.3.0.260818.1.nupkg' + Uris = @('https://www.nuget.org/api/v2/package/Microsoft.Windows.CppWinRT/3.0.260818.1') + Pins = @() +} + +if (-not $Destination) { $Destination = Join-Path $PSScriptRoot 'artifacts\dependencies' } +$Destination = [IO.Path]::GetFullPath($Destination) +$downloads = Join-Path $PSScriptRoot 'artifacts\downloads' +$tar = Join-Path $env:SystemRoot 'System32\tar.exe' +if (-not (Test-Path -LiteralPath $tar -PathType Leaf)) { throw "Windows archive tool is missing: $tar" } +[IO.Directory]::CreateDirectory($Destination) | Out-Null +[IO.Directory]::CreateDirectory($downloads) | Out-Null + +function Assert-Pinned($Package) { + foreach ($pin in $Package.Pins) { + if (-not (Test-Path -LiteralPath $pin.File -PathType Leaf)) { throw "Pin source is missing: $($pin.File)" } + $content = [IO.File]::ReadAllText($pin.File) + if (-not $content.Contains($pin.Text)) { + throw "$($Package.Name) is pinned to '$($pin.Text)' here but $($pin.File) no longer declares it; update both." + } + } +} + +function Get-Archive($Package) { + $archive = Join-Path $downloads $Package.File + if (Test-Path -LiteralPath $archive -PathType Leaf) { return $archive } + $temporary = $archive + '.partial' + $failures = @() + foreach ($uri in $Package.Uris) { + Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue + Write-Host "Downloading $($Package.Name) from $uri..." + try { + Invoke-WebRequest -UseBasicParsing -Uri $uri -OutFile $temporary + } catch { + $failures += "$uri ($($_.Exception.Message))" + continue + } + Move-Item -LiteralPath $temporary -Destination $archive -Force + return $archive + } + Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue + throw "$($Package.Name) could not be fetched: $($failures -join '; ')" +} + +function Expand-Package([string]$Archive, [string]$Target) { + # Release archives wrap everything in one versioned directory (SDL3-3.4.4/, tracy-/) while + # dawn-build's package is flat; either way the tree lands directly under the target. + $extract = Join-Path (Split-Path -Parent $Target) ('.extract-' + (Split-Path -Leaf $Target)) + if (Test-Path -LiteralPath $extract) { Remove-Item -LiteralPath $extract -Recurse -Force } + [IO.Directory]::CreateDirectory($extract) | Out-Null + # Symbolic links need a privilege Windows tar usually lacks; the only ones in these archives + # are zstd test aliases, and none are build inputs. + $listing = & $tar -tvf $Archive + if ($LASTEXITCODE -ne 0) { throw "Listing $Archive failed with exit code $LASTEXITCODE." } + $excludes = @($listing | ForEach-Object { if ($_ -match '^l.*\s(\S+)\s->\s') { '--exclude=' + $Matches[1] } }) + & $tar -xf $Archive -C $extract @excludes + if ($LASTEXITCODE -ne 0) { throw "Extracting $Archive failed with exit code $LASTEXITCODE." } + $entries = @(Get-ChildItem -LiteralPath $extract -Force) + $source = $extract + if ($entries.Count -eq 1 -and $entries[0].PSIsContainer) { $source = $entries[0].FullName } + if (Test-Path -LiteralPath $Target) { Remove-Item -LiteralPath $Target -Recurse -Force } + Move-Item -LiteralPath $source -Destination $Target + if (Test-Path -LiteralPath $extract) { Remove-Item -LiteralPath $extract -Recurse -Force } +} + +foreach ($package in $packages) { + Assert-Pinned $package + $target = Join-Path $Destination $package.Name + if (Test-Path -LiteralPath $target -PathType Container) { continue } + Expand-Package (Get-Archive $package) $target + Write-Host "Prepared $($package.Name)" +} + +$cppWinRt = Join-Path $Destination 'cppwinrt' +if (-not (Test-Path -LiteralPath (Join-Path $cppWinRt 'winrt\base.h') -PathType Leaf)) { + $toolRoot = Join-Path $PSScriptRoot 'artifacts\cppwinrt-tool' + $compiler = Join-Path $toolRoot 'bin\cppwinrt.exe' + if (-not (Test-Path -LiteralPath $compiler -PathType Leaf)) { + Expand-Package (Get-Archive $cppWinRtTool) $toolRoot + } + if (-not (Test-Path -LiteralPath $compiler -PathType Leaf)) { throw "cppwinrt.exe is missing: $compiler" } + if (Test-Path -LiteralPath $cppWinRt) { Remove-Item -LiteralPath $cppWinRt -Recurse -Force } + [IO.Directory]::CreateDirectory($cppWinRt) | Out-Null + Write-Host "Generating C++/WinRT headers (-input $CppWinRtInput)..." + & $compiler -input $CppWinRtInput -output $cppWinRt + if ($LASTEXITCODE -ne 0) { throw "cppwinrt.exe failed with exit code $LASTEXITCODE." } + if (-not (Test-Path -LiteralPath (Join-Path $cppWinRt 'winrt\base.h') -PathType Leaf)) { + throw "cppwinrt.exe produced no winrt\base.h under $cppWinRt" + } + Copy-Item -LiteralPath (Join-Path $toolRoot 'LICENSE') -Destination (Join-Path $cppWinRt 'LICENSE.txt') + Write-Host 'Prepared cppwinrt' +} + +Write-Host "Pinned dependency sources ready: $Destination" diff --git a/Launcher/Prepare-NativePrebuilt.ps1 b/Launcher/Prepare-NativePrebuilt.ps1 index 10a36b6..2757565 100644 --- a/Launcher/Prepare-NativePrebuilt.ps1 +++ b/Launcher/Prepare-NativePrebuilt.ps1 @@ -61,8 +61,6 @@ Assert-File $windres 'Portable resource compiler' Assert-File $clangBinary 'Portable clang driver binary' Assert-Directory $dependencies 'Pinned offline dependency sources' Assert-Directory $auroraSource 'aurora-main source tree' -Assert-File (Join-Path $repoRoot 'generated\build_shards\shards.cmake') ` - 'Translator shard manifest (run the developer build once so runtime/ can configure)' if ($Parallel -le 0) { $Parallel = [Environment]::ProcessorCount } diff --git a/Launcher/Prepare-NativePrebuilt.sh b/Launcher/Prepare-NativePrebuilt.sh new file mode 100755 index 0000000..3d4e6ed --- /dev/null +++ b/Launcher/Prepare-NativePrebuilt.sh @@ -0,0 +1,590 @@ +#!/usr/bin/env bash +# Builds the redistributable precompiled aurora + third-party package for native Linux: aurora +# (~43% of local build CPU time per Prepare-NativePrebuilt.ps1) and vendored Crypto++ are identical +# for every user under the pinned toolchain prepare-portable-tools.sh bundles, so this configures +# runtime/ against that toolchain, builds just that closure, and harvests the archives plus a +# generated CMake description into an output package - the Linux counterpart to +# Launcher/Prepare-NativePrebuilt.ps1, consumed by the same platform-agnostic +# runtime/cmake/NativePrebuilt.cmake either script's package works with unmodified. +# +# Not a byte-for-byte port of the Windows script: Linux needs no offline pinned dependency cache. +# Every FetchContent dependency in aurora-main/extern/CMakeLists.txt is a fixed-version URL already +# (verified directly), and aurora-main/CMakeLists.txt's own _default_linkage is "static" on any +# non-Windows platform. Windows pins Launcher/artifacts/dependencies because its installer ships +# pre-fetched sources to end users for a fully offline build; this harvest only ever runs on a +# maintainer's own machine (network access needed once, here, not shipped to anyone), so a plain +# networked configure is exactly as reproducible - what actually makes the harvested archives safe +# to link into any consumer's own build is the pinned compiler below, not an offline source cache. +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +workspace=$(cd "$script_dir/.." && pwd) + +arch="" +output_dir="" +stage_dir="$workspace/build/native-prebuilt-stage" +keep_stage=0 +reuse_stage=0 +parallel=0 +print_fingerprint_only=0 + +usage() { + cat <<'EOF' +Usage: Prepare-NativePrebuilt.sh --arch {x86_64|aarch64} [options] + + --arch ARCH Target architecture; selects the matching bundled toolchain (required) + --output-dir DIR Where the package is written (default: Launcher/artifacts/native-prebuilt-ARCH) + --stage-dir DIR Staging build directory (default: build/native-prebuilt-stage) + --keep-stage Do not delete the staging build directory afterward + --reuse-stage Reuse an existing staging build directory (maintainer iteration aid: a + re-harvest does not recompile aurora from scratch) + --parallel N Ninja build parallelism (default: nproc) + --print-fingerprint-only Print the four provenance inputs (compiler_sha256, flag_fingerprint, + aurora_fingerprint, third_party_fingerprint) as "key=value" lines and + exit, without configuring/building/harvesting anything - lets a caller + (build-appimage.sh) decide whether an existing package is still current + without paying for a full aurora rebuild just to find out. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --arch) arch=$2; shift 2 ;; + --output-dir) output_dir=$2; shift 2 ;; + --stage-dir) stage_dir=$2; shift 2 ;; + --keep-stage) keep_stage=1; shift ;; + --reuse-stage) reuse_stage=1; shift ;; + --parallel) parallel=$2; shift 2 ;; + --print-fingerprint-only) print_fingerprint_only=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Prepare-NativePrebuilt.sh: unknown argument: $1" >&2; exit 1 ;; + esac +done + +case "$arch" in + x86_64|aarch64) ;; + *) echo "Prepare-NativePrebuilt.sh: --arch must be x86_64 or aarch64" >&2; usage; exit 1 ;; +esac + +fail() { echo "Prepare-NativePrebuilt.sh: error: $*" >&2; exit 1; } +assert_file() { [[ -f "$1" ]] || fail "$2 is missing: $1"; } +assert_dir() { [[ -d "$1" ]] || fail "$2 is missing: $1"; } +sha256_of() { sha256sum "$1" | awk '{print $1}'; } +normalize() { readlink -f "$1"; } + +[[ -n "$output_dir" ]] || output_dir="$script_dir/artifacts/native-prebuilt-$arch" +[[ "$stage_dir" = /* ]] || stage_dir="$workspace/$stage_dir" + +toolchain_dir="$script_dir/artifacts/portable-tools/toolchain-$arch" +cc="$toolchain_dir/bin/clang" +cxx="$toolchain_dir/bin/clang++" +cmake_bin="$toolchain_dir/bin/cmake" +ninja_bin="$toolchain_dir/bin/ninja" +runtime_source="$workspace/runtime" +aurora_source="$workspace/aurora-main" + +assert_file "$cmake_bin" "Portable CMake (run prepare-portable-tools.sh --arch $arch first)" +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-22") +assert_file "$clang_binary" "Portable clang driver binary" + +(( parallel > 0 )) || parallel=$(nproc) + +fingerprint_tree() { + # $1 = root dir, remaining args = top-level subdirectory names to exclude + local root=$1; shift + root=$(normalize "$root") + [[ -d "$root" ]] || return 0 + local find_args=("$root") + local ex + for ex in "$@"; do find_args+=(-path "$root/$ex" -prune -o); done + find_args+=(-type f -print) + find "${find_args[@]}" | LC_ALL=C sort | while IFS= read -r abs; do + printf '%s %s\n' "${abs#$root/}" "$(sha256_of "$abs")" + done | sha256sum | awk '{print $1}' +} + +# The fixed (path-independent) half of the configure command line - identical for every +# harvest, which is what lets one package be valid regardless of where it was built. +# -DBUILD_SHARED_LIBS=OFF: aurora-main/extern/CMakeLists.txt derives its own _USE_SHARED from +# whether BUILD_SHARED_LIBS is *defined at all* (not its value), so leaving it unset would default +# zlib/libpng to shared. -DAURORA_SDL3_PROVIDER=vendor: without it, "auto" resolves to "system" on +# any machine that happens to have an SDL3 dev package installed. -DCMAKE_DISABLE_FIND_PACKAGE_*: +# absl/PNG/Freetype each call find_package() unconditionally, with no provider flag to gate them +# (unlike SDL3/Dawn) - verified directly that this machine's system libpng-dev/freetype-dev get +# linked in shared instead of the pinned vendored source otherwise. Disabling find_package for +# these three forces the same FetchContent-vendored, statically-built result regardless of what a +# given maintainer's machine happens to have installed. ZLIB is deliberately NOT disabled the same +# way: libpng's own vendored CMakeLists.txt calls find_package(ZLIB REQUIRED) internally even when +# zlib came from aurora's own FetchContent (extern/CMakeLists.txt writes a redirect config for +# exactly this), and CMAKE_DISABLE_FIND_PACKAGE_ZLIB errors out on any REQUIRED call site outright +# (verified directly) - it cannot be scoped to only aurora's own initial, non-required check. +# Dawn stays a prebuilt package regardless (Linux x86_64/aarch64 always auto-resolve to "package" - +# see AuroraDawnProvider.cmake). +fixed_configure_flags=( + -DCMAKE_BUILD_TYPE=Release + -DBUILD_SHARED_LIBS=OFF + -DAURORA_SDL3_PROVIDER=vendor + -DCMAKE_DISABLE_FIND_PACKAGE_absl=ON + -DCMAKE_DISABLE_FIND_PACKAGE_PNG=ON + -DCMAKE_DISABLE_FIND_PACKAGE_Freetype=ON + # Freetype's own vendored CMakeLists.txt separately probes for system BZip2 (optional + # bzip2-compressed-font support aurora-main never asked for) regardless of the Freetype + # find_package disable above, since that only stops aurora's own outer find_package(Freetype) + # from picking up a system Freetype - it does not reach into the FetchContent-built copy's own + # internal find_package(BZip2) call. FT_DISABLE_BZIP2 is Freetype's own documented flag for + # exactly this (verified in its CMakeLists.txt), unlike the ZLIB/libpng situation where no such + # source-level flag exists. + -DFT_DISABLE_BZIP2=ON + -DCMAKE_POLICY_DEFAULT_CMP0168=NEW +) +flag_fingerprint=$(printf '%s\n' "${fixed_configure_flags[@]}" | sha256sum | awk '{print $1}') + +# extern/ is excluded because the payload ships that tree separately (aurora-main/extern is bundled +# whole by build-appimage.sh); build/ is a plain developer build directory. +aurora_fingerprint=$(fingerprint_tree "$aurora_source" extern build) +[[ -n "$aurora_fingerprint" ]] || fail "The aurora source tree could not be fingerprinted: $aurora_source" + +# The harvested Crypto++ archive is consumed against this tree's headers, so it is fingerprinted +# for the same reason as aurora above. No exclusions: unlike aurora's extern/, nothing under +# runtime/third_party is shipped separately. +third_party_fingerprint=$(fingerprint_tree "$runtime_source/third_party") +[[ -n "$third_party_fingerprint" ]] || fail "The vendored third-party tree could not be fingerprinted: $runtime_source/third_party" + +compiler_sha256=$(sha256_of "$clang_binary") + +if [[ "$print_fingerprint_only" -eq 1 ]]; then + printf 'compiler_sha256=%s\n' "$compiler_sha256" + printf 'flag_fingerprint=%s\n' "$flag_fingerprint" + printf 'aurora_fingerprint=%s\n' "$aurora_fingerprint" + printf 'third_party_fingerprint=%s\n' "$third_party_fingerprint" + exit 0 +fi + +# The package must never contain a stale mixture of two builds. +rm -rf "$output_dir" +mkdir -p "$output_dir/lib" "$output_dir/bin" "$output_dir/include" + +# The staging build has to be configured without the package present, otherwise the runtime would +# consume the very package this script is producing. +if [[ "$reuse_stage" -eq 0 ]]; then rm -rf "$stage_dir"; fi +export_dir="$stage_dir/export" +mkdir -p "$export_dir" + +# fixed_configure_flags/flag_fingerprint were already computed above (needed before the +# --print-fingerprint-only early exit). +echo "Prepare-NativePrebuilt.sh: configuring the aurora/third-party staging build..." +"$cmake_bin" -S "$runtime_source" -B "$stage_dir" -G Ninja \ + "${fixed_configure_flags[@]}" \ + -DCMAKE_C_COMPILER="$cc" -DCMAKE_CXX_COMPILER="$cxx" \ + -DCMAKE_MAKE_PROGRAM="$ninja_bin" \ + -DCMAKE_EXE_LINKER_FLAGS="-fuse-ld=lld" \ + -DMKW_NATIVE_PREBUILT_EXPORT_DIR="$export_dir" + +# mkw_np_probe links exactly what mkw_runtime_common and the public products link, so building it +# compiles the whole redistributable closure and nothing else - and a successful link proves the +# harvested archives are complete. +echo "Prepare-NativePrebuilt.sh: building the aurora/third-party closure..." +"$cmake_bin" --build "$stage_dir" --target mkw_np_probe --parallel "$parallel" + +# --------------------------------------------------------------------------- +# Read the description CMake wrote, plus the resolved command lines Ninja holds. +# --------------------------------------------------------------------------- + +meta_path="$export_dir/meta.txt" +assert_file "$meta_path" "Native prebuilt export metadata" +get_meta() { awk -F= -v k="$1" '$0 ~ "^" k "=" { sub(/^[^=]*=/, ""); print; exit }' "$meta_path"; } + +build_ninja="$stage_dir/build.ninja" +assert_file "$build_ninja" "Generated build.ninja" + +extract_ninja_vars() { + # $1 = ninja file, $2 = exact rule name. Prints "KEY\tVALUE" for every " KEY = VALUE" line + # following the first "build ...: RULE ..." statement that uses that rule. + awk -v rule="$2" ' + BEGIN { in_stmt = 0 } + /^build / { + if (in_stmt) exit + colon = index($0, ": ") + if (colon == 0) next + rest = substr($0, colon + 2) + sp = index(rest, " ") + stmt_rule = (sp > 0) ? substr(rest, 1, sp - 1) : rest + if (stmt_rule == rule) in_stmt = 1 + next + } + in_stmt { + if (substr($0, 1, 2) != " ") exit + line = substr($0, 3) + eq = index(line, " = ") + if (eq == 0) exit + print substr(line, 1, eq - 1) "\t" substr(line, eq + 3) + } + ' "$1" +} + +declare -A probe_compile=() control_compile=() probe_link=() control_link=() +load_vars() { + local -n dest=$1 + local k v + while IFS=$'\t' read -r k v; do dest["$k"]=$v; done < <(extract_ninja_vars "$build_ninja" "$2") +} +load_vars probe_compile "CXX_COMPILER__mkw_np_probe_unscanned_Release" +load_vars control_compile "CXX_COMPILER__mkw_np_probe_control_unscanned_Release" +load_vars probe_link "CXX_EXECUTABLE_LINKER__mkw_np_probe_Release" +load_vars control_link "CXX_EXECUTABLE_LINKER__mkw_np_probe_control_Release" +[[ -n "${probe_compile[INCLUDES]+x}" ]] || fail "No Ninja statement used rule CXX_COMPILER__mkw_np_probe_unscanned_Release." +[[ -n "${probe_link[LINK_LIBRARIES]+x}" ]] || fail "No Ninja statement used rule CXX_EXECUTABLE_LINKER__mkw_np_probe_Release." + +split_list() { + # $1 = string value, $2 = name of the array to fill (nameref) + local -n out=$2 + out=() + [[ -n "$1" ]] || return 0 + read -ra out <<< "$1" +} + +subtract_multiset() { + # $1 = "all" array name, $2 = "remove" array name, $3 = result array name (all namerefs) + local -n all_ref=$1 remove_ref=$2 result_ref=$3 + local -A pending=() + local item + for item in "${remove_ref[@]}"; do + pending["$item"]=$(( ${pending["$item"]:-0} + 1 )) + done + result_ref=() + for item in "${all_ref[@]}"; do + if [[ "${pending["$item"]:-0}" -gt 0 ]]; then + pending["$item"]=$(( pending["$item"] - 1 )) + continue + fi + result_ref+=("$item") + done +} + +split_list "${probe_compile[INCLUDES]:-}" _probe_includes +split_list "${control_compile[INCLUDES]:-}" _control_includes +subtract_multiset _probe_includes _control_includes include_arguments + +split_list "${probe_compile[DEFINES]:-}" _probe_defines +split_list "${control_compile[DEFINES]:-}" _control_defines +subtract_multiset _probe_defines _control_defines define_arguments + +split_list "${probe_compile[FLAGS]:-}" _probe_flags +split_list "${control_compile[FLAGS]:-}" _control_flags +subtract_multiset _probe_flags _control_flags compile_options_raw + +split_list "${probe_link[LINK_LIBRARIES]:-}" _probe_link +split_list "${control_link[LINK_LIBRARIES]:-}" _control_link +subtract_multiset _probe_link _control_link link_items + +[[ ${#include_arguments[@]} -gt 0 ]] || fail "The aurora compile interface is empty; the probe did not link aurora." +[[ ${#link_items[@]} -gt 0 ]] || fail "The aurora link interface is empty; the probe did not link aurora." + +# --------------------------------------------------------------------------- +# Harvest the built libraries. +# --------------------------------------------------------------------------- + +binary_root=$(normalize "$stage_dir") +aurora_root=$(normalize "$(get_meta aurora_dir)") +runtime_root=$(normalize "$(get_meta runtime_dir)") + +generated_include_index=0 +copy_harvested() { + # $1 = source path, $2 = subdirectory under the package. Echoes "subdir/name". + local source=$1 subdir=$2 + local name destination + name=$(basename "$source") + destination="$output_dir/$subdir/$name" + [[ ! -e "$destination" ]] || fail "Two harvested files collide on the name $name: $source" + cp "$source" "$destination" + printf '%s/%s' "$subdir" "$name" +} +declare -A generated_include_tokens=() +convert_to_token() { + # $1 = absolute path, $2 = 'include' or another kind. Sets $TOKEN_RESULT - NOT echoed/command- + # substituted: this needs to mutate generated_include_tokens/generated_include_index, and a + # $(...) call runs in a subshell, silently discarding that mutation once it returns (verified + # directly: every build-tree include directory collided into the same "generated01" until this + # was written this way instead). + local path kind=$2 + path=$(normalize "$1") + case "$path" in + "$aurora_root"/*) TOKEN_RESULT="@AURORA@/${path#$aurora_root/}"; return ;; + "$runtime_root"/*) TOKEN_RESULT="@RUNTIME@/${path#$runtime_root/}"; return ;; + "$binary_root"/*) + [[ "$kind" == include ]] || fail "Unhandled build-tree artifact: $path" + if [[ -n "${generated_include_tokens[$path]:-}" ]]; then + TOKEN_RESULT=${generated_include_tokens[$path]} + return + fi + generated_include_index=$((generated_include_index + 1)) + local name destination + name=$(printf 'generated%02d' "$generated_include_index") + destination="$output_dir/include/$name" + mkdir -p "$destination" + cp -a "$path/." "$destination/" + TOKEN_RESULT="@PKG@/include/$name" + generated_include_tokens[$path]=$TOKEN_RESULT + return ;; + *) fail "Path escapes every shippable root ($kind): $path" ;; + esac +} + +declare -A linker_file_to_reference=() +shared_import_targets=() shared_import_runtime=() shared_import_implib=() +targets_txt="$export_dir/targets.txt" +assert_file "$targets_txt" "Native prebuilt export targets list" +while IFS='|' read -r name type file linkerfile; do + [[ -n "$name" ]] || continue + [[ -f "$linkerfile" ]] || continue # not part of the closure mkw_np_probe pulled in + linkerfile=$(normalize "$linkerfile") + relative_linker=$(copy_harvested "$linkerfile" lib) + if [[ "$type" == "SHARED_LIBRARY" ]]; then + file_abs=$(normalize "$file") + # Unlike Windows (a .dll + a separate .dll.a import library), a Linux shared object's + # TARGET_FILE and TARGET_LINKER_FILE are the same path - one file, harvested once. + if [[ "$file_abs" == "$linkerfile" ]]; then + relative_runtime=$relative_linker + else + [[ -f "$file_abs" ]] || fail "Shared library $name has no runtime file: $file_abs" + relative_runtime=$(copy_harvested "$file_abs" bin) + fi + shared_import_targets+=("mkw_np::$name") + shared_import_runtime+=("$relative_runtime") + shared_import_implib+=("$relative_linker") + linker_file_to_reference["$linkerfile"]="mkw_np::$name" + else + linker_file_to_reference["$linkerfile"]="@PKG@/$relative_linker" + fi +done < "$targets_txt" +# Unlike Windows (SDL/zlib/libpng ship as DLLs by default), everything here was forced static above +# and Dawn's own Linux package (verified directly) ships libwebgpu_dawn.a, also static - so zero +# shared imports is the expected, normal outcome, not a failure. +echo "Prepare-NativePrebuilt.sh: harvested ${#linker_file_to_reference[@]} archive(s) (${#shared_import_targets[@]} shared)" + +dawn_linker_file="" dawn_runtime_file="" +dawn_txt="$export_dir/dawn.txt" +if [[ -s "$dawn_txt" ]]; then + IFS='|' read -r _ dawn_runtime_file dawn_linker_file < "$dawn_txt" + dawn_runtime_file=$(normalize "$dawn_runtime_file") + dawn_linker_file=$(normalize "$dawn_linker_file") +fi +[[ -n "$dawn_linker_file" ]] && linker_file_to_reference["$dawn_linker_file"]="dawn::webgpu_dawn" + +# --------------------------------------------------------------------------- +# Rewrite the compile/link interface into workspace-relative tokens. +# --------------------------------------------------------------------------- + +package_include_dirs=() +include_count=${#include_arguments[@]} +i=0 +while (( i < include_count )); do + entry=${include_arguments[i]} + case "$entry" in + -I*) convert_to_token "${entry#-I}" include; package_include_dirs+=("$TOKEN_RESULT") ;; + -isystem) + i=$((i + 1)) + (( i < include_count )) || fail "Trailing -isystem with no argument" + convert_to_token "${include_arguments[i]}" include; package_include_dirs+=("$TOKEN_RESULT") ;; + -isystem*) convert_to_token "${entry#-isystem}" include; package_include_dirs+=("$TOKEN_RESULT") ;; + *) fail "Unexpected include argument: $entry" ;; + esac + i=$((i + 1)) +done + +package_definitions=() +for entry in "${define_arguments[@]}"; do + case "$entry" in + # Ninja records a quoted define's value with its quotes backslash-escaped (verified + # directly: IMGUI_USER_CONFIG=\"aurora/imgui_config.h\" in build.ninja) regardless of + # platform - format_cmake_block below re-escapes for CMake's own string syntax, so the + # literal backslash has to come out here first or the result is double-escaped and the + # define expands to literal backslash-quote characters instead of a quoted string. + -D*) package_definitions+=("${entry#-D}") ;; + *) fail "Unexpected define argument: $entry" ;; + esac +done +package_definitions=("${package_definitions[@]//\\\"/\"}") + +# fmt's `-include cstdlib` (and anything like it) is recorded from a C++ compile line, so re-scope +# it to C++. The runtime also assembles generated .S blobs through the same targets, and a forced +# C++ header include would break them. +package_compile_options=() +for entry in "${compile_options_raw[@]}"; do + package_compile_options+=("\$<\$:$entry>") +done + +package_link_items=() +for item in "${link_items[@]}"; do + case "$item" in + -*) package_link_items+=("$item"); continue ;; + esac + if [[ "$item" = /* ]]; then item_abs=$(normalize "$item"); else item_abs=$(normalize "$stage_dir/$item"); fi + ref=${linker_file_to_reference["$item_abs"]:-} + if [[ -n "$ref" ]]; then + package_link_items+=("$ref") + continue + fi + case "$item_abs" in + */libz.so*) + # The one unavoidable system reference: libpng's own vendored CMakeLists.txt calls + # find_package(ZLIB REQUIRED) internally even when zlib came from aurora's own + # FetchContent (extern/CMakeLists.txt writes a redirect config for exactly this case), + # and CMAKE_DISABLE_FIND_PACKAGE_ZLIB errors out on any REQUIRED call site outright + # (verified directly), so it cannot be forced to vendor/static the way absl/PNG/Freetype + # are above. zlib is as close to a universal baseline as a Linux shared library gets + # (glibc-adjacent - practically every distro has it already), so this is recorded as a + # portable `-lz` link flag instead of the harvesting machine's absolute path. + package_link_items+=("-lz") + continue ;; + esac + fail "Link item is neither a system library nor a harvested archive: $item" +done + +# --------------------------------------------------------------------------- +# Emit the consumer-facing CMake description. +# --------------------------------------------------------------------------- + +format_cmake_block() { + # $1 = CMake variable name, remaining args = list items + local name=$1; shift + if [[ $# -eq 0 ]]; then + printf 'set(%s "")\n' "$name" + return + fi + printf 'set(%s\n' "$name" + local item escaped + for item in "$@"; do + escaped=${item//\\/\\\\} + escaped=${escaped//\"/\\\"} + printf ' "%s"\n' "$escaped" + done + printf ')\n' +} + +dawn_config_dir_meta=$(get_meta dawn_config_dir) +dawn_config_token="" +if [[ -n "$dawn_config_dir_meta" ]]; then + dawn_config_dir_abs=$(normalize "$dawn_config_dir_meta") + case "$dawn_config_dir_abs" in + "$binary_root"/*) + # Dawn's find_package() config directory (lib/cmake/Dawn under its own fetched package + # root) cannot go through the generic single-directory copy other build-tree artifacts + # use: DawnConfig.cmake/DawnTargets.cmake derive _IMPORT_PREFIX/PACKAGE_PREFIX_DIR from + # their OWN file location by walking up three parent directories (verified directly), so + # the whole package root - include/, lib/, lib/cmake/Dawn/ together - must be copied as + # one self-contained unit for that relative navigation to still resolve once moved. + dawn_package_root=$(normalize "$dawn_config_dir_abs/../../..") + dawn_config_relative=${dawn_config_dir_abs#$dawn_package_root/} + mkdir -p "$output_dir/include/dawn_package" + cp -a "$dawn_package_root/." "$output_dir/include/dawn_package/" + dawn_config_token="@PKG@/include/dawn_package/$dawn_config_relative" + ;; + *) convert_to_token "$dawn_config_dir_abs" include; dawn_config_token=$TOKEN_RESULT ;; + esac +fi + +generated_cmake="$output_dir/native_prebuilt.cmake" +{ + echo "# Generated by Launcher/Prepare-NativePrebuilt.sh - do not edit." + echo "#" + echo "# Describes the precompiled aurora + third-party archives that replace a" + echo "# from-source aurora-main build. Every path is a token resolved against the" + echo "# consuming workspace, because the source trees still ship next to this" + echo "# package and the runtime compiles against their headers." + echo "" + echo 'if(NOT MKW_NP_PACKAGE_DIR OR NOT MKW_NP_AURORA_DIR OR NOT MKW_NP_RUNTIME_DIR OR NOT MKW_NP_DEPS_DIR)' + echo ' message(FATAL_ERROR "native_prebuilt.cmake must be included by runtime/cmake/NativePrebuilt.cmake")' + echo 'endif()' + echo "" + format_cmake_block MKW_NP_INCLUDE_DIRECTORIES "${package_include_dirs[@]}" + format_cmake_block MKW_NP_COMPILE_DEFINITIONS "${package_definitions[@]}" + format_cmake_block MKW_NP_COMPILE_OPTIONS "${package_compile_options[@]}" + format_cmake_block MKW_NP_LINK_LIBRARIES "${package_link_items[@]}" + format_cmake_block MKW_NP_AURORA_TARGETS "aurora::gx" "aurora::pad" "aurora::si" "aurora::vi" "aurora::mtx" + echo "" + printf 'set(MKW_NP_DAWN_CONFIG_DIR "%s")\n' "$dawn_config_token" + printf 'set(MKW_NP_DAWN_VERSION "%s")\n' "$(get_meta aurora_dawn_version)" + echo "" + echo "# Shared third-party libraries keep imported SHARED targets so that" + echo '# $ still copies them next to the game executable.' + for idx in "${!shared_import_targets[@]}"; do + printf 'add_library(%s SHARED IMPORTED GLOBAL)\n' "${shared_import_targets[$idx]}" + printf 'set_target_properties(%s PROPERTIES\n' "${shared_import_targets[$idx]}" + printf ' IMPORTED_LOCATION "${MKW_NP_PACKAGE_DIR}/%s"\n' "${shared_import_runtime[$idx]}" + printf ' IMPORTED_IMPLIB "${MKW_NP_PACKAGE_DIR}/%s")\n' "${shared_import_implib[$idx]}" + done +} > "$generated_cmake" + +# --------------------------------------------------------------------------- +# Provenance: the pinned toolchain is what makes a precompiled archive interchangeable with +# locally compiled objects (bit-for-bit the compiler and flag set the user's machine will use); +# LocalBuild.ps1's Windows equivalent re-checks both before consuming a package, and a Linux +# consumer-side check (once local-build.sh gains --native-prebuilt-dir) should do the same. +# compiler_sha256/flag_fingerprint/aurora_fingerprint/third_party_fingerprint were already computed +# above (needed before the --print-fingerprint-only early exit). +# --------------------------------------------------------------------------- + +dawn_runtime_sha256="" +[[ -n "$dawn_runtime_file" && -f "$dawn_runtime_file" ]] && dawn_runtime_sha256=$(sha256_of "$dawn_runtime_file") +[[ -n "$dawn_runtime_sha256" ]] || fail "The Dawn runtime could not be hashed: $dawn_runtime_file" + +compiler_version=$(get_meta cxx_compiler_version) +sdl3_target=$(get_meta aurora_sdl3_target) +dawn_version=$(get_meta aurora_dawn_version) +harvested_count=${#linker_file_to_reference[@]} + +python3 - "$output_dir" "$compiler_sha256" "$compiler_version" "$flag_fingerprint" \ + "$dawn_version" "$dawn_runtime_sha256" "$aurora_fingerprint" "$third_party_fingerprint" \ + "$sdl3_target" "$harvested_count" <<'PY' +import hashlib, json, os, sys, datetime + +(output_dir, compiler_sha256, compiler_version, flag_fingerprint, dawn_version, + dawn_runtime_sha256, aurora_fingerprint, third_party_fingerprint, sdl3_target, + harvested_count) = sys.argv[1:] + +contents = [] +for root, dirs, files in os.walk(output_dir): + dirs.sort() + for name in sorted(files): + if name == "provenance.json": + continue + path = os.path.join(root, name) + rel = os.path.relpath(path, output_dir).replace(os.sep, "/") + with open(path, "rb") as fh: + digest = hashlib.sha256(fh.read()).hexdigest() + contents.append({"Path": rel, "Bytes": os.path.getsize(path), "Sha256": digest}) +contents.sort(key=lambda c: c["Path"]) + +provenance = { + "SchemaVersion": 1, + "BuiltUtc": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z", + "CompilerSha256": compiler_sha256, + "CompilerVersion": compiler_version, + "FlagFingerprint": flag_fingerprint, + "DawnVersion": dawn_version, + "DawnRuntimeSha256": dawn_runtime_sha256, + "AuroraSourceFingerprint": aurora_fingerprint, + "ThirdPartySourceFingerprint": third_party_fingerprint, + "Sdl3Target": sdl3_target, + "HarvestedLibraryCount": int(harvested_count), + "Contents": contents, +} +with open(os.path.join(output_dir, "provenance.json"), "w", encoding="utf-8") as fh: + json.dump(provenance, fh, indent=2) + fh.write("\n") + +total_bytes = sum(c["Bytes"] for c in contents) +print(f" files: {len(contents)}; size: {total_bytes / (1024 * 1024):.1f} MiB") +PY + +if [[ "$keep_stage" -eq 0 ]]; then rm -rf "$stage_dir"; fi + +echo "" +echo "Native prebuilt package: $output_dir" +echo " archives: $(find "$output_dir/lib" -maxdepth 1 -type f | wc -l); shared imports: ${#shared_import_targets[@]}" +echo " flag fingerprint: $flag_fingerprint" diff --git a/Launcher/Test-PinnedFacts.ps1 b/Launcher/Test-PinnedFacts.ps1 index bd65c84..1d51bcf 100644 --- a/Launcher/Test-PinnedFacts.ps1 +++ b/Launcher/Test-PinnedFacts.ps1 @@ -77,14 +77,28 @@ if ($runtimeEntry -ne $pins.EntryPoint) { Add-Failure "system_bridge.h enters at $runtimeEntry but recomp.yml translates from $($pins.EntryPoint)." } -# --- The pinned dependency set: Build-Installer.ps1 stages it, the installed host requires it. +# --- The pinned dependency set: Build-Installer.ps1 stages it, the installed host requires it, +# --- and Prepare-Dependencies.ps1 must be able to supply every source tree. native_prebuilt is +# --- intentionally produced later by Prepare-NativePrebuilt.ps1; cppwinrt is generated separately +# --- by Prepare-Dependencies.ps1 rather than appearing in its downloaded $packages array. $installedLayout = Read-SourceFile (Join-Path $setup 'InstalledLayout.cs') 'InstalledLayout.cs' $buildInstaller = Read-SourceFile (Join-Path $launcher 'Build-Installer.ps1') 'Build-Installer.ps1' -Compare-Set (Get-QuotedList $installedLayout '(?s)DependencyNames\s*=\s*\[(.*?)\]' '"([^"]+)"' ` - 'InstalledLayout.DependencyNames') ` - (Get-QuotedList $buildInstaller '(?s)\$requiredDependencies\s*=\s*@\((.*?)\)' "'([^']+)'" ` - 'Build-Installer.ps1 $requiredDependencies') ` +$dependencyNames = Get-QuotedList $installedLayout '(?s)DependencyNames\s*=\s*\[(.*?)\]' ` + '"([^"]+)"' 'InstalledLayout.DependencyNames' +$installerDependencies = Get-QuotedList $buildInstaller ` + '(?s)\$requiredDependencies\s*=\s*@\((.*?)\)' "'([^']+)'" ` + 'Build-Installer.ps1 $requiredDependencies' +Compare-Set $dependencyNames $installerDependencies ` 'InstalledLayout.DependencyNames' 'Build-Installer.ps1 $requiredDependencies' +$dependencyPreparer = Read-SourceFile (Join-Path $launcher 'Prepare-Dependencies.ps1') ` + 'Prepare-Dependencies.ps1' +$downloadedDependencies = Get-QuotedList $dependencyPreparer ` + '(?sm)\$packages\s*=\s*@\((.*?)^\)' "Name\s*=\s*'([^']+)'" ` + 'Prepare-Dependencies.ps1 $packages' +$preparedDependencies = @($downloadedDependencies) + @('cppwinrt') +$sourceDependencies = @($dependencyNames | Where-Object { $_ -ne 'native_prebuilt' }) +Compare-Set $sourceDependencies $preparedDependencies ` + 'InstalledLayout.DependencyNames source entries' 'Prepare-Dependencies.ps1 outputs' # --- The runtime assets copied beside a product: the host hashes exactly these names, so the build # --- script must publish every one of them or every product reports its support files as stale. diff --git a/Launcher/WiiCompiled.Setup.Common/RetroWfcPayload.cs b/Launcher/WiiCompiled.Setup.Common/RetroWfcPayload.cs index f100171..22d1c23 100644 --- a/Launcher/WiiCompiled.Setup.Common/RetroWfcPayload.cs +++ b/Launcher/WiiCompiled.Setup.Common/RetroWfcPayload.cs @@ -53,9 +53,22 @@ public static class RetroWfcPayload var root = Path.GetFullPath(stagedDirectory); var payload = Path.Combine(root, RetroWfcOfflinePayloadFile); - if (!File.Exists(payload)) + try + { + if ((File.GetAttributes(payload) & FileAttributes.Directory) != 0) + throw new InvalidDataException( + "The staged Retro-WFC payload directory does not contain binary\\payload.RMCPD00.bin."); + } + catch (FileNotFoundException) + { throw new InvalidDataException( "The staged Retro-WFC payload directory does not contain binary\\payload.RMCPD00.bin."); + } + catch (DirectoryNotFoundException) + { + throw new InvalidDataException( + "The staged Retro-WFC payload directory does not contain binary\\payload.RMCPD00.bin."); + } ValidateRetroWfcPayloadFile(payload, signingKey); return root; } diff --git a/Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs b/Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs index 677b757..60e1711 100644 --- a/Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs +++ b/Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs @@ -12,7 +12,9 @@ internal static class BuildRunner public static async Task RunAsync( string workspace, string profile, string outputDir, string? baseOutputDir, string? retroDir, string? retroWfcOfflineDir, bool skipRetroWfcPayload, - bool forceCleanBuild, string? translatorBin, IInstallReporter reporter, CancellationToken cancellationToken) + bool forceCleanBuild, string? translatorBin, string? ccBin, string? cxxBin, string? fuseLd, + string? cmakeBin, string? ninjaBin, string? nativePrebuiltDir, IInstallReporter reporter, + CancellationToken cancellationToken) { var script = Path.Combine(workspace, "Launcher", "local-build.sh"); if (!File.Exists(script)) throw new FileNotFoundException("local-build.sh is missing", script); @@ -47,6 +49,34 @@ internal static class BuildRunner { startInfo.ArgumentList.Add("--translator-bin"); startInfo.ArgumentList.Add(translatorBin); } + // Forwarded by AppRun so the AppImage's bundled clang/lld (see prepare-portable-clang.sh) + // is used instead of local-build.sh's own default of whatever clang is on $PATH. + if (!string.IsNullOrEmpty(ccBin)) + { + startInfo.ArgumentList.Add("--cc"); startInfo.ArgumentList.Add(ccBin); + } + if (!string.IsNullOrEmpty(cxxBin)) + { + startInfo.ArgumentList.Add("--cxx"); startInfo.ArgumentList.Add(cxxBin); + } + if (!string.IsNullOrEmpty(fuseLd)) + { + startInfo.ArgumentList.Add("--fuse-ld"); startInfo.ArgumentList.Add(fuseLd); + } + if (!string.IsNullOrEmpty(cmakeBin)) + { + startInfo.ArgumentList.Add("--cmake"); startInfo.ArgumentList.Add(cmakeBin); + } + if (!string.IsNullOrEmpty(ninjaBin)) + { + startInfo.ArgumentList.Add("--ninja"); startInfo.ArgumentList.Add(ninjaBin); + } + // Forwarded by AppRun so the AppImage's bundled precompiled aurora/third-party package (see + // Prepare-NativePrebuilt.sh) is used instead of local-build.sh compiling aurora-main itself. + if (!string.IsNullOrEmpty(nativePrebuiltDir)) + { + startInfo.ArgumentList.Add("--native-prebuilt-dir"); startInfo.ArgumentList.Add(nativePrebuiltDir); + } using var process = new Process { StartInfo = startInfo }; var window = new BuildProgressWindow(reporter, InstallStages.Build, start: 6, end: 96); diff --git a/Launcher/WiiCompiled.Setup.Linux/Program.cs b/Launcher/WiiCompiled.Setup.Linux/Program.cs index dd68523..267bb68 100644 --- a/Launcher/WiiCompiled.Setup.Linux/Program.cs +++ b/Launcher/WiiCompiled.Setup.Linux/Program.cs @@ -150,6 +150,12 @@ internal static class Program skipPayload, flags.ContainsKey("force-clean-build"), flags.GetValueOrDefault("translator-bin"), + flags.GetValueOrDefault("cc"), + flags.GetValueOrDefault("cxx"), + flags.GetValueOrDefault("fuse-ld"), + flags.GetValueOrDefault("cmake"), + flags.GetValueOrDefault("ninja"), + flags.GetValueOrDefault("native-prebuilt-dir"), reporter, token); reporter.Progress(InstallStages.Shortcuts, "Creating shortcuts", 98); @@ -317,7 +323,8 @@ internal static class Program install [--game ISO_PATH] [--install-dir DIR] [--retro-dir DIR {--download-retro-wfc-payload | --skip-retro-wfc-payload}] [--force-clean-build] [--translator-bin PATH] [--disc-tool-bin PATH] - [--progress-json] [--workspace DIR] + [--cc PATH] [--cxx PATH] [--fuse-ld NAME_OR_PATH] [--cmake PATH] [--ninja PATH] + [--native-prebuilt-dir DIR] [--progress-json] [--workspace DIR] uninstall launch-base launch-retro diff --git a/Launcher/WiiCompiled.Setup.Windows/Program.cs b/Launcher/WiiCompiled.Setup.Windows/Program.cs index 382fae1..3caeff9 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.25"; + public const string Version = "0.2.26"; /// /// 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 ed84b32..08c9a6d 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.25 + 0.2.26 patchzy WiiCompiled Command-line installer and launcher for WiiCompiled diff --git a/Launcher/build-appimage.sh b/Launcher/build-appimage.sh index 0fb3311..5612405 100755 --- a/Launcher/build-appimage.sh +++ b/Launcher/build-appimage.sh @@ -5,8 +5,12 @@ # self-contained binaries, and `nodtool` (a prebuilt MIT/Apache-2.0 CLI from encounter/nod, see # NodToolProvider.cs) is downloaded and bundled too - AppRun passes --translator-bin and # --disc-tool-bin so local-build.sh/DiscTool.cs skip their from-source/download fallbacks entirely. -# It still shells out to system clang/cmake/ninja - no C/C++ toolchain is bundled, matching -# Launcher/local-build.sh's own remaining prerequisites. +# A pruned native clang/lld/cmake/ninja toolchain (see prepare-portable-tools.sh) is bundled the +# same way - AppRun passes --cc/--cxx/--fuse-ld/--cmake/--ninja so local-build.sh never has to find +# a system compiler, CMake, or Ninja. It still shells out to system pkg-config and Vulkan headers, +# matching Launcher/local-build.sh's own remaining prerequisites. A precompiled aurora + +# third-party package (see Prepare-NativePrebuilt.sh) is bundled the same way too - AppRun passes +# --native-prebuilt-dir so local-build.sh never compiles aurora-main from source at all. # # An AppImage mounts read-only, but local-build.sh writes generated/, native-build/, Assets/, etc. # into the workspace it's given. So AppRun (written below) copies the bundled workspace snapshot @@ -15,7 +19,17 @@ # - generated/native-build/Assets/PulsarPacks live only in that writable cache and are never # touched by the sync, so local-build.sh's own incremental caching survives across runs and across # AppImage updates. translator/ isn't part of this snapshot at all: it's published as its own -# self-contained binary (usr/bin/translator-cli) below and never needs a writable copy. +# self-contained binary (usr/bin/translator-cli) below and never needs a writable copy. Neither +# native-prebuilt/ nor the toolchain are copied into the cache either - both are large +# (~90 MiB / ~500 MiB) and local-build.sh only ever reads from them - but AppRun does point +# $CACHE/toolchain and $CACHE/native-prebuilt symlinks at the current mount on every single launch +# (see AppRun's own comment): an AppImage's FUSE mount is at a fresh random /tmp/.mount_XXXXXX +# every run, and CMake bakes whatever compiler/tool path it's given directly into each +# build.ninja rule's command line, so referencing $HERE straight would change that command line - +# and Ninja reruns any rule whose command line changed - forcing a full rebuild on every single +# launch even though the compiler itself never actually changed. The symlink keeps the path +# string CMake/Ninja see identical across runs while what it resolves to tracks the current mount +# underneath. set -euo pipefail script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) @@ -107,6 +121,47 @@ nodtool_path=$(dotnet run --project "$workspace/Launcher/WiiCompiled.Setup.Commo cp "$nodtool_path" "$appdir/usr/bin/nodtool" chmod +x "$appdir/usr/bin/nodtool" +echo "Preparing the portable clang/lld/cmake/ninja toolchain ($appimagetool_arch)..." +bash "$script_dir/prepare-portable-tools.sh" --arch "$appimagetool_arch" +mkdir -p "$appdir/usr/toolchain" +cp -a "$workspace/Launcher/artifacts/portable-tools/toolchain-$appimagetool_arch"/. "$appdir/usr/toolchain/" + +# Precompiled aurora + third-party package (see Prepare-NativePrebuilt.sh) so a user's own +# local-build.sh never has to compile aurora itself (~43% of local build CPU time). Re-harvesting +# recompiles the whole aurora/Crypto++ closure with the toolchain above, so this is skipped unless +# --print-fingerprint-only (a fast, build-free check) says the existing package no longer matches +# the current compiler/flags/aurora/third_party sources. +native_prebuilt_dir="$workspace/Launcher/artifacts/native-prebuilt-$appimagetool_arch" +echo "Checking whether the precompiled aurora + third-party package ($appimagetool_arch) is current..." +current_fingerprint=$(bash "$script_dir/Prepare-NativePrebuilt.sh" --arch "$appimagetool_arch" --print-fingerprint-only) +package_current=0 +if [[ -f "$native_prebuilt_dir/provenance.json" ]]; then + package_current=$(CURRENT_FINGERPRINT="$current_fingerprint" python3 - "$native_prebuilt_dir/provenance.json" <<'PY' +import json +import os +import sys + +provenance = json.load(open(sys.argv[1], encoding="utf-8")) +current = dict(line.split("=", 1) for line in os.environ["CURRENT_FINGERPRINT"].splitlines() if line) +fields = { + "compiler_sha256": "CompilerSha256", + "flag_fingerprint": "FlagFingerprint", + "aurora_fingerprint": "AuroraSourceFingerprint", + "third_party_fingerprint": "ThirdPartySourceFingerprint", +} +print(1 if all(provenance.get(v) == current.get(k) for k, v in fields.items()) else 0) +PY + ) +fi +if [[ "$package_current" == "1" ]]; then + echo "Native prebuilt package is current; reusing $native_prebuilt_dir" +else + echo "Native prebuilt package is missing or stale; harvesting a fresh one (compiles aurora once, can take a while)..." + bash "$script_dir/Prepare-NativePrebuilt.sh" --arch "$appimagetool_arch" +fi +mkdir -p "$appdir/native-prebuilt" +cp -a "$native_prebuilt_dir/." "$appdir/native-prebuilt/" + echo "Staging the bundled workspace snapshot..." for dir in runtime aurora-main projects; do cp -r "$workspace/$dir" "$appdir/workspace/$dir" @@ -120,8 +175,21 @@ find "$appdir/workspace/aurora-main/extern" -mindepth 1 -maxdepth 1 -type d -exe rm -rf "$appdir/workspace/runtime/build" cp "$workspace/Launcher/local-build.sh" "$appdir/workspace/Launcher/local-build.sh" +# AppRun re-syncs runtime/aurora-main/projects/local-build.sh into the writable cache only when +# this changes, so it must change whenever any of those bundled paths actually did - a bare commit +# hash gets this wrong for an uncommitted change (verified directly: rebuilding after editing +# local-build.sh with no commit produced the same hash as the stale cache, so AppRun kept serving +# the old script and failed on a flag that didn't exist yet). `git status --porcelain` catches both +# modified tracked files and new untracked ones; appending a fresh timestamp when it's non-empty +# guarantees this never matches a previous build's stamp, forcing a resync every time the tree is +# dirty. A clean tree (a real tagged release) keeps the stable commit-hash behavior, so identical +# reruns of the same release AppImage don't resync needlessly. if git -C "$workspace" rev-parse HEAD >/dev/null 2>&1; then - git -C "$workspace" rev-parse HEAD > "$appdir/workspace/.bundle-version" + version=$(git -C "$workspace" rev-parse HEAD) + if [[ -n "$(git -C "$workspace" status --porcelain 2>/dev/null)" ]]; then + version="$version-dirty-$(date -u +%s)" + fi + echo "$version" > "$appdir/workspace/.bundle-version" else date -u +%s > "$appdir/workspace/.bundle-version" fi @@ -132,6 +200,7 @@ cat > "$appdir/AppRun" <<'APPRUN' set -euo pipefail HERE="$(dirname "$(readlink -f "$0")")" CACHE="${XDG_DATA_HOME:-$HOME/.local/share}/WiiCompiled/workspace" +mkdir -p "$CACHE" if [ ! -f "$CACHE/.bundle-version" ] || \ [ "$(cat "$HERE/workspace/.bundle-version")" != "$(cat "$CACHE/.bundle-version")" ]; then mkdir -p "$CACHE/Launcher" @@ -142,9 +211,31 @@ if [ ! -f "$CACHE/.bundle-version" ] || \ cp "$HERE/workspace/Launcher/local-build.sh" "$CACHE/Launcher/local-build.sh" cp "$HERE/workspace/.bundle-version" "$CACHE/.bundle-version" fi +# toolchain/ and native-prebuilt/ are NOT copied into the cache (they're large - ~500 MiB / +# ~90 MiB - and local-build.sh only ever reads from them): $CACHE/toolchain and +# $CACHE/native-prebuilt are symlinks re-pointed at the current mount on every single launch +# (unconditionally, not gated on .bundle-version above, since the mount path itself - unlike the +# bundled content - changes every run regardless). CMake bakes a compiler/tool path directly into +# each build.ninja rule's command line and Ninja reruns any rule whose command line changed since +# the last build (verified directly) - an AppImage's FUSE mount is at a fresh random +# /tmp/.mount_XXXXXX every launch, so referencing $HERE straight would change that command line, +# and therefore force a full rebuild, on every single run even though the compiler itself never +# actually changed. A symlink keeps the *path string* CMake/Ninja see identical across runs while +# what it resolves to tracks the current mount underneath (verified directly: CMake records +# whatever path it's given as-is - including a symlink - without resolving it first). +[ -L "$CACHE/toolchain" ] || rm -rf "$CACHE/toolchain" +[ -L "$CACHE/native-prebuilt" ] || rm -rf "$CACHE/native-prebuilt" +ln -sfn "$HERE/usr/toolchain" "$CACHE/toolchain" +ln -sfn "$HERE/native-prebuilt" "$CACHE/native-prebuilt" exec "$HERE/usr/bin/wiicompiled-setup" --workspace "$CACHE" \ --translator-bin "$HERE/usr/bin/translator-cli" \ - --disc-tool-bin "$HERE/usr/bin/nodtool" "$@" + --disc-tool-bin "$HERE/usr/bin/nodtool" \ + --cc "$CACHE/toolchain/bin/clang" \ + --cxx "$CACHE/toolchain/bin/clang++" \ + --fuse-ld lld \ + --cmake "$CACHE/toolchain/bin/cmake" \ + --ninja "$CACHE/toolchain/bin/ninja" \ + --native-prebuilt-dir "$CACHE/native-prebuilt" "$@" APPRUN chmod +x "$appdir/AppRun" diff --git a/Launcher/local-build-macos.command b/Launcher/local-build-macos.command new file mode 100755 index 0000000..1f91b0c --- /dev/null +++ b/Launcher/local-build-macos.command @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# Native macOS build automation: optionally extract -> translate -> compile -> publish .app. +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +default_workspace=$(cd "$script_dir/.." && pwd) + +fail() { printf 'local-build-macos.command: error: %s\n' "$*" >&2; exit 1; } +step() { printf 'MKWCBUILD:STEP:%s %s\n' "$1" "$2"; } +assert_file() { [[ -f "$1" ]] || fail "$2 is missing: $1"; } +sha256() { shasum -a 256 "$1" | awk '{ print $1 }'; } + +usage() { + cat <<'EOF' +Usage: local-build-macos.command --output-dir DIR [options] + + --workspace DIR Repository root (default: this script's parent directory) + --profile {base|retro-rewind|both} Product to build (default: base) + --output-dir DIR Output .app directory (required; Retro Rewind for both) + --base-output-dir DIR Base .app directory (required with --profile both) + --game IMAGE --nodtool PATH Extract and verify a clean PAL RMCP01 disc image first + --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 + --parallel N Pin translation and build parallelism + --cmake PATH --ninja PATH Override build tools + --dotnet PATH Override dotnet + --translator-bin PATH Use a self-contained Translator.Cli executable +EOF +} + +workspace="$default_workspace"; profile=base; output_dir=""; base_output_dir="" +game=""; nodtool=""; retro_root=""; retro_wfc=""; skip_retro_wfc=0; force_clean=0 +parallel=0; cmake_bin=cmake; ninja_bin=ninja; dotnet_bin=dotnet; translator_bin="" +while (($#)); do + case "$1" in + --workspace) workspace=${2:-}; shift 2 ;; + --profile) profile=${2:-}; shift 2 ;; + --output-dir) output_dir=${2:-}; shift 2 ;; + --base-output-dir) base_output_dir=${2:-}; shift 2 ;; + --game) game=${2:-}; shift 2 ;; + --nodtool) nodtool=${2:-}; shift 2 ;; + --retro-rewind-package-dir) retro_root=${2:-}; shift 2 ;; + --retro-wfc-offline-dir) retro_wfc=${2:-}; shift 2 ;; + --skip-retro-wfc-payload) skip_retro_wfc=1; shift ;; + --force-clean-build) force_clean=1; shift ;; + --parallel) parallel=${2:-}; shift 2 ;; + --cmake) cmake_bin=${2:-}; shift 2 ;; + --ninja) ninja_bin=${2:-}; shift 2 ;; + --dotnet) dotnet_bin=${2:-}; shift 2 ;; + --translator-bin) translator_bin=${2:-}; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) fail "unknown option: $1" ;; + esac +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' +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 +builds_retro=0; [[ "$profile" != base ]] && builds_retro=1 +if [[ "$profile" == both && -z "$base_output_dir" ]]; then fail '--base-output-dir is required with --profile both'; fi +if [[ "$profile" != both && -n "$base_output_dir" ]]; then fail '--base-output-dir is valid only with --profile both'; fi +if [[ -n "$game" || -n "$nodtool" ]]; then [[ -n "$game" && -n "$nodtool" ]] || fail '--game and --nodtool must be supplied together'; fi +if (( builds_retro )); then + [[ -n "$retro_root" ]] || fail '--retro-rewind-package-dir is required for Retro Rewind' + [[ -n "$retro_wfc" ]] && (( skip_retro_wfc )) && fail 'choose only one Retro-WFC mode' + [[ -n "$retro_wfc" || $skip_retro_wfc -eq 1 ]] || fail 'choose a Retro-WFC payload directory or --skip-retro-wfc-payload' +fi +for tool in "$cmake_bin" "$ninja_bin" clang clang++ shasum; do command -v "$tool" >/dev/null || fail "required tool not found: $tool"; done + +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" +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' + +if (( force_clean )); then + step force-clean 'Discarding translation and native build caches' + rm -rf "$generated" "$manifest_dir" "$native_build" +fi + +if [[ -n "$translator_bin" ]]; then + assert_file "$translator_bin" 'Translator.Cli executable' + translator() { "$translator_bin" "$@"; } +else + command -v "$dotnet_bin" >/dev/null || fail "required tool not found: $dotnet_bin" + translator_dll="$workspace/translator/src/Translator.Cli/bin/Release/net8.0/Translator.Cli.dll" + if [[ ! -f "$translator_dll" ]]; then + step build-translator 'Building the translator' + "$dotnet_bin" build "$workspace/translator/src/Translator.Cli/Translator.Cli.csproj" -c Release + fi + translator() { "$dotnet_bin" "$translator_dll" "$@"; } +fi + +entry_point=$(awk '/^translation:/{inside=1} inside && /^[[:space:]]*-[[:space:]]*0[xX][0-9a-fA-F]+[[:space:]]*$/{gsub(/^[[:space:]]*-[[:space:]]*/, ""); print; exit}' "$project") +[[ -n "$entry_point" ]] || fail 'could not find the translation entry point' +cpu=$(sysctl -n hw.ncpu); mem_gib=$(( $(sysctl -n hw.memsize) / 1024 / 1024 / 1024 )); (( mem_gib < 1 )) && mem_gib=1 +if (( parallel > 0 )); then translator_threads=$parallel; translated_jobs=$parallel; global_jobs=$parallel +else translator_threads=$(( cpu < 16 ? cpu : 16 )); translated_jobs=$(( cpu < mem_gib / 2 ? cpu : mem_gib / 2 )); (( translated_jobs < 1 )) && translated_jobs=1; global_jobs=$cpu; fi + +if (( builds_retro )); then + retro_root=$(cd "$retro_root" && pwd) + if [[ ! -f "$retro_root/Binaries/Code.pul" && -f "$retro_root/RetroRewind6/Binaries/Code.pul" ]]; then + retro_root="$retro_root/RetroRewind6" + fi + assert_file "$retro_root/Binaries/Code.pul" 'Retro Rewind Code.pul' + stage="$workspace/PulsarPacks/completed/RetroRewind/RetroRewind6/Binaries" + mkdir -p "$stage" + if [[ ! "$retro_root/Binaries/Code.pul" -ef "$stage/Code.pul" ]]; then + cp -f "$retro_root/Binaries/Code.pul" "$stage/Code.pul" + fi +fi + +step translate-base 'Translating the user-owned base game' +rm -rf "$functions" "$metadata" "$manifest_dir"; mkdir -p "$functions" "$manifest_dir" +translator translate-recursive "$entry_point" --project "$project" --outdir "$functions" --output-metadata "$metadata" --production-source-bundle "$generated/base_translation_sources.bin" --no-function-files --prune-stale --threads "$translator_threads" +step emit-base-manifest 'Creating the local base translation manifest' +translator emit-base-manifest --project "$project" --out "$manifest_dir" --functions-dir "$functions" --translation-output-metadata "$metadata" --region P +if (( builds_retro )); then + mod_out="$workspace/build/mods/retro_rewind_full_cpp"; args=(translate-mod --project "$project" --profile retro-rewind --base-manifest "$manifest" --base-translation-output-metadata "$metadata" --code-pul "$retro_root/Binaries/Code.pul" --mod-root "$retro_root" --mod-name 'Retro Rewind' --region P --out "$mod_out" --prefer-cached-inputs --emit-cpp --threads "$translator_threads") + if (( skip_retro_wfc )); then args+=(--skip-retro-wfc); else offline_payload="$retro_wfc/binary/payload.RMCPD00.bin"; assert_file "$offline_payload" 'Offline Retro-WFC payload'; args+=(--retro-wfc-payload "$offline_payload"); fi + step translate-mod 'Translating Retro Rewind'; translator "${args[@]}" +fi +step generate-data-init 'Generating local game data initialization'; translator generate-data-init --project "$project" +args=(emit-build-shards --project "$project" --base-metadata "$metadata" --base-functions-dir "$functions" --native-source-dir "$workspace/runtime/src" --out "$shards") +if (( builds_retro )); then args+=(--resolved-profile "$mod_out/resolved_dispatch_profile.json" --retro-cpp-dir "$mod_out/cpp"); fi +step emit-build-shards 'Preparing native build shards'; translator "${args[@]}" + +step configure-native 'Configuring the native toolchain' +"$cmake_bin" -S "$workspace/runtime" -B "$native_build" -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_MAKE_PROGRAM="$ninja_bin" -DMKW_TRANSLATED_COMPILE_JOBS="$translated_jobs" +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 +printf 'MKWCBUILD:OUTPUT=%s\n' "$output_dir" diff --git a/Launcher/local-build.sh b/Launcher/local-build.sh index ac869c9..7d9989d 100755 --- a/Launcher/local-build.sh +++ b/Launcher/local-build.sh @@ -2,11 +2,12 @@ # Linux build automation: translate -> emit build shards -> configure -> compile -> publish. # # This is the native-Linux counterpart to Launcher/LocalBuild.ps1. It is a from-scratch parallel -# implementation, not a port of NativeBuildFlags.ps1: that file's canonical flags and -# prebuilt-package fingerprinting exist only for the Windows/mingw toolchain (a precompiled -# aurora/third-party package, offline pinned dependencies) that this script does not build. -# Linux always builds aurora from source, letting its own CMake auto-detect Vulkan + vendor -# SDL3/Dawn via FetchContent - the same configuration already verified working by hand. +# implementation, not a port of NativeBuildFlags.ps1: that file's canonical flags exist only for +# the Windows/mingw toolchain's offline pinned dependencies, which this script does not use. +# Without --native-prebuilt-dir, aurora is built from source, letting its own CMake auto-detect +# Vulkan + vendor SDL3/Dawn via FetchContent - the same configuration already verified working by +# hand. With it, aurora is not compiled at all - see Launcher/Prepare-NativePrebuilt.sh, which +# harvests exactly that package (the Linux counterpart to Prepare-NativePrebuilt.ps1). set -euo pipefail # --------------------------------------------------------------------------- @@ -62,6 +63,8 @@ ninja_override="" dotnet_override="" translator_dll_override="" translator_bin_override="" +fuse_ld_override="" +native_prebuilt_dir="" usage() { cat <<'EOF' @@ -77,10 +80,13 @@ Usage: local-build.sh --output-dir DIR [options] --force-clean-build Discard every translation/build cache first --parallel N Pin translator threads, translated-shard job pool, and Ninja parallelism to N --cc PATH / --cxx PATH C/C++ compiler (default: cc/c++ on PATH) + --fuse-ld NAME_OR_PATH Linker passed to clang as -fuse-ld=NAME_OR_PATH (default: clang's own default linker) --cmake PATH / --ninja PATH Build tools (default: on PATH) --dotnet PATH dotnet executable (default: on PATH) --translator-dll PATH Pre-built Translator.Cli.dll (skips building the translator; still needs --dotnet to run it) --translator-bin PATH Self-contained Translator.Cli executable (skips building AND needs no dotnet at all) + --native-prebuilt-dir DIR Precompiled aurora/third-party package (see Prepare-NativePrebuilt.sh); + skips compiling aurora-main from source entirely EOF } @@ -97,11 +103,13 @@ while [[ $# -gt 0 ]]; do --parallel) parallel_override=$2; shift 2 ;; --cc) cc_override=$2; shift 2 ;; --cxx) cxx_override=$2; shift 2 ;; + --fuse-ld) fuse_ld_override=$2; shift 2 ;; --cmake) cmake_override=$2; shift 2 ;; --ninja) ninja_override=$2; shift 2 ;; --dotnet) dotnet_override=$2; shift 2 ;; --translator-dll) translator_dll_override=$2; shift 2 ;; --translator-bin) translator_bin_override=$2; shift 2 ;; + --native-prebuilt-dir) native_prebuilt_dir=$2; shift 2 ;; -h|--help) usage; exit 0 ;; *) fail "unknown argument: $1" ;; esac @@ -157,6 +165,10 @@ require_command "$ninja_bin" ninja require_command "$cc_bin" cc require_command "$cxx_bin" cxx +if [[ -n "$native_prebuilt_dir" ]]; then + assert_file "$native_prebuilt_dir/native_prebuilt.cmake" "Native prebuilt package" +fi + project=$workspace/projects/mkwii/recomp.yml assets=$workspace/Assets generated=$workspace/generated @@ -336,26 +348,75 @@ translator "${shard_args[@]}" # --------------------------------------------------------------------------- keep_native_build=0 +stale_reason="" if [[ -f "$build/CMakeCache.txt" ]]; then expected_home=$workspace/runtime cache_home=$(grep '^CMAKE_HOME_DIRECTORY:INTERNAL=' "$build/CMakeCache.txt" | cut -d= -f2- || true) if [[ -n "$cache_home" && "$(cd "$cache_home" 2>/dev/null && pwd)" == "$expected_home" ]]; then keep_native_build=1 + # CMake auto-detects and caches auxiliary tool paths (CMAKE_AR/RANLIB/LINKER/ASM_COMPILER + # and their per-language *_AR/*_RANLIB variants) only once, the first time a language's + # compiler is checked - unlike CMAKE_C_COMPILER/CMAKE_CXX_COMPILER, which get overwritten by + # the -D flags below on every configure, these are never refreshed on a plain reconfigure. + # An AppImage's own AppRun works around this at the source by keeping --cc/--cxx/--cmake/ + # --ninja pointed at a stable symlink it re-targets at the current mount every launch + # (see build-appimage.sh), so the *path string* CMake caches never actually changes run to + # run - but this check stays as a general fallback for any tool path that goes stale some + # other way (a moved/removed system toolchain, a relocated portable-tools directory, etc): + # any :FILEPATH= cache entry whose recorded path no longer exists means this cache belongs + # to a toolchain location that's gone - not just a workspace/path mismatch. + while IFS= read -r tool_path; do + [[ -n "$tool_path" ]] || continue + # CMake's own sentinel for "this optional tool was legitimately never found" (e.g. + # clang-scan-deps, not required here) - not a path at all, and not a sign of anything + # stale. Without this exclusion, every configure wiped the cache unconditionally. + [[ "$tool_path" != *-NOTFOUND ]] || continue + if [[ ! -e "$tool_path" ]]; then + keep_native_build=0 + stale_reason=" (cached tool path no longer exists: $tool_path)" + break + fi + done < <(grep -o ':FILEPATH=.*' "$build/CMakeCache.txt" | sed 's/^:FILEPATH=//') fi fi if [[ -d "$build" && "$keep_native_build" -eq 0 ]]; then - echo "MKWCBUILD: The native build cache does not belong to this workspace path; rebuilding from scratch" + echo "MKWCBUILD: The native build cache does not belong to this workspace path or toolchain; rebuilding from scratch$stale_reason" rm -rf "$build" elif [[ "$keep_native_build" -eq 1 ]]; then echo "MKWCBUILD: Reusing the incremental native build directory" fi +configure_args=(-S "$workspace/runtime" -B "$build" -G Ninja + -DCMAKE_BUILD_TYPE=Release + -DCMAKE_C_COMPILER="$cc_bin" -DCMAKE_CXX_COMPILER="$cxx_bin" + -DCMAKE_MAKE_PROGRAM="$ninja_bin" + -DMKW_TRANSLATED_COMPILE_JOBS="$translated_jobs") +# CMAKE_C_COMPILER/CXX_COMPILER stay stable across AppImage runs on their own (they're exactly +# what's passed via -D above, and AppRun points --cc/--cxx at a symlink it re-targets at the +# current mount every launch - see build-appimage.sh). CMAKE_AR/RANLIB/LINKER/ASM_COMPILER do NOT +# inherit that stability just because $cc_bin does: verified directly that even with a stable +# --cc symlink, CMake's own auto-detection of these still resolved to the *real*, ephemeral mount +# path underneath (clang's own driver locates its sibling llvm-ar/ld.lld tools by resolving its own +# invoked path, symlinks included, rather than trusting the symlink CMake invoked it through) - +# each subsequent run's differing command line then made Ninja rebuild every object from scratch +# even though nothing had actually changed. Deriving these from $cc_bin (itself already stable) +# and re-passing them explicitly every configure keeps them pinned to the same stable value too. +if [[ "$cc_bin" == */* ]]; then + toolchain_bin=$(dirname "$cc_bin") + [[ -x "$toolchain_bin/llvm-ar" ]] && configure_args+=(-DCMAKE_AR="$toolchain_bin/llvm-ar" -DCMAKE_ASM_COMPILER_AR="$toolchain_bin/llvm-ar" -DCMAKE_C_COMPILER_AR="$toolchain_bin/llvm-ar" -DCMAKE_CXX_COMPILER_AR="$toolchain_bin/llvm-ar") + [[ -x "$toolchain_bin/llvm-ranlib" ]] && configure_args+=(-DCMAKE_RANLIB="$toolchain_bin/llvm-ranlib" -DCMAKE_ASM_COMPILER_RANLIB="$toolchain_bin/llvm-ranlib" -DCMAKE_C_COMPILER_RANLIB="$toolchain_bin/llvm-ranlib" -DCMAKE_CXX_COMPILER_RANLIB="$toolchain_bin/llvm-ranlib") + [[ -x "$toolchain_bin/ld.lld" ]] && configure_args+=(-DCMAKE_LINKER="$toolchain_bin/ld.lld") + configure_args+=(-DCMAKE_ASM_COMPILER="$cc_bin") +fi +if [[ -n "$fuse_ld_override" ]]; then + configure_args+=(-DCMAKE_EXE_LINKER_FLAGS="-fuse-ld=$fuse_ld_override") +fi +if [[ -n "$native_prebuilt_dir" ]]; then + configure_args+=(-DMKW_NATIVE_PREBUILT_DIR="$native_prebuilt_dir") +fi + log_step configure-native "Configuring the native toolchain" -"$cmake_bin" -S "$workspace/runtime" -B "$build" -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_C_COMPILER="$cc_bin" -DCMAKE_CXX_COMPILER="$cxx_bin" \ - -DCMAKE_MAKE_PROGRAM="$ninja_bin" \ - -DMKW_TRANSLATED_COMPILE_JOBS="$translated_jobs" +"$cmake_bin" "${configure_args[@]}" case "$profile" in base) targets=(WiiCompiled) ;; diff --git a/Launcher/macos/build-setup-pkg.command b/Launcher/macos/build-setup-pkg.command new file mode 100755 index 0000000..50082c9 --- /dev/null +++ b/Launcher/macos/build-setup-pkg.command @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Maintainer release builder. It packages setup/source/tooling only -- never a +# translated executable, extracted DATA tree, disc image, or Retro Rewind data. +set -euo pipefail + +fail() { printf 'build-setup-pkg.command: error: %s\n' "$*" >&2; exit 1; } +# Release payloads must not inherit Finder metadata, resource forks, or a +# downloaded-file quarantine bit from a maintainer's working volume. +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] + +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. + + --workspace DIR Repository root (default: script's grandparent) + --version VERSION Bundle/package version (default: 0.1.0) + --installer-identity NAME Developer ID Installer identity for productbuild +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="" +while (($#)); do + case "$1" in + --workspace) workspace=${2:-}; shift 2 ;; + --nodtool) nodtool=${2:-}; shift 2 ;; + --translator) translator=${2:-}; shift 2 ;; + --cmake-root) cmake_root=${2:-}; shift 2 ;; + --ninja) ninja=${2:-}; shift 2 ;; + --output) output=${2:-}; shift 2 ;; + --version) version=${2:-}; shift 2 ;; + --installer-identity) identity=${2:-}; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) fail "unknown option: $1" ;; + esac +done +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' +[[ -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' +workspace=$(cd "$workspace" && pwd); output=$(cd "$(dirname "$output")" && pwd)/$(basename "$output") +stage=$(mktemp -d "${TMPDIR:-/tmp}/wiicompiled-pkg.XXXXXX") +trap 'rm -rf "$stage"' EXIT +app="$stage/root/Applications/WiiCompiled Setup.app" +resources="$app/Contents/Resources" +mkdir -p "$app/Contents/MacOS" "$resources/tools" +cat > "$app/Contents/Info.plist" < + + +CFBundleExecutableWiiCompiledSetup +CFBundleIdentifierorg.wiicompiled.setup +CFBundleNameWiiCompiled Setup +CFBundlePackageTypeAPPL +CFBundleShortVersionString$short_version +CFBundleVersion$version +LSMinimumSystemVersion14.0 + +EOF +cat > "$app/Contents/MacOS/WiiCompiledSetup" <<'EOF' +#!/usr/bin/env bash +resources="$(cd "$(dirname "$0")/../Resources" && pwd)" + +# Finder launches an app with no terminal attached. The setup work deliberately +# writes human-readable build progress to stdout, so run its .command entry +# point in Terminal instead of discarding that output behind an inert app icon. +exec /usr/bin/osascript - "$resources/setup.command" "$@" <<'APPLESCRIPT' +on run argv + set commandLine to quoted form of (item 1 of argv) + if (count of argv) > 1 then + repeat with argumentIndex from 2 to (count of argv) + set commandLine to commandLine & " " & quoted form of (item argumentIndex of argv) + end repeat + end if + tell application "Terminal" + activate + do script commandLine + end tell +end run +APPLESCRIPT +EOF +chmod +x "$app/Contents/MacOS/WiiCompiledSetup" +copy_clean "$script_dir/setup.command" "$resources/setup.command"; chmod +x "$resources/setup.command" +# Copy only the build inputs. This deliberately avoids a maintainer's ignored +# output directories, local disc extraction, and developer-only packaging. +mkdir -p "$resources/workspace" +for source in aurora-main projects runtime translator; do + [[ -d "$workspace/$source" ]] || fail "required workspace directory is missing: $source" + copy_clean "$workspace/$source" "$resources/workspace/$source" +done +mkdir -p "$resources/workspace/Launcher/macos" +copy_clean "$workspace/Launcher/local-build-macos.command" "$resources/workspace/Launcher/local-build-macos.command" +copy_clean "$workspace/Launcher/macos/extract-disc.command" "$resources/workspace/Launcher/macos/extract-disc.command" +copy_clean "$workspace/Launcher/macos/publish-app.command" "$resources/workspace/Launcher/macos/publish-app.command" +chmod +x "$resources/workspace/Launcher/local-build-macos.command" "$resources/workspace/Launcher/macos/"*.command +mkdir -p "$resources/tools/cmake" +copy_clean "$nodtool" "$resources/tools/nodtool"; chmod +x "$resources/tools/nodtool" +copy_clean "$translator" "$resources/tools/Translator.Cli"; chmod +x "$resources/tools/Translator.Cli" +copy_clean "$cmake_root" "$resources/tools/cmake" +copy_clean "$ninja" "$resources/tools/ninja"; chmod +x "$resources/tools/ninja" +copy_clean "$workspace/LICENSE" "$resources/LICENSE" +copy_clean "$workspace/THIRD-PARTY-NOTICES.md" "$resources/THIRD-PARTY-NOTICES.md" +codesign --force --deep --sign - "$app" +pkg="$stage/WiiCompiled-Setup-unsigned.pkg" +DITTONORSRC=1 COPYFILE_DISABLE=1 pkgbuild --root "$stage/root" --identifier org.wiicompiled.setup --version "$version" --install-location / "$pkg" +if [[ -n "$identity" ]]; then productbuild --sign "$identity" --package "$pkg" "$output"; else ditto "$pkg" "$output"; fi +printf 'Created game-code-free package: %s\n' "$output" diff --git a/Launcher/macos/extract-disc.command b/Launcher/macos/extract-disc.command new file mode 100755 index 0000000..663b542 --- /dev/null +++ b/Launcher/macos/extract-disc.command @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Extract a user-owned Mario Kart Wii PAL (RMCP01) disc image for a local build. +# This script deliberately contains no game data and is intended for the macOS +# setup application and for maintainers testing that setup path. +set -euo pipefail + +readonly EXPECTED_DOL_SHA256=80d18895b39c63bd80f457398bfcbb91b7d16ac116a41a88967e954080155b05 +readonly EXPECTED_REL_SHA256=16d9d146112541fefea701ecb5bc1a496f9d50e4a752fbb5b6778e7c6399f67d + +fail() { printf 'extract-disc.command: error: %s\n' "$*" >&2; exit 1; } +sha256() { shasum -a 256 "$1" | awk '{ print $1 }'; } + +usage() { + cat <<'EOF' +Usage: extract-disc.command --game IMAGE --assets-dir DIR --nodtool PATH + +Extracts a user-owned Mario Kart Wii PAL RMCP01 image into DIR. The final +layout is DIR/main.dol, DIR/StaticR.rel, and DIR/DATA. Existing data is left +untouched unless the complete new extraction passes both content hash checks. +EOF +} + +game="" +assets_dir="" +nodtool="" +while (($#)); do + case "$1" in + --game) game=${2:-}; shift 2 ;; + --assets-dir) assets_dir=${2:-}; shift 2 ;; + --nodtool) nodtool=${2:-}; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) fail "unknown option: $1" ;; + esac +done + +[[ -f "$game" ]] || fail "disc image does not exist: $game" +[[ -n "$assets_dir" ]] || fail "--assets-dir is required" +[[ -x "$nodtool" ]] || fail "nodtool is not executable: $nodtool" + +assets_dir=$(mkdir -p "$assets_dir" && cd "$assets_dir" && pwd) +scratch=$(mktemp -d "${TMPDIR:-/tmp}/wiicompiled-disc.XXXXXX") +cleanup() { rm -rf "$scratch"; } +trap cleanup EXIT + +printf 'MKWCBUILD:STEP:validate-disc Checking the selected disc image\n' +"$nodtool" info "$game" >/dev/null +printf 'MKWCBUILD:STEP:extract-disc Extracting the user-owned disc image\n' +"$nodtool" extract "$game" "$scratch/extracted" + +dol=$(find "$scratch/extracted" -type f -path '*/sys/main.dol' -print -quit) +rel=$(find "$scratch/extracted" -type f -path '*/files/rel/StaticR.rel' -print -quit) +[[ -n "$dol" ]] || fail 'nodtool extraction did not contain sys/main.dol' +[[ -n "$rel" ]] || fail 'nodtool extraction did not contain files/rel/StaticR.rel' +[[ $(sha256 "$dol") == "$EXPECTED_DOL_SHA256" ]] || fail 'disc is not the supported clean PAL RMCP01 Mario Kart Wii image' +[[ $(sha256 "$rel") == "$EXPECTED_REL_SHA256" ]] || fail 'disc has an unexpected StaticR.rel; use a clean PAL RMCP01 image' + +data_root=$(dirname "$(dirname "$dol")") +[[ -d "$data_root/files" ]] || fail 'nodtool extraction did not contain the Wii files directory' + +# Stage beside the destination so the final replacement stays on one volume. +stage="$assets_dir/.extract-stage-$$" +rm -rf "$stage" +mkdir -p "$stage" +ditto "$data_root" "$stage/DATA" +ditto "$dol" "$stage/main.dol" +ditto "$rel" "$stage/StaticR.rel" + +backup="$assets_dir/.previous-extraction-$(date +%Y%m%d-%H%M%S)" +if [[ -e "$assets_dir/DATA" || -e "$assets_dir/main.dol" || -e "$assets_dir/StaticR.rel" ]]; then + mkdir -p "$backup" + for item in DATA main.dol StaticR.rel; do + [[ -e "$assets_dir/$item" ]] && mv "$assets_dir/$item" "$backup/$item" + done +fi +mv "$stage/DATA" "$assets_dir/DATA" +mv "$stage/main.dol" "$assets_dir/main.dol" +mv "$stage/StaticR.rel" "$assets_dir/StaticR.rel" +rmdir "$stage" +rm -rf "$backup" + +printf 'MKWCBUILD:STEP:disc-ready Verified and extracted clean PAL RMCP01 game assets\n' diff --git a/Launcher/macos/publish-app.command b/Launcher/macos/publish-app.command new file mode 100755 index 0000000..b384a55 --- /dev/null +++ b/Launcher/macos/publish-app.command @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Turn one locally compiled macOS product into a self-contained .app bundle. +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 + +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. +EOF +} + +build_dir=""; product=""; output_dir="" +while (($#)); do + case "$1" in + --build-dir) build_dir=${2:-}; shift 2 ;; + --product) product=${2:-}; shift 2 ;; + --output-dir) output_dir=${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 +[[ -x "$build_dir/$product" ]] || fail "missing compiled product: $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" +macos="$app/Contents/MacOS" +frameworks="$app/Contents/Frameworks" +resources="$app/Contents/Resources" +rm -rf "$app" +mkdir -p "$macos" "$frameworks" "$resources" +cat > "$app/Contents/Info.plist" < + + + CFBundleDevelopmentRegionen + CFBundleExecutable$product + CFBundleIdentifierorg.wiicompiled.$product + CFBundleInfoDictionaryVersion6.0 + CFBundleName$product + CFBundlePackageTypeAPPL + CFBundleShortVersionString0.1.0 + CFBundleVersion1 + LSMinimumSystemVersion14.0 + NSHighResolutionCapable + +EOF +ditto "$build_dir/$product" "$macos/$product" +for asset in dsp_coef.bin initial_pipeline_cache.db wii_bootstrap; do + ditto "$build_dir/$asset" "$resources/$asset" + ln -s "../Resources/$asset" "$macos/$asset" +done + +# Build a closure of Homebrew dylibs. System libraries remain system references. +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 + name=$(basename "$dependency") + if [[ ! -f "$frameworks/$name" ]]; then + ditto "$dependency" "$frameworks/$name" + install_name_tool -id "@rpath/$name" "$frameworks/$name" + queue+=("$frameworks/$name") + 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 + install_name_tool -change "$old" "@executable_path/../Frameworks/$name" "$binary" + else + install_name_tool -change "$old" "@loader_path/$name" "$binary" + fi + done < <(otool -L "$binary" | tail -n +2 | awk '{print $1}') +done < <(find "$frameworks" -type f -print; printf '%s\n' "$macos/$product") + +find "$frameworks" -type f -exec codesign --force --sign - {} + +codesign --force --deep --sign - "$app" +codesign --verify --deep --strict "$app" +printf 'MKWCBUILD:APP=%s\n' "$app" diff --git a/Launcher/macos/setup.command b/Launcher/macos/setup.command new file mode 100755 index 0000000..8676e7a --- /dev/null +++ b/Launcher/macos/setup.command @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# Entry point bundled in WiiCompiled Setup.app. The package contains source and +# tools only; a user's own verified disc is extracted into Application Support. +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" + +fail() { printf 'WiiCompiled Setup: %s\n' "$*" >&2; exit 1; } +notice() { /usr/bin/osascript -e "display dialog \"${1//\"/\\\"}\" buttons {\"OK\"} default button \"OK\" with icon caution" >/dev/null; } +usage() { + cat <<'EOF' +Usage: setup.command --game IMAGE [--retro-dir DIR] [--install-location {user|applications}] + +Without arguments this script opens file pickers. It is normally launched by +WiiCompiled Setup.app, not run directly. +EOF +} + +game=""; retro_dir=""; install_location=applications +while (($#)); do + case "$1" in + --game) game=${2:-}; shift 2 ;; + --retro-dir) retro_dir=${2:-}; shift 2 ;; + --install-location) install_location=${2:-}; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) fail "unknown option: $1" ;; + esac +done +[[ "$install_location" == user || "$install_location" == applications ]] || fail '--install-location must be user or applications' + +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" +POSIX path of selectedFile +APPLESCRIPT +) || exit 0 + choice=$(/usr/bin/osascript -e 'button returned of (display dialog "Would you like to build Retro Rewind too?" buttons {"Base game only", "Choose Retro Rewind folder"} default button "Base game only")') + if [[ "$choice" == 'Choose Retro Rewind folder' ]]; then + retro_dir=$(/usr/bin/osascript <<'APPLESCRIPT' +set selectedFolder to choose folder with prompt "Choose the RetroRewind6 folder (or its parent folder)" +POSIX path of selectedFolder +APPLESCRIPT +) || exit 0 + fi +fi + +[[ -x "$nodtool" ]] || fail 'the packaged nodtool is missing or not executable' +[[ -x "$translator" ]] || fail 'the packaged Translator.Cli is missing or not executable' +[[ -x "$cmake_bin" && -x "$ninja_bin" ]] || fail 'the packaged CMake or Ninja tool is missing' +if ! /usr/bin/xcode-select -p >/dev/null 2>&1; then + notice 'Xcode Command Line Tools are required once to compile WiiCompiled. Click OK, complete the Apple installer, then run WiiCompiled Setup again.' + /usr/bin/xcode-select --install || true + exit 1 +fi + +mkdir -p "$support_root" "$products" +if [[ ! -d "$workspace/.git" && ! -f "$workspace/projects/mkwii/recomp.yml" ]]; then + printf 'Preparing the local build workspace...\n' + rm -rf "$workspace" + /usr/bin/ditto "$workspace_source" "$workspace" +fi + +profile=base +build_args=(--workspace "$workspace" --game "$game" --nodtool "$nodtool" --output-dir "$products") +if [[ -n "$retro_dir" ]]; then + profile=both + # Online play needs the shared Retro-WFC payload. Keep it in the per-user + # support directory rather than the packaged app or build workspace, then + # verify its pinned signature before publishing it into the local cache. + retro_wfc_dir="$support_root/RetroWfcPayload" + retro_wfc_payload="$retro_wfc_dir/binary/payload.RMCPD00.bin" + if [[ -f "$retro_wfc_payload" ]] && ! "$translator" validate-retro-wfc-payload --directory "$retro_wfc_dir"; then + printf 'Discarding an invalid cached Retro-WFC payload...\n' >&2 + rm -f "$retro_wfc_payload" + fi + if [[ ! -f "$retro_wfc_payload" ]]; then + printf 'Downloading the Retro-WFC payload needed for online play...\n' + mkdir -p "$retro_wfc_dir" + payload_stage=$(mktemp -d "$retro_wfc_dir/.payload-download.XXXXXX") + temporary_payload="$payload_stage/binary/payload.RMCPD00.bin" + mkdir -p "$(dirname "$temporary_payload")" + trap 'rm -rf "$payload_stage"' EXIT + /usr/bin/curl --fail --silent --show-error --connect-timeout 10 --max-time 30 \ + --retry 1 --output "$temporary_payload" \ + 'http://nas.play.rwfc.net/payload?g=RMCPD00' || fail 'could not download the Retro-WFC payload needed for online play' + "$translator" validate-retro-wfc-payload --directory "$payload_stage" || \ + fail 'downloaded Retro-WFC payload failed signature validation' + mkdir -p "$retro_wfc_dir/binary" + mv "$temporary_payload" "$retro_wfc_payload" + rmdir "$payload_stage/binary" "$payload_stage" + trap - EXIT + fi + build_args+=(--profile both --base-output-dir "$products" --retro-rewind-package-dir "$retro_dir" --retro-wfc-offline-dir "$retro_wfc_dir") +fi +"$workspace/Launcher/local-build-macos.command" "${build_args[@]}" --profile "$profile" --cmake "$cmake_bin" --ninja "$ninja_bin" --translator-bin "$translator" + +config="$support_root/Config.toml" +toml_string() { printf '%s' "$1" | sed -e 's/\\\\/\\\\\\\\/g' -e 's/"/\\\\"/g'; } +set_path() { + local key=$1 value=$2 encoded line temporary + encoded=$(toml_string "$value"); line="$key = \"$encoded\""; temporary="$config.tmp" + touch "$config" + if grep -q '^[[:space:]]*\[paths\][[:space:]]*$' "$config"; then + awk -v key="$key" -v line="$line" ' + /^[[:space:]]*\[paths\][[:space:]]*$/ { print; print line; inside = 1; next } + inside && /^[[:space:]]*\[/ { inside = 0 } + inside && $0 ~ "^[[:space:]]*" key "[[:space:]]*=" { next } + { print } + ' "$config" > "$temporary" + else + { cat "$config"; printf '\n[paths]\n%s\n' "$line"; } > "$temporary" + fi + mv "$temporary" "$config" +} +set_path dvd_root "$workspace/Assets/DATA" +[[ -n "$retro_dir" ]] && set_path retro_rewind_root "$retro_dir" + +destination="$HOME/Applications" +if [[ "$install_location" == applications ]]; then destination=/Applications; fi +install_app() { + local app=$1 + [[ -d "$products/$app" ]] || return 0 + if [[ "$destination" == /Applications ]]; then + command="mkdir -p /Applications && rm -rf '/Applications/$app' && ditto '$products/$app' '/Applications/$app'" + /usr/bin/osascript -e "do shell script \"$command\" with administrator privileges" + else + mkdir -p "$destination"; rm -rf "$destination/$app"; /usr/bin/ditto "$products/$app" "$destination/$app" + fi +} +install_app WiiCompiled.app +[[ "$profile" == both ]] && install_app RetroRewind.app +notice "Installation complete. Your apps are in $destination." diff --git a/Launcher/prepare-portable-tools.sh b/Launcher/prepare-portable-tools.sh new file mode 100755 index 0000000..9602345 --- /dev/null +++ b/Launcher/prepare-portable-tools.sh @@ -0,0 +1,340 @@ +#!/usr/bin/env bash +# Prepares a self-contained native-Linux build toolchain bundled into the AppImage by +# build-appimage.sh, so a user needs no `clang`/`cmake`/`ninja` of their own to build the +# translated game (mirrors why Windows bundles llvm-mingw + CMake + Ninja via +# Prepare-PortableTools.ps1 - this is that script's Linux counterpart, for the same reason). +# +# clang/lld/llvm-ar: pruned from the official llvm.org GitHub release tarball (NOT llvm-mingw - +# that project targets Windows/mingw, never native Linux) down to just what's needed to compile +# and link: clang, lld, llvm-ar, the clang resource dir (builtin headers + compiler-rt), and +# libc++/libc++abi/libunwind (so the toolchain never has to fall back to the host's system +# libstdc++ headers). The raw release is ~1.9 GiB per arch (every LLVM backend, mlir, flang, lldb, +# docs, tests); pruned it is ~500 MiB uncompressed / ~100 MiB compressed, verified against a real +# build of this project. +# +# cmake: pruned from the official Kitware GitHub release tarball down to bin/cmake (not +# ccmake/cmake-gui/cpack/ctest, which local-build.sh never invokes) plus the Modules/Templates +# CMake needs at runtime (found relative to bin/cmake via CMAKE_ROOT auto-detection - Help/doc/man/ +# the desktop-integration files under share/ are documentation/GUI-only and dropped). Verified with +# a real configure+build using the pruned cmake+ninja+clang together. +# +# ninja: the official ninja-build GitHub release zip, used as-is - it is already a single small +# (~130 KiB compressed) static-ish binary with nothing to prune. +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +workspace=$(cd "$script_dir/.." && pwd) + +llvm_version=22.1.8 +cmake_version=4.3.3 +ninja_version=1.13.2 +destination="$script_dir/artifacts/portable-tools" +arch="" + +usage() { + cat <<'EOF' +Usage: prepare-portable-tools.sh --arch {x86_64|aarch64} [--destination DIR] + + --arch ARCH Target architecture (required) + --destination DIR Where the toolchain is written, as DIR/toolchain-ARCH + (default: Launcher/artifacts/portable-tools) +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --arch) arch=$2; shift 2 ;; + --destination) destination=$2; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "prepare-portable-tools.sh: unknown argument: $1" >&2; exit 1 ;; + esac +done + +case "$arch" in + x86_64) llvm_release_arch=X64; target_triple=x86_64-unknown-linux-gnu + 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=805efad2bb91cb4967fa569e0881d10c0f69c04461cf671cccbae19f547acc34 + cmake_release_arch=aarch64 + cmake_sha256=9ea38356dbd3e32e51029a3e09a0f2f8e117ef4fbcaad7a21ffb36409bbd5cb4 + ninja_asset=ninja-linux-aarch64.zip + ninja_sha256=fd2cacc8050a7f12a16a2e48f9e06fca5c14fc4c2bee2babb67b58be17a607fc ;; + *) echo "prepare-portable-tools.sh: --arch must be x86_64 or aarch64" >&2; usage; exit 1 ;; +esac + +destination=$(mkdir -p "$destination" && cd "$destination" && pwd) +toolchain_dir="$destination/toolchain-$arch" +downloads="$script_dir/artifacts/downloads" +mkdir -p "$downloads" + +sha256_of() { sha256sum "$1" | awk '{print $1}'; } + +download_verified() { + # $1 = destination path, $2 = URL, $3 = expected sha256 + local dest=$1 url=$2 expected=$3 + if [[ -f "$dest" ]] && [[ "$(sha256_of "$dest")" == "$expected" ]]; then return; fi + echo "prepare-portable-tools.sh: downloading $(basename "$dest")..." + local tmp="$dest.partial" + rm -f "$tmp" + curl -fL --progress-bar -o "$tmp" "$url" + local actual + actual=$(sha256_of "$tmp") + if [[ "$actual" != "$expected" ]]; then + echo "prepare-portable-tools.sh: $(basename "$dest") hash mismatch: expected $expected, got $actual" >&2 + rm -f "$tmp" + exit 1 + fi + mv "$tmp" "$dest" +} + +# lib/bundled is part of the check so a toolchain prepared before runtime-library bundling existed +# is rebuilt rather than reused. +if [[ -x "$toolchain_dir/bin/clang" && -x "$toolchain_dir/bin/ninja" && -x "$toolchain_dir/bin/cmake" && -d "$toolchain_dir/lib/bundled" ]]; then + echo "prepare-portable-tools.sh: reusing existing toolchain at $toolchain_dir" + exit 0 +fi + +for tool in patchelf ldd strip; do + command -v "$tool" >/dev/null || { echo "prepare-portable-tools.sh: '$tool' is required (apt-get install patchelf binutils libc-bin)" >&2; exit 1; } +done + +work="$destination/.building-toolchain-$arch" +rm -rf "$work" +mkdir -p "$work/bin" "$work/lib/$target_triple" "$work/include/$target_triple/c++/v1" + +# --- clang/lld/llvm-ar, pruned from the official LLVM release --- + +llvm_archive_name="LLVM-$llvm_version-Linux-$llvm_release_arch.tar.xz" +llvm_archive="$downloads/$llvm_archive_name" +download_verified "$llvm_archive" \ + "https://github.com/llvm/llvm-project/releases/download/llvmorg-$llvm_version/$llvm_archive_name" \ + "$llvm_release_sha256" + +extract_root="$script_dir/artifacts/.extract-clang-$arch" +rm -rf "$extract_root" +mkdir -p "$extract_root" +echo "prepare-portable-tools.sh: extracting $llvm_archive_name (this is the full ~1.9 GiB release; only a fraction is kept)..." +tar -xf "$llvm_archive" -C "$extract_root" +src="$extract_root/LLVM-$llvm_version-Linux-$llvm_release_arch" +[[ -d "$src" ]] || { echo "prepare-portable-tools.sh: unexpected archive layout, expected $src" >&2; exit 1; } + +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-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 - +# see local-build.sh's --fuse-ld option. +cp -a "$src/bin/lld" "$work/bin/" +strip "$work/bin/lld" +ln -s lld "$work/bin/ld.lld" + +# llvm-ar/llvm-ranlib: CMake's archiver for the many static libraries this project builds +# (aurora, Crypto++, SDL3, Dawn's dependency closure, the translated game shards). +cp -a "$src/bin/llvm-ar" "$work/bin/" +strip "$work/bin/llvm-ar" +ln -s llvm-ar "$work/bin/llvm-ranlib" + +# Clang's resource directory: builtin headers (stddef.h, immintrin.h, ...) and compiler-rt +# (builtins, sanitizer runtimes). `clang -print-resource-dir` must find this at lib/clang//. +cp -a "$src/lib/clang" "$work/lib/" + +# libc++/libc++abi/libunwind: so this toolchain never has to fall back to whatever libstdc++ the +# host distro happens to have installed. Not the default yet (local-build.sh still resolves the +# system libstdc++ unless -stdlib=libc++ is passed), but bundled so that option exists. +cp -a "$src/include/c++" "$work/include/" +cp -a "$src/include/$target_triple/c++/v1/__config_site" "$work/include/$target_triple/c++/v1/" +cp -a "$src/lib/$target_triple"/libc++.a "$src/lib/$target_triple"/libc++abi.a "$src/lib/$target_triple"/libunwind.a "$work/lib/$target_triple/" +cp -a "$src/lib/$target_triple"/libc++.so* "$src/lib/$target_triple"/libc++abi.so* "$src/lib/$target_triple"/libunwind.so* "$work/lib/$target_triple/" + +rm -rf "$extract_root" + +# --- cmake, pruned from the official Kitware release --- + +cmake_share_version=${cmake_version%.*} +cmake_archive_name="cmake-$cmake_version-linux-$cmake_release_arch.tar.gz" +cmake_archive="$downloads/$cmake_archive_name" +download_verified "$cmake_archive" \ + "https://github.com/Kitware/CMake/releases/download/v$cmake_version/$cmake_archive_name" \ + "$cmake_sha256" + +cmake_extract_root="$script_dir/artifacts/.extract-cmake-$arch" +rm -rf "$cmake_extract_root" +mkdir -p "$cmake_extract_root" +echo "prepare-portable-tools.sh: extracting $cmake_archive_name..." +tar -xzf "$cmake_archive" -C "$cmake_extract_root" +cmake_src="$cmake_extract_root/cmake-$cmake_version-linux-$cmake_release_arch" +[[ -d "$cmake_src" ]] || { echo "prepare-portable-tools.sh: unexpected archive layout, expected $cmake_src" >&2; exit 1; } + +mkdir -p "$work/share/cmake-$cmake_share_version" +cp -a "$cmake_src/bin/cmake" "$work/bin/" +cp -a "$cmake_src/share/cmake-$cmake_share_version/Modules" "$cmake_src/share/cmake-$cmake_share_version/Templates" \ + "$work/share/cmake-$cmake_share_version/" +rm -rf "$cmake_extract_root" + +# --- ninja, used as-is --- + +ninja_archive="$downloads/ninja-$ninja_version-$arch.zip" +download_verified "$ninja_archive" \ + "https://github.com/ninja-build/ninja/releases/download/v$ninja_version/$ninja_asset" \ + "$ninja_sha256" +echo "prepare-portable-tools.sh: staging ninja $ninja_version..." +unzip -oq "$ninja_archive" -d "$work/bin" +chmod +x "$work/bin/ninja" + +# --- runtime libraries the llvm.org binaries expect the host to provide --- +# +# The official LLVM release binaries are dynamically linked against a few host libraries beyond +# glibc: lld needs libxml2, clang/lld need zlib and zstd, and whatever those pull in. A user's +# distro is not guaranteed to have them under the same soname - libxml2 2.14 renamed +# libxml2.so.2 to libxml2.so.16, so on current Arch/Fedora a fresh install died with +# "ld.lld: error while loading shared libraries: libxml2.so.2". The smoke test below never caught +# it because the build machine has every one of them. So: copy the transitive closure of non-core +# libraries next to the binaries and point the binaries at that directory through an RPATH, the +# way AppImages ship any dynamically linked tool. glibc and its companions stay on the host: they +# exist everywhere, and a bundled copy could only ever be older than the host's own. +bundled_lib_dir="$work/lib/bundled" +mkdir -p "$bundled_lib_dir" + +is_host_provided_lib() { + case "$1" in + linux-vdso*|ld-linux*|libc.so*|libm.so*|libdl.so*|libpthread.so*|librt.so*|libresolv.so*|libutil.so*|\ + libgcc_s.so*|libstdc++.so*) return 0 ;; + *) return 1 ;; + esac +} + +# Prints "soname path" for every dynamically resolved dependency of $1 that the host must not be +# relied on for. ldd already reports the transitive closure, so no recursion is needed. +non_host_deps() { + local line soname path + ldd "$1" 2>/dev/null | while read -r line; do + [[ "$line" == *"=>"* ]] || continue + soname=${line%% =>*}; soname=${soname## } + path=${line#*=> }; path=${path%% (*} + is_host_provided_lib "$soname" && continue + echo "$soname $path" + done +} + +# A dependency the build machine itself lacks cannot be bundled; say so instead of shipping a +# toolchain that is broken everywhere. (Checked separately because a failure inside the +# process-substituted reader above could not stop the script.) +assert_deps_resolved() { + if ldd "$1" 2>/dev/null | grep -q "not found"; then + echo "prepare-portable-tools.sh: $(basename "$1") has dependencies missing on this machine:" >&2 + ldd "$1" | grep "not found" >&2 + exit 1 + fi +} + +# Copies every non-core library $1 needs into lib/bundled, then does the same for each copy, so +# a library's own dependencies (libxml2 -> ICU, ...) are bundled even if ldd's listing for the +# executable did not already spell out the whole closure. The "already bundled" check ends the +# recursion. +bundle_deps_of() { + local file=$1 soname path + assert_deps_resolved "$file" + while read -r soname path; do + [[ -n "$soname" ]] || continue + [[ -e "$bundled_lib_dir/$soname" ]] && continue + cp -L "$path" "$bundled_lib_dir/$soname" + chmod u+w "$bundled_lib_dir/$soname" + # A bundled library must find its own dependencies beside itself: RUNPATH is not + # inherited from the executable that loaded it. + patchelf --set-rpath '$ORIGIN' "$bundled_lib_dir/$soname" + bundle_deps_of "$bundled_lib_dir/$soname" + done < <(non_host_deps "$file") +} + +bundle_runtime_libs() { + local file=$1 existing + bundle_deps_of "$file" + # Prepend, keeping whatever RPATH the release build set (typically $ORIGIN/../lib). + existing=$(patchelf --print-rpath "$file" 2>/dev/null || true) + patchelf --set-rpath "\$ORIGIN/../lib/bundled${existing:+:$existing}" "$file" +} + +echo "prepare-portable-tools.sh: bundling the host runtime libraries the toolchain binaries depend on..." +for binary in "$work/bin/clang-22" "$work/bin/lld" "$work/bin/llvm-ar" "$work/bin/cmake"; do + bundle_runtime_libs "$binary" +done +echo "prepare-portable-tools.sh: bundled $(ls "$bundled_lib_dir" | wc -l) librar(y/ies): $(ls "$bundled_lib_dir" | tr '\n' ' ')" + +# Fail here, on the build machine, if any non-core dependency still resolves to a host path: +# that is exactly the file a user's machine may lack. +for binary in "$work/bin/clang-22" "$work/bin/lld" "$work/bin/llvm-ar" "$work/bin/cmake" "$bundled_lib_dir"/*; do + [[ -f "$binary" ]] || continue + while read -r soname path; do + [[ -n "$soname" ]] || continue + if [[ "$path" != "$bundled_lib_dir/"* ]]; then + echo "prepare-portable-tools.sh: $(basename "$binary") still resolves $soname from the host ($path); bundling failed" >&2 + exit 1 + fi + done < <(non_host_deps "$binary") +done + +cat > "$work/LICENSE.txt" < "$smoke_dir/t.cpp" <<'EOF' +#include +#include +int main() { + std::vector v{1, 2, 3}; + int sum = 0; + for (int x : v) sum += x; + 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" + +# 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' +cmake_minimum_required(VERSION 3.16) +project(smoke CXX) +add_executable(smoke t.cpp) +EOF +"$work/bin/cmake" -S "$smoke_dir" -B "$smoke_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" + +rm -rf "$smoke_dir" +trap - EXIT + +mv "$work" "$toolchain_dir" +echo "prepare-portable-tools.sh: toolchain ready at $toolchain_dir ($(du -sh "$toolchain_dir" | cut -f1))" diff --git a/README.md b/README.md index 0895bcc..19c0caf 100644 --- a/README.md +++ b/README.md @@ -60,19 +60,47 @@ Everything you change is saved to `Config.toml` on the spot and restored next la **Real controller support.** Controllers are fed to the game as a GameCube controller. -The port does NOT pretend to be a Wii Remote or Classic Controller. Mappings are positional (`south`, `east`, `west`, `north`) rather than Xbox-labelled, so the same config makes sense on Xbox, PlayStation, Nintendo and generic SDL pads alike, and extra inputs like paddles, touchpads and share buttons show up when the hardware reports them. The official Wii U / Switch GameCube adapter (WUP-028) works too; as with Dolphin, on Windows the adapter must be switched to the WinUSB driver once (Zadig). +**Real Wii Remotes over Bluetooth.** +Pair a Wii Remote with Windows (Settings > Bluetooth > Add device, press 1+2 or SYNC, leave the +PIN empty) and the game reads it as an actual Wii Remote through KPAD: Wii Remote icons and +prompts, Wii Wheel tilt steering, wheelies and tricks all come from the game's own motion code. +Nunchuk and Classic Controller are real Wii extensions too: the game gets the Nunchuk's stick, +C/Z and accelerometer, and the Classic Controller through `KPADGetUnifiedWpadStatus` with its own +layout and icons, so its buttons do what the game says they do and no mapping is involved. Plug an +extension in or pull it out mid-game and the game switches control scheme like on the console +(the runtime patches SDL's Wii driver, which otherwise loses the remote for good on an extension +change). Only the Wii U Pro Controller, which has no Wii-era equivalent, is fed to the game as a +GameCube pad with Nintendo's layout. If a remote drops out or was switched on after launch, the +runtime keeps rescanning Bluetooth until it comes back (F10 > Controller settings > Wii Remotes). SDL's read of +the remote's factory accelerometer calibration often times out over Bluetooth (`console.log` +then says "Using fallback accelerometer calibration") and it falls back to a nominal zero point, +so the same menu has a one-button calibration (remote flat, buttons up) that removes the small +tilt offset some remotes show. + +Known limitations of the Wii Remote path: +- No IR pointer yet: menus are navigated with the D-pad and A (the game treats the remote as + pointing away from the screen). +- Battery level is not reported to the game and the remote's speaker is not implemented. +- Only the Wii Remote's own accelerometer is calibrated; the Nunchuk's uses SDL's fixed zero point. +- The Classic Controller's L/R triggers reach the game as digital (full pull on click): SDL does not + expose their analog travel. +- Turn the Wii Remote support off in that menu if you use a Mayflash DolphinBar, which already + presents the remote as a regular gamepad. + ## Requirements - Windows 10 or 11, 64-bit - 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) - A clean, unmodified **PAL `RMCP01`** disc image of Mario Kart Wii, dumped by you. ISO, GCM, GCZ, CISO, WBFS, WIA and RVZ are accepted. @@ -93,6 +121,7 @@ image under Settings, turn on **WiiCompiled (beta)**, and hit install from the H Wheel Wizard downloads the setup tool from this repo and walks you through install, updates and launching. The backend itself is deliberately command-line only, Wheel Wizard is a wrapper around it. + > [!CAUTION] > Only take builds from this repository's > [Releases](https://github.com/patchzyy/Wiicompiled/releases) page. If someone's sharing an diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index c297c89..1693dec 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -26,10 +26,6 @@ Aurora itself vendors: (shagkur) and Dave Murphy (WinterMute). `aurora-main/lib/card/SRAM.hpp`. Source: -> [!NOTE] -> Upstream aurora ships `assets/screenshot.png`, a rendered frame from a different Nintendo -> title. It is intentionally omitted from this repository. - ### Dolphin Emulator data files - GPL-2.0-or-later Copyright (c) 2003+ Dolphin Emulator Project. @@ -121,7 +117,8 @@ Source: . Full license text: These are pinned in `aurora-main/extern/CMakeLists.txt`, `aurora-main/CMakeLists.txt` and `aurora-main/cmake/AuroraDawnProvider.cmake`. They are not stored in this repository; the build downloads them, and release installers carry the resulting binaries. Their license texts are -included in the installer's `licenses/` folder. +included in the installer's `licenses/` folder. The Windows installer bundles the pinned source +trees themselves (fetched by `Launcher/Prepare-Dependencies.ps1`) so end-user builds run offline. | Component | Version | License | Upstream | | --- | --- | --- | --- | diff --git a/aurora-main/cmake/AuroraSDL3Patches.cmake b/aurora-main/cmake/AuroraSDL3Patches.cmake new file mode 100644 index 0000000..7af051e --- /dev/null +++ b/aurora-main/cmake/AuroraSDL3Patches.cmake @@ -0,0 +1,126 @@ +# Source-level fixes applied to the vendored SDL3 tree before it is built. +# +# Each patch is an exact string replacement, checked and idempotent: a tree that +# already carries the fix is left alone, a tree where the anchor text is missing +# (a different SDL version) stops the configure with a clear message instead of +# silently building without the fix. +# +# Used two ways: +# - included by AuroraSDL3Provider.cmake, which calls aurora_sdl3_apply_patches() +# on a pre-provided source tree (FETCHCONTENT_SOURCE_DIR_SDL); +# - run as `cmake -DSDL_SOURCE_DIR= -P AuroraSDL3Patches.cmake` from the +# FetchContent PATCH_COMMAND for a freshly extracted tarball. + +function(_aurora_sdl3_replace file description old new) + file(READ "${file}" _content) + string(FIND "${_content}" "${new}" _already) + if (NOT _already EQUAL -1) + return() + endif () + string(FIND "${_content}" "${old}" _anchor) + if (_anchor EQUAL -1) + message(FATAL_ERROR "aurora: SDL3 patch '${description}' does not apply to ${file}; " + "the vendored SDL version changed, review AuroraSDL3Patches.cmake") + endif () + string(REPLACE "${old}" "${new}" _content "${_content}") + file(WRITE "${file}" "${_content}") + message(STATUS "aurora: SDL3 patch applied: ${description}") +endfunction() + +function(aurora_sdl3_apply_patches sdl_source_dir) + set(_wii "${sdl_source_dir}/src/joystick/hidapi/SDL_hidapi_wii.c") + if (NOT EXISTS "${_wii}") + message(FATAL_ERROR "aurora: SDL3 source tree at ${sdl_source_dir} has no SDL_hidapi_wii.c") + endif () + + # Wii Remote: rebuild the joystick in place after an extension change. + # + # When a Nunchuk or Classic Controller is plugged in or pulled out, the driver + # flags the joystick as disconnected so it can come back with the new name and + # capabilities. But the HID device stays open, and HIDAPI only removes a + # joystick-less device once its handle is closed, so nothing ever re-creates + # the joystick: the remote is gone for good until the application toggles the + # driver hint (which closes and re-opens the Bluetooth HID handle, something + # some Windows Bluetooth stacks answer by dropping the link). Instead, when the + # device has no joystick, probe the extension again (two bounded attempts, at + # most once a second) and connect a new joystick on the still-open handle. + _aurora_sdl3_replace("${_wii}" "Wii Remote in-place reconnect (context field)" +[==[ Uint64 m_ulNextMotionPlusCheck; + bool m_bDisconnected; +]==] +[==[ Uint64 m_ulNextMotionPlusCheck; + bool m_bDisconnected; + Uint64 m_ulNextReconnect; /* WiiCompiled: see HIDAPI_DriverWii_UpdateDevice */ +]==]) + _aurora_sdl3_replace("${_wii}" "Wii Remote in-place reconnect (bounded extension probe)" +[==[static EWiiExtensionControllerType ReadExtensionControllerType(SDL_HIDAPI_Device *device) +{ + SDL_DriverWii_Context *ctx = (SDL_DriverWii_Context *)device->context; + EWiiExtensionControllerType eExtensionControllerType = k_eWiiExtensionControllerType_Unknown; + const int MAX_ATTEMPTS = 20; + int attempts = 0; +]==] +[==[static EWiiExtensionControllerType ReadExtensionControllerTypeAttempts(SDL_HIDAPI_Device *device, int MAX_ATTEMPTS) +{ + SDL_DriverWii_Context *ctx = (SDL_DriverWii_Context *)device->context; + EWiiExtensionControllerType eExtensionControllerType = k_eWiiExtensionControllerType_Unknown; + int attempts = 0; +]==]) + _aurora_sdl3_replace("${_wii}" "Wii Remote in-place reconnect (probe wrapper)" +[==[static void UpdateDeviceIdentity(SDL_HIDAPI_Device *device) +{ +]==] +[==[static EWiiExtensionControllerType ReadExtensionControllerType(SDL_HIDAPI_Device *device) +{ + return ReadExtensionControllerTypeAttempts(device, 20); +} + +static void UpdateDeviceIdentity(SDL_HIDAPI_Device *device) +{ +]==]) + _aurora_sdl3_replace("${_wii}" "Wii Remote in-place reconnect (UpdateDevice)" +[==[ if (device->num_joysticks > 0) { + joystick = SDL_GetJoystickFromID(device->joysticks[0]); + } else { + return false; + } + + now = SDL_GetTicks(); +]==] +[==[ if (device->num_joysticks > 0) { + joystick = SDL_GetJoystickFromID(device->joysticks[0]); + } else { + /* WiiCompiled: the joystick was dropped (extension plugged or unplugged, + * or a failed read) but the HID device is still open, and the device + * list only removes a joystick-less device once its handle is closed. + * Rebuild the joystick in place, so an extension change behaves like it + * does on the console: the remote never goes away, it just changes + * type. If the remote does not answer, keep the device and retry. */ + now = SDL_GetTicks(); + if (ctx->m_ulNextReconnect != 0 && now < ctx->m_ulNextReconnect) { + return false; + } + ctx->m_ulNextReconnect = now + 1000; + { + EWiiExtensionControllerType type = ReadExtensionControllerTypeAttempts(device, 2); + if (type == k_eWiiExtensionControllerType_Unknown) { + return false; + } + ctx->m_bDisconnected = false; + ctx->m_ulLastInput = now; + ctx->m_ulNextReconnect = 0; + ctx->m_eExtensionControllerType = type; + UpdateDeviceIdentity(device); + SDL_LogDebug(SDL_LOG_CATEGORY_INPUT, "HIDAPI Wii: Reconnected joystick as %s", device->name); + return HIDAPI_JoystickConnected(device, NULL); + } + } + + now = SDL_GetTicks(); +]==]) +endfunction() + +# Script mode (FetchContent PATCH_COMMAND). +if (CMAKE_SCRIPT_MODE_FILE AND DEFINED SDL_SOURCE_DIR) + aurora_sdl3_apply_patches("${SDL_SOURCE_DIR}") +endif () diff --git a/aurora-main/cmake/AuroraSDL3Provider.cmake b/aurora-main/cmake/AuroraSDL3Provider.cmake index d48aef7..16ce933 100644 --- a/aurora-main/cmake/AuroraSDL3Provider.cmake +++ b/aurora-main/cmake/AuroraSDL3Provider.cmake @@ -135,9 +135,18 @@ elseif (_aurora_sdl3_provider STREQUAL "vendor") endif () include(FetchContent) + # Source fixes for the vendored SDL (see AuroraSDL3Patches.cmake). A freshly + # downloaded tarball is patched by the PATCH_COMMAND; a tree handed in through + # FETCHCONTENT_SOURCE_DIR_SDL skips the download steps, so patch it here. + set(_aurora_sdl3_patches "${CMAKE_CURRENT_LIST_DIR}/AuroraSDL3Patches.cmake") + include("${_aurora_sdl3_patches}") + if (DEFINED FETCHCONTENT_SOURCE_DIR_SDL AND EXISTS "${FETCHCONTENT_SOURCE_DIR_SDL}") + aurora_sdl3_apply_patches("${FETCHCONTENT_SOURCE_DIR_SDL}") + endif () FetchContent_Declare(SDL URL "https://github.com/libsdl-org/SDL/releases/download/release-${AURORA_SDL3_VERSION}/SDL3-${AURORA_SDL3_VERSION}.tar.gz" DOWNLOAD_EXTRACT_TIMESTAMP TRUE + PATCH_COMMAND "${CMAKE_COMMAND}" -DSDL_SOURCE_DIR= -P "${_aurora_sdl3_patches}" EXCLUDE_FROM_ALL ) FetchContent_MakeAvailable(SDL) diff --git a/aurora-main/lib/aurora.cpp b/aurora-main/lib/aurora.cpp index fc691dc..bb0fe5d 100644 --- a/aurora-main/lib/aurora.cpp +++ b/aurora-main/lib/aurora.cpp @@ -277,6 +277,14 @@ FrameWorkerState g_frameWorker; bool frame_worker_requested() noexcept { #ifdef AURORA_ENABLE_GX static const bool enabled = [] { +#if defined(__APPLE__) + // ImGui's SDL backend may raise an SDL window from ImGui::NewFrame(). On + // macOS that reaches AppKit, whose window operations are main-thread-only; + // doing it on the frame worker terminates the process with EXC_BREAKPOINT. + // Keep all SDL/ImGui work on the calling thread until the worker no longer + // owns frame preparation on Apple platforms. + return false; +#endif #if defined(_WIN32) // RenderDoc's D3D12 layer is injected before Aurora starts and needs device and command // ownership on one thread, so keep frame submission synchronous there. @@ -1824,6 +1832,15 @@ std::vector encode_sealed_frame(gfx::SealedFrame& sealedFrame, // Phase 3: hand the encoded group to whoever owns presentation. void publish_presentations(std::vector&& presentationJobs, bool interpolationActive) { +#if defined(__APPLE__) + (void)interpolationActive; + // Presenting reaches SDL/AppKit, whose window operations must stay on the + // main thread. Interpolation normally starts the presenter worker, so keep + // its jobs synchronous on Apple platforms. + for (const auto& job : presentationJobs) { + present_presentation_job(job); + } +#else // Keep presentation on the presenter whenever the async frame worker runs, even with // interpolation off, so every mode shares one surface/resize path. RenderDoc keeps the sync path. if (frame_worker_requested() || interpolationActive || @@ -1834,6 +1851,7 @@ void publish_presentations(std::vector&& presentationJobs, bool present_presentation_job(job); } } +#endif } void record_frame_telemetry() { diff --git a/aurora-main/lib/dolphin/pad/pad.cpp b/aurora-main/lib/dolphin/pad/pad.cpp index ff8d4c6..bb82c24 100644 --- a/aurora-main/lib/dolphin/pad/pad.cpp +++ b/aurora-main/lib/dolphin/pad/pad.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -193,6 +194,32 @@ std::array g_defaultButtonsJoyPair{{ {SDL_GAMEPAD_BUTTON_DPAD_RIGHT, PAD_BUTTON_RIGHT}, }}; +// Wii U Pro Controllers through SDL's HIDAPI Wii driver. No SDL_GamepadType +// singles them out, so they are picked by the name the driver gives them (see +// __PADSetDefaultMapping). Wii Remotes, with or without a Nunchuk or Classic +// Controller, are read by the game through KPAD instead and never get a +// GameCube mapping (the runtime hides those ports from PADRead). + +// Nintendo's labelled layout: A on the right accelerates, B at the bottom +// brakes. SDL's Wii driver reports ZL/ZR as the LEFT_TRIGGER/RIGHT_TRIGGER +// axes, never as shoulder buttons, so they are left unbound here and picked up +// by aurora's default axis mapping (g_defaultAxes) the same way every +// analog-trigger pad's L/R is. +std::array g_defaultButtonsWiiUPro{{ + {SDL_GAMEPAD_BUTTON_EAST, PAD_BUTTON_A}, + {SDL_GAMEPAD_BUTTON_SOUTH, PAD_BUTTON_B}, + {SDL_GAMEPAD_BUTTON_NORTH, PAD_BUTTON_X}, + {SDL_GAMEPAD_BUTTON_WEST, PAD_BUTTON_Y}, + {SDL_GAMEPAD_BUTTON_START, PAD_BUTTON_START}, + {SDL_GAMEPAD_BUTTON_BACK, PAD_TRIGGER_Z}, + {PAD_NATIVE_BUTTON_INVALID, PAD_TRIGGER_L}, + {PAD_NATIVE_BUTTON_INVALID, PAD_TRIGGER_R}, + {SDL_GAMEPAD_BUTTON_DPAD_UP, PAD_BUTTON_UP}, + {SDL_GAMEPAD_BUTTON_DPAD_DOWN, PAD_BUTTON_DOWN}, + {SDL_GAMEPAD_BUTTON_DPAD_LEFT, PAD_BUTTON_LEFT}, + {SDL_GAMEPAD_BUTTON_DPAD_RIGHT, PAD_BUTTON_RIGHT}, +}}; + std::array g_defaultKeys{{ {PAD_KEY_INVALID, PAD_BUTTON_A}, {PAD_KEY_INVALID, PAD_BUTTON_B}, @@ -409,8 +436,26 @@ static void reset_alt_button_mapping(aurora::input::GameController* controller) } } +// SDL's hidapi Wii driver names the pad "Nintendo Wii U Pro Controller"; the +// other names it produces are Wii Remotes, which the game reads through KPAD +// and which therefore never take a GameCube mapping. +static bool wii_default_mapping(const aurora::input::GameController* controller, + std::array& out) { + const char* name = SDL_GetGamepadName(controller->m_controller); + if (name == nullptr || SDL_strstr(name, "Wii U Pro Controller") == nullptr) { + return false; + } + out = g_defaultButtonsWiiUPro; + return true; +} + +// Picks the default button table for a controller by name (Wii pads) or SDL gamepad type. void __PADSetDefaultMapping(aurora::input::GameController* controller) /* NOLINT(*-reserved-identifier) */ { + if (wii_default_mapping(controller, controller->m_buttonMapping)) { + reset_alt_button_mapping(controller); + return; + } switch (SDL_GetGamepadType(controller->m_controller)) { case SDL_GAMEPAD_TYPE_XBOX360: controller->m_buttonMapping = g_defaultButtonsXBox360; @@ -745,6 +790,47 @@ u32 PADRead(PADStatus* status) { if (controller) { EnsureMappingLoaded(controller); + + // Wii U Pro Controller raw D-pad fallback. SDL's HIDAPI Wii driver posts + // the D-pad as joystick buttons 11-14 (the SDL_GAMEPAD_BUTTON_DPAD_* + // values) and never as a hat, but the mapping SDL generates for HIDAPI + // pads binds the D-pad to hat 0, so SDL_GetGamepadButton(DPAD_*) stays + // false. Keep this restricted to the Wii driver's pad so raw button + // indices don't interfere with other controller types. + const char* name = SDL_GetGamepadName(controller->m_controller); + const bool isWiiUPro = name != nullptr && SDL_strstr(name, "Wii U Pro Controller") != nullptr; + + if (isWiiUPro) { + SDL_Joystick* joystick = + SDL_GetGamepadJoystick(controller->m_controller); + + uint32_t raw = 0; + const int buttonCount = SDL_GetNumJoystickButtons(joystick); + + for (int b = 0; b < buttonCount && b < 32; ++b) { + if (SDL_GetJoystickButton(joystick, b)) { + raw |= (1u << b); + } + } + + // Up = button 11 + if (raw & (1u << 11)) { + status[i].button |= PAD_BUTTON_UP; + } + // Down = button 12 + if (raw & (1u << 12)) { + status[i].button |= PAD_BUTTON_DOWN; + } + // Left = button 13 + if (raw & (1u << 13)) { + status[i].button |= PAD_BUTTON_LEFT; + } + // Right = button 14 + if (raw & (1u << 14)) { + status[i].button |= PAD_BUTTON_RIGHT; + } + } + bool leftTriggerSet = false; bool rightTriggerSet = false; std::ranges::for_each(controller->m_buttonMapping, [&controller, &i, &status, &leftTriggerSet, @@ -778,6 +864,7 @@ u32 PADRead(PADStatus* status) { } }); + // TODO: Add serializable mappings for these (probably not necessary)? static constexpr std::array, PAD_EXT_BUTTON_COUNT> kExtButtonMappings{{ {SDL_GAMEPAD_BUTTON_BACK, PAD_BUTTON_BACK}, diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index ba3d582..3ba72f0 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1,18 +1,28 @@ cmake_minimum_required(VERSION 3.16) project(mkw_recompiled) -if((NOT (WIN32 AND MINGW)) AND (NOT CMAKE_SYSTEM_NAME STREQUAL "Linux")) - message(FATAL_ERROR "WiiCompiled requires Windows (LLVM-MinGW) or native Linux") +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(NOT CMAKE_CXX_COMPILER_ID STREQUAL "Clang" OR - NOT CMAKE_SIZEOF_VOID_P EQUAL 8 OR - NOT CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64|aarch64|arm64|ARM64)$") - message(FATAL_ERROR "WiiCompiled requires 64-bit Clang targeting x86_64 or aarch64") + +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. + set(MKW_PLATFORM_MACOS TRUE) +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() if(NOT CMAKE_BUILD_TYPE STREQUAL "Release") message(FATAL_ERROR "WiiCompiled only supports Release builds") endif() +option(MKW_BUILD_PRODUCTS "Build translated WiiCompiled product targets" ON) + # Preprocessor definitions that belong to this project's own code (the runtime, # the translated shards and the product glue) and to nothing else. They are # applied directory-scoped, immediately after the aurora add_subdirectory() call, @@ -49,17 +59,14 @@ 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) -# Non-Windows guest-fiber scheduling (runtime/src/fiber_manager.cpp) needs a symmetric +# Linux 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) - all of libco's non-Windows -# CPU-architecture backends are kept, even though libco.c's own preprocessor dispatch -# (__amd64__/__i386__/__arm__/__aarch64__/etc.) only ever selects amd64.c for this project's -# x86_64-only target (see the platform/arch check above). Windows keeps using native Fibers -# untouched, so this target is never built there. -if(NOT WIN32) +# 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) 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) @@ -110,7 +117,7 @@ set(MKW_CPPWINRT_INCLUDE_DIR "" CACHE PATH "Optional self-contained C++/WinRT in # available to explicit developer builds without making a nonfunctional SDK # fetch part of every normal build. set(MKW_ENABLE_OPENXR_DEFAULT OFF) -if(WIN32) +if(MKW_PLATFORM_WINDOWS) set(MKW_ENABLE_OPENXR_DEFAULT ON) endif() option(MKW_ENABLE_OPENXR "Build OpenXR VR support" ${MKW_ENABLE_OPENXR_DEFAULT}) @@ -143,31 +150,35 @@ else() message(FATAL_ERROR "Requested aurora-main but ${MKW_AURORA_DIR} is missing") endif() set(DAWN_ENABLE_D3D11 OFF CACHE BOOL "" FORCE) - if(WIN32) + if(MKW_PLATFORM_WINDOWS) set(DAWN_ENABLE_D3D12 ON CACHE BOOL "" FORCE) + set(DAWN_ENABLE_VULKAN ON CACHE BOOL "" FORCE) + set(DAWN_ENABLE_METAL OFF CACHE BOOL "" FORCE) set(TINT_BUILD_HLSL_WRITER ON CACHE BOOL "" FORCE) - set(DAWN_USE_WINDOWS_UI OFF CACHE BOOL "" FORCE) - else() - # Non-Windows (Linux): mirrors aurora-main's own - # _aurora_dawn_set_platform_backends() choice for this platform - Vulkan only, no - # D3D/HLSL. Kept in sync here because this project's own CMake FORCEs these cache - # variables before aurora-main's add_subdirectory() runs, which pre-empts aurora's - # auto-detection (CACHE ... INTERNAL "" without FORCE never overrides an existing value). + elseif(MKW_PLATFORM_MACOS) set(DAWN_ENABLE_D3D12 OFF CACHE BOOL "" FORCE) + set(DAWN_ENABLE_VULKAN OFF CACHE BOOL "" FORCE) + set(DAWN_ENABLE_METAL ON CACHE BOOL "" FORCE) + set(TINT_BUILD_HLSL_WRITER OFF CACHE BOOL "" FORCE) + else() + set(DAWN_ENABLE_D3D12 OFF CACHE BOOL "" FORCE) + set(DAWN_ENABLE_VULKAN ON CACHE BOOL "" FORCE) + set(DAWN_ENABLE_METAL OFF CACHE BOOL "" FORCE) set(TINT_BUILD_HLSL_WRITER OFF CACHE BOOL "" FORCE) endif() - set(DAWN_ENABLE_VULKAN ON CACHE BOOL "" FORCE) set(DAWN_BUILD_SAMPLES OFF CACHE BOOL "" FORCE) set(DAWN_BUILD_TESTS OFF CACHE BOOL "" FORCE) # Provide a tiny stub for DXProgrammableCapture when the SDK/PIX headers are # missing (common on MinGW). Dawn only includes the header; no symbols are # referenced when PIX isn't present. - set(MKW_DX_STUB_DIR "${CMAKE_BINARY_DIR}/aurora_dx_stubs") - if(NOT EXISTS "${MKW_DX_STUB_DIR}/DXProgrammableCapture.h") - file(MAKE_DIRECTORY ${MKW_DX_STUB_DIR}) - file(WRITE "${MKW_DX_STUB_DIR}/DXProgrammableCapture.h" -"#pragma once\n// Stubbed PIX capture header for Dawn; no functionality when PIX is absent.\n") + if(MKW_PLATFORM_WINDOWS) + set(MKW_DX_STUB_DIR "${CMAKE_BINARY_DIR}/aurora_dx_stubs") + if(NOT EXISTS "${MKW_DX_STUB_DIR}/DXProgrammableCapture.h") + file(MAKE_DIRECTORY ${MKW_DX_STUB_DIR}) + file(WRITE "${MKW_DX_STUB_DIR}/DXProgrammableCapture.h" +"#pragma once\n// Stubbed PIX capture header for Dawn; no functionality when PIX isn't present.\n") + endif() endif() # Deliberately NOT injected project-wide. Only a from-source Dawn build ever # includes DXProgrammableCapture.h, and this tree consumes Dawn as a prebuilt @@ -196,7 +207,9 @@ else() if(TARGET ${t}) set_target_properties(${t} PROPERTIES UNITY_BUILD OFF) target_compile_options(${t} PRIVATE -O3 -ffast-math -w -pipe) - target_include_directories(${t} PRIVATE ${MKW_DX_STUB_DIR}) + if(MKW_PLATFORM_WINDOWS) + target_include_directories(${t} PRIVATE ${MKW_DX_STUB_DIR}) + endif() # Aurora's own sources include Windows headers and call std::min/max; # they relied on the old project-wide NOMINMAX that no longer leaks # into this subtree, so the define is applied per target here. @@ -237,7 +250,7 @@ endif() set(MKW_OPENXR_TARGET "") if(MKW_ENABLE_OPENXR) - if(NOT WIN32 AND NOT CMAKE_SYSTEM_NAME STREQUAL "Linux") + if(NOT MKW_PLATFORM_WINDOWS AND NOT MKW_PLATFORM_LINUX) message(WARNING "OpenXR is not wired for this platform; disabling MKW_ENABLE_OPENXR") set(MKW_ENABLE_OPENXR OFF) else() @@ -286,6 +299,18 @@ endif() # a registration file is silently never compiled and never errors. The stale-glob # failure mode is worth far more than the milliseconds. 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") +else() + list(REMOVE_ITEM SOURCES "${CMAKE_CURRENT_LIST_DIR}/src/guest_flat_memory_macos.cpp") +endif() +set(MKW_PLATFORM_SOURCE "${CMAKE_CURRENT_LIST_DIR}/src/platform/host_platform.cpp") set(MKW_BASE_PRODUCT_SOURCE "${CMAKE_CURRENT_LIST_DIR}/src/product/base_product.cpp") set(MKW_RETRO_REWIND_PRODUCT_SOURCE "${CMAKE_CURRENT_LIST_DIR}/src/product/retro_rewind_product.cpp") # The host ISA guard is the one translation unit that must not receive the @@ -293,25 +318,113 @@ set(MKW_RETRO_REWIND_PRODUCT_SOURCE "${CMAKE_CURRENT_LIST_DIR}/src/product/retro # mkw_runtime_common. See cmake/PublicProducts.cmake and the file's own header. set(MKW_CPU_BASELINE_SOURCE "${CMAKE_CURRENT_LIST_DIR}/src/host_cpu_baseline.cpp") list(REMOVE_ITEM SOURCES ${MKW_BASE_PRODUCT_SOURCE} ${MKW_RETRO_REWIND_PRODUCT_SOURCE} - ${MKW_CPU_BASELINE_SOURCE}) + ${MKW_CPU_BASELINE_SOURCE} ${MKW_PLATFORM_SOURCE}) + +# This deliberately small library contains host services that are safe to +# validate before guest memory and fiber work makes a full runtime build viable. +add_library(mkw_platform STATIC "${MKW_PLATFORM_SOURCE}") +target_include_directories(mkw_platform PUBLIC "${CMAKE_CURRENT_LIST_DIR}/include") +target_compile_features(mkw_platform PUBLIC cxx_std_17) +set_target_properties(mkw_platform PROPERTIES UNITY_BUILD OFF) + +# Keep these independent from Aurora's BUILD_TESTING option: they validate the +# project's host-platform contracts, not Aurora's third-party test suite. +enable_testing() +add_executable(mkw_platform_paths_tests "${CMAKE_CURRENT_LIST_DIR}/tests/platform_paths_tests.cpp") +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) + +# HostContext deliberately keeps the platform-specific context primitive out +# of fiber_manager.cpp. Exercise the Linux libco handoff directly so future +# refactors cannot silently remove its headers, implementation, or link edge. +if(MKW_PLATFORM_LINUX) + add_executable(mkw_linux_host_context_tests + "${CMAKE_CURRENT_LIST_DIR}/tests/host_context_tests.cpp" + "${CMAKE_CURRENT_LIST_DIR}/src/host_context.cpp") + target_include_directories(mkw_linux_host_context_tests PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/include" + "${CMAKE_CURRENT_LIST_DIR}/third_party/libco") + target_compile_features(mkw_linux_host_context_tests PRIVATE cxx_std_17) + target_link_libraries(mkw_linux_host_context_tests PRIVATE mkw::libco) + add_test(NAME mkw_linux_host_context_tests COMMAND mkw_linux_host_context_tests) +endif() + +if(MKW_PLATFORM_MACOS) + # Exercise the Apple Silicon context ABI and the public host-memory + # contracts separately from translated products. + enable_language(ASM) + add_executable(mkw_macos_context_abi_tests + "${CMAKE_CURRENT_LIST_DIR}/tests/macos_context_abi_tests.cpp" + "${CMAKE_CURRENT_LIST_DIR}/src/platform/macos/co_switch.S") + target_compile_features(mkw_macos_context_abi_tests PRIVATE cxx_std_17) + add_test(NAME mkw_macos_context_abi_tests COMMAND mkw_macos_context_abi_tests) + + add_executable(mkw_macos_host_context_tests + "${CMAKE_CURRENT_LIST_DIR}/tests/host_context_tests.cpp" + "${CMAKE_CURRENT_LIST_DIR}/src/host_context.cpp" + "${CMAKE_CURRENT_LIST_DIR}/src/platform/macos/co_switch.S") + target_include_directories(mkw_macos_host_context_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") + 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) + + add_executable(mkw_macos_guest_flat_memory_tests + "${CMAKE_CURRENT_LIST_DIR}/tests/macos_guest_flat_memory_tests.cpp" + "${CMAKE_CURRENT_LIST_DIR}/src/guest_flat_memory_macos.cpp") + target_include_directories(mkw_macos_guest_flat_memory_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include") + target_compile_features(mkw_macos_guest_flat_memory_tests PRIVATE cxx_std_17) + add_test(NAME mkw_macos_guest_flat_memory_tests COMMAND mkw_macos_guest_flat_memory_tests) +endif() # The translator emits the complete, content-addressed source graph. Consuming # this one manifest keeps configure independent of the 28k generated function # files and of optional Retro Rewind artifacts such as code.map. -set(MKW_TRANSLATED_SHARD_MANIFEST - "${CMAKE_CURRENT_LIST_DIR}/../generated/build_shards/shards.cmake" - CACHE FILEPATH "Translator-owned aggregate shard manifest") -if(NOT EXISTS "${MKW_TRANSLATED_SHARD_MANIFEST}") - message(FATAL_ERROR - "Missing translator-owned shard manifest: ${MKW_TRANSLATED_SHARD_MANIFEST}. " - "Run Translator.Cli emit-build-shards first; see translator/README.md.") -endif() -include("${MKW_TRANSLATED_SHARD_MANIFEST}") -set(MKW_HAVE_RETRO_REWIND ${MKW_HAVE_RETRO_REWIND_SHARDS}) -message(STATUS - "Translator graph: ${MKW_SHARED_BASE_FUNCTION_COUNT}/${MKW_BASE_FUNCTION_COUNT} base functions shared; " - "${MKW_PROFILE_SENSITIVE_CALLER_COUNT} profile-sensitive callers; " - "${MKW_RETRO_REWIND_FUNCTION_COUNT} Retro Rewind functions") +if(MKW_BUILD_PRODUCTS) + set(MKW_TRANSLATED_SHARD_MANIFEST + "${CMAKE_CURRENT_LIST_DIR}/../generated/build_shards/shards.cmake" + CACHE FILEPATH "Translator-owned aggregate shard manifest") + # The prebuilt export only needs the aurora/third-party closure configured above, so a + # packaging machine without a translation stops here instead of failing. + if(MKW_NATIVE_PREBUILT_EXPORT_DIR AND NOT EXISTS "${MKW_TRANSLATED_SHARD_MANIFEST}") + message(STATUS "No translator shard manifest; configuring the native prebuilt export only") + return() + endif() + if(NOT EXISTS "${MKW_TRANSLATED_SHARD_MANIFEST}") + message(FATAL_ERROR + "Missing translator-owned shard manifest: ${MKW_TRANSLATED_SHARD_MANIFEST}. " + "Run Translator.Cli emit-build-shards first; see translator/README.md.") + endif() + include("${MKW_TRANSLATED_SHARD_MANIFEST}") + set(MKW_HAVE_RETRO_REWIND ${MKW_HAVE_RETRO_REWIND_SHARDS}) + message(STATUS + "Translator graph: ${MKW_SHARED_BASE_FUNCTION_COUNT}/${MKW_BASE_FUNCTION_COUNT} base functions shared; " + "${MKW_PROFILE_SENSITIVE_CALLER_COUNT} profile-sensitive callers; " + "${MKW_RETRO_REWIND_FUNCTION_COUNT} Retro Rewind functions") -set(MKW_RUNTIME_SOURCE_DIR "${CMAKE_CURRENT_LIST_DIR}") -include("${CMAKE_CURRENT_LIST_DIR}/cmake/PublicProducts.cmake") + set(MKW_RUNTIME_SOURCE_DIR "${CMAKE_CURRENT_LIST_DIR}") + include("${CMAKE_CURRENT_LIST_DIR}/cmake/PublicProducts.cmake") +else() + if(MKW_PLATFORM_MACOS) + # Compile-only audit of native runtime sources. It deliberately avoids + # translated products until their host dependencies are portable. + # These sources depend on generated/RuntimeConfig.h, which is emitted for + # a particular game by the translator and is intentionally unavailable + # in this platform-only configuration. + set(MKW_MACOS_NATIVE_AUDIT_SOURCES ${SOURCES}) + list(REMOVE_ITEM MKW_MACOS_NATIVE_AUDIT_SOURCES + "${CMAKE_CURRENT_LIST_DIR}/src/abi_bridge.cpp" + "${CMAKE_CURRENT_LIST_DIR}/src/hle/os/os_alarm.cpp") + add_library(mkw_macos_native_compile OBJECT ${MKW_MACOS_NATIVE_AUDIT_SOURCES}) + target_include_directories(mkw_macos_native_compile PRIVATE + "${CMAKE_CURRENT_LIST_DIR}/include" "${CMAKE_CURRENT_LIST_DIR}/src" + "${CMAKE_CURRENT_LIST_DIR}/.." "${CMAKE_CURRENT_LIST_DIR}/../aurora-main/include") + target_compile_features(mkw_macos_native_compile PRIVATE cxx_std_20) + target_compile_definitions(mkw_macos_native_compile PRIVATE SDL_MAIN_HANDLED TARGET_PC) + target_link_libraries(mkw_macos_native_compile PRIVATE + aurora::gx aurora::pad aurora::si aurora::vi aurora::mtx + mkw::pugixml mkw::toml11 mkw::cryptopp) + set_target_properties(mkw_macos_native_compile PROPERTIES UNITY_BUILD OFF) + endif() + add_custom_target(mkw_platform_paths_check DEPENDS mkw_platform) + message(STATUS "Translated product targets disabled (MKW_BUILD_PRODUCTS=OFF)") +endif() diff --git a/runtime/cmake/NativePrebuilt.cmake b/runtime/cmake/NativePrebuilt.cmake index f889ecb..2208a11 100644 --- a/runtime/cmake/NativePrebuilt.cmake +++ b/runtime/cmake/NativePrebuilt.cmake @@ -51,6 +51,16 @@ foreach(_dir IN LISTS _mkw_np_includes) endif() endforeach() +# Dawn's own packaged config (DawnTargets.cmake) links dawn::webgpu_dawn against +# Threads::Threads directly. A from-source aurora build resolves that as a side effect of +# add_subdirectory(aurora-main) pulling in Dawn's own CMakeLists.txt; this mode never runs that +# subdirectory at all, so nothing else would ever define it (verified directly: configuring without +# this fails with "The link interface of target dawn::webgpu_dawn contains: Threads::Threads but +# the target was not found"). find_package(Threads) is one of CMake's most basic finder modules and +# is a no-op on Windows (its threading support is already part of the CRT), so this is safe on +# every platform this package format targets, not just the one that first hit the failure. +find_package(Threads REQUIRED) + # Dawn is a prebuilt package on both sides; resolve the same install tree the # package was built against so its imported target (and therefore # webgpu_dawn.dll / dxcompiler.dll / dxil.dll) is available exactly as a diff --git a/runtime/cmake/PublicProducts.cmake b/runtime/cmake/PublicProducts.cmake index 08902c5..97abf1f 100644 --- a/runtime/cmake/PublicProducts.cmake +++ b/runtime/cmake/PublicProducts.cmake @@ -25,6 +25,11 @@ if(EXISTS "${DATA_INIT_BLOB_ASM}") endif() list(REMOVE_DUPLICATES SOURCES) +if(MKW_PLATFORM_MACOS) + find_library(MKW_IOKIT_FRAMEWORK IOKit REQUIRED) + find_library(MKW_COREFOUNDATION_FRAMEWORK CoreFoundation REQUIRED) +endif() + function(mkw_apply_common_compile_options target) target_compile_options(${target} PRIVATE -O3 -ffast-math -w -pipe) endfunction() @@ -76,13 +81,13 @@ target_compile_definitions(mkw_runtime_common PRIVATE _DISABLE_STRING_ANNOTATION _DISABLE_VECTOR_ANNOTATION) target_link_libraries(mkw_runtime_common PRIVATE aurora::gx aurora::pad aurora::si aurora::vi aurora::mtx) -target_link_libraries(mkw_runtime_common PRIVATE mkw::pugixml mkw::toml11 mkw::cryptopp) +target_link_libraries(mkw_runtime_common PRIVATE mkw_platform mkw::pugixml mkw::toml11 mkw::cryptopp) if(MKW_ENABLE_OPENXR) target_link_libraries(mkw_runtime_common PRIVATE ${MKW_OPENXR_TARGET}) endif() -if(WIN32) +if(MKW_PLATFORM_WINDOWS) target_link_libraries(mkw_runtime_common PRIVATE shell32 windowsapp) -else() +elseif(MKW_PLATFORM_LINUX) # ${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. target_link_libraries(mkw_runtime_common PRIVATE mkw::libco ${CMAKE_DL_LIBS}) @@ -130,16 +135,16 @@ set_target_properties(mkw_runtime_common PROPERTIES UNITY_BUILD ON UNITY_BUILD_M target_precompile_headers(mkw_runtime_common PRIVATE "${MKW_RUNTIME_SOURCE_DIR}/include/mkw_pch.h") mkw_apply_common_compile_options(mkw_runtime_common) -# Host ISA guard. Everything in MKW_ALL_BUILD_TARGETS below is compiled with -# -march=x86-64-v3; this object library deliberately is not, which -# is the whole point of keeping it out of mkw_runtime_common. It runs a CPUID -# check from a C initializer so an unsupported machine gets a readable error -# instead of an illegal-instruction crash. Excluded from the unity build and the -# precompiled header because both are produced with the owning target's flags. -add_library(mkw_cpu_baseline OBJECT "${MKW_CPU_BASELINE_SOURCE}") -target_compile_features(mkw_cpu_baseline PRIVATE cxx_std_17) -set_target_properties(mkw_cpu_baseline PROPERTIES UNITY_BUILD OFF) -target_compile_options(mkw_cpu_baseline PRIVATE -w) +# Host ISA guard. Windows and Linux x86_64 product targets use x86-64-v3, so +# this object deliberately keeps the plain baseline ISA and checks the CPU +# before any AVX2/FMA code can execute. AArch64 has no equivalent optional ISA +# floor to probe: NEON/FMA are architectural requirements. +if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64)$") + add_library(mkw_cpu_baseline OBJECT "${MKW_CPU_BASELINE_SOURCE}") + target_compile_features(mkw_cpu_baseline PRIVATE cxx_std_17) + set_target_properties(mkw_cpu_baseline PROPERTIES UNITY_BUILD OFF) + target_compile_options(mkw_cpu_baseline PRIVATE -w) +endif() if(NOT MKW_BASE_COMMON_SHARDS) message(FATAL_ERROR "Translator build graph contains no shared base shards") @@ -179,7 +184,9 @@ function(mkw_configure_product target) target_sources(${target} PRIVATE $) # Startup CPU check. Must stay a separate object library so it keeps the # plain baseline ISA while everything around it is built for x86-64-v3. - target_sources(${target} PRIVATE $) + if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64)$") + target_sources(${target} PRIVATE $) + endif() target_include_directories(${target} PRIVATE "${MKW_RUNTIME_SOURCE_DIR}/include" "${MKW_RUNTIME_SOURCE_DIR}/src" @@ -195,7 +202,7 @@ function(mkw_configure_product target) # include the same fat translated headers; bound them by the same pool. mkw_bound_translated_compiles(${target}) target_link_libraries(${target} PRIVATE - mkw_base_shared mkw::pugixml mkw::toml11 mkw::cryptopp) + mkw_platform mkw_base_shared mkw::pugixml mkw::toml11 mkw::cryptopp) target_link_libraries(${target} PRIVATE aurora::gx aurora::pad aurora::si aurora::vi aurora::mtx) @@ -204,6 +211,10 @@ function(mkw_configure_product target) # loader dependency must also be present on each final product link. target_link_libraries(${target} PRIVATE ${MKW_OPENXR_TARGET}) endif() + if(MKW_PLATFORM_MACOS) + target_link_libraries(${target} PRIVATE + "${MKW_IOKIT_FRAMEWORK}" "${MKW_COREFOUNDATION_FRAMEWORK}") + endif() if(EXISTS "${MKW_AURORA_DIR}/cmake/AuroraCopyRuntimeDLLs.cmake") include("${MKW_AURORA_DIR}/cmake/AuroraCopyRuntimeDLLs.cmake") aurora_copy_runtime_dlls(${target}) @@ -218,23 +229,23 @@ function(mkw_configure_product target) $ $) endif() - if(WIN32) + if(MKW_PLATFORM_WINDOWS) target_link_libraries(${target} PRIVATE dbghelp user32 winmm ws2_32 iphlpapi secur32 crypt32 windowsapp) set_target_properties(${target} PROPERTIES WIN32_EXECUTABLE TRUE) - else() + elseif(MKW_PLATFORM_LINUX) # 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 # that isn't itself linked against as a target). fiber_manager.cpp's co_* calls live in # those objects, so the actual executable link needs mkw::libco directly, same as it - # needs it independently of that first `if(WIN32)` branch above. ${CMAKE_DL_LIBS} is + # needs it independently of the platform branch above. ${CMAKE_DL_LIBS} is # here for the same reason: music_attenuation.cpp's dlopen(libdbus-1) lives in those # objects (empty string on glibc >= 2.34, where dl* is in libc). target_link_libraries(${target} PRIVATE mkw::libco ${CMAKE_DL_LIBS}) endif() - if(WIN32) + if(MKW_PLATFORM_WINDOWS) foreach(runtime_dll libc++.dll libunwind.dll) execute_process( COMMAND "${CMAKE_CXX_COMPILER}" "--print-file-name=${runtime_dll}" @@ -310,13 +321,10 @@ else() message(STATUS "RetroRewind target disabled (run translate-mod and emit-build-shards)") endif() -# x86-64-v3 (SSE3/SSSE3/SSE4.1/FMA/AVX2/BMI2) is the baseline runtime/src/host_cpu_baseline.cpp -# guards against - a fixed, portable floor since an x86_64 build may run on a different machine -# than the one that built it. AArch64 has no such redistribution path here: every build this -# project produces runs only on the machine that built it (local-build.sh, and the AppImage which -# wraps it, always build from source on the target), so -mcpu=native is safe and strictly better - -# real per-core tuning (scheduling, whatever NEON/atomic extensions that exact CPU actually has) -# instead of the generic armv8-a baseline Clang would otherwise assume. +# Windows and Linux x86_64 share the x86-64-v3 floor that the CPU baseline +# object above checks. AArch64 builds are compiled locally for the host that +# will run them, so both Linux and Apple Silicon use the compiler's native CPU +# tuning rather than leaving target-specific performance on the table. if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64)$") set(MKW_BASELINE_ARCH_FLAG -march=x86-64-v3) elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "^(aarch64|arm64|ARM64)$") diff --git a/runtime/include/discord_presence.h b/runtime/include/discord_presence.h new file mode 100644 index 0000000..3bd6025 --- /dev/null +++ b/runtime/include/discord_presence.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +// Host implementation of Dolphin's /dev/dolphin Discord contract. The guest +// sends only strings and big-endian integer fields; the IPC wire protocol is +// owned here so translated game code never needs host SDK headers. +namespace DiscordPresence { + +struct Activity { + std::string details; + std::string state; + std::string largeImageKey; + std::string largeImageText; + std::string smallImageKey; + std::string smallImageText; + int64_t startTimestamp = 0; + int64_t endTimestamp = 0; + uint32_t partySize = 0; + uint32_t partyMax = 0; +}; + +void Initialize(const std::string& basicClientId, const std::string& basicTitle); +void SetClient(const std::string& clientId); +void SetActivity(Activity activity); +void Reset(); +void Shutdown(); + +} // namespace DiscordPresence diff --git a/runtime/include/fiber_manager.h b/runtime/include/fiber_manager.h index bdefcf3..cad48c4 100644 --- a/runtime/include/fiber_manager.h +++ b/runtime/include/fiber_manager.h @@ -6,6 +6,7 @@ #include #include #include +#include #if defined(_WIN32) #ifndef NOMINMAX @@ -22,7 +23,7 @@ // Forward declarations struct CpuContext; -// GuestFiberManager: each guest OSThread maps to a Windows Fiber. A scheduler fiber picks +// GuestFiberManager: each guest OSThread maps to a host context. A scheduler context picks // which guest fiber runs; a real timer thread queues VI retraces at the VI cadence. Guest // threads only switch at explicit yield points (OSSleepThread, OSYieldThread, ...), matching // Wii cooperative semantics exactly. @@ -39,7 +40,7 @@ enum class ThreadState : uint32_t { // Information about a guest fiber struct GuestFiber { - void* fiber = nullptr; // Windows fiber handle + void* fiber = nullptr; // Host context handle uint32_t entryPoint = 0; // Thread entry function uint32_t entryArg = 0; // Argument to entry function CpuContext cpuContext{}; // Saved CPU context for this fiber @@ -102,10 +103,6 @@ private: static void CALLBACK FiberProc(void* param); #else static void FiberProc(void* param); - // libco's co_create() entry points take no argument (unlike CreateFiber's FiberProc(void*)), - // so this trampoline reads the guest thread address staged by CreateGuestFiber() and forwards - // into the (platform-neutral-bodied) FiberProc above. See fiber_manager.cpp. - static void FiberProcTrampoline(); #endif // Switch from whichever fiber is currently active straight to the scheduler fiber, without // the SwitchToThread bookkeeping (CPU context save/restore, s_currentGuestThread). Used for @@ -117,9 +114,8 @@ private: static std::mutex s_mutex; static std::unordered_map s_fibers; static std::vector s_fibersPendingDelete; - // The scheduler's own "fiber": a Windows HFIBER, or (non-Windows) libco's cothread_t for - // whichever native call stack first called GuestFiberManager::Initialize() - both are - // plain void* handles, so one field serves both platforms. + // The scheduler's own host context. Its opaque handle is supplied by the + // active HostContext backend, so one field serves every supported host. static void* s_schedulerFiber; static uint32_t s_currentGuestThread; static bool s_initialized; @@ -135,4 +131,3 @@ private: extern std::atomic g_viRetracePendingCount; } // namespace Fiber - diff --git a/runtime/include/guest_flat_memory.h b/runtime/include/guest_flat_memory.h index 0973ae7..6c0bdd3 100644 --- a/runtime/include/guest_flat_memory.h +++ b/runtime/include/guest_flat_memory.h @@ -14,10 +14,17 @@ namespace GuestFlat { // Fixed base so the emitted access is `[reg + imm64-in-register]` with no load // of a global. inline constexpr uint64_t kGuestSpaceSize = 0x1'0000'0000ull; +inline constexpr size_t kGuestPageSize = 0x1000; #if defined(__x86_64__) // 16 TiB: clear of the Windows ASan shadow (32 TiB) and of the usual image/heap // placement. inline constexpr uintptr_t kFixedFlatGuestBase = 0x0000'1000'0000'0000ull; +#elif defined(__aarch64__) && defined(__APPLE__) +// Keep this well above the low address ranges that Darwin's ASLR may use for +// a PIE executable and its shared cache. Apple Silicon's user VA is wider +// than Linux's 39-bit minimum, so this 512 GiB region is available while the +// Linux AArch64 target retains its 64 GiB placement below. +inline constexpr uintptr_t kFixedFlatGuestBase = 0x0000'0080'0000'0000ull; #elif defined(__aarch64__) // 16 TiB (this arch's x86_64 sibling value) is unreachable on any AArch64 // kernel configured for 39-bit virtual addresses (512 GiB ceiling) - common on @@ -58,6 +65,25 @@ struct FaultCounters { // True once the reservation exists and translated code may use the flat path. bool IsActive(); +// True when a host VM page covers more than one 4 KiB Wii page. In that +// configuration, guest-view page protection cannot safely represent per-Wii- +// page MMIO, deferred-read, or executable-write state, so general translated +// accesses must use the checked Memory::* path. +// Windows user mode and x86-64 always use a 4 KiB base page, so those builds +// fold this to a compile-time false: it appears in every flat access and must +// not become a hot-path load. Only AArch64, where the page size is a kernel +// configuration (4/16/64 KiB), has to probe it at runtime. +#if defined(_WIN32) || defined(__x86_64__) +#define MKW_GUEST_FLAT_FIXED_PAGE_SIZE 1 +#endif + +#if defined(MKW_GUEST_FLAT_FIXED_PAGE_SIZE) +inline constexpr bool RequiresCheckedAccess() noexcept { return false; } +#else +extern bool g_requiresCheckedAccess; +inline bool RequiresCheckedAccess() noexcept { return g_requiresCheckedAccess; } +#endif + // Reserves the 4 GiB space (once per process) and maps every requested region // into both views. Throws std::runtime_error with a precise diagnosis when the // reservation, the section objects or a view cannot be created - a silent diff --git a/runtime/include/host_context.h b/runtime/include/host_context.h new file mode 100644 index 0000000..4d7355f --- /dev/null +++ b/runtime/include/host_context.h @@ -0,0 +1,24 @@ +#pragma once + +#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. +namespace HostContext { + +using Handle = void*; +using Entry = void (*)(void*); + +bool InitializeScheduler(Handle* scheduler); +void ShutdownScheduler(Handle scheduler); + +Handle Create(std::size_t stackSize, Entry entry, void* argument); +void Destroy(Handle context); +bool IsCurrent(Handle context); +void Switch(Handle target); + +} // namespace HostContext diff --git a/runtime/include/isa/ppc_isa_quantized.h b/runtime/include/isa/ppc_isa_quantized.h index 9f5c807..bc22f01 100644 --- a/runtime/include/isa/ppc_isa_quantized.h +++ b/runtime/include/isa/ppc_isa_quantized.h @@ -264,6 +264,8 @@ inline void PpcWritePairPsqInline(uint32_t addr, T first, T second) // reading stale bytes, and unmapped pages commit on demand, same as MemoryInline::Flat* loads. MKW_PPC_FORCE_INLINE const uint8_t* PpcTryGetPsqReadableHostInline(uint32_t addr) { + if (GuestFlat::RequiresCheckedAccess()) [[unlikely]] + return nullptr; return MKW_FLAT_GUEST_BASE + addr; } @@ -274,6 +276,8 @@ MKW_PPC_FORCE_INLINE const uint8_t* PpcTryGetPsqReadableHostInline(uint32_t addr // executable, and unmapped pages still trap. MKW_PPC_FORCE_INLINE uint8_t* PpcTryGetPsqWritableHostInline(uint32_t addr) { + if (GuestFlat::RequiresCheckedAccess()) [[unlikely]] + return nullptr; if (addr > UINT32_MAX - 7u) [[unlikely]] return nullptr; if (MemoryInline::FlatWriteNeedsPolicy(addr) || diff --git a/runtime/include/memory_access.h b/runtime/include/memory_access.h index 8b19e02..7ac535d 100644 --- a/runtime/include/memory_access.h +++ b/runtime/include/memory_access.h @@ -231,6 +231,13 @@ MKW_MEMORY_FORCE_INLINE uint8_t* ResolveRangeHost(uint32_t base, int32_t minOffs (void)needsRead; const uint32_t guestStart = base + static_cast(minOffset); if (length == 0 || length > kPageSize || guestStart > UINT32_MAX - (length - 1)) return nullptr; + if (GuestFlat::RequiresCheckedAccess()) { + // A host page can cover multiple independently-special Wii pages. + // Returning null keeps resolved accesses on the checked Memory::* + // path, which materializes deferred reads and applies write policy. + (void)needsWrite; + return nullptr; + } if (needsWrite && (FlatWriteNeedsPolicy(guestStart) || FlatWriteNeedsPolicy(guestStart + (length - 1)))) [[unlikely]] return nullptr; @@ -522,6 +529,13 @@ MKW_MEMORY_FORCE_INLINE void WriteResolvedFloat64(uint8_t* r, uint32_t o, uint32 // around `*(T*)(base + addr)`, no page-table load or limit check (interception model documented // in guest_flat_memory.h). The one exception kept inline is the MMIO write policy, since the // written value can't be recovered from a fault record. +// +// When a host VM page is larger than a 4 KiB Wii page, guest-view protections +// cannot distinguish adjacent special Wii pages. The general FlatRead*/ +// FlatWrite* helpers then use the checked page-table path, which materializes +// deferred reads and applies executable-write/MMIO policy before touching RAM. +// FlatWriteRam* remains direct because the translator emits it only for +// addresses it has proven are ordinary RAM. template MKW_MEMORY_FORCE_INLINE T FlatLoad(uint32_t address) { @@ -536,47 +550,62 @@ MKW_MEMORY_FORCE_INLINE void FlatStore(uint32_t address, T value) { std::memcpy(MKW_FLAT_GUEST_BASE + address, &swapped, sizeof(T)); } -MKW_MEMORY_FORCE_INLINE uint8_t FlatRead8(uint32_t address) { return FlatLoad(address); } -MKW_MEMORY_FORCE_INLINE uint16_t FlatRead16(uint32_t address) { return FlatLoad(address); } -MKW_MEMORY_FORCE_INLINE uint32_t FlatRead32(uint32_t address) { return FlatLoad(address); } +MKW_MEMORY_FORCE_INLINE uint8_t FlatRead8(uint32_t address) { + if (GuestFlat::RequiresCheckedAccess()) return Memory::Read8(address); + return FlatLoad(address); +} +MKW_MEMORY_FORCE_INLINE uint16_t FlatRead16(uint32_t address) { + if (GuestFlat::RequiresCheckedAccess()) return Memory::Read16(address); + return FlatLoad(address); +} +MKW_MEMORY_FORCE_INLINE uint32_t FlatRead32(uint32_t address) { + if (GuestFlat::RequiresCheckedAccess()) return Memory::Read32(address); + return FlatLoad(address); +} MKW_MEMORY_FORCE_INLINE float FlatReadFloat32(uint32_t address) { - const uint32_t bits = FlatLoad(address); + const uint32_t bits = FlatRead32(address); float value = 0.0f; std::memcpy(&value, &bits, sizeof(value)); return value; } MKW_MEMORY_FORCE_INLINE double FlatReadFloat64(uint32_t address) { - const uint64_t bits = FlatLoad(address); + const uint64_t bits = GuestFlat::RequiresCheckedAccess() + ? Memory::Read64(address) : FlatLoad(address); double value = 0.0; std::memcpy(&value, &bits, sizeof(value)); return value; } MKW_MEMORY_FORCE_INLINE void FlatWrite8(uint32_t address, uint8_t value) { + if (GuestFlat::RequiresCheckedAccess()) { Memory::Write8(address, value); return; } if (FlatWriteNeedsPolicy(address)) [[unlikely]] { Write8Slow(address, value); return; } FlatStore(address, value); } MKW_MEMORY_FORCE_INLINE void FlatWrite16(uint32_t address, uint16_t value) { + if (GuestFlat::RequiresCheckedAccess()) { Memory::Write16(address, value); return; } if (FlatWriteNeedsPolicy(address)) [[unlikely]] { Write16Slow(address, value); return; } FlatStore(address, value); } MKW_MEMORY_FORCE_INLINE void FlatWrite32(uint32_t address, uint32_t value) { + if (GuestFlat::RequiresCheckedAccess()) { Memory::Write32(address, value); return; } if (FlatWriteNeedsPolicy(address)) [[unlikely]] { Write32Slow(address, value); return; } FlatStore(address, value); } MKW_MEMORY_FORCE_INLINE void FlatWriteFloat32(uint32_t address, double value) { + if (GuestFlat::RequiresCheckedAccess()) { Memory::WriteFloat32(address, value); return; } const uint32_t bits = ConvertPpcDoubleToSingleBits(value); if (FlatWriteNeedsPolicy(address)) [[unlikely]] { WriteFloat32Slow(address, value); return; } FlatStore(address, bits); } MKW_MEMORY_FORCE_INLINE void FlatWriteFloat64(uint32_t address, double value) { + if (GuestFlat::RequiresCheckedAccess()) { Memory::WriteFloat64(address, value); return; } uint64_t bits = 0; std::memcpy(&bits, &value, sizeof(bits)); if (FlatWriteNeedsPolicy(address)) [[unlikely]] { WriteFloat64Slow(address, value); return; } diff --git a/runtime/include/platform/host_platform.h b/runtime/include/platform/host_platform.h new file mode 100644 index 0000000..1fd961e --- /dev/null +++ b/runtime/include/platform/host_platform.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include +#include + +// Small host-services boundary for functionality that must not leak Win32 +// assumptions into runtime or game code. Guest execution, virtual memory, and +// cooperative contexts remain outside this layer until dedicated macOS +// prototypes establish a safe abstraction. +namespace RuntimePlatform { + +std::optional ExecutableDirectory() noexcept; + +// Returns the platform's conventional per-user application-data directory. +// It does not create the directory, leaving that policy to the caller. +std::filesystem::path ApplicationDataDirectory(std::string_view applicationName); + +// The root for per-run diagnostics. Keeping this here ensures log placement +// follows the same host convention as configuration and other user data. +std::filesystem::path LogDirectory(std::string_view applicationName); + +uint64_t CurrentProcessId() noexcept; + +} // namespace RuntimePlatform diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h index 8bf90e4..2e086ab 100644 --- a/runtime/include/runtime_config.h +++ b/runtime/include/runtime_config.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -17,6 +18,7 @@ #include #include #include +#include "platform/host_platform.h" #ifdef _WIN32 #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN @@ -59,7 +61,31 @@ struct RuntimeUserConfig { std::optional audioMuted; std::optional audioMixWorker; std::optional attenuateMusicWhenMediaPlays; + // Real Wii Remotes (with or without Nunchuk / Classic Controller) and Wii U Pro + // Controllers paired over Bluetooth, driven by SDL's HIDAPI Wii driver. The driver + // is opt-in on SDL's side, so this decides whether the runtime turns it on. + std::optional wiiRemotes; + // Keep re-enumerating Bluetooth HID devices while no Wii controller is connected + // (Dolphin's "continuous scanning"), so a remote that dropped or was switched on + // after launch shows up without restarting. + std::optional wiiContinuousScan; + // Accelerometer zero-point correction for the Bluetooth Wii Remote, in g and in + // SDL's sensor frame (x right, y out of the button face, z towards the user). + // SDL's Wii driver falls back to a nominal zero point when its read of the + // remote's calibration block times out (common over Bluetooth), so this is + // measured in the overlay with the remote at rest. + std::optional wiiAccelOffsetX; + std::optional wiiAccelOffsetY; + std::optional wiiAccelOffsetZ; + // Debugging aid: append every KPAD sample of the Bluetooth remote (raw and + // corrected accelerometer, buttons) to wii_accel_trace.csv next to Config.toml. + std::optional wiiAccelTrace; std::optional networkEnabled; + std::optional discordPresenceEnabled; + // The application ID of the WiiCompiled Discord application. This is only + // used by the base product; Retro Rewind supplies its own ID through the + // standard Dolphin /dev/dolphin interface. + std::optional discordClientId; std::optional nandRoot; std::optional dvdRoot; // The one canonical Retro Rewind installation, owned and updated by the frontend. Setup records @@ -143,7 +169,14 @@ inline bool IsSupportedResolutionMultiplier(float value) { // Must stay in step with the backend table in main.cpp, which is what actually // maps these to AuroraBackend. inline bool IsSupportedGraphicsApi(std::string_view value) { +#if defined(__APPLE__) + static constexpr std::array values{"auto", "metal"}; +// only vulkan for linux +#elif defined(__linux__) + static constexpr std::array values{"auto", "vulkan"}; +#elif defined(_WIN32) static constexpr std::array values{"auto", "d3d12", "vulkan"}; +#endif return std::find(values.begin(), values.end(), value) != values.end(); } @@ -174,6 +207,8 @@ inline std::optional ExecutableDirectory() { } buffer.resize(buffer.size() * 2); } +#elif defined(__APPLE__) + return RuntimePlatform::ExecutableDirectory(); #else // /proc/self/exe is a Linux-specific magic symlink to the running executable; readlink() // does not NUL-terminate and silently truncates if the buffer is too small, so this grows @@ -231,6 +266,8 @@ inline std::filesystem::path ApplicationDataDirectory() { CoTaskMemFree(rawPath); return directory; } +#elif defined(__APPLE__) + return RuntimePlatform::ApplicationDataDirectory(kApplicationDirectoryName); #else // XDG Base Directory spec equivalent of FOLDERID_LocalAppData: $XDG_DATA_HOME if set and // non-empty, otherwise its default of $HOME/.local/share. @@ -308,6 +345,12 @@ inline void EnsureConfigFile() { "mix_worker = true\n\n" "[network]\n" "enabled = true\n\n" + "[discord]\n" + "# Rich Presence talks only to a locally-running Discord client.\n" + "# Retro Rewind supplies its official app ID automatically. Set this\n" + "# to WiiCompiled's Discord application ID for basic base-game presence.\n" + "enabled = true\n" + "# client_id = \"123456789012345678\"\n\n" "[paths]\n" "# dvd_root = \"D:\\\\MarioKartWii\\\\DATA\"\n" "# nand_root = \"D:\\\\WiiNand\"\n" @@ -374,6 +417,7 @@ inline void AppendOverlayRoots(RuntimeUserConfig& config, const std::string& roo } } +// Reads every supported setting out of a parsed Config.toml document. inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) { RuntimeUserConfig config; @@ -456,7 +500,15 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) { config.audioMixWorker = FindConfigValue(document, "audio", "mix_worker"); config.attenuateMusicWhenMediaPlays = FindConfigValue(document, "audio", "attenuate_music_when_media_plays"); + config.wiiRemotes = FindConfigValue(document, "controller", "wii_remotes"); + config.wiiContinuousScan = FindConfigValue(document, "controller", "wii_continuous_scan"); + config.wiiAccelOffsetX = FindConfigValue(document, "controller", "wii_accel_offset_x"); + config.wiiAccelOffsetY = FindConfigValue(document, "controller", "wii_accel_offset_y"); + config.wiiAccelOffsetZ = FindConfigValue(document, "controller", "wii_accel_offset_z"); + config.wiiAccelTrace = FindConfigValue(document, "controller", "wii_accel_trace"); config.networkEnabled = FindConfigValue(document, "network", "enabled"); + config.discordPresenceEnabled = FindConfigValue(document, "discord", "enabled"); + config.discordClientId = FindConfigValue(document, "discord", "client_id"); config.nandRoot = FindConfigValue(document, "paths", "nand_root"); config.dvdRoot = FindConfigValue(document, "paths", "dvd_root"); @@ -788,14 +840,75 @@ inline bool AudioMixWorkerEnabled(bool fallback = true) { return Get().audioMixWorker.value_or(fallback); } +// Whether background music should duck automatically for other media playback. inline bool AttenuateMusicWhenMediaPlays(bool fallback = false) { return Get().attenuateMusicWhenMediaPlays.value_or(fallback); } +// Bluetooth Wii Remotes / Wii U Pro Controllers. Read once before SDL's joystick +// subsystem comes up, so a change only takes effect on the next launch. +inline bool WiiRemotesEnabled(bool fallback = true) { + return Get().wiiRemotes.value_or(fallback); +} + +// Persists the Bluetooth Wii Remote driver switch. +inline bool SetWiiRemotesEnabled(bool value) { + Mutable().wiiRemotes = value; + return WriteSetting("controller", "wii_remotes", value ? "true" : "false"); +} + +// Whether to keep rescanning Bluetooth while no Wii controller is connected. +inline bool WiiContinuousScanEnabled(bool fallback = true) { + return Get().wiiContinuousScan.value_or(fallback); +} + +// Persists the continuous scanning switch. +inline bool SetWiiContinuousScanEnabled(bool value) { + Mutable().wiiContinuousScan = value; + return WriteSetting("controller", "wii_continuous_scan", value ? "true" : "false"); +} + +// Wii Remote accelerometer zero-point correction (g, SDL sensor frame); all zero +// when the remote has not been calibrated. +inline std::array WiiAccelOffset() { + const RuntimeUserConfig& config = Get(); + return {config.wiiAccelOffsetX.value_or(0.0), config.wiiAccelOffsetY.value_or(0.0), + config.wiiAccelOffsetZ.value_or(0.0)}; +} + +// Whether to write the per-frame accelerometer trace (off unless asked for). +inline bool WiiAccelTraceEnabled(bool fallback = false) { + return Get().wiiAccelTrace.value_or(fallback); +} + +// True while a non-zero correction is stored ("Clear calibration" writes zeros). +inline bool HasWiiAccelOffset() { + const std::array offset = WiiAccelOffset(); + return offset[0] != 0.0 || offset[1] != 0.0 || offset[2] != 0.0; +} + +// Persists the accelerometer correction measured by the overlay's calibration. +inline bool SetWiiAccelOffset(const std::array& offset) { + Mutable().wiiAccelOffsetX = offset[0]; + Mutable().wiiAccelOffsetY = offset[1]; + Mutable().wiiAccelOffsetZ = offset[2]; + bool ok = true; + const char* keys[3] = {"wii_accel_offset_x", "wii_accel_offset_y", "wii_accel_offset_z"}; + for (size_t i = 0; i < 3; ++i) { + // Always a float literal, so a whole-number offset does not come back as a TOML integer. + std::ostringstream formatted; + formatted << std::fixed << std::setprecision(4) << offset[i]; + ok = WriteSetting("controller", keys[i], formatted.str()) && ok; + } + return ok; +} + +// Target frame rate for frame interpolation, or 0 to disable it. inline uint32_t FrameInterpolationFps(uint32_t fallback = 0) { return Get().frameInterpolationFps.value_or(fallback); } +// Whether to skip draws whose graphics pipeline has not finished compiling yet. inline bool SkipUnreadyPipelines(bool fallback = true) { return Get().skipUnreadyPipelines.value_or(fallback); } @@ -897,6 +1010,14 @@ inline std::string RetroRewindRoot(std::string fallback = "") { return Get().retroRewindRoot.value_or(std::move(fallback)); } +inline bool DiscordPresenceEnabled(bool fallback = true) { + return Get().discordPresenceEnabled.value_or(fallback); +} + +inline std::string DiscordClientId(std::string fallback = "1543984562369990706") { + return Get().discordClientId.value_or(std::move(fallback)); +} + inline const std::vector& OverlayRoots() { return Get().overlayRoots; } @@ -959,6 +1080,9 @@ inline void LogLoadedConfig() { if (config.networkEnabled) { std::cout << " network_enabled=" << (*config.networkEnabled ? "true" : "false"); } + if (config.discordPresenceEnabled) { + std::cout << " discord_enabled=" << (*config.discordPresenceEnabled ? "true" : "false"); + } if (config.nandRoot) { std::cout << " nand_root=" << *config.nandRoot; } diff --git a/runtime/include/system_bridge.h b/runtime/include/system_bridge.h index 1f57f0d..a2d4cf6 100644 --- a/runtime/include/system_bridge.h +++ b/runtime/include/system_bridge.h @@ -36,6 +36,7 @@ extern thread_local uint32_t g_sehLastAccessType; void WriteFatalLog(std::string_view reason); void SetRuntimeExitCode(int code); +void MarkFatalErrorReported(); // Centralized crash reporting (defined in main.cpp). Every fatal path funnels // through these so the per-run log folder always receives the same artifact diff --git a/runtime/include/wii_remote_input.h b/runtime/include/wii_remote_input.h new file mode 100644 index 0000000..d7c1c9f --- /dev/null +++ b/runtime/include/wii_remote_input.h @@ -0,0 +1,110 @@ +#pragma once + +#include + +struct PADStatus; + +// Real Wii Remotes paired over Bluetooth. +// +// SDL 3 ships a HIDAPI driver for them (Wii Remote alone, with Nunchuk, with a +// Classic Controller, and the Wii U Pro Controller) that exposes each as a +// regular SDL gamepad; it is off by default on SDL's side and ConfigureSdlHints +// turns it on. A remote, alone or with a Nunchuk or Classic Controller, is +// handed to the game as a real Wii Remote with that extension: the KPAD/WPAD +// HLE builds a KPADStatus (and the WPADCLStatus behind KPADGetUnifiedWpadStatus) +// from it every frame (see ReadKpadSample), so the game's own code does wheelies, +// tricks, Wii Wheel steering and the Classic Controller layout, and plugging an +// extension in or out mid-game switches control scheme like on the console. Only +// the Wii U Pro Controller, which has no Wii-era equivalent, goes through +// aurora's PAD layer as a GameCube pad. +namespace WiiRemoteInput { + +enum class Kind : uint8_t { + NotWii, + Remote, // Wii Remote with no extension + RemoteWithNunchuk, + RemoteWithClassic, + WiiUPro, +}; + +// Must run before SDL's joystick subsystem is initialized (aurora does that +// inside aurora_initialize); SDL only consults the hint on its first device scan. +void ConfigureSdlHints(bool enabled); + +// Classifies a gamepad by the name SDL's Wii driver reports for it. +Kind KindForName(const char* gamepadName); +// Classification of the SDL gamepad currently assigned to a game port. +Kind KindForPort(uint32_t port); +const char* KindLabel(Kind kind); + +// One frame of a Wii Remote in the units KPAD uses. +struct KpadSample { + uint32_t hold = 0; // WPAD button bits (WPAD_BUTTON_*), Nunchuk C/Z included + float acc[3] = {}; // remote accelerometer in g, KPAD frame (rest: y = -1) + bool hasNunchuk = false; + float stick[2] = {}; // Nunchuk stick, -1..1, +y up + float nunchukAcc[3] = {}; + bool hasClassic = false; + uint32_t clHold = 0; // WPAD_CL_BUTTON_* bits + float clLStick[2] = {}; // Classic sticks, -1..1, +y up + float clRStick[2] = {}; + int16_t clLStickRaw[2] = {}; // as WPADCLStatus reports them: -512..511, centre 0, +y up + int16_t clRStickRaw[2] = {}; + uint8_t clTriggerL = 0; // 0..255; SDL only exposes the digital click + uint8_t clTriggerR = 0; +}; + +// What the game should see on `chan`: the controller SDL has there right now, +// or, for a few seconds after a remote vanished, the kind it had. SDL's driver +// destroys and re-creates the joystick when an extension is plugged in or out, +// and the console never disconnects for that, so the gap is papered over with +// neutral input instead of a "communications interrupted" prompt. +Kind EffectiveKind(uint32_t chan); +// True when the game reads `chan` through KPAD: a Wii Remote alone, with a +// Nunchuk or with a Classic Controller (live or within the swap grace period). +bool IsRemoteChannel(uint32_t chan); +// Reads the current state of the remote on `chan`; false when IsRemoteChannel +// is false. During the swap grace period the sample is neutral. +bool ReadKpadSample(uint32_t chan, KpadSample& sample); + +// Remote accelerometer for the overlay readout: the SDL sample in g after the +// zero-point correction (x right across the face, y out of the button face, z +// towards the user) and the KPAD vector built from it. False when the port has +// no remote or SDL has not delivered a sample yet. +bool ReadAccelDebug(uint32_t chan, float sdlG[3], float kpadAcc[3]); + +// Accelerometer zero-point calibration. SDL's Wii driver tries to read the +// remote's factory calibration block, but over Bluetooth that read often times +// out ("Using fallback accelerometer calibration" in console.log) and it falls +// back to a nominal 0x200 zero point; a real remote then carries a per-axis +// bias of up to ~0.2 g, which held sideways as a wheel is a permanent steering +// offset. +// StartAccelCalibration expects the remote lying still with the buttons up; +// Poll() then collects samples for about a second and stores the difference to +// the ideal (0, 1, 0) g in Config.toml. +void StartAccelCalibration(uint32_t chan); +// Forgets the stored correction. +void ClearAccelCalibration(); +bool IsAccelCalibrating(); +// 0..1 while a calibration is collecting samples. +float AccelCalibrationProgress(); +// Outcome of the last calibration attempt for the overlay, or nullptr. +const char* AccelCalibrationMessage(); + +// Reports "no controller" on the GameCube side for every port served through +// KPAD, so the game never sees the same remote twice. Runs after aurora's PADRead. +void HideRemotesFromPad(PADStatus* statuses, uint32_t count); + +// Dolphin-style continuous scanning. SDL's HIDAPI Wii driver drops a remote on +// a failed Bluetooth read or an extension change and never re-adds it on its +// own. Poll() runs once per PADRead and, while no Wii controller is present, +// periodically forces SDL to re-enumerate HIDAPI by toggling the driver hint. +void Poll(); +// Forces one re-enumeration right now (settings overlay "Rescan now"). +void RescanNow(); +// True while Poll() is actively rescanning (no Wii controller connected). +bool IsScanning(); +// Rescans issued since a Wii controller was last seen. +uint32_t ScanCount(); + +} // namespace WiiRemoteInput diff --git a/runtime/src/discord_presence.cpp b/runtime/src/discord_presence.cpp new file mode 100644 index 0000000..a7b9caa --- /dev/null +++ b/runtime/src/discord_presence.cpp @@ -0,0 +1,466 @@ +#include "discord_presence.h" + +#include "runtime_log.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#else +#include +#include +#include +#endif + +namespace DiscordPresence { +namespace { + +constexpr uint32_t kHandshakeOpcode = 0; +constexpr uint32_t kFrameOpcode = 1; +constexpr size_t kMaxClientIdLength = 32; +constexpr auto kConnectionRetryCooldown = std::chrono::seconds(1); + +bool IsClientId(std::string_view value) { + return !value.empty() && value.size() <= kMaxClientIdLength && + std::all_of(value.begin(), value.end(), [](unsigned char ch) { return std::isdigit(ch) != 0; }); +} + +std::string EscapeJson(std::string_view value) { + std::string escaped; + escaped.reserve(value.size()); + for (const unsigned char ch : value) { + switch (ch) { + case '\\': escaped += "\\\\"; break; + case '\"': escaped += "\\\""; break; + case '\b': escaped += "\\b"; break; + case '\f': escaped += "\\f"; break; + case '\n': escaped += "\\n"; break; + case '\r': escaped += "\\r"; break; + case '\t': escaped += "\\t"; break; + default: + if (ch < 0x20) { + static constexpr char kHex[] = "0123456789abcdef"; + escaped += "\\u00"; + escaped += kHex[ch >> 4]; + escaped += kHex[ch & 0x0f]; + } else { + escaped += static_cast(ch); + } + } + } + return escaped; +} + +void AppendJsonString(std::ostringstream& output, bool& hasValue, std::string_view key, std::string_view value) { + if (value.empty()) { + return; + } + if (hasValue) { + output << ','; + } + output << '\"' << key << "\":\"" << EscapeJson(value) << '\"'; + hasValue = true; +} + +std::string BuildActivityPayload(const Activity& activity) { + std::ostringstream json; + json << "{\"cmd\":\"SET_ACTIVITY\",\"nonce\":\"wiicompiled\",\"args\":{\"pid\":"; +#if defined(_WIN32) + json << static_cast(::GetCurrentProcessId()); +#else + json << static_cast(::getpid()); +#endif + json << ",\"activity\":{"; + bool hasActivityField = false; + AppendJsonString(json, hasActivityField, "details", activity.details); + AppendJsonString(json, hasActivityField, "state", activity.state); + const auto appendSection = [&](std::string_view name, const auto& append) { + if (hasActivityField) { + json << ','; + } + json << '\"' << name << "\":{"; + append(); + json << '}'; + hasActivityField = true; + }; + const bool hasAssets = !activity.largeImageKey.empty() || !activity.largeImageText.empty() || + !activity.smallImageKey.empty() || !activity.smallImageText.empty(); + if (hasAssets) { + appendSection("assets", [&] { + bool hasAsset = false; + AppendJsonString(json, hasAsset, "large_image", activity.largeImageKey); + AppendJsonString(json, hasAsset, "large_text", activity.largeImageText); + AppendJsonString(json, hasAsset, "small_image", activity.smallImageKey); + AppendJsonString(json, hasAsset, "small_text", activity.smallImageText); + }); + } + if (activity.startTimestamp > 0 || activity.endTimestamp > 0) { + appendSection("timestamps", [&] { + if (activity.startTimestamp > 0) { + json << "\"start\":" << activity.startTimestamp; + } + if (activity.endTimestamp > 0) { + if (activity.startTimestamp > 0) { + json << ','; + } + json << "\"end\":" << activity.endTimestamp; + } + }); + } + if (activity.partySize > 0 && activity.partyMax > 0) { + appendSection("party", [&] { + json << "\"size\":[" << activity.partySize << ',' << activity.partyMax << ']'; + }); + } + if (hasActivityField) { + json << ','; + } + json << "\"instance\":false}}}"; + return json.str(); +} + +class Client { +public: + void Initialize(const std::string& clientId, const std::string& title) { + std::lock_guard lock(mutex_); + basicClientId_ = IsClientId(clientId) ? clientId : std::string{}; + basicActivity_ = {}; + basicActivity_.details = title; + basicActivity_.startTimestamp = UnixSeconds(); + customClient_ = false; + activity_ = basicActivity_; + ReconnectAndSendLocked(); + } + + void SetClient(const std::string& clientId) { + std::lock_guard lock(mutex_); + if (!IsClientId(clientId)) { + RT_LOG(RT_TAG_RUNTIME) << "Ignoring invalid Discord client ID from /dev/dolphin" << std::endl; + return; + } + if (clientId_ == clientId && connected_) { + return; + } + customClient_ = true; + clientId_ = clientId; + CloseLocked(); + ReconnectAndSendLocked(); + } + + void SetActivity(Activity activity) { + std::lock_guard lock(mutex_); + if (!customClient_) { + return; + } + activity_ = std::move(activity); + SendActivityLocked(); + } + + void Reset() { + std::lock_guard lock(mutex_); + customClient_ = false; + clientId_.clear(); + activity_ = basicActivity_; + CloseLocked(); + ReconnectAndSendLocked(); + } + + void Shutdown() { + std::lock_guard lock(mutex_); + CloseLocked(); + } + +private: + static int64_t UnixSeconds() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + } + + std::string ActiveClientIdLocked() const { + return customClient_ ? clientId_ : basicClientId_; + } + + bool WriteFrameLocked(uint32_t opcode, std::string_view payload) { + std::array header{}; + const uint32_t size = static_cast(payload.size()); + for (size_t i = 0; i < 4; ++i) { + header[i] = static_cast(opcode >> (i * 8)); + header[4 + i] = static_cast(size >> (i * 8)); + } + return WriteAllLocked(header.data(), header.size()) && + WriteAllLocked(reinterpret_cast(payload.data()), payload.size()); + } + + void ReconnectAndSendLocked() { + const std::string clientId = ActiveClientIdLocked(); + if (clientId.empty() || !ConnectLocked()) { + return; + } + const std::string handshake = "{\"v\":1,\"client_id\":\"" + clientId + "\"}"; + if (!WriteFrameLocked(kHandshakeOpcode, handshake)) { + RecordFailedConnectionLocked(); + CloseLocked(); + return; + } + if (!ReadReadyLocked()) { + RecordFailedConnectionLocked(); + CloseLocked(); + return; + } + SendActivityLocked(); + } + + void SendActivityLocked() { + if (!connected_) { + ReconnectAndSendLocked(); + return; + } + if (ActiveClientIdLocked().empty()) { + return; + } + if (!WriteFrameLocked(kFrameOpcode, BuildActivityPayload(activity_))) { + RecordFailedConnectionLocked(); + CloseLocked(); + } + } + + bool ConnectionRetryAllowedLocked() const { + return !lastFailedConnectionAttempt_ || + std::chrono::steady_clock::now() - *lastFailedConnectionAttempt_ >= kConnectionRetryCooldown; + } + + void RecordFailedConnectionLocked() { + lastFailedConnectionAttempt_ = std::chrono::steady_clock::now(); + } + +#if defined(_WIN32) + bool ConnectLocked() { + if (connected_) { + return true; + } + if (!ConnectionRetryAllowedLocked()) { + return false; + } + for (unsigned int index = 0; index < 10; ++index) { + const std::string name = "\\\\.\\pipe\\discord-ipc-" + std::to_string(index); + handle_ = ::CreateFileA(name.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr); + if (handle_ != INVALID_HANDLE_VALUE) { + DWORD mode = PIPE_READMODE_BYTE; + ::SetNamedPipeHandleState(handle_, &mode, nullptr, nullptr); + connected_ = true; + lastFailedConnectionAttempt_.reset(); + return true; + } + } + RecordFailedConnectionLocked(); + return false; + } + + bool WriteAllLocked(const uint8_t* data, size_t size) { + while (size != 0) { + DWORD written = 0; + if (!::WriteFile(handle_, data, static_cast(size), &written, nullptr) || written == 0) { + return false; + } + data += written; + size -= written; + } + return true; + } + + bool ReadAllLocked(uint8_t* data, size_t size) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(1); + while (size != 0) { + if (std::chrono::steady_clock::now() >= deadline) { + return false; + } + DWORD available = 0; + if (!::PeekNamedPipe(handle_, nullptr, 0, nullptr, &available, nullptr)) { + return false; + } + if (available == 0) { + if (std::chrono::steady_clock::now() >= deadline) { + return false; + } + ::Sleep(10); + continue; + } + DWORD read = 0; + const DWORD requested = static_cast(std::min(size, available)); + if (!::ReadFile(handle_, data, requested, &read, nullptr) || read == 0) { + return false; + } + data += read; + size -= read; + } + return true; + } + + void CloseLocked() { + if (handle_ != INVALID_HANDLE_VALUE) { + ::CloseHandle(handle_); + handle_ = INVALID_HANDLE_VALUE; + } + connected_ = false; + } + + HANDLE handle_ = INVALID_HANDLE_VALUE; +#else + bool ConnectLocked() { + if (connected_) { + return true; + } + if (!ConnectionRetryAllowedLocked()) { + return false; + } + std::array roots{}; + size_t rootCount = 0; + if (const char* runtimeDir = std::getenv("XDG_RUNTIME_DIR"); runtimeDir && *runtimeDir) { + roots[rootCount++] = runtimeDir; + } + if (const char* tempDir = std::getenv("TMPDIR"); tempDir && *tempDir) { + roots[rootCount++] = tempDir; + } + if (const char* tempDir = std::getenv("TMP"); tempDir && *tempDir && rootCount < roots.size()) { + roots[rootCount++] = tempDir; + } + if (const char* tempDir = std::getenv("TEMP"); tempDir && *tempDir && rootCount < roots.size()) { + roots[rootCount++] = tempDir; + } + roots[rootCount++] = "/tmp"; + for (size_t rootIndex = 0; rootIndex < rootCount; ++rootIndex) { + for (unsigned int index = 0; index < 10; ++index) { + const std::string path = roots[rootIndex] + "/discord-ipc-" + std::to_string(index); + if (path.size() >= sizeof(sockaddr_un::sun_path)) { + continue; + } + const int socketFd = ::socket(AF_UNIX, SOCK_STREAM, 0); + if (socketFd < 0) { + continue; + } + sockaddr_un address{}; + address.sun_family = AF_UNIX; + std::memcpy(address.sun_path, path.c_str(), path.size() + 1); + if (::connect(socketFd, reinterpret_cast(&address), sizeof(address)) == 0) { + timeval timeout{}; + timeout.tv_sec = 1; + ::setsockopt(socketFd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + fd_ = socketFd; + connected_ = true; + lastFailedConnectionAttempt_.reset(); + return true; + } + ::close(socketFd); + } + } + RecordFailedConnectionLocked(); + return false; + } + + bool WriteAllLocked(const uint8_t* data, size_t size) { + while (size != 0) { + const ssize_t written = ::send(fd_, data, size, MSG_NOSIGNAL); + if (written <= 0) { + return false; + } + data += written; + size -= static_cast(written); + } + return true; + } + + bool ReadAllLocked(uint8_t* data, size_t size) { + while (size != 0) { + const ssize_t read = ::recv(fd_, data, size, 0); + if (read <= 0) { + return false; + } + data += read; + size -= static_cast(read); + } + return true; + } + + void CloseLocked() { + if (fd_ >= 0) { + ::close(fd_); + fd_ = -1; + } + connected_ = false; + } + + int fd_ = -1; +#endif + + bool ReadReadyLocked() { + std::array header{}; + if (!ReadAllLocked(header.data(), header.size())) { + return false; + } + uint32_t opcode = 0; + uint32_t size = 0; + for (size_t i = 0; i < 4; ++i) { + opcode |= static_cast(header[i]) << (i * 8); + size |= static_cast(header[4 + i]) << (i * 8); + } + if (opcode != kFrameOpcode || size > 1024 * 1024) { + return false; + } + std::vector payload(size); + if (!ReadAllLocked(payload.data(), payload.size())) { + return false; + } + const std::string_view json(reinterpret_cast(payload.data()), payload.size()); + return json.find("\"evt\":\"READY\"") != std::string_view::npos; + } + + std::mutex mutex_; + bool connected_ = false; + bool customClient_ = false; + std::optional lastFailedConnectionAttempt_; + std::string basicClientId_; + std::string clientId_; + Activity basicActivity_; + Activity activity_; +}; + +Client g_client; + +} // namespace + +void Initialize(const std::string& basicClientId, const std::string& basicTitle) { + g_client.Initialize(basicClientId, basicTitle); +} + +void SetClient(const std::string& clientId) { + g_client.SetClient(clientId); +} + +void SetActivity(Activity activity) { + g_client.SetActivity(std::move(activity)); +} + +void Reset() { + g_client.Reset(); +} + +void Shutdown() { + g_client.Shutdown(); +} + +} // namespace DiscordPresence diff --git a/runtime/src/fiber_manager.cpp b/runtime/src/fiber_manager.cpp index 8e15c1a..fb4575d 100644 --- a/runtime/src/fiber_manager.cpp +++ b/runtime/src/fiber_manager.cpp @@ -2,6 +2,7 @@ #include "memory.h" #include "abi_bridge.h" #include "hle_stubs.h" +#include "host_context.h" #include "runtime_log.h" // Defined in hle/os/os_sleep.cpp; the sleep-timer table is file-local there. @@ -13,24 +14,8 @@ #include #include -#if !defined(_WIN32) -#include "libco.h" -#endif - namespace Fiber { -#if !defined(_WIN32) -namespace { -// libco's co_create() entry points take no argument, unlike CreateFiber(size, FiberProc, param). -// CreateGuestFiber() stages the guest thread address here immediately before the first co_switch -// into a freshly created cothread; FiberProcTrampoline reads it exactly once, at the top of the -// fiber's very first activation. Safe because guest fibers are strictly cooperative on a single -// OS thread: nothing else can run (and so nothing else can overwrite this) between the staging -// write and the trampoline's read of it. -thread_local uint32_t s_pendingFiberArg = 0; -} // namespace -#endif - std::mutex GuestFiberManager::s_mutex; std::unordered_map GuestFiberManager::s_fibers; std::vector GuestFiberManager::s_fibersPendingDelete; @@ -45,21 +30,11 @@ void GuestFiberManager::PurgePendingFibers() { std::lock_guard lock(s_mutex); toDelete.swap(s_fibersPendingDelete); } -#if defined(_WIN32) - const void* current = GetCurrentFiber(); for (void* f : toDelete) { - if (f && f != current) { - DeleteFiber(f); + if (f && !HostContext::IsCurrent(f)) { + HostContext::Destroy(f); } } -#else - const void* current = co_active(); - for (void* f : toDelete) { - if (f && f != current) { - co_delete(static_cast(f)); - } - } -#endif } // Global VI retrace counter @@ -212,27 +187,13 @@ void GuestFiberManager::Initialize() { return; } -#if defined(_WIN32) - // Convert the main thread to a fiber (the scheduler fiber) - s_schedulerFiber = ConvertThreadToFiber(nullptr); - if (!s_schedulerFiber) { - // May already be a fiber - s_schedulerFiber = GetCurrentFiber(); - if (!s_schedulerFiber) { - RT_LOG(RT_TAG_OS) << "FATAL: Failed to initialize scheduler fiber!" << std::endl; - ShowRuntimeFatalPopup("guest scheduler initialization failed", - "Windows could not create the scheduler fiber required to run guest threads."); - std::abort(); - } + if (!HostContext::InitializeScheduler(&s_schedulerFiber)) { + RT_LOG(RT_TAG_OS) << "FATAL: Failed to initialize scheduler context!" << std::endl; + ShowRuntimeFatalPopup("guest scheduler initialization failed", + "The host could not create the scheduler context required to run guest threads."); + std::abort(); } -#else - // co_active() returns a handle for whichever native stack is currently running, creating one - // on first call if needed - the libco analogue of ConvertThreadToFiber(nullptr): it converts - // this call's own stack into a switchable target without altering control flow. - s_schedulerFiber = co_active(); -#endif - s_currentGuestThread = 0; s_initialized = true; } @@ -240,32 +201,18 @@ void GuestFiberManager::Initialize() { void GuestFiberManager::Shutdown() { std::lock_guard lock(s_mutex); -#if defined(_WIN32) for (auto& [addr, fiber] : s_fibers) { if (fiber.fiber && !fiber.isSchedulerFiber) { - DeleteFiber(fiber.fiber); + HostContext::Destroy(fiber.fiber); fiber.fiber = nullptr; } } s_fibers.clear(); - // Convert scheduler fiber back to thread if (s_schedulerFiber) { - ConvertFiberToThread(); + HostContext::ShutdownScheduler(s_schedulerFiber); s_schedulerFiber = nullptr; } -#else - for (auto& [addr, fiber] : s_fibers) { - if (fiber.fiber && !fiber.isSchedulerFiber) { - co_delete(static_cast(fiber.fiber)); - fiber.fiber = nullptr; - } - } - s_fibers.clear(); - // Unlike ConvertFiberToThread, libco has no "undo" for co_active(): the scheduler's own - // stack was never separately allocated, so there is nothing to release here. - s_schedulerFiber = nullptr; -#endif s_initialized = false; } @@ -288,11 +235,7 @@ bool GuestFiberManager::CreateGuestFiber(uint32_t guestThreadAddr, uint32_t entr if (existingIt != s_fibers.end()) { // Delete the old fiber if it exists and is not the scheduler fiber if (existingIt->second.fiber && !existingIt->second.isSchedulerFiber) { -#if defined(_WIN32) - DeleteFiber(existingIt->second.fiber); -#else - co_delete(static_cast(existingIt->second.fiber)); -#endif + HostContext::Destroy(existingIt->second.fiber); } s_fibers.erase(existingIt); } @@ -312,31 +255,17 @@ bool GuestFiberManager::CreateGuestFiber(uint32_t guestThreadAddr, uint32_t entr gf.cpuContext.pc = entryPoint; gf.cpuContext.srr0 = entryPoint; -#if defined(_WIN32) - // Create Windows fiber with reasonable stack size - // Use host stack size (64KB should be plenty for translated code) + // 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; - gf.fiber = CreateFiber(kHostStackSize, FiberProc, reinterpret_cast(static_cast(guestThreadAddr))); + gf.fiber = HostContext::Create(kHostStackSize, FiberProc, + reinterpret_cast(static_cast(guestThreadAddr))); if (!gf.fiber) { - DWORD err = GetLastError(); - RT_LOG(RT_TAG_OS) << "CreateFiber failed for thread 0x" - << std::hex << guestThreadAddr - << " error=" << std::dec << err << std::endl; - return false; - } -#else - // libco's co_create() entry point takes no argument; SwitchToThread() stages guestThreadAddr - // into s_pendingFiberArg immediately before the co_switch that first activates this handle. - constexpr unsigned int kHostStackSize = 64 * 1024; - gf.fiber = co_create(kHostStackSize, &FiberProcTrampoline); - - if (!gf.fiber) { - RT_LOG(RT_TAG_OS) << "co_create failed for thread 0x" + RT_LOG(RT_TAG_OS) << "Failed to create host context for thread 0x" << std::hex << guestThreadAddr << std::dec << std::endl; return false; } -#endif s_fibers[guestThreadAddr] = gf; @@ -390,24 +319,11 @@ void GuestFiberManager::ExitGuestThread(uint32_t guestThreadAddr, ThreadState fi } if (it->second.fiber && !it->second.isSchedulerFiber) { -#if defined(_WIN32) - const void* current = GetCurrentFiber(); - if (it->second.fiber == current) { + if (HostContext::IsCurrent(it->second.fiber)) { s_fibersPendingDelete.push_back(it->second.fiber); } else { - DeleteFiber(it->second.fiber); + HostContext::Destroy(it->second.fiber); } -#else - const void* current = co_active(); - if (it->second.fiber == current) { - // Deleting the coroutine we're currently executing on would free the very stack - // this call is running on; defer it (PurgePendingFibers) until some other fiber is - // active, exactly like the Windows branch above. - s_fibersPendingDelete.push_back(it->second.fiber); - } else { - co_delete(static_cast(it->second.fiber)); - } -#endif it->second.fiber = nullptr; } } @@ -474,12 +390,7 @@ void GuestFiberManager::SwitchToThread(uint32_t guestThreadAddr, CpuContext* cpu // Check if we're already on the target fiber (e.g., switching to main thread // when we're already on the scheduler fiber) -#if defined(_WIN32) - void* currentFiber = GetCurrentFiber(); -#else - void* currentFiber = co_active(); -#endif - if (currentFiber == fiberHandle) { + if (HostContext::IsCurrent(fiberHandle)) { // Already executing on the target host fiber. This is common for the // default guest thread, which also owns the scheduler fiber. Keep the // live CPU context instead of restoring a possibly stale saved copy @@ -496,15 +407,7 @@ void GuestFiberManager::SwitchToThread(uint32_t guestThreadAddr, CpuContext* cpu } // Switch to the target fiber (the target fiber will load its own context) -#if defined(_WIN32) - SwitchToFiber(fiberHandle); -#else - // Staged for FiberProcTrampoline's first (and only) read; a no-op for a fiber that has - // already started, since resuming it re-enters mid-function rather than through the - // trampoline's entry point. - s_pendingFiberArg = guestThreadAddr; - co_switch(static_cast(fiberHandle)); -#endif + HostContext::Switch(fiberHandle); // When we return here, the fiber that issued SwitchToThread has resumed. // That does not automatically mean the previous guest thread became runnable @@ -618,14 +521,6 @@ void GuestFiberManager::ProcessTimerEvents(CpuContext* cpu) { } } -void GuestFiberManager::SwitchToScheduler() { -#if defined(_WIN32) - SwitchToFiber(s_schedulerFiber); -#else - co_switch(static_cast(s_schedulerFiber)); -#endif -} - #if defined(_WIN32) void CALLBACK GuestFiberManager::FiberProc(void* param) #else @@ -634,6 +529,7 @@ void GuestFiberManager::FiberProc(void* param) { uint32_t guestThreadAddr = static_cast(reinterpret_cast(param)); + // Get our fiber info GuestFiber* fiber = nullptr; uint32_t entryPoint = 0; @@ -644,7 +540,7 @@ void GuestFiberManager::FiberProc(void* param) auto it = s_fibers.find(guestThreadAddr); if (it == s_fibers.end()) { RT_LOG(RT_TAG_OS) << "FiberProc: fiber not found!" << std::endl; - SwitchToScheduler(); + HostContext::Switch(s_schedulerFiber); return; } fiber = &it->second; @@ -707,7 +603,7 @@ void GuestFiberManager::FiberProc(void* param) << ", fn=0x" << startFn << ") after retries; continuing anyway." << std::dec << std::endl; break; } - SwitchToScheduler(); + HostContext::Switch(s_schedulerFiber); } // The deferral loop above yields to the scheduler and therefore can resume @@ -764,18 +660,7 @@ void GuestFiberManager::FiberProc(void* param) } // Return to scheduler - SwitchToScheduler(); + HostContext::Switch(s_schedulerFiber); } -#if !defined(_WIN32) -void GuestFiberManager::FiberProcTrampoline() { - const uint32_t guestThreadAddr = s_pendingFiberArg; - FiberProc(reinterpret_cast(static_cast(guestThreadAddr))); - // FiberProc always calls SwitchToScheduler() on every exit path and never falls off its own - // end; this is only a safety net in case that ever changes; falling off co_create's entry - // function is otherwise undefined behavior (libco's own crash() fallback aborts instead). - SwitchToScheduler(); -} -#endif - } // namespace Fiber diff --git a/runtime/src/guest_flat_memory.cpp b/runtime/src/guest_flat_memory.cpp index 17780d9..06d1898 100644 --- a/runtime/src/guest_flat_memory.cpp +++ b/runtime/src/guest_flat_memory.cpp @@ -36,6 +36,9 @@ #endif namespace GuestFlat { +#if !defined(MKW_GUEST_FLAT_FIXED_PAGE_SIZE) +bool g_requiresCheckedAccess = false; +#endif namespace { #if defined(_WIN32) @@ -48,6 +51,16 @@ constexpr DWORD kMemPreservePlaceholder = 0x00000002; constexpr size_t kAllocationGranularity = 0x10000; // 64 KiB constexpr size_t kHostPageSize = 0x1000; +// Only hosts that can expose a page larger than 4 KiB need to discover their +// size at runtime; see RequiresCheckedAccess() in guest_flat_memory.h. +#if !defined(MKW_GUEST_FLAT_FIXED_PAGE_SIZE) +size_t HostPageSize() +{ + const long size = sysconf(_SC_PAGESIZE); + return size > 0 ? static_cast(size) : kGuestPageSize; +} +#endif + // Named, platform-neutral protection modes so every fault-interception call site below (the // MMIO window, the executable-write guard, deferred-EFB-read protection, the on-demand // unmapped-block commit) can stay identical text on both platforms; only ProtectRange() and @@ -349,7 +362,7 @@ bool IsMmio(uint32_t address) { return MemoryInline::IsMmioAddress(address); } bool IsGpuFifo(uint32_t address) { return MemoryInline::IsGpuFifoAddress(address); } void ApplyExecutableProtectionLocked() { - if (g_base == nullptr) return; + if (g_base == nullptr || RequiresCheckedAccess()) return; auto& protectedPages = ExecutableProtectedPages(); for (const auto& range : ExecutableRanges()) { // Only pages fully inside the range are protected: edge pages often share a page with data @@ -497,6 +510,10 @@ bool IsActive() { void Initialize(const std::vector& regions) { std::lock_guard lock(StateMutex()); +#if !defined(MKW_GUEST_FLAT_FIXED_PAGE_SIZE) + g_requiresCheckedAccess = HostPageSize() > kGuestPageSize; +#endif + if (g_initialized) { if (!SameLayout(g_activeRegions, regions)) { throw std::runtime_error( @@ -615,7 +632,7 @@ uint8_t* HostPointer(uint32_t guestAddress) { } void ProtectDeferredRange(uint32_t address, size_t length) { - if (!g_initialized || length == 0) return; + if (RequiresCheckedAccess() || !g_initialized || length == 0) return; const uint64_t end = static_cast(address) + length; if (end > kGuestSpaceSize) return; std::lock_guard lock(StateMutex()); @@ -630,7 +647,7 @@ void ProtectDeferredRange(uint32_t address, size_t length) { } void UnprotectDeferredRange(uint32_t address, size_t length) { - if (!g_initialized || length == 0) return; + if (RequiresCheckedAccess() || !g_initialized || length == 0) return; std::lock_guard lock(StateMutex()); auto& ranges = DeferredRanges(); const uint64_t end = static_cast(address) + length; @@ -645,7 +662,7 @@ void UnprotectDeferredRange(uint32_t address, size_t length) { } void RegisterExecutableRange(uint32_t start, uint32_t end) { - if (end <= start) return; + if (RequiresCheckedAccess() || end <= start) return; std::lock_guard lock(StateMutex()); auto& ranges = ExecutableRanges(); if (std::any_of(ranges.begin(), ranges.end(), [&](const GuardedRange& range) { diff --git a/runtime/src/guest_flat_memory_macos.cpp b/runtime/src/guest_flat_memory_macos.cpp new file mode 100644 index 0000000..7a628e8 --- /dev/null +++ b/runtime/src/guest_flat_memory_macos.cpp @@ -0,0 +1,80 @@ +#include "guest_flat_memory.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace GuestFlat { +bool g_requiresCheckedAccess = false; +namespace { +struct Mapping { uint32_t base; uint64_t size; uint8_t* host; }; +std::mutex g_mutex; +std::vector g_mappings; +std::vector g_layout; +uint8_t* g_base = nullptr; +bool g_active = false; + +uint64_t Offset(const RegionRequest& r) { + if (r.backing == Backing::Mem1) return r.base & 0x1fffffffu; + if (r.backing == Backing::Mem2) return (r.base & 0x1fffffffu) - 0x10000000u; + return 0; +} +bool Same(const std::vector& a, const std::vector& b) { + return a.size() == b.size() && std::equal(a.begin(), a.end(), b.begin(), + [](const auto& x, const auto& y) { return x.base == y.base && x.size == y.size && x.backing == y.backing; }); +} +int BackingFile(size_t size) { + char name[] = "/tmp/wiicompiled-guest-XXXXXX"; + const int fd = mkstemp(name); + if (fd >= 0) { unlink(name); if (ftruncate(fd, static_cast(size)) != 0) { close(fd); return -1; } } + return fd; +} +} // namespace + +bool IsActive() { return g_active; } +void Initialize(const std::vector& regions) { + std::lock_guard lock(g_mutex); + g_requiresCheckedAccess = static_cast(getpagesize()) > kGuestPageSize; + if (g_active) { if (!Same(g_layout, regions)) throw std::runtime_error("flat guest layout cannot be remapped"); return; } + mach_vm_address_t address = kFixedFlatGuestBase; + if (mach_vm_allocate(mach_task_self(), &address, kGuestSpaceSize, VM_FLAGS_FIXED) != KERN_SUCCESS || address != kFixedFlatGuestBase) + throw std::runtime_error("unable to reserve fixed 4 GiB macOS guest address space"); + g_base = reinterpret_cast(address); + struct Store { Backing kind; uint32_t owned; uint64_t size; int fd; }; + std::vector stores; + for (const auto& r : regions) { + if (!r.size) continue; + const uint32_t owned = r.backing == Backing::Owned ? r.base : 0; + auto it = std::find_if(stores.begin(), stores.end(), [&](const Store& s) { return s.kind == r.backing && s.owned == owned; }); + const uint64_t need = Offset(r) + r.size; + if (it == stores.end()) stores.push_back({r.backing, owned, need, -1}); else it->size = std::max(it->size, need); + } + for (auto& s : stores) { s.fd = BackingFile(s.size); if (s.fd < 0) throw std::runtime_error("unable to create macOS guest backing store"); } + for (const auto& r : regions) { + if (!r.size) continue; + const uint32_t owned = r.backing == Backing::Owned ? r.base : 0; + const auto& s = *std::find_if(stores.begin(), stores.end(), [&](const Store& x) { return x.kind == r.backing && x.owned == owned; }); + auto* host = static_cast(mmap(nullptr, r.size, PROT_READ | PROT_WRITE, MAP_SHARED, s.fd, Offset(r))); + auto* guest = mmap(g_base + r.base, r.size, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_FIXED, s.fd, Offset(r)); + if (host == MAP_FAILED || guest != g_base + r.base) throw std::runtime_error("unable to map macOS guest alias"); + g_mappings.push_back({r.base, r.size, host}); + } + for (auto& s : stores) close(s.fd); + g_layout = regions; g_active = true; +} +uint8_t* HostPointer(uint32_t a) { for (const auto& m : g_mappings) if (a >= m.base && uint64_t(a - m.base) < m.size) return m.host + (a - m.base); return nullptr; } +void ProtectDeferredRange(uint32_t, size_t) {} +void UnprotectDeferredRange(uint32_t, size_t) {} +void RegisterExecutableRange(uint32_t, uint32_t) {} +FaultCounters Counters() { return {}; } +void LogFaultSummary() noexcept {} +bool HandleAccessViolation(void*, bool) noexcept { return false; } +} // namespace GuestFlat diff --git a/runtime/src/hle/gx/gx_internal.h b/runtime/src/hle/gx/gx_internal.h index 25e9e6d..8656dba 100644 --- a/runtime/src/hle/gx/gx_internal.h +++ b/runtime/src/hle/gx/gx_internal.h @@ -394,3 +394,4 @@ extern std::mutex g_tlutObjMutex; // that happens on another key: unordered_map keeps references valid across a // rehash, an open-addressed table would not. extern std::unordered_map g_TexObjMeta; +extern std::map g_TlutObjMeta; diff --git a/runtime/src/hle/gx/gx_utils.cpp b/runtime/src/hle/gx/gx_utils.cpp index aed1e5d..81d7c07 100644 --- a/runtime/src/hle/gx/gx_utils.cpp +++ b/runtime/src/hle/gx/gx_utils.cpp @@ -33,6 +33,15 @@ void WriteGuestFloat(uint32_t addr, float value, const char* label) { void* GuestToHostPtr(uint32_t addr, size_t len) { if (addr == 0) return nullptr; +#if defined(__APPLE__) + // The macOS flat guest map exposes separate host aliases for cached, + // uncached, and physical MEM1/MEM2 addresses. GX resources are identified + // by their host pointer, so all aliases of one guest allocation must use + // the same physical mapping before they reach Aurora. Kept macOS-only: + // changing which alias the other hosts hand out would re-key their existing + // GX resource identity. + addr = CanonicalizeGxMainRamAddress(addr); +#endif try { return Memory::GetPointer(addr, len); } catch (const Memory::AccessViolation& e) { LogMemoryError(RT_TAG_GX, "GX guest pointer", e); return nullptr; } } diff --git a/runtime/src/hle/input/kpad.cpp b/runtime/src/hle/input/kpad.cpp index 5e87c4a..c7c2ffa 100644 --- a/runtime/src/hle/input/kpad.cpp +++ b/runtime/src/hle/input/kpad.cpp @@ -1,23 +1,249 @@ #include "hle_stubs.h" +#include "memory.h" +#include "wii_remote_input.h" +#include +#include +#include #include +// KPAD HLE fed by a real Bluetooth Wii Remote. The game calls KPADRead once per +// frame with room for 16 KPADStatus entries and only looks at entry 0; with a +// Classic Controller it also calls KPADGetUnifiedWpadStatus for the raw +// WPADCLStatus (buttons, sticks and triggers of the extension). +namespace { + +constexpr uint32_t kKpadStatusSize = 0x84; + +// KPADStatus field offsets (RVL SDK). +constexpr uint32_t kHold = 0x00, kTrig = 0x04, kRelease = 0x08, kAcc = 0x0C, kAccValue = 0x18, + kAccSpeed = 0x1C, kPos = 0x20, kAccVertical = 0x54, kDevType = 0x5C, kWpadErr = 0x5D, + kDpdValidFg = 0x5E, kDataFormat = 0x5F, kFsStick = 0x60, kFsAcc = 0x68, kFsAccValue = 0x74, + kFsAccSpeed = 0x78; +// KPADStatus.ex_status.cl (KPADEXStatus, Classic Controller view). +constexpr uint32_t kClHold = 0x60, kClTrig = 0x64, kClRelease = 0x68, kClLStick = 0x6C, kClRStick = 0x74, + kClLTrigger = 0x7C, kClRTrigger = 0x80; + +// KPADUnifiedWpadStatus: WPADStatus / WPADFSStatus / WPADCLStatus union, then fmt. +constexpr uint32_t kUnifiedSize = 0x38; +constexpr uint32_t kUButton = 0x00, kUAccX = 0x02, kUAccY = 0x04, kUAccZ = 0x06, kUObj = 0x08, kUDev = 0x28, + kUErr = 0x29, kUFsStickX = 0x2A, kUFsStickY = 0x2B, kUFsAccX = 0x2C, kUFsAccY = 0x2E, + kUFsAccZ = 0x30, kUClButton = 0x2A, kUClLStickX = 0x2C, kUClLStickY = 0x2E, kUClRStickX = 0x30, + kUClRStickY = 0x32, kUClTriggerL = 0x34, kUClTriggerR = 0x35, kUFmt = 0x36; + +// WPAD device types (WPAD_DEV_*) and the data formats KPAD runs each of them +// in (WPAD_FMT_*_ACC_DPD): the values KPADStatus.dev_type / data_format and +// KPADUnifiedWpadStatus.dev / fmt carry on the console. +constexpr uint8_t kDevCore = 0; +constexpr uint8_t kDevFreestyle = 1; +constexpr uint8_t kDevClassic = 2; +constexpr uint8_t kFmtCoreAccDpd = 2; +constexpr uint8_t kFmtFreestyleAccDpd = 5; +constexpr uint8_t kFmtClassicAccDpd = 8; +constexpr int8_t kWpadErrNone = 0; +constexpr int8_t kWpadErrNoController = -1; + +// Raw accelerometer as WPADStatus carries it: 10 bits, 0x200 at 0 g, 100 per g. +constexpr float kRawAccZero = 512.0f; +constexpr float kRawAccPerG = 100.0f; + +struct ChannelState { + uint32_t prevHold = 0; + uint32_t prevClHold = 0; + float prevAcc[3] = {0.0f, -1.0f, 0.0f}; + float prevFsAcc[3] = {0.0f, -1.0f, 0.0f}; +}; + +std::array g_channels{}; + +// Euclidean length of a 3-vector. +float Length(const float* v) { + return std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); +} + +// Euclidean distance between two 3-vectors. +float Distance(const float* a, const float* b) { + const float d[3] = {a[0] - b[0], a[1] - b[1], a[2] - b[2]}; + return Length(d); +} + +// Writes three big-endian floats to guest memory. +void WriteVec3(uint32_t addr, const float* v) { + Memory::WriteFloat32(addr, v[0]); + Memory::WriteFloat32(addr + 4, v[1]); + Memory::WriteFloat32(addr + 8, v[2]); +} + +// Zeroes `count` consecutive floats in guest memory. +void WriteZeroFloats(uint32_t addr, uint32_t count) { + for (uint32_t i = 0; i < count; ++i) { + Memory::WriteFloat32(addr + i * 4, 0.0f); + } +} + +// Fills one KPADStatus at `addr` and returns the number of valid entries (1), +// or writes an "unplugged" status and returns 0. +int32_t WriteStatus(uint32_t chan, uint32_t addr, const WiiRemoteInput::KpadSample* sample) { + ChannelState& state = g_channels[chan]; + if (sample == nullptr) { + state = {}; + Memory::Write32(addr + kHold, 0); + Memory::Write32(addr + kTrig, 0); + Memory::Write32(addr + kRelease, 0); + Memory::Write8(addr + kDevType, kDevCore); + Memory::Write8(addr + kWpadErr, static_cast(kWpadErrNoController)); + Memory::Write8(addr + kDpdValidFg, 0); + return 0; + } + + const uint32_t hold = sample->hold; + Memory::Write32(addr + kHold, hold); + Memory::Write32(addr + kTrig, hold & ~state.prevHold); + Memory::Write32(addr + kRelease, state.prevHold & ~hold); + state.prevHold = hold; + + WriteVec3(addr + kAcc, sample->acc); + Memory::WriteFloat32(addr + kAccValue, Length(sample->acc)); + Memory::WriteFloat32(addr + kAccSpeed, Distance(sample->acc, state.prevAcc)); + for (int i = 0; i < 3; ++i) state.prevAcc[i] = sample->acc[i]; + + // No IR pointer: pos .. acc_vertical zeroed and dpd_valid_fg clear, which + // the game treats as "pointing away from the screen". + WriteZeroFloats(addr + kPos, (kAccVertical + 8 - kPos) / 4); + Memory::Write8(addr + kDpdValidFg, 0); + + const uint8_t devType = sample->hasClassic ? kDevClassic : sample->hasNunchuk ? kDevFreestyle : kDevCore; + const uint8_t dataFormat = + sample->hasClassic ? kFmtClassicAccDpd : sample->hasNunchuk ? kFmtFreestyleAccDpd : kFmtCoreAccDpd; + Memory::Write8(addr + kDevType, devType); + Memory::Write8(addr + kWpadErr, static_cast(kWpadErrNone)); + Memory::Write8(addr + kDataFormat, dataFormat); + + if (sample->hasClassic) { + const uint32_t clHold = sample->clHold; + Memory::Write32(addr + kClHold, clHold); + Memory::Write32(addr + kClTrig, clHold & ~state.prevClHold); + Memory::Write32(addr + kClRelease, state.prevClHold & ~clHold); + state.prevClHold = clHold; + Memory::WriteFloat32(addr + kClLStick, sample->clLStick[0]); + Memory::WriteFloat32(addr + kClLStick + 4, sample->clLStick[1]); + Memory::WriteFloat32(addr + kClRStick, sample->clRStick[0]); + Memory::WriteFloat32(addr + kClRStick + 4, sample->clRStick[1]); + Memory::WriteFloat32(addr + kClLTrigger, sample->clTriggerL / 255.0f); + Memory::WriteFloat32(addr + kClRTrigger, sample->clTriggerR / 255.0f); + } else if (sample->hasNunchuk) { + state.prevClHold = 0; + Memory::WriteFloat32(addr + kFsStick, sample->stick[0]); + Memory::WriteFloat32(addr + kFsStick + 4, sample->stick[1]); + WriteVec3(addr + kFsAcc, sample->nunchukAcc); + Memory::WriteFloat32(addr + kFsAccValue, Length(sample->nunchukAcc)); + Memory::WriteFloat32(addr + kFsAccSpeed, Distance(sample->nunchukAcc, state.prevFsAcc)); + for (int i = 0; i < 3; ++i) state.prevFsAcc[i] = sample->nunchukAcc[i]; + } else { + state.prevClHold = 0; + WriteZeroFloats(addr + kFsStick, (kKpadStatusSize - kFsStick) / 4); + } + return 1; +} + +// One accelerometer axis of KPAD's g vector back to the 10-bit raw WPAD value. +uint16_t RawAcc(float g) { + const float raw = kRawAccZero + g * kRawAccPerG; + return static_cast(std::clamp(raw, 0.0f, 1023.0f)); +} + +// Fills one KPADUnifiedWpadStatus at `addr` from the sample: the WPADStatus core +// (remote buttons, raw accelerometer, no IR objects), then the Nunchuk or Classic +// Controller tail, then the data format. +void WriteUnifiedStatus(uint32_t addr, const WiiRemoteInput::KpadSample* sample) { + for (uint32_t offset = 0; offset < kUnifiedSize; offset += 4) { + Memory::Write32(addr + offset, 0); + } + if (sample == nullptr) { + Memory::Write8(addr + kUDev, kDevCore); + Memory::Write8(addr + kUErr, static_cast(kWpadErrNoController)); + Memory::Write8(addr + kUFmt, kFmtCoreAccDpd); + return; + } + Memory::Write16(addr + kUButton, static_cast(sample->hold & 0xFFFF)); + // KPAD's acc is (-wiiX, -wiiZ, wiiY); WPADStatus keeps the remote's own axes. + Memory::Write16(addr + kUAccX, RawAcc(-sample->acc[0])); + Memory::Write16(addr + kUAccY, RawAcc(sample->acc[2])); + Memory::Write16(addr + kUAccZ, RawAcc(-sample->acc[1])); + // No IR: every DPDObject invalid (x/y at the sensor's out-of-range value). + for (uint32_t i = 0; i < 4; ++i) { + Memory::Write16(addr + kUObj + i * 8, 0x3FF); + Memory::Write16(addr + kUObj + i * 8 + 2, 0x3FF); + } + Memory::Write8(addr + kUErr, static_cast(kWpadErrNone)); + if (sample->hasClassic) { + Memory::Write8(addr + kUDev, kDevClassic); + Memory::Write16(addr + kUClButton, static_cast(sample->clHold & 0xFFFF)); + Memory::Write16(addr + kUClLStickX, static_cast(sample->clLStickRaw[0])); + Memory::Write16(addr + kUClLStickY, static_cast(sample->clLStickRaw[1])); + Memory::Write16(addr + kUClRStickX, static_cast(sample->clRStickRaw[0])); + Memory::Write16(addr + kUClRStickY, static_cast(sample->clRStickRaw[1])); + Memory::Write8(addr + kUClTriggerL, sample->clTriggerL); + Memory::Write8(addr + kUClTriggerR, sample->clTriggerR); + Memory::Write8(addr + kUFmt, kFmtClassicAccDpd); + } else if (sample->hasNunchuk) { + Memory::Write8(addr + kUDev, kDevFreestyle); + // WPADFSStatus: 8-bit stick (centre 128) and 10-bit Nunchuk accelerometer. + Memory::Write8(addr + kUFsStickX, + static_cast(std::clamp(128.0f + sample->stick[0] * 100.0f, 0.0f, 255.0f))); + Memory::Write8(addr + kUFsStickY, + static_cast(std::clamp(128.0f + sample->stick[1] * 100.0f, 0.0f, 255.0f))); + Memory::Write16(addr + kUFsAccX, RawAcc(-sample->nunchukAcc[0])); + Memory::Write16(addr + kUFsAccY, RawAcc(sample->nunchukAcc[2])); + Memory::Write16(addr + kUFsAccZ, RawAcc(-sample->nunchukAcc[1])); + Memory::Write8(addr + kUFmt, kFmtFreestyleAccDpd); + } else { + Memory::Write8(addr + kUDev, kDevCore); + Memory::Write8(addr + kUFmt, kFmtCoreAccDpd); + } +} + +} // namespace + +// KPADRead: fills KPADStatus[0] for `chan` from the Bluetooth remote, returns the entry count. extern "C" int32_t KPAD__Read_HLE(uint32_t chan, uint32_t statusPtr, uint32_t count) { - (void)chan; - (void)statusPtr; - (void)count; - return 0; + if (chan >= g_channels.size() || statusPtr == 0 || count == 0) { + return 0; + } + WiiRemoteInput::KpadSample sample; + const bool have = WiiRemoteInput::ReadKpadSample(chan, sample); + try { + return WriteStatus(chan, statusPtr, have ? &sample : nullptr); + } catch (const Memory::AccessViolation&) { + return 0; + } } PPC_NATIVE_OVERRIDE(80197380, KPAD__Read_HLE, int32_t, (uint32_t chan, uint32_t statusPtr, uint32_t count), (chan, statusPtr, count)); +// KPADGetUnifiedWpadStatus: the raw WPAD status behind KPADStatus. The game +// reads the Classic Controller's buttons, sticks and triggers from here. The +// SDK fills `count` entries with the channel's recent samples (the game asks for +// as many as it asked KPADRead for and looks at entry 0); with one sample per +// frame here, every entry gets the current one. extern "C" int32_t KPAD__GetUnifiedWpadStatus_HLE(uint32_t chan, uint32_t statusPtr, uint32_t count) { - (void)chan; - (void)statusPtr; - (void)count; - return 0; + constexpr uint32_t kMaxEntries = 16; // KPAD_MAX_READ_BUFS + if (chan >= g_channels.size() || statusPtr == 0 || count == 0) { + return 0; + } + WiiRemoteInput::KpadSample sample; + const bool have = WiiRemoteInput::ReadKpadSample(chan, sample); + try { + const uint32_t entries = std::min(count, kMaxEntries); + for (uint32_t i = 0; i < entries; ++i) { + WriteUnifiedStatus(statusPtr + i * kUnifiedSize, have ? &sample : nullptr); + } + } catch (const Memory::AccessViolation&) { + return 0; + } + return have ? 1 : 0; } PPC_NATIVE_OVERRIDE(8019812C, KPAD__GetUnifiedWpadStatus_HLE, int32_t, (uint32_t chan, uint32_t statusPtr, uint32_t count), (chan, statusPtr, count)); diff --git a/runtime/src/hle/input/pad.cpp b/runtime/src/hle/input/pad.cpp index 606dd0a..1939a84 100644 --- a/runtime/src/hle/input/pad.cpp +++ b/runtime/src/hle/input/pad.cpp @@ -1,6 +1,7 @@ #include "hle_stubs.h" #include "memory.h" #include "hle/controller_status_contract.h" +#include "wii_remote_input.h" #include #include @@ -37,6 +38,7 @@ extern "C" uint32_t PAD__Init_HLE() } PPC_NATIVE_OVERRIDE(801AF2F0, PAD__Init_HLE, uint32_t, (), ()); +// PADRead: gathers every GameCube pad source for the frame and writes the statuses to guest memory. extern "C" uint32_t PAD__Read_HLE(uint32_t statusPtr) { if (statusPtr == 0) { @@ -44,7 +46,13 @@ extern "C" uint32_t PAD__Read_HLE(uint32_t statusPtr) } PADStatus statuses[PAD_CHANMAX]{}; + // Keep looking for a Bluetooth Wii Remote that dropped out (or was turned on late). + WiiRemoteInput::Poll(); uint32_t rumbleMask = PADRead(statuses); + // Wii Remotes reach the game through KPAD, not as GameCube pads. This also + // applies while input is blocked (overlay open) so the port does not flip + // between "connected" and "no controller" every time the overlay toggles. + WiiRemoteInput::HideRemotesFromPad(statuses, PAD_CHANMAX); try { for (uint32_t i = 0; i < PAD_CHANMAX; ++i) { diff --git a/runtime/src/hle/input/wpad.cpp b/runtime/src/hle/input/wpad.cpp index c6a59c7..b3985ff 100644 --- a/runtime/src/hle/input/wpad.cpp +++ b/runtime/src/hle/input/wpad.cpp @@ -1,6 +1,7 @@ #include "hle_stubs.h" #include "memory.h" #include "hle/controller_status_contract.h" +#include "wii_remote_input.h" #include @@ -98,18 +99,41 @@ extern "C" int32_t WPADGetDataFormat_HLE(uint32_t chan) } PPC_NATIVE_OVERRIDE(801C0B54, WPADGetDataFormat_HLE, int32_t, (uint32_t chan), (chan)); +// WPADSetDataFormat: records the per-channel data format the game asked for. extern "C" int32_t WPADSetDataFormat_HLE(uint32_t chan, int32_t format) { return g_state.contract.SetDataFormat(chan, format); } PPC_NATIVE_OVERRIDE(801C0B9C, WPADSetDataFormat_HLE, int32_t, (uint32_t chan, int32_t format), (chan, format)); +// WPADProbe: reports the extension type of a Bluetooth remote on `chan`, or no controller. extern "C" int32_t WPADProbe_HLE(uint32_t chan, uint32_t typePtr) { if (chan >= WpadContract::kChannelCount) { return WpadContract::kErrorBadChannel; } + // Drive the rescan state machine here too: a reconnect probe can arrive + // before the next PADRead, and only Poll() brings a dropped remote back. + WiiRemoteInput::Poll(); + + // A real Bluetooth remote: WPAD_DEV_CORE (0) for a bare remote, + // WPAD_DEV_FREESTYLE (1) with a Nunchuk, WPAD_DEV_CLASSIC (2) with a Classic + // Controller. The game reads the type from here (not from + // KPADStatus.dev_type) to pick its control scheme, and re-reads it when it + // changes, which is what makes an extension swap mid-game work like on the + // console. EffectiveKind keeps the last type through SDL's re-creation of + // the joystick after a swap. + const WiiRemoteInput::Kind kind = WiiRemoteInput::EffectiveKind(chan); + if (WiiRemoteInput::IsRemoteChannel(chan)) { + if (typePtr != 0) { + uint32_t type = WpadContract::kExtensionCore; + if (kind == WiiRemoteInput::Kind::RemoteWithNunchuk) type = 1u; + if (kind == WiiRemoteInput::Kind::RemoteWithClassic) type = 2u; + Memory::Write32(typePtr, type); + } + return kStatusOk; + } if (typePtr != 0) { Memory::Write32(typePtr, WpadContract::kExtensionCore); } diff --git a/runtime/src/hle/net/network_ssl.cpp b/runtime/src/hle/net/network_ssl.cpp index d0bb9ac..01ec284 100644 --- a/runtime/src/hle/net/network_ssl.cpp +++ b/runtime/src/hle/net/network_ssl.cpp @@ -270,7 +270,13 @@ static int32_t EnsureSslCredentials(SslSession& ssl) { SCHANNEL_CRED cred{}; cred.dwVersion = SCHANNEL_CRED_VERSION; - cred.dwFlags = SCH_USE_STRONG_CRYPTO | SCH_CRED_NO_DEFAULT_CREDS | SCH_CRED_MANUAL_CRED_VALIDATION; + // Leave certificate validation to Schannel. SCH_CRED_MANUAL_CRED_VALIDATION + // suppresses that validation and requires an explicit CertGetCertificateChain + // implementation, which this HLE does not provide. The target hostname passed + // to InitializeSecurityContextA below is therefore checked together with the + // server certificate chain. + cred.dwFlags = SCH_USE_STRONG_CRYPTO | SCH_CRED_NO_DEFAULT_CREDS | + SCH_CRED_AUTO_CRED_VALIDATION; TimeStamp expiry{}; const SECURITY_STATUS status = AcquireCredentialsHandleA( @@ -289,6 +295,13 @@ static int32_t SslHandshakeImpl(SslSession& ssl) { return SSL_OK; } + // Schannel can authenticate a certificate chain without authenticating a + // server identity when no target name is supplied. Refuse that ambiguous + // mode rather than accepting a certificate for an unrelated endpoint. + if (ssl.hostname.empty()) { + return SSL_ERR_VCOMMONNAME; + } + const int32_t credRet = EnsureSslCredentials(ssl); if (credRet != SSL_OK) { return credRet; @@ -331,7 +344,7 @@ static int32_t SslHandshakeImpl(SslSession& ssl) { const SECURITY_STATUS status = InitializeSecurityContextA( &ssl.cred, ssl.haveContext ? &ssl.context : nullptr, - ssl.hostname.empty() ? nullptr : const_cast(ssl.hostname.c_str()), flags, 0, SECURITY_NATIVE_DREP, + const_cast(ssl.hostname.c_str()), flags, 0, SECURITY_NATIVE_DREP, inDescPtr, 0, &ssl.context, &outDesc, &attrs, &expiry); if (status != SEC_E_INVALID_HANDLE) { ssl.haveContext = true; @@ -567,10 +580,9 @@ int32_t HandleSslIoctlv(uint32_t cmd, const std::vector& in, const std switch (cmd) { case IOCTLV_NET_SSL_NEW: { - // out[0] carries the verify option. Its value is not consulted - every - // certificate check is delegated to Schannel with - // SCH_CRED_MANUAL_CRED_VALIDATION - but it is still read so that a - // request pointing outside guest memory faults here as it always has. + // out[0] carries the guest verify option. Host TLS verification is + // always enforced by Schannel, but still read this value so a request + // pointing outside guest memory faults here as it always has. if (!out.empty() && out[0].address && out[0].size >= 4) { (void)Memory::Read32(out[0].address); } diff --git a/runtime/src/hle/storage/nand_isfs.cpp b/runtime/src/hle/storage/nand_isfs.cpp index d8a0887..6ea0ad4 100644 --- a/runtime/src/hle/storage/nand_isfs.cpp +++ b/runtime/src/hle/storage/nand_isfs.cpp @@ -4,6 +4,7 @@ #include "nand_internal.h" +#include "discord_presence.h" #include "runtime_log.h" extern "C" void OSSleepThread_HLE_801aa9b8(CpuContext* ctx); @@ -201,9 +202,69 @@ static int32_t HandleDolphinIoctlv(uint32_t cmd, uint32_t numIn, uint32_t numOut } case DOLPHIN_IOCTL_SET_SPEED_LIMIT: - case DOLPHIN_IOCTL_DISCORD_SET_CLIENT: - case DOLPHIN_IOCTL_DISCORD_SET_PRESENCE: + return ISFS_OK; + + case DOLPHIN_IOCTL_DISCORD_SET_CLIENT: { + if (numIn != 1 || numOut != 0 || vectorPtr == 0) { + return ISFS_EINVAL; + } + const IosVector client = ReadIosVector(vectorPtr, 0); + if (!IsValidGuestRange(client.address, client.size)) { + return ISFS_EINVAL; + } + if (RuntimeConfigFile::DiscordPresenceEnabled()) { + DiscordPresence::SetClient(ReadGuestCString(client.address, client.size)); + } + return ISFS_OK; + } + + case DOLPHIN_IOCTL_DISCORD_SET_PRESENCE: { + if (numIn != 10 || numOut != 0 || vectorPtr == 0) { + return ISFS_EINVAL; + } + std::array values{}; + for (uint32_t index = 0; index < values.size(); ++index) { + values[index] = ReadIosVector(vectorPtr, index); + if (!IsValidGuestRange(values[index].address, values[index].size)) { + return ISFS_EINVAL; + } + } + if (RuntimeConfigFile::DiscordPresenceEnabled()) { + DiscordPresence::Activity activity; + activity.details = ReadGuestCString(values[0].address, values[0].size); + activity.state = ReadGuestCString(values[1].address, values[1].size); + activity.largeImageKey = ReadGuestCString(values[2].address, values[2].size); + activity.largeImageText = ReadGuestCString(values[3].address, values[3].size); + activity.smallImageKey = ReadGuestCString(values[4].address, values[4].size); + activity.smallImageText = ReadGuestCString(values[5].address, values[5].size); + if (values[6].size >= 8 && Memory::Contains(values[6].address, 8)) { + activity.startTimestamp = static_cast( + (static_cast(Memory::Read32(values[6].address)) << 32) | + Memory::Read32(values[6].address + 4)); + } + if (values[7].size >= 8 && Memory::Contains(values[7].address, 8)) { + activity.endTimestamp = static_cast( + (static_cast(Memory::Read32(values[7].address)) << 32) | + Memory::Read32(values[7].address + 4)); + } + if (values[8].size >= 4) { + activity.partySize = Memory::Read32(values[8].address); + } + if (values[9].size >= 4) { + activity.partyMax = Memory::Read32(values[9].address); + } + DiscordPresence::SetActivity(std::move(activity)); + } + return ISFS_OK; + } + case DOLPHIN_IOCTL_DISCORD_RESET: + if (numIn != 0 || numOut != 0) { + return ISFS_EINVAL; + } + if (RuntimeConfigFile::DiscordPresenceEnabled()) { + DiscordPresence::Reset(); + } return ISFS_OK; case DOLPHIN_IOCTL_GET_SYSTEM_TIME: { diff --git a/runtime/src/hle/vi.cpp b/runtime/src/hle/vi.cpp index fc5ba7b..a18279c 100644 --- a/runtime/src/hle/vi.cpp +++ b/runtime/src/hle/vi.cpp @@ -6,6 +6,7 @@ #include "aurora_events.h" #include "settings_overlay.h" #include "fiber_manager.h" +#include "platform/host_platform.h" #include "runtime_log.h" #include "vr/mkw_vr_policy.h" #include "vr/openxr_integration.h" @@ -22,18 +23,15 @@ #include #include -#include - #if defined(_WIN32) -#ifndef NOMINMAX -#define NOMINMAX -#endif #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN #endif #include #endif +#include + // Forward declaration for OSWakeupThread - used to wake threads on VI retrace queue extern "C" void OSWakeupThread_HLE_801aaaa4(CpuContext* ctx); diff --git a/runtime/src/host_context.cpp b/runtime/src/host_context.cpp new file mode 100644 index 0000000..e23c1c0 --- /dev/null +++ b/runtime/src/host_context.cpp @@ -0,0 +1,270 @@ +#include "host_context.h" + +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#elif defined(__APPLE__) && defined(__aarch64__) +#include +#include + +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__) +#include + +#include +#include +#else +#error "HostContext needs a supported cooperative-context backend" +#endif + +namespace HostContext { + +#if defined(_WIN32) + +namespace { +thread_local bool g_convertedScheduler = false; +} + +bool InitializeScheduler(Handle* scheduler) +{ + void* context = ConvertThreadToFiber(nullptr); + g_convertedScheduler = context != nullptr; + if (!context) { + context = GetCurrentFiber(); + } + *scheduler = context; + return context != nullptr; +} + +void ShutdownScheduler(Handle scheduler) +{ + if (scheduler && g_convertedScheduler) { + ConvertFiberToThread(); + } + g_convertedScheduler = false; +} + +Handle Create(std::size_t stackSize, Entry entry, void* argument) +{ + return CreateFiber(stackSize, entry, argument); +} + +void Destroy(Handle context) +{ + if (context) { + DeleteFiber(context); + } +} + +bool IsCurrent(Handle context) +{ + return context != nullptr && GetCurrentFiber() == context; +} + +void Switch(Handle target) +{ + SwitchToFiber(target); +} + +#elif defined(__APPLE__) && defined(__aarch64__) + +namespace { +struct Context { + void* savedStackPointer = nullptr; + void* stack = nullptr; + std::size_t stackSize = 0; +}; + +// Guest scheduling is confined to the initialized main host thread. Keeping +// this as ordinary process state also avoids relying on Darwin TLS internals +// while executing on a manually managed stack. +Context* g_current = nullptr; +} + +bool InitializeScheduler(Handle* scheduler) +{ + auto* context = new Context(); + g_current = context; + *scheduler = context; + return true; +} + +void ShutdownScheduler(Handle scheduler) +{ + auto* context = static_cast(scheduler); + if (g_current == context) { + g_current = nullptr; + } + delete context; +} + +Handle Create(std::size_t stackSize, Entry entry, void* argument) +{ + auto* context = new Context(); + const std::size_t guardSize = static_cast(getpagesize()); + const std::size_t totalSize = stackSize + guardSize; + context->stack = mmap(nullptr, totalSize, PROT_READ | PROT_WRITE, + MAP_ANON | MAP_PRIVATE, -1, 0); + if (context->stack == MAP_FAILED) { + delete context; + return nullptr; + } + // Fault on stack overflow instead of corrupting the preceding mapping. + if (mprotect(context->stack, guardSize, PROT_NONE) != 0) { + munmap(context->stack, totalSize); + delete context; + return nullptr; + } + context->stackSize = totalSize; + + auto* stackTop = static_cast(context->stack) + totalSize; + context->savedStackPointer = mkw_co_init(stackTop, entry, argument); + return context; +} + +void Destroy(Handle context) +{ + auto* nativeContext = static_cast(context); + if (!nativeContext) { + return; + } + if (nativeContext->stack) { + munmap(nativeContext->stack, nativeContext->stackSize); + } + delete nativeContext; +} + +bool IsCurrent(Handle context) +{ + return context != nullptr && context == g_current; +} + +void Switch(Handle target) +{ + auto* destination = static_cast(target); + Context* source = g_current; + if (!destination || destination == source) { + return; + } + + g_current = destination; + mkw_co_switch(&destination->savedStackPointer, &source->savedStackPointer); + g_current = source; +} + +#elif defined(__linux__) + +namespace { +struct Context { + cothread_t native = nullptr; + Entry entry = nullptr; + void* argument = nullptr; + bool ownsNative = false; +}; + +thread_local Context* g_current = nullptr; +thread_local std::unordered_map g_contexts; + +void ContextEntry() +{ + const auto found = g_contexts.find(co_active()); + if (found == g_contexts.end() || !found->second || !found->second->entry) { + std::abort(); + } + + Context* context = found->second; + g_current = context; + context->entry(context->argument); + + // A guest fiber must return through FiberProc's scheduler handoff. There + // is no valid native caller to return to from libco's entry trampoline. + std::abort(); +} +} // namespace + +bool InitializeScheduler(Handle* scheduler) +{ + auto* context = new Context(); + context->native = co_active(); + if (!context->native) { + delete context; + return false; + } + + g_current = context; + g_contexts.emplace(context->native, context); + *scheduler = context; + return true; +} + +void ShutdownScheduler(Handle scheduler) +{ + auto* context = static_cast(scheduler); + if (!context) { + return; + } + + g_contexts.erase(context->native); + if (g_current == context) { + g_current = nullptr; + } + delete context; +} + +Handle Create(std::size_t stackSize, Entry entry, void* argument) +{ + auto* context = new Context(); + context->entry = entry; + context->argument = argument; + context->native = co_create(static_cast(stackSize), ContextEntry); + context->ownsNative = context->native != nullptr; + if (!context->native) { + delete context; + return nullptr; + } + + g_contexts.emplace(context->native, context); + return context; +} + +void Destroy(Handle context) +{ + auto* nativeContext = static_cast(context); + if (!nativeContext) { + return; + } + + g_contexts.erase(nativeContext->native); + if (nativeContext->ownsNative) { + co_delete(nativeContext->native); + } + delete nativeContext; +} + +bool IsCurrent(Handle context) +{ + return context != nullptr && context == g_current; +} + +void Switch(Handle target) +{ + auto* destination = static_cast(target); + Context* source = g_current; + if (!destination || destination == source) { + return; + } + + g_current = destination; + co_switch(destination->native); + g_current = source; +} + +#endif + +} // namespace HostContext diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 37e4859..068c6bf 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -23,6 +23,10 @@ #include #include +#if !defined(_WIN32) +#include +#endif + #if defined(_WIN32) #ifndef NOMINMAX #define NOMINMAX @@ -38,7 +42,12 @@ #include #else #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. #include +#endif #include #endif @@ -49,6 +58,8 @@ #include "system_bridge.h" #include "ppc_runtime.h" #include "aurora_events.h" +#include "wii_remote_input.h" +#include "discord_presence.h" #include "fiber_manager.h" #include "hle_stubs.h" #include "runtime_config.h" @@ -922,6 +933,7 @@ constexpr DWORD kCppExceptionCodeMsvc = 0xE06D7363; // AddressSanitizer uses STATUS_FATAL_APP_EXIT when it detects an error and wants to report it. // We must let ASan's handler run so it can print file/line information. constexpr DWORD kAsanFatalAppExit = 0x40000015; // STATUS_FATAL_APP_EXIT +LONG ReportFatalSehAndExit(EXCEPTION_POINTERS* info); void ReportStructuredException(EXCEPTION_POINTERS* info) { if (!info || !info->ExceptionRecord) { @@ -1023,13 +1035,34 @@ LONG CALLBACK SehLogger(EXCEPTION_POINTERS* info) { info->ExceptionRecord->ExceptionCode == kAsanFatalAppExit) { // ASan reporting - let it print first return EXCEPTION_CONTINUE_SEARCH; } - + // Software-raised exceptions (customer bit set) are used for internal control flow by + // system DLLs (e.g. msxml6 while mscms parses a display colour profile) and are caught + // by their own frame handlers. Only hardware faults are fatal at first chance; anything + // else that truly goes unhandled reaches UnhandledSehFilter. + if ((info->ExceptionRecord->ExceptionCode & 0x20000000u) != 0) { + return EXCEPTION_CONTINUE_SEARCH; + } + return ReportFatalSehAndExit(info); +} + +LONG WINAPI UnhandledSehFilter(EXCEPTION_POINTERS* info) { + if (info == nullptr || info->ExceptionRecord == nullptr) { + return EXCEPTION_CONTINUE_SEARCH; + } + const DWORD code = info->ExceptionRecord->ExceptionCode; + if (code == kCppExceptionCodeGcc || code == kCppExceptionCodeMsvc || code == kAsanFatalAppExit) { + return EXCEPTION_CONTINUE_SEARCH; + } + return ReportFatalSehAndExit(info); +} + +LONG ReportFatalSehAndExit(EXCEPTION_POINTERS* info) { // Guard against re-entrancy: if we crash while reporting, don't recurse static std::atomic_flag s_inCrashHandler = ATOMIC_FLAG_INIT; if (s_inCrashHandler.test_and_set()) { std::_Exit(EXIT_FAILURE); } - + // Report the structured exception with detailed information ReportStructuredException(info); const auto* record = info->ExceptionRecord; @@ -1064,6 +1097,7 @@ LONG CALLBACK SehLogger(EXCEPTION_POINTERS* info) { void InstallSehLogger() { if (!g_vectoredSehHandle) { g_vectoredSehHandle = AddVectoredExceptionHandler(1, SehLogger); + SetUnhandledExceptionFilter(UnhandledSehFilter); } } #else @@ -1269,6 +1303,7 @@ static void TerminateHandler() { std::_Exit(EXIT_FAILURE); } +// Runtime entry point: loads the configuration, brings up aurora and runs the game. int RuntimeMain(int argc, char** argv) { // Must run before the transcript duplicates stdout/stderr: it decides what // those descriptors are mirrored to now that the products are GUI-subsystem. @@ -1293,6 +1328,9 @@ int RuntimeMain(int argc, char** argv) { throw std::invalid_argument("The game runtime does not accept command-line options; use Config.toml through the installed host."); } RuntimeConfigFile::LogLoadedConfig(); + if (RuntimeConfigFile::DiscordPresenceEnabled()) { + DiscordPresence::Initialize(RuntimeConfigFile::DiscordClientId(), "Mario Kart Wii"); + } SystemBridge::Initialize(); TranslatedFunctionRegistry::Finalize(); @@ -1344,9 +1382,21 @@ int RuntimeMain(int argc, char** argv) { const char* configName; AuroraBackend backend; }; +#if defined(__APPLE__) + static constexpr std::array kGraphicsBackends{{ + {"auto", BACKEND_AUTO}, {"metal", BACKEND_METAL}, + }}; +// only vulkan for linux +#elif defined(__linux__) + static constexpr std::array kGraphicsBackends{{ + {"auto", BACKEND_AUTO}, {"vulkan", BACKEND_VULKAN}, + }}; +#elif defined(_WIN32) static constexpr std::array kGraphicsBackends{{ {"auto", BACKEND_AUTO}, {"d3d12", BACKEND_D3D12}, {"vulkan", BACKEND_VULKAN}, }}; + +#endif const auto backendDisplayName = [](AuroraBackend value) -> const char* { for (const auto& entry : kGraphicsBackends) { if (entry.backend == value) { @@ -1375,6 +1425,11 @@ int RuntimeMain(int argc, char** argv) { } const AuroraBackend requestedBackend = auroraConfig.desiredBackend; + // SDL only reads its Wii driver hint when the joystick subsystem starts, which + // aurora_initialize does; a Bluetooth Wii Remote paired before launch must be + // visible on that first scan. + WiiRemoteInput::ConfigureSdlHints(RuntimeConfigFile::WiiRemotesEnabled(true)); + const AuroraInfo auroraInfo = aurora_initialize(0, nullptr, &auroraConfig); if (requestedBackend != BACKEND_AUTO && auroraInfo.backend != requestedBackend) { RT_LOG(RT_TAG_RUNTIME) << "graphics_api=\"" << backend @@ -1441,6 +1496,7 @@ int RuntimeMain(int argc, char** argv) { WindowPlacementPersistence::Flush(true); mkw::vr::OpenXRShutdownBeforeAurora(); aurora_shutdown(); + DiscordPresence::Shutdown(); SetRuntimeExitCodeImpl(0); ShutdownProcessTranscript(); return 0; @@ -1459,6 +1515,7 @@ int RuntimeMain(int argc, char** argv) { WindowPlacementPersistence::Flush(true); mkw::vr::OpenXRShutdownBeforeAurora(); aurora_shutdown(); + DiscordPresence::Shutdown(); ShutdownProcessTranscript(); return 1; } catch (const std::exception& ex) { @@ -1471,6 +1528,7 @@ int RuntimeMain(int argc, char** argv) { WindowPlacementPersistence::Flush(true); mkw::vr::OpenXRShutdownBeforeAurora(); aurora_shutdown(); + DiscordPresence::Shutdown(); ShutdownProcessTranscript(); return 1; } diff --git a/runtime/src/platform/host_platform.cpp b/runtime/src/platform/host_platform.cpp new file mode 100644 index 0000000..c0caa95 --- /dev/null +++ b/runtime/src/platform/host_platform.cpp @@ -0,0 +1,85 @@ +#include "platform/host_platform.h" + +#include + +#if defined(_WIN32) +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#include +#else +#include +#endif + +#if defined(__APPLE__) +#include +#include +#endif + +namespace RuntimePlatform { + +std::optional ExecutableDirectory() noexcept { +#if defined(_WIN32) + std::wstring buffer(MAX_PATH, L'\0'); + for (;;) { + const DWORD length = GetModuleFileNameW(nullptr, buffer.data(), static_cast(buffer.size())); + if (length == 0) { + return std::nullopt; + } + if (length < buffer.size() - 1) { + buffer.resize(length); + return std::filesystem::path(buffer).parent_path(); + } + buffer.resize(buffer.size() * 2); + } +#elif defined(__APPLE__) + uint32_t size = 0; + if (_NSGetExecutablePath(nullptr, &size) != -1 || size == 0) { + return std::nullopt; + } + std::string path(size, '\0'); + if (_NSGetExecutablePath(path.data(), &size) != 0) { + return std::nullopt; + } + path.resize(std::char_traits::length(path.c_str())); + std::error_code ec; + const auto resolved = std::filesystem::weakly_canonical(path, ec); + return (ec ? std::filesystem::path(path) : resolved).parent_path(); +#else + return std::nullopt; +#endif +} + +std::filesystem::path ApplicationDataDirectory(std::string_view applicationName) { +#if defined(_WIN32) + PWSTR rawPath = nullptr; + if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_LocalAppData, KF_FLAG_CREATE, nullptr, &rawPath)) && rawPath) { + const std::filesystem::path directory = std::filesystem::path(rawPath) / applicationName; + CoTaskMemFree(rawPath); + return directory; + } +#elif defined(__APPLE__) + if (const char* home = std::getenv("HOME"); home && *home) { + return std::filesystem::path(home) / "Library" / "Application Support" / applicationName; + } + if (const passwd* user = getpwuid(getuid()); user && user->pw_dir && *user->pw_dir) { + return std::filesystem::path(user->pw_dir) / "Library" / "Application Support" / applicationName; + } +#endif + return std::filesystem::current_path() / applicationName; +} + +std::filesystem::path LogDirectory(std::string_view applicationName) { + return ApplicationDataDirectory(applicationName) / "Logs"; +} + +uint64_t CurrentProcessId() noexcept { +#if defined(_WIN32) + return static_cast(::GetCurrentProcessId()); +#else + return static_cast(::getpid()); +#endif +} + +} // namespace RuntimePlatform diff --git a/runtime/src/platform/macos/co_switch.S b/runtime/src/platform/macos/co_switch.S new file mode 100644 index 0000000..cdd2b50 --- /dev/null +++ b/runtime/src/platform/macos/co_switch.S @@ -0,0 +1,59 @@ +.text +.align 2 + +// AArch64 Darwin cooperative context frame (240 bytes): x18-x30, then v8-v15. +// x18 is platform-reserved on Darwin and is needed by code that accesses TLS. +// x0 = address holding the target frame pointer; x1 = address to receive the +// current frame pointer. This is intentionally leaf-only: it never calls C++. +.globl _mkw_co_switch +_mkw_co_switch: + sub sp, sp, #240 + str x18, [sp, #0] + stp x19, x20, [sp, #16] + stp x21, x22, [sp, #32] + stp x23, x24, [sp, #48] + stp x25, x26, [sp, #64] + stp x27, x28, [sp, #80] + stp x29, x30, [sp, #96] + stp q8, q9, [sp, #112] + stp q10, q11, [sp, #144] + stp q12, q13, [sp, #176] + stp q14, q15, [sp, #208] + mov x2, sp + str x2, [x1] + + ldr x2, [x0] + mov sp, x2 + ldr x18, [sp, #0] + ldp x19, x20, [sp, #16] + ldp x21, x22, [sp, #32] + ldp x23, x24, [sp, #48] + ldp x25, x26, [sp, #64] + ldp x27, x28, [sp, #80] + ldp x29, x30, [sp, #96] + ldp q8, q9, [sp, #112] + ldp q10, q11, [sp, #144] + ldp q12, q13, [sp, #176] + ldp q14, q15, [sp, #208] + add sp, sp, #240 + ret + +// Creates a frame compatible with mkw_co_switch and returns its saved SP. +// x0 = one-past-end stack pointer, x1 = entry(void*), x2 = entry argument. +.globl _mkw_co_init +_mkw_co_init: + bic x0, x0, #0xf + sub x0, x0, #240 + str x18, [x0, #0] // Darwin platform register / TLS base + str x1, [x0, #16] // x19: entry + str x2, [x0, #24] // x20: argument + str xzr, [x0, #96] // x29 + adrp x3, _mkw_co_entry_trampoline@PAGE + add x3, x3, _mkw_co_entry_trampoline@PAGEOFF + str x3, [x0, #104] // x30 + ret + +_mkw_co_entry_trampoline: + mov x0, x20 + blr x19 + brk #0 diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 8795ebd..6bc531b 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -5,6 +5,7 @@ #include "music_attenuation.h" #include "runtime_config.h" #include "runtime_log.h" +#include "wii_remote_input.h" #include #include @@ -282,6 +283,15 @@ void ApplyConfiguredMappings() { if (controllerIndex < 0) { continue; } + // The [controller] bindings are positional and shared by every port, so + // they describe whatever pad the user set them up with (usually an Xbox + // layout: a = south). A Wii U Pro Controller has a fixed, known layout + // (A on the east position) that aurora already maps by name; applying + // the shared bindings on top swaps A/B and X/Y. (Wii Remotes with any + // extension never reach the PAD layer: the game reads them through KPAD.) + if (WiiRemoteInput::KindForPort(port) == WiiRemoteInput::Kind::WiiUPro) { + continue; + } uint32_t count = 0; if (PADGetButtonMappings(port, &count) == nullptr || count != PAD_BUTTON_COUNT) { @@ -313,6 +323,141 @@ void ApplyConfiguredMappings() { } } +bool g_wiiRemotesEnabled = RuntimeConfigFile::WiiRemotesEnabled(true); +bool g_wiiContinuousScan = RuntimeConfigFile::WiiContinuousScanEnabled(true); + +// Accelerometer readout and zero-point calibration for a bare remote / remote + Nunchuk. +void DrawWiiRemoteAccelerometer(uint32_t port) { + ImGui::SeparatorText("Accelerometer"); + float sdlG[3] = {}; + float kpad[3] = {}; + if (WiiRemoteInput::ReadAccelDebug(port, sdlG, kpad)) { + ImGui::Text("KPAD acc: x %+.2f y %+.2f z %+.2f g", kpad[0], kpad[1], kpad[2]); + ImGui::TextDisabled("Flat, buttons up: (0, -1, 0). Sideways as a wheel: (1, 0, 0); z follows the turn."); + } else { + ImGui::TextDisabled("No accelerometer data yet."); + } + // SDL's read of the remote's calibration block often times out over Bluetooth + // and it falls back to a nominal zero point, leaving a small per-axis bias; + // measured here with the remote at rest. + if (WiiRemoteInput::IsAccelCalibrating()) { + ImGui::ProgressBar(WiiRemoteInput::AccelCalibrationProgress(), ImVec2(220.0f, 0.0f), "Hold still..."); + } else if (ImGui::Button("Calibrate (remote lying flat, buttons up)")) { + WiiRemoteInput::StartAccelCalibration(port); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Put the remote down on a flat surface with the buttons facing up and do not touch it\n" + "for about two seconds. Corrects the steering offset of a remote held sideways."); + } + ImGui::SameLine(); + ImGui::BeginDisabled(!RuntimeConfigFile::HasWiiAccelOffset() || WiiRemoteInput::IsAccelCalibrating()); + if (ImGui::Button("Clear calibration")) { + WiiRemoteInput::ClearAccelCalibration(); + } + ImGui::EndDisabled(); + if (const char* message = WiiRemoteInput::AccelCalibrationMessage()) { + ImGui::TextWrapped("%s", message); + } else if (RuntimeConfigFile::HasWiiAccelOffset()) { + const std::array offset = RuntimeConfigFile::WiiAccelOffset(); + ImGui::TextDisabled("Stored offset: x %+.3f y %+.3f z %+.3f g", offset[0], offset[1], offset[2]); + } else { + ImGui::TextDisabled("Not calibrated (using SDL's zero point; see console.log for \"fallback accelerometer calibration\")."); + } +} + +// Wii Remotes (Bluetooth) menu: driver switch, pairing help, continuous scanning and the port's controller kind. +void DrawWiiRemoteSettings(uint32_t selectedGamePort) { + if (!ImGui::BeginMenu("Wii Remotes (Bluetooth)")) { + return; + } + if (ImGui::Checkbox("Use Wii Remotes / Wii U Pro Controllers", &g_wiiRemotesEnabled)) { + RuntimeConfigFile::SetWiiRemotesEnabled(g_wiiRemotesEnabled); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Takes effect on the next launch. Turn this off if you use a Mayflash DolphinBar."); + } + ImGui::TextDisabled("Pairing: Windows Settings > Bluetooth > Add device, then press 1+2"); + ImGui::TextDisabled("(or the red SYNC button) on the remote. Leave the PIN empty."); + ImGui::TextDisabled("A remote that was paired before also needs to be turned on with 1+2/SYNC."); + if (ImGui::Checkbox("Keep scanning for Wii Remotes (like Dolphin's Continuous Scanning)", + &g_wiiContinuousScan)) { + RuntimeConfigFile::SetWiiContinuousScanEnabled(g_wiiContinuousScan); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("While no Wii controller is connected, re-check Bluetooth every 2 seconds so a\n" + "remote that dropped out (\"Communications with the controller have been\n" + "interrupted\") or was turned on after launch comes back by itself."); + } + // The driver hint is only read at launch, so a rescan after the user turned + // the setting off would still re-enumerate Wii devices in this session. + ImGui::BeginDisabled(!g_wiiRemotesEnabled); + if (ImGui::Button("Rescan now")) { + WiiRemoteInput::RescanNow(); + } + ImGui::EndDisabled(); + ImGui::SameLine(); + if (WiiRemoteInput::IsScanning()) { + ImGui::TextDisabled("Scanning... (%u so far) - press 1+2 on the remote", WiiRemoteInput::ScanCount()); + } else { + ImGui::TextDisabled("Not scanning"); + } + ImGui::Separator(); + + const WiiRemoteInput::Kind kind = WiiRemoteInput::KindForPort(selectedGamePort); + ImGui::Text("Port %u: %s", static_cast(selectedGamePort + 1), WiiRemoteInput::KindLabel(kind)); + if (kind == WiiRemoteInput::Kind::RemoteWithClassic) { + WiiRemoteInput::KpadSample sample; + if (WiiRemoteInput::ReadKpadSample(selectedGamePort, sample)) { + // WPAD_CL_BUTTON_* bits, in the game's own layout (no mapping involved). + const auto held = [&](uint32_t bit, const char* on, const char* off) { return (sample.clHold & bit) ? on : off; }; + ImGui::Text("Classic: %s %s %s %s %s %s %s %s %s %s %s %s %s %s", held(0x0010, "A", "a"), + held(0x0040, "B", "b"), held(0x0008, "X", "x"), held(0x0020, "Y", "y"), held(0x2000, "L", "l"), + held(0x0200, "R", "r"), held(0x0080, "ZL", "zl"), held(0x0004, "ZR", "zr"), + held(0x0400, "PLUS", "plus"), held(0x1000, "MINUS", "minus"), held(0x0001, "UP", "up"), + held(0x4000, "DOWN", "down"), held(0x0002, "LEFT", "left"), held(0x8000, "RIGHT", "right")); + ImGui::Text("Sticks: L %+.2f %+.2f (WPAD %+d %+d) R %+.2f %+.2f (WPAD %+d %+d)", sample.clLStick[0], + sample.clLStick[1], static_cast(sample.clLStickRaw[0]), + static_cast(sample.clLStickRaw[1]), sample.clRStick[0], sample.clRStick[1], + static_cast(sample.clRStickRaw[0]), static_cast(sample.clRStickRaw[1])); + ImGui::TextDisabled("Capitals = held. The game reads this Classic Controller through KPAD, as on the"); + ImGui::TextDisabled("console: its buttons mean what the game says they mean, no mapping applies."); + } + } + if (kind == WiiRemoteInput::Kind::WiiUPro) { + if (SDL_Gamepad* gamepad = SDL_GetGamepadFromPlayerIndex(static_cast(selectedGamePort))) { + // SDL's Wii driver posts the D-pad as joystick buttons 11-14 (the + // SDL_GAMEPAD_BUTTON_DPAD_* values) while its default HIDAPI mapping + // expects a hat, so SDL_GetGamepadButton never sees them; read the + // joystick directly, like the fallback in aurora's PADRead does. + SDL_Joystick* joystick = SDL_GetGamepadJoystick(gamepad); + const auto rawButton = [&](int index) { + return joystick != nullptr && SDL_GetJoystickButton(joystick, index); + }; + ImGui::Text("Raw D-pad: %s %s %s %s", rawButton(SDL_GAMEPAD_BUTTON_DPAD_UP) ? "UP" : "up", + rawButton(SDL_GAMEPAD_BUTTON_DPAD_DOWN) ? "DOWN" : "down", + rawButton(SDL_GAMEPAD_BUTTON_DPAD_LEFT) ? "LEFT" : "left", + rawButton(SDL_GAMEPAD_BUTTON_DPAD_RIGHT) ? "RIGHT" : "right"); + ImGui::Text("Raw face buttons: %s %s %s %s", rawButton(SDL_GAMEPAD_BUTTON_EAST) ? "A" : "a", + rawButton(SDL_GAMEPAD_BUTTON_SOUTH) ? "B" : "b", rawButton(SDL_GAMEPAD_BUTTON_NORTH) ? "X" : "x", + rawButton(SDL_GAMEPAD_BUTTON_WEST) ? "Y" : "y"); + ImGui::Text("Raw ZL/ZR: %d / %d (pressed above 0)", + SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER), + SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER)); + ImGui::TextDisabled("Capitals = held. If a button never turns to capitals while physically held,"); + ImGui::TextDisabled("that press is not reaching SDL at all (a driver-level issue, not a mapping one)."); + ImGui::TextDisabled("This pad uses Nintendo's own layout (a/b/x/y as labelled); the shared"); + ImGui::TextDisabled("button mapping above does not apply to it."); + } + } + if (kind == WiiRemoteInput::Kind::Remote || kind == WiiRemoteInput::Kind::RemoteWithNunchuk || + kind == WiiRemoteInput::Kind::RemoteWithClassic) { + DrawWiiRemoteAccelerometer(selectedGamePort); + } + + ImGui::EndMenu(); +} + +// Controller settings menu: port selection, controller assignment and button mapping. void DrawControllerSettings() { for (int port = 0; port < PAD_MAX_CONTROLLERS; ++port) { const std::string label = "Port " + std::to_string(port + 1); @@ -332,6 +477,7 @@ void DrawControllerSettings() { } ImGui::Separator(); controller_mapping_wizard::DrawSetupList(); + DrawWiiRemoteSettings(selectedGamePort); const uint32_t controllerCount = PADCount(); if (controllerCount == 0) { ImGui::TextDisabled("No controller connected"); @@ -931,6 +1077,11 @@ void Draw() noexcept { // Wait for the frame worker's DONE phase: it has replayed the previous frame's ImGui draw lists // and started the next ImGui frame, so all overlay callers can now safely issue ImGui commands. aurora_wait_for_frame_worker(); + // Also drive the Wii Remote rescan from here: PADRead runs it too, but this + // runs once per presented frame whatever the game is doing (e.g. sitting in + // its "communications interrupted" prompt without polling pads). Same guest + // thread as PADRead, so no concurrent access to the scanner's state. + WiiRemoteInput::Poll(); ApplyConfiguredMappings(); PersistDisplayModeIfChanged(); UpdateCursorAutoHide(); diff --git a/runtime/src/wii_remote_input.cpp b/runtime/src/wii_remote_input.cpp new file mode 100644 index 0000000..6c0cf6f --- /dev/null +++ b/runtime/src/wii_remote_input.cpp @@ -0,0 +1,741 @@ +#include "wii_remote_input.h" + +#include "runtime_config.h" +#include "runtime_log.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace WiiRemoteInput { +namespace { + +// Dolphin's continuous scanning polls Bluetooth about once a second; SDL's +// enumeration walks every HID device on the main thread, so stay a bit lazier. +constexpr uint64_t kScanIntervalMs = 2000; +// Right after a remote drops it is almost certainly still there, but the first +// re-open attempts tend to fail on timed-out reads, so retry quickly for a while. +constexpr uint64_t kFastScanIntervalMs = 500; +constexpr uint64_t kFastScanWindowMs = 15000; +// How long the Wii driver hint stays at "0" during a rescan; SDL applies hint +// changes on its next joystick update, once per pumped frame. +constexpr uint64_t kRescanDriverOffMs = 100; + +constexpr float kStandardGravity = 9.80665f; + +// SDL's Wii driver posts the remote's own buttons as raw joystick buttons +// starting at SDL_GAMEPAD_BUTTON_MISC1, in this order (SDL_hidapi_wii.c, +// EWiiButtons), whatever the extension. +enum RawWiiButton : int { + kRawA = SDL_GAMEPAD_BUTTON_MISC1, + kRawB, + kRawOne, + kRawTwo, + kRawPlus, + kRawMinus, + kRawHome, + kRawDpadUp, + kRawDpadDown, + kRawDpadLeft, + kRawDpadRight, +}; + +// WPAD_BUTTON_* bits as the game reads them from KPADStatus.hold. +constexpr uint32_t kWpadLeft = 0x0001, kWpadRight = 0x0002, kWpadDown = 0x0004, kWpadUp = 0x0008, + kWpadPlus = 0x0010, kWpadTwo = 0x0100, kWpadOne = 0x0200, kWpadB = 0x0400, kWpadA = 0x0800, + kWpadMinus = 0x1000, kWpadZ = 0x2000, kWpadC = 0x4000, kWpadHome = 0x8000; + +// WPAD_CL_BUTTON_* bits (WPADCLStatus.clButton / KPADStatus.ex_status.cl.hold): +// the Classic Controller's two button bytes, inverted, high byte first. +constexpr uint32_t kClUp = 0x0001, kClLeft = 0x0002, kClZR = 0x0004, kClX = 0x0008, kClA = 0x0010, kClY = 0x0020, + kClB = 0x0040, kClZL = 0x0080, kClR = 0x0200, kClPlus = 0x0400, kClHome = 0x0800, + kClMinus = 0x1000, kClL = 0x2000, kClDown = 0x4000, kClRight = 0x8000; + +// How long a vanished remote keeps its channel alive with neutral input. SDL's +// in-place reconnect after an extension change takes well under a second; a +// remote that is really gone shows up as disconnected after this. +constexpr uint64_t kExtensionSwapGraceMs = 3000; +// Rescanning closes and re-opens the Bluetooth HID handle, which some Windows +// stacks answer by dropping the link; leave SDL's own reconnect this long first. +constexpr uint64_t kScanStartDelayMs = 3000; + +// Per-port memory of the last Wii controller seen there, for EffectiveKind. +struct PortMemory { + Kind lastKind = Kind::NotWii; + uint64_t lastSeenMs = 0; +}; +std::array g_ports{}; + +bool g_wiiDriverEnabled = false; +uint64_t g_lastScanMs = 0; +uint32_t g_scanCount = 0; +bool g_scanning = false; +// Non-zero while a rescan has the Wii driver hint switched off (see RescanNow). +uint64_t g_driverOffSinceMs = 0; +// When the current scan started (last Wii controller seen). +uint64_t g_lostAtMs = 0; + +// Instance ids whose accelerometers have been switched on. SDL keeps sensors +// off until asked and forgets that when the gamepad is closed, so a re-paired +// remote gets a fresh id and is enabled again. +std::array g_sensorsEnabledFor{}; + +// Zero-point correction subtracted from the remote's accelerometer, in g and in +// SDL's sensor frame; loaded from Config.toml on first use, replaced by a +// calibration run. The Nunchuk accelerometer is left uncorrected. +std::array g_accelOffset{}; +bool g_accelOffsetLoaded = false; + +// Frames sampled by a calibration run (about 1.5 s at 60 Hz) and how far a +// sample may stray from the first one before the run is declared "moved". +constexpr int kCalibrationSamples = 90; +constexpr float kCalibrationMaxDeviationG = 0.15f; +// |mean| outside this range means the remote was not at rest or the +// accelerometer is far off its nominal scale; either way the offset is useless. +constexpr float kCalibrationMinGravityG = 0.8f; +constexpr float kCalibrationMaxGravityG = 1.2f; + +struct AccelCalibration { + bool active = false; + uint32_t chan = 0; + int count = 0; + double sum[3] = {}; + float first[3] = {}; +}; +AccelCalibration g_calibration; +char g_calibrationMessage[160] = {}; + +// Last accepted KPAD acc per port, for the remote and for the Nunchuk. Reused on +// frames that bring no sample or a glitched one, so the game never sees a jump. +struct LastAcc { + bool valid = false; + float acc[3] = {0.0f, -1.0f, 0.0f}; +}; +std::array g_lastAcc{}; +std::array g_lastNunchukAcc{}; + +// Any axis beyond this is not a reading the remote's +-3 g sensor can produce. +constexpr float kMaxPlausibleRemoteG = 4.0f; + +// Case-sensitive substring test that tolerates a null name. +bool NameContains(const char* name, const char* needle) { + return name != nullptr && std::strstr(name, needle) != nullptr; +} + +// Turns on the remote (and Nunchuk) accelerometers once per gamepad instance. +void EnsureSensors(SDL_Gamepad* gamepad, uint32_t port) { + const SDL_JoystickID id = SDL_GetGamepadID(gamepad); + if (g_sensorsEnabledFor[port] == id) { + return; + } + bool allEnabled = true; + for (SDL_SensorType sensor : {SDL_SENSOR_ACCEL, SDL_SENSOR_ACCEL_L}) { + if (SDL_GamepadHasSensor(gamepad, sensor) && !SDL_SetGamepadSensorEnabled(gamepad, sensor, true)) { + RT_LOG(RT_TAG_CONFIG) << "Wii Remote on port " << (port + 1) + << ": could not enable an accelerometer: " << SDL_GetError() << std::endl; + allEnabled = false; + } + } + // Only remember the instance once every sensor is on, so a failed attempt + // is retried on the next sample instead of leaving the accelerometer off. + if (allEnabled) { + g_sensorsEnabledFor[port] = id; + } +} + +// Loads the stored zero-point correction once. +const std::array& AccelOffset() { + if (!g_accelOffsetLoaded) { + const std::array stored = RuntimeConfigFile::WiiAccelOffset(); + for (size_t i = 0; i < 3; ++i) g_accelOffset[i] = static_cast(stored[i]); + g_accelOffsetLoaded = true; + } + return g_accelOffset; +} + +// Raw SDL sample in m/s^2, rejecting anything SDL has not delivered yet. +bool ReadSdlAccel(SDL_Gamepad* gamepad, SDL_SensorType sensor, float* sdl) { + return SDL_GamepadSensorEnabled(gamepad, sensor) && SDL_GetGamepadSensorData(gamepad, sensor, sdl, 3) && + std::isfinite(sdl[0]) && std::isfinite(sdl[1]) && std::isfinite(sdl[2]); +} + +// True for a sample that cannot have come from the sensor. Over Bluetooth on +// Windows the remote delivers, a few times a minute, a report whose +// accelerometer bytes are all zero; SDL decodes that as -0x200 on every axis, +// i.e. (+5.12, -5.12, -5.12) g for the remote (100 units/g) and +// (+2.56, -2.56, -2.56) g for the Nunchuk (200 units/g). Handed to the game as +// is, one such frame is a full-lock steer plus a 9 g "shake". `g` is the +// uncorrected SDL sample. +bool IsGlitchedSample(SDL_SensorType sensor, const float* g) { + // An exact zero vector is SDL's sensor buffer before the first report, not + // a reading (the remote never delivers 0 g on all three axes at once). + if (g[0] == 0.0f && g[1] == 0.0f && g[2] == 0.0f) { + return true; + } + const float zero = sensor == SDL_SENSOR_ACCEL ? 5.12f : 2.56f; + if (std::fabs(g[0] - zero) < 0.03f && std::fabs(g[1] + zero) < 0.03f && std::fabs(g[2] + zero) < 0.03f) { + return true; + } + if (sensor == SDL_SENSOR_ACCEL) { + for (int i = 0; i < 3; ++i) { + if (std::fabs(g[i]) > kMaxPlausibleRemoteG) return true; + } + } + return false; +} + +// One accelerometer sample in g, SDL's sensor frame. The remote's own axes are +// +x left, +y towards the user, +z out of the button face (wiibrew, Dolphin); +// SDL_hidapi_wii.c posts (-wiiX, wiiZ, wiiY): x right across the face, y out of +// the face (+1 at rest, buttons up), z towards the user, i.e. away from the tip. +// The remote's sample gets the zero-point correction; the Nunchuk's does not. +// False when there is no sample yet or the sample is a glitch (see above). +bool ReadAccelG(SDL_Gamepad* gamepad, SDL_SensorType sensor, float* g) { + float sdl[3] = {}; + if (!ReadSdlAccel(gamepad, sensor, sdl)) { + return false; + } + for (int i = 0; i < 3; ++i) g[i] = sdl[i] / kStandardGravity; + if (IsGlitchedSample(sensor, g)) { + return false; + } + if (sensor == SDL_SENSOR_ACCEL) { + const std::array& offset = AccelOffset(); + for (int i = 0; i < 3; ++i) g[i] -= offset[i]; + } + return true; +} + +// KPAD's acc is the remote reading as (-wiiX, -wiiZ, wiiY): x right across the +// face, y through the back of the remote (rest: -1 with the buttons up), z +// towards the user. That is SDL's frame with y negated. Held sideways as a +// wheel the rest vector is (1, 0, 0) and a turn shows up as z = sin(angle), so +// the sign of z is the direction of the turn; Cemu does the same conversion for +// real remotes on the Wii U. +void AccelGToKpad(const float* g, float* kpad) { + kpad[0] = g[0]; + kpad[1] = -g[1]; + kpad[2] = g[2]; +} + +// Sensor -> KPAD acc for ReadKpadSample. +bool ReadAccelAsKpad(SDL_Gamepad* gamepad, SDL_SensorType sensor, float* kpad) { + float g[3] = {}; + if (!ReadAccelG(gamepad, sensor, g)) { + return false; + } + AccelGToKpad(g, kpad); + return true; +} + +// Debug trace of every remote sample (controller.wii_accel_trace = true): +// milliseconds, port, WPAD hold bits, the uncorrected SDL sample in g and the +// KPAD acc handed to the game. One CSV per run, truncated at startup. +void TraceSample(uint32_t chan, const KpadSample& sample, const float* rawG, bool haveRaw, bool accepted) { + static std::ofstream trace; + static bool opened = false; + if (!opened) { + opened = true; + const std::filesystem::path path = RuntimeConfigFile::ApplicationDataDirectory() / "wii_accel_trace.csv"; + trace.open(path, std::ios::trunc); + if (trace) { + trace << "ms,port,hold,raw_x,raw_y,raw_z,ok,kpad_x,kpad_y,kpad_z\n"; + RT_LOG(RT_TAG_CONFIG) << "Wii Remote accelerometer trace: " << path.string() << std::endl; + } else { + RT_LOG(RT_TAG_CONFIG) << "Wii Remote accelerometer trace: could not open " << path.string() << std::endl; + } + } + if (!trace) { + return; + } + char line[192]; + if (haveRaw) { + std::snprintf(line, sizeof(line), "%llu,%u,%04x,%.4f,%.4f,%.4f,%d,%.4f,%.4f,%.4f\n", + static_cast(SDL_GetTicks()), chan + 1, sample.hold, rawG[0], rawG[1], + rawG[2], accepted ? 1 : 0, sample.acc[0], sample.acc[1], sample.acc[2]); + } else { + std::snprintf(line, sizeof(line), "%llu,%u,%04x,,,,0,%.4f,%.4f,%.4f\n", + static_cast(SDL_GetTicks()), chan + 1, sample.hold, sample.acc[0], + sample.acc[1], sample.acc[2]); + } + trace << line; + // A frame per line; flush so a crash or a killed process keeps the tail. + trace.flush(); +} + +// Ends a calibration run with a message for the overlay. +void FinishAccelCalibration(const char* message) { + g_calibration.active = false; + std::snprintf(g_calibrationMessage, sizeof(g_calibrationMessage), "%s", message); + RT_LOG(RT_TAG_CONFIG) << "Wii Remote accelerometer calibration: " << message << std::endl; +} + +// One frame of a calibration run: accumulates the uncorrected sample and, once +// enough frames are in, stores the mean minus the ideal rest vector (0, 1, 0). +void StepAccelCalibration() { + if (!g_calibration.active) { + return; + } + SDL_Gamepad* gamepad = SDL_GetGamepadFromPlayerIndex(static_cast(g_calibration.chan)); + if (gamepad == nullptr || !IsRemoteChannel(g_calibration.chan)) { + FinishAccelCalibration("Cancelled: the Wii Remote went away."); + return; + } + EnsureSensors(gamepad, g_calibration.chan); + float sdl[3] = {}; + if (!ReadSdlAccel(gamepad, SDL_SENSOR_ACCEL, sdl)) { + return; // no sample this frame; keep waiting + } + float g[3]; + for (int i = 0; i < 3; ++i) g[i] = sdl[i] / kStandardGravity; + if (IsGlitchedSample(SDL_SENSOR_ACCEL, g)) { + return; // a zeroed report, not a movement + } + if (g_calibration.count == 0) { + for (int i = 0; i < 3; ++i) g_calibration.first[i] = g[i]; + } else { + for (int i = 0; i < 3; ++i) { + if (std::fabs(g[i] - g_calibration.first[i]) > kCalibrationMaxDeviationG) { + FinishAccelCalibration("Failed: the remote moved. Put it down, buttons up, and try again."); + return; + } + } + } + for (int i = 0; i < 3; ++i) g_calibration.sum[i] += g[i]; + if (++g_calibration.count < kCalibrationSamples) { + return; + } + std::array mean{}; + for (int i = 0; i < 3; ++i) mean[i] = g_calibration.sum[i] / g_calibration.count; + const double length = std::sqrt(mean[0] * mean[0] + mean[1] * mean[1] + mean[2] * mean[2]); + if (length < kCalibrationMinGravityG || length > kCalibrationMaxGravityG || mean[1] < 0.5) { + FinishAccelCalibration("Failed: the remote was not resting flat with the buttons up."); + return; + } + const std::array offset = {mean[0], mean[1] - 1.0, mean[2]}; + for (int i = 0; i < 3; ++i) g_accelOffset[i] = static_cast(offset[i]); + g_accelOffsetLoaded = true; + const bool saved = RuntimeConfigFile::SetWiiAccelOffset(offset); + char message[160]; + std::snprintf(message, sizeof(message), "%s offset x %+.3f y %+.3f z %+.3f g", + saved ? "Calibrated:" : "Calibrated (could not write Config.toml):", offset[0], offset[1], + offset[2]); + FinishAccelCalibration(message); +} + +// True when any controller aurora knows about is a Wii device. +bool AnyWiiControllerConnected() { + const uint32_t count = PADCount(); + for (uint32_t index = 0; index < count; ++index) { + if (KindForName(PADGetNameForControllerIndex(index)) != Kind::NotWii) { + return true; + } + } + return false; +} + +// Route SDL's input diagnostics (HIDAPI open failures, the Wii driver's +// extension/status messages) into console.log, minus the periodic chatter. +void SDLCALL LogSdlMessage(void*, int category, SDL_LogPriority priority, const char* message) { + if (message == nullptr) { + return; + } + if (category == SDL_LOG_CATEGORY_INPUT && priority < SDL_LOG_PRIORITY_WARN && + (std::strstr(message, "Motion Plus") != nullptr || std::strstr(message, "Resetting report mode") != nullptr)) { + return; + } + if (category == SDL_LOG_CATEGORY_INPUT || priority >= SDL_LOG_PRIORITY_WARN) { + RT_LOG("sdl") << message << std::endl; + } +} + +// Second half of a rescan: re-enables the Wii driver once SDL has seen it off. +void FinishRescan(uint64_t now) { + if (g_driverOffSinceMs == 0 || now - g_driverOffSinceMs < kRescanDriverOffMs) { + return; + } + SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_WII, "1"); + g_driverOffSinceMs = 0; + g_lastScanMs = now; + ++g_scanCount; + // The first few and then every tenth, so a remote that never comes back + // leaves a trail in console.log without flooding it. + if (g_scanCount <= 3 || g_scanCount % 10 == 0) { + RT_LOG(RT_TAG_CONFIG) << "Wii Remote rescan #" << g_scanCount << ": HIDAPI Wii driver re-enabled (" + << PADCount() << " controller(s) known to aurora)" << std::endl; + } +} + +} // namespace + +// Enables SDL's HIDAPI Wii driver and player LEDs, and routes SDL's input log. +void ConfigureSdlHints(bool enabled) { + // A rescan may be mid-flight; drop its bookkeeping so Poll() is not left + // waiting for a FinishRescan() that can no longer happen. + g_driverOffSinceMs = 0; + if (!SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_WII, enabled ? "1" : "0")) { + RT_LOG(RT_TAG_CONFIG) << "Failed to set " << SDL_HINT_JOYSTICK_HIDAPI_WII << ": " << SDL_GetError() + << std::endl; + } + // Light the player LED that matches the SDL player index, like the console does. + if (!SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_WII_PLAYER_LED, "1")) { + RT_LOG(RT_TAG_CONFIG) << "Failed to set " << SDL_HINT_JOYSTICK_HIDAPI_WII_PLAYER_LED << ": " + << SDL_GetError() << std::endl; + } + RT_LOG(RT_TAG_CONFIG) << "Bluetooth Wii Remote support " << (enabled ? "enabled" : "disabled") << std::endl; + g_wiiDriverEnabled = enabled; + if (enabled) { + SDL_SetLogPriority(SDL_LOG_CATEGORY_INPUT, SDL_LOG_PRIORITY_DEBUG); + SDL_SetLogOutputFunction(LogSdlMessage, nullptr); + } +} + +// Starts a rescan by disabling the Wii driver hint; Poll() finishes it. +void RescanNow() { + if (!g_wiiDriverEnabled || g_driverOffSinceMs != 0) { + return; + } + // SDL only closes the HID handle of a remote it dropped while the Wii driver + // is disabled, and only re-opens it when the driver is enabled again; both + // must happen on separate joystick updates, so the hint stays at "0" until + // FinishRescan() a few frames later. Flipping 1->0->1 within one frame does + // nothing: SDL only ever sees the final "1". + SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_WII, "0"); + g_driverOffSinceMs = SDL_GetTicks(); + if (g_scanCount < 3) { + RT_LOG(RT_TAG_CONFIG) << "Wii Remote rescan #" << (g_scanCount + 1) << ": HIDAPI Wii driver disabled" + << std::endl; + } +} + +// Per-frame scanning state machine: rescans while no Wii controller is present. +// Also advances an accelerometer calibration run, which needs a sample per frame +// whether or not the game is reading KPAD at that moment. +void Poll() { + StepAccelCalibration(); + // Remember what each port had, so EffectiveKind can bridge a swap. + for (uint32_t port = 0; port < PAD_MAX_CONTROLLERS; ++port) { + (void)EffectiveKind(port); + } + if (!g_wiiDriverEnabled) { + return; + } + // Always complete a rescan in progress so the driver is never left disabled. + FinishRescan(SDL_GetTicks()); + if (g_driverOffSinceMs != 0) { + return; + } + if (AnyWiiControllerConnected()) { + if (g_scanning) { + RT_LOG(RT_TAG_CONFIG) << "Wii Remote found after " << g_scanCount << " rescan(s)" << std::endl; + } + g_scanning = false; + g_scanCount = 0; + g_lastScanMs = SDL_GetTicks(); + return; + } + if (!RuntimeConfigFile::WiiContinuousScanEnabled(true)) { + g_scanning = false; + return; + } + const uint64_t now = SDL_GetTicks(); + if (!g_scanning) { + RT_LOG(RT_TAG_CONFIG) << "No Wii Remote connected; scanning for one (press 1+2 on the remote)" + << std::endl; + g_scanning = true; + g_lostAtMs = now; + } + if (now - g_lostAtMs < kScanStartDelayMs) { + return; + } + const uint64_t interval = now - g_lostAtMs < kFastScanWindowMs ? kFastScanIntervalMs : kScanIntervalMs; + if (now - g_lastScanMs < interval) { + return; + } + RescanNow(); +} + +// True while Poll() is looking for a remote. +bool IsScanning() { + return g_scanning; +} + +// Number of rescans since a Wii controller was last seen. +uint32_t ScanCount() { + return g_scanCount; +} + +// Maps the gamepad name SDL's Wii driver reports to a Kind. +Kind KindForName(const char* name) { + // Names come from SDL's hidapi Wii driver: "Nintendo Wii Remote", + // "Nintendo Wii Remote with Nunchuk", "Nintendo Wii Remote with Classic + // Controller" and "Nintendo Wii U Pro Controller". + if (NameContains(name, "Wii U Pro Controller")) return Kind::WiiUPro; + if (!NameContains(name, "Wii Remote")) return Kind::NotWii; + if (NameContains(name, "Nunchuk")) return Kind::RemoteWithNunchuk; + if (NameContains(name, "Classic Controller")) return Kind::RemoteWithClassic; + return Kind::Remote; +} + +// Kind of the SDL gamepad assigned to a game port, NotWii when empty. +Kind KindForPort(uint32_t port) { + if (port >= PAD_MAX_CONTROLLERS) return Kind::NotWii; + SDL_Gamepad* gamepad = SDL_GetGamepadFromPlayerIndex(static_cast(port)); + if (gamepad == nullptr) return Kind::NotWii; + return KindForName(SDL_GetGamepadName(gamepad)); +} + +// Human-readable name of a Kind for the settings overlay. +const char* KindLabel(Kind kind) { + switch (kind) { + case Kind::Remote: return "Wii Remote"; + case Kind::RemoteWithNunchuk: return "Wii Remote + Nunchuk"; + case Kind::RemoteWithClassic: return "Wii Remote + Classic Controller"; + case Kind::WiiUPro: return "Wii U Pro Controller"; + default: return "Not a Wii controller"; + } +} + +// True for the kinds the game reads through KPAD. +static bool IsKpadKind(Kind kind) { + return kind == Kind::Remote || kind == Kind::RemoteWithNunchuk || kind == Kind::RemoteWithClassic; +} + +// Live kind of the port, or the remembered one while a swap is in flight. +// Called from the guest thread only (PADRead, KPADRead, WPADProbe and the +// overlay's Draw all run there), so the port memory needs no locking. +Kind EffectiveKind(uint32_t chan) { + if (chan >= PAD_MAX_CONTROLLERS) return Kind::NotWii; + PortMemory& memory = g_ports[chan]; + SDL_Gamepad* gamepad = SDL_GetGamepadFromPlayerIndex(static_cast(chan)); + const Kind live = gamepad != nullptr ? KindForName(SDL_GetGamepadName(gamepad)) : Kind::NotWii; + const uint64_t now = SDL_GetTicks(); + if (live != Kind::NotWii) { + memory.lastKind = live; + memory.lastSeenMs = now; + return live; + } + if (gamepad != nullptr) { + // Another controller took the port: the remote is not coming back here. + memory.lastKind = Kind::NotWii; + return Kind::NotWii; + } + if (IsKpadKind(memory.lastKind) && memory.lastSeenMs != 0 && now - memory.lastSeenMs < kExtensionSwapGraceMs) { + return memory.lastKind; + } + return Kind::NotWii; +} + +// True when the game reads the port through KPAD (live or bridging a swap). +bool IsRemoteChannel(uint32_t chan) { + return IsKpadKind(EffectiveKind(chan)); +} + +// Marks KPAD-served ports as "no controller" in the GameCube pad statuses. +void HideRemotesFromPad(PADStatus* statuses, uint32_t count) { + for (uint32_t port = 0; port < count && port < PAD_MAX_CONTROLLERS; ++port) { + if (IsRemoteChannel(port)) { + statuses[port] = {}; + statuses[port].err = PAD_ERR_NO_CONTROLLER; + } + } +} + +// Neutral sample of the remembered kind, for the frames of an extension swap. +void FillGraceSample(uint32_t chan, Kind kind, KpadSample& sample) { + sample = {}; + for (int i = 0; i < 3; ++i) sample.acc[i] = g_lastAcc[chan].acc[i]; + sample.hasNunchuk = kind == Kind::RemoteWithNunchuk; + sample.hasClassic = kind == Kind::RemoteWithClassic; +} + +// SDL's stick axis (-32767..32767, y down) as WPADCLStatus carries it: WPAD +// normalises every Classic Controller stick, whatever the report's resolution, +// to a signed 10-bit value, -512..511 with 0 at the centre and +y up (RVL SDK +// WPAD.h; wut's WPADStatusClassic documents the same range). +int16_t ClassicStickRaw(Sint16 axis, bool invert) { + float value = static_cast(axis) / 32767.0f; + if (invert) value = -value; + value = std::clamp(value, -1.0f, 1.0f); + return static_cast(std::clamp(std::lround(value * 512.0f), -512L, 511L)); +} + +// Samples buttons, accelerometers and the extension of the remote on a port. +bool ReadKpadSample(uint32_t chan, KpadSample& sample) { + if (chan >= PAD_MAX_CONTROLLERS) { + return false; + } + SDL_Gamepad* gamepad = SDL_GetGamepadFromPlayerIndex(static_cast(chan)); + const Kind kind = gamepad != nullptr ? KindForName(SDL_GetGamepadName(gamepad)) : Kind::NotWii; + if (!IsKpadKind(kind)) { + const Kind remembered = EffectiveKind(chan); + if (!IsKpadKind(remembered)) { + return false; + } + FillGraceSample(chan, remembered, sample); + return true; + } + SDL_Joystick* joystick = SDL_GetGamepadJoystick(gamepad); + if (joystick == nullptr) { + return false; + } + EnsureSensors(gamepad, chan); + + sample = {}; + const auto raw = [&](int index, uint32_t bit) { + if (SDL_GetJoystickButton(joystick, index)) sample.hold |= bit; + }; + raw(kRawA, kWpadA); + raw(kRawB, kWpadB); + raw(kRawOne, kWpadOne); + raw(kRawTwo, kWpadTwo); + raw(kRawPlus, kWpadPlus); + raw(kRawMinus, kWpadMinus); + raw(kRawHome, kWpadHome); + raw(kRawDpadUp, kWpadUp); + raw(kRawDpadDown, kWpadDown); + raw(kRawDpadLeft, kWpadLeft); + raw(kRawDpadRight, kWpadRight); + + float rawG[3] = {}; + const bool haveRaw = ReadSdlAccel(gamepad, SDL_SENSOR_ACCEL, rawG); + for (float& v : rawG) v /= kStandardGravity; + LastAcc& last = g_lastAcc[chan]; + const bool accepted = ReadAccelAsKpad(gamepad, SDL_SENSOR_ACCEL, sample.acc); + if (accepted) { + last.valid = true; + for (int i = 0; i < 3; ++i) last.acc[i] = sample.acc[i]; + } else { + // No sample this frame or a glitched one: repeat the last good reading + // (rest pose, buttons up, until there is one). + for (int i = 0; i < 3; ++i) sample.acc[i] = last.acc[i]; + } + if (RuntimeConfigFile::WiiAccelTraceEnabled(false)) { + TraceSample(chan, sample, rawG, haveRaw, accepted); + } + + if (kind == Kind::RemoteWithClassic) { + sample.hasClassic = true; + // The driver posts the extension's buttons as joystick buttons numbered + // by SDL_GAMEPAD_BUTTON_*: a/b/x/y by position (a on the east), +/-, + // Home, the L/R clicks as shoulders, the D-pad as buttons 11-14 (never + // through SDL's gamepad mapping, which expects a hat), ZL/ZR as the + // trigger axes. + const auto cl = [&](int index, uint32_t bit) { + if (SDL_GetJoystickButton(joystick, index)) sample.clHold |= bit; + }; + cl(SDL_GAMEPAD_BUTTON_EAST, kClA); + cl(SDL_GAMEPAD_BUTTON_SOUTH, kClB); + cl(SDL_GAMEPAD_BUTTON_NORTH, kClX); + cl(SDL_GAMEPAD_BUTTON_WEST, kClY); + cl(SDL_GAMEPAD_BUTTON_START, kClPlus); + cl(SDL_GAMEPAD_BUTTON_BACK, kClMinus); + cl(SDL_GAMEPAD_BUTTON_GUIDE, kClHome); + cl(SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, kClL); + cl(SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, kClR); + cl(SDL_GAMEPAD_BUTTON_DPAD_UP, kClUp); + cl(SDL_GAMEPAD_BUTTON_DPAD_DOWN, kClDown); + cl(SDL_GAMEPAD_BUTTON_DPAD_LEFT, kClLeft); + cl(SDL_GAMEPAD_BUTTON_DPAD_RIGHT, kClRight); + if (SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER) > 0) sample.clHold |= kClZL; + if (SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER) > 0) sample.clHold |= kClZR; + const Sint16 lx = SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFTX); + const Sint16 ly = SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFTY); + const Sint16 rx = SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHTX); + const Sint16 ry = SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHTY); + sample.clLStick[0] = std::clamp(static_cast(lx) / 32767.0f, -1.0f, 1.0f); + sample.clLStick[1] = std::clamp(-static_cast(ly) / 32767.0f, -1.0f, 1.0f); + sample.clRStick[0] = std::clamp(static_cast(rx) / 32767.0f, -1.0f, 1.0f); + sample.clRStick[1] = std::clamp(-static_cast(ry) / 32767.0f, -1.0f, 1.0f); + sample.clLStickRaw[0] = ClassicStickRaw(lx, false); + sample.clLStickRaw[1] = ClassicStickRaw(ly, true); + sample.clRStickRaw[0] = ClassicStickRaw(rx, false); + sample.clRStickRaw[1] = ClassicStickRaw(ry, true); + // Only the full-press click of L/R reaches SDL; report it as a full pull. + sample.clTriggerL = (sample.clHold & kClL) ? 255 : 0; + sample.clTriggerR = (sample.clHold & kClR) ? 255 : 0; + } + + if (kind == Kind::RemoteWithNunchuk) { + sample.hasNunchuk = true; + if (SDL_GetGamepadButton(gamepad, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER)) sample.hold |= kWpadC; + if (SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER) > 0) sample.hold |= kWpadZ; + sample.stick[0] = static_cast(SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFTX)) / 32767.0f; + // SDL's y grows downwards; KPAD's stick y is up-positive. + sample.stick[1] = -static_cast(SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFTY)) / 32767.0f; + for (float& v : sample.stick) v = std::clamp(v, -1.0f, 1.0f); + LastAcc& lastNunchuk = g_lastNunchukAcc[chan]; + if (ReadAccelAsKpad(gamepad, SDL_SENSOR_ACCEL_L, sample.nunchukAcc)) { + lastNunchuk.valid = true; + for (int i = 0; i < 3; ++i) lastNunchuk.acc[i] = sample.nunchukAcc[i]; + } else { + for (int i = 0; i < 3; ++i) sample.nunchukAcc[i] = lastNunchuk.acc[i]; + } + } + return true; +} + +// Corrected SDL sample and KPAD vector of the remote on a port, for the overlay. +bool ReadAccelDebug(uint32_t chan, float sdlG[3], float kpadAcc[3]) { + if (!IsRemoteChannel(chan)) { + return false; + } + SDL_Gamepad* gamepad = SDL_GetGamepadFromPlayerIndex(static_cast(chan)); + if (gamepad == nullptr) { + return false; + } + EnsureSensors(gamepad, chan); + if (!ReadAccelG(gamepad, SDL_SENSOR_ACCEL, sdlG)) { + return false; + } + AccelGToKpad(sdlG, kpadAcc); + return true; +} + +// Begins collecting rest samples from the remote on `chan`. +void StartAccelCalibration(uint32_t chan) { + if (!IsRemoteChannel(chan)) { + FinishAccelCalibration("No Wii Remote on this port."); + return; + } + g_calibration = {}; + g_calibration.active = true; + g_calibration.chan = chan; + g_calibrationMessage[0] = '\0'; +} + +// Drops the stored correction and goes back to SDL's raw reading. +void ClearAccelCalibration() { + g_calibration.active = false; + g_accelOffset = {}; + g_accelOffsetLoaded = true; + RuntimeConfigFile::SetWiiAccelOffset({0.0, 0.0, 0.0}); + std::snprintf(g_calibrationMessage, sizeof(g_calibrationMessage), "Calibration cleared."); +} + +// True while a calibration run is collecting samples. +bool IsAccelCalibrating() { + return g_calibration.active; +} + +// Share of the calibration samples collected so far, 0..1; 0 when idle. +float AccelCalibrationProgress() { + return g_calibration.active ? static_cast(g_calibration.count) / kCalibrationSamples : 0.0f; +} + +// Outcome of the last calibration run for the overlay, or nullptr before any. +const char* AccelCalibrationMessage() { + return g_calibrationMessage[0] != '\0' ? g_calibrationMessage : nullptr; +} + +} // namespace WiiRemoteInput diff --git a/runtime/tests/host_context_tests.cpp b/runtime/tests/host_context_tests.cpp new file mode 100644 index 0000000..f6b457d --- /dev/null +++ b/runtime/tests/host_context_tests.cpp @@ -0,0 +1,49 @@ +#include "host_context.h" + +#include + +namespace { +// The worker yields twice; each return to the scheduler must preserve both +// context identities and the worker's continuation point. +HostContext::Handle g_scheduler = nullptr; +HostContext::Handle g_worker = nullptr; +int g_steps = 0; + +void Worker(void*) +{ + if (!HostContext::IsCurrent(g_worker)) { + std::abort(); + } + ++g_steps; + HostContext::Switch(g_scheduler); + if (!HostContext::IsCurrent(g_worker)) { + std::abort(); + } + ++g_steps; + HostContext::Switch(g_scheduler); +} +} // namespace + +int main() +{ + if (!HostContext::InitializeScheduler(&g_scheduler) || + !HostContext::IsCurrent(g_scheduler)) { + return 1; + } + g_worker = HostContext::Create(64 * 1024, Worker, nullptr); + if (!g_worker) { + return 1; + } + + HostContext::Switch(g_worker); + if (g_steps != 1 || !HostContext::IsCurrent(g_scheduler)) { + return 1; + } + HostContext::Switch(g_worker); + if (g_steps != 2 || !HostContext::IsCurrent(g_scheduler)) { + return 1; + } + + HostContext::Destroy(g_worker); + HostContext::ShutdownScheduler(g_scheduler); +} diff --git a/runtime/tests/macos_context_abi_tests.cpp b/runtime/tests/macos_context_abi_tests.cpp new file mode 100644 index 0000000..a1fc6f7 --- /dev/null +++ b/runtime/tests/macos_context_abi_tests.cpp @@ -0,0 +1,46 @@ +#include +#include +#include +#include +#include + +extern "C" void mkw_co_switch(void** targetSp, void** sourceSp); +extern "C" void* mkw_co_init(void* stackTop, void (*entry)(void*), void* argument); + +namespace { +// Exercise the raw AArch64 context ABI independently of HostContext so a +// callee-saved-register or stack-frame regression is localized to this layer. + +std::array g_workerStack{}; +void* g_schedulerSp = nullptr; +void* g_workerSp = nullptr; +std::vector g_events; + +void Worker(void*) { + g_events.push_back(1); + mkw_co_switch(&g_schedulerSp, &g_workerSp); + g_events.push_back(2); + mkw_co_switch(&g_schedulerSp, &g_workerSp); + std::abort(); +} + +} // namespace + +int main() { + g_workerSp = mkw_co_init(g_workerStack.data() + g_workerStack.size(), Worker, nullptr); + if (!g_workerSp) { + std::cerr << "failed to create AArch64 context frame\n"; + return 1; + } + mkw_co_switch(&g_workerSp, &g_schedulerSp); + if (g_events != std::vector{1}) { + std::cerr << "worker did not yield to scheduler\n"; + return 1; + } + mkw_co_switch(&g_workerSp, &g_schedulerSp); + if (g_events != std::vector{1, 2}) { + std::cerr << "worker did not resume from saved context\n"; + return 1; + } + return 0; +} diff --git a/runtime/tests/macos_guest_flat_memory_tests.cpp b/runtime/tests/macos_guest_flat_memory_tests.cpp new file mode 100644 index 0000000..116b687 --- /dev/null +++ b/runtime/tests/macos_guest_flat_memory_tests.cpp @@ -0,0 +1,66 @@ +#include "guest_flat_memory.h" + +#include +#include +#include +#include + +int main() { + GuestFlat::Initialize({ + {0x00000000u, 0x4000u, GuestFlat::Backing::Mem1}, + {0x80000000u, 0x4000u, GuestFlat::Backing::Mem1}, + {0x10000000u, 0x4000u, GuestFlat::Backing::Mem2}, + {0x90000000u, 0x4000u, GuestFlat::Backing::Mem2}, + }); + if (!GuestFlat::IsActive()) { + std::cerr << "guest address space did not become active\n"; + return 1; + } + if (GuestFlat::RequiresCheckedAccess() != + (static_cast(getpagesize()) > GuestFlat::kGuestPageSize)) { + std::cerr << "guest access mode does not reflect the host page size\n"; + return 1; + } + auto* mem1Physical = GuestFlat::HostPointer(0x00000000u); + auto* mem1Cached = GuestFlat::HostPointer(0x80000000u); + auto* mem2Physical = GuestFlat::HostPointer(0x10000000u); + auto* mem2Cached = GuestFlat::HostPointer(0x90000000u); + if (!mem1Physical || !mem1Cached || !mem2Physical || !mem2Cached) { + std::cerr << "missing host alias\n"; + return 1; + } + if (GuestFlat::HostPointer(0x4000u) != nullptr || + GuestFlat::HostPointer(0xa0000000u) != nullptr) { + std::cerr << "unmapped guest address resolved to host memory\n"; + return 1; + } + mem1Physical[7] = 0x5a; + mem2Cached[9] = 0xa5; + const auto* guest = reinterpret_cast(GuestFlat::kFixedFlatGuestBase); + if (mem1Cached[7] != 0x5a || guest[0x80000007u] != 0x5a || + mem2Physical[9] != 0xa5 || guest[0x10000009u] != 0xa5) { + std::cerr << "guest aliases are not coherent\n"; + return 1; + } + auto* guestWritable = reinterpret_cast(GuestFlat::kFixedFlatGuestBase); + guestWritable[0x80000008u] = 0x3c; + guestWritable[0x1000000au] = 0xc3; + if (mem1Physical[8] != 0x3c || mem2Cached[10] != 0xc3) { + std::cerr << "guest writes were not visible through host aliases\n"; + return 1; + } + + GuestFlat::Initialize({ + {0x00000000u, 0x4000u, GuestFlat::Backing::Mem1}, + {0x80000000u, 0x4000u, GuestFlat::Backing::Mem1}, + {0x10000000u, 0x4000u, GuestFlat::Backing::Mem2}, + {0x90000000u, 0x4000u, GuestFlat::Backing::Mem2}, + }); + try { + GuestFlat::Initialize({{0x00000000u, 0x8000u, GuestFlat::Backing::Mem1}}); + std::cerr << "guest address space accepted a different layout\n"; + return 1; + } catch (const std::runtime_error&) { + } + return 0; +} diff --git a/runtime/tests/platform_paths_tests.cpp b/runtime/tests/platform_paths_tests.cpp new file mode 100644 index 0000000..66cf79a --- /dev/null +++ b/runtime/tests/platform_paths_tests.cpp @@ -0,0 +1,30 @@ +#include "platform/host_platform.h" + +#include + +int main() { + if (!RuntimePlatform::ExecutableDirectory()) { + std::cerr << "unable to resolve the current executable directory\n"; + return 1; + } + + const auto userData = RuntimePlatform::ApplicationDataDirectory("WiiCompiledPlatformPathsTest"); + if (userData.filename() != "WiiCompiledPlatformPathsTest") { + std::cerr << "application-data directory lost its application name: " << userData << '\n'; + return 1; + } + if (RuntimePlatform::LogDirectory("WiiCompiledPlatformPathsTest") != userData / "Logs") { + std::cerr << "log directory is not derived from application data\n"; + return 1; + } +#if defined(__APPLE__) + if (userData.parent_path().filename() != "Application Support" || + userData.parent_path().parent_path().filename() != "Library") { + std::cerr << "macOS application-data directory is not under ~/Library/Application Support: " + << userData << '\n'; + return 1; + } +#endif + + return 0; +} diff --git a/translator/src/Translator.Cli/Program.cs b/translator/src/Translator.Cli/Program.cs index 26dda80..915cdd6 100644 --- a/translator/src/Translator.Cli/Program.cs +++ b/translator/src/Translator.Cli/Program.cs @@ -172,6 +172,7 @@ return command switch "emit-build-shards" => RunEmitBuildShards(tail), "emit-base-manifest" => RunEmitBaseManifest(tail), "check-base-mod-awareness" => RunCheckBaseModAwareness(tail), + "validate-retro-wfc-payload" => RunValidateRetroWfcPayload(tail), _ => ShowHelp(command) }; @@ -360,6 +361,33 @@ int RunInfo() return 0; } +int RunValidateRetroWfcPayload(string[] argsTail) +{ + var directory = OptionValue(argsTail, "--directory"); + if (string.IsNullOrWhiteSpace(directory)) + { + Console.Error.WriteLine("--directory is required."); + return 1; + } + + try + { + WiiCompiled.Setup.Common.RetroWfcPayload.ValidateStagedRetroWfcPayloadDirectory(directory); + Console.WriteLine("[translator] Retro WFC payload signature validated."); + return 0; + } + catch (InvalidDataException ex) + { + Console.Error.WriteLine($"[translator] Retro WFC payload validation failed: {ex.Message}"); + return 2; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"[translator] Could not read Retro WFC payload: {ex.Message}"); + return 1; + } +} + int RunTranslateRecursive(string[] argsTail) { if (argsTail.Length == 0 || argsTail[0].StartsWith("--", StringComparison.Ordinal)) @@ -3671,7 +3699,8 @@ static string[] KnownCommands() => new[] "translate-mod", "emit-base-manifest", "emit-build-shards", - "check-base-mod-awareness" + "check-base-mod-awareness", + "validate-retro-wfc-payload" }; /// @@ -3714,6 +3743,10 @@ static (string? Positional, CommandOption[] Options)? CommandSpec(string command new("--translation-output-metadata", "path"), new("--code-pul", "path") }), + "validate-retro-wfc-payload" => (null, new CommandOption[] + { + new("--directory", "directory", Required: true) + }), "emit-base-manifest" => (null, new CommandOption[] { new("--out", "path"), @@ -4110,4 +4143,3 @@ sealed record ResolvedDispatchEntry( bool MustRemainDynamicallyDispatchable, string SourceFile); - diff --git a/translator/src/Translator.Cli/Translator.Cli.csproj b/translator/src/Translator.Cli/Translator.Cli.csproj index d6f98c9..6543c9f 100644 --- a/translator/src/Translator.Cli/Translator.Cli.csproj +++ b/translator/src/Translator.Cli/Translator.Cli.csproj @@ -15,6 +15,7 @@ + diff --git a/translator/src/Translator.Core/IO/AssemblyBlobWriter.cs b/translator/src/Translator.Core/IO/AssemblyBlobWriter.cs index 3a295f0..2201e54 100644 --- a/translator/src/Translator.Core/IO/AssemblyBlobWriter.cs +++ b/translator/src/Translator.Core/IO/AssemblyBlobWriter.cs @@ -22,14 +22,15 @@ internal static class AssemblyBlobWriter var expectedFiles = new HashSet(StringComparer.OrdinalIgnoreCase); var assembly = new StringBuilder(); foreach (var header in headerLines) assembly.AppendLine(header); - // PE/COFF (Windows) and ELF (Linux) spell a read-only data section differently in GNU-as - // syntax - COFF section flags ("dr" = data, read-only) versus an ELF section needing an - // allocatable-only flag plus an explicit @progbits type. The build always targets + // PE/COFF (Windows), Mach-O (macOS), and ELF (Linux) spell a read-only data section + // differently in GNU-as syntax. The build always targets // whichever platform the translator itself runs on (there is no cross-compilation // support), so that's what this picks the section syntax from. assembly.AppendLine(OperatingSystem.IsWindows() ? ".section .rdata,\"dr\"" - : ".section .rodata,\"a\",@progbits"); + : OperatingSystem.IsMacOS() + ? ".section __TEXT,__const" + : ".section .rodata,\"a\",@progbits"); assembly.AppendLine(); foreach (var blob in blobs) @@ -40,8 +41,12 @@ internal static class AssemblyBlobWriter var hash = ChecksumUtilities.Sha256Hex(blob.Data.Span); assembly.AppendLine($"// {blob.Comment}; sha256={hash}"); assembly.AppendLine(".p2align 4"); - assembly.AppendLine($".globl {blob.Symbol}"); - assembly.AppendLine($"{blob.Symbol}:"); + // C/C++ external symbols carry a leading underscore in Mach-O, + // unlike ELF and COFF. The generated C++ still names the symbol + // without that ABI decoration, so emit the platform spelling here. + var assemblySymbol = OperatingSystem.IsMacOS() ? $"_{blob.Symbol}" : blob.Symbol; + assembly.AppendLine($".globl {assemblySymbol}"); + assembly.AppendLine($"{assemblySymbol}:"); var referencePath = Path.Combine(blobReferenceDirectory, blob.FileName); assembly.AppendLine($".incbin \"{SanitizeAssemblyPath(referencePath)}\""); assembly.AppendLine(); @@ -51,7 +56,7 @@ internal static class AssemblyBlobWriter { if (!expectedFiles.Contains(Path.GetFullPath(stalePath))) File.Delete(stalePath); } - if (!OperatingSystem.IsWindows()) + if (!OperatingSystem.IsWindows() && !OperatingSystem.IsMacOS()) { // Absence of a .note.GNU-stack section makes the linker assume the oldest, most // conservative default for this object (an executable stack) and warn about it; this diff --git a/translator/src/Translator.Core/Parsing/Kamek/KamekChunk.cs b/translator/src/Translator.Core/Parsing/Kamek/KamekChunk.cs index c8af538..a2b6c2b 100644 --- a/translator/src/Translator.Core/Parsing/Kamek/KamekChunk.cs +++ b/translator/src/Translator.Core/Parsing/Kamek/KamekChunk.cs @@ -6,6 +6,12 @@ namespace Translator.Core.Parsing.Kamek; public sealed class KamekChunk { public const uint Magic0 = 0x4B616D65; + /// + /// Kamek v2 is still emitted by current Retro Rewind releases. Its command + /// stream matches v3, but combined v2 files carry each chunk length only in + /// the outer size table. + /// + public const uint Magic1V2 = 0x6B000002; public const uint Magic1 = 0x6B000003; public const int HeaderSize = 0x20; @@ -58,7 +64,7 @@ public sealed class KamekChunk } return BinaryPrimitives.ReadUInt32BigEndian(data.Slice(offset, 4)) == Magic0 && - BinaryPrimitives.ReadUInt32BigEndian(data.Slice(offset + 4, 4)) == Magic1; + IsSupportedFormatVersion(BinaryPrimitives.ReadUInt32BigEndian(data.Slice(offset + 4, 4))); } public static KamekChunk Parse(byte[] data, int offset, int expectedSize, int index) @@ -74,7 +80,7 @@ public sealed class KamekChunk var magic0 = reader.ReadUInt32(); var magic1 = reader.ReadUInt32(); - if (magic0 != Magic0 || magic1 != Magic1) + if (magic0 != Magic0 || !IsSupportedFormatVersion(magic1)) { throw new InvalidDataException($"Kamek chunk {index} at 0x{offset:X} has invalid magic 0x{magic0:X8}/0x{magic1:X8}."); } @@ -83,9 +89,15 @@ public sealed class KamekChunk var codeSize = reader.ReadUInt32(); var ctorStart = reader.ReadUInt32(); var ctorEnd = reader.ReadUInt32(); - var chunkSize = reader.ReadUInt32(); + var declaredChunkSize = reader.ReadUInt32(); _ = reader.ReadUInt32(); // reserved + // v3 writes each chunk's length in its header. v2 leaves that field at + // zero and relies on the combined-file table (or the raw file length). + var chunkSize = magic1 == Magic1V2 + ? checked((uint)(expectedSize != 0 ? expectedSize : data.Length - offset)) + : declaredChunkSize; + if (chunkSize < HeaderSize + codeSize) { throw new InvalidDataException($"Kamek chunk {index} chunkSize 0x{chunkSize:X} is smaller than header+code."); @@ -171,4 +183,7 @@ public sealed class KamekChunk KamekCommandId.BranchLink => 1, _ => throw new ArgumentOutOfRangeException(nameof(id), id, "Unsupported Kamek command id") }; + + private static bool IsSupportedFormatVersion(uint magic1) => + magic1 is Magic1V2 or Magic1; } diff --git a/translator/src/Translator.Core/Parsing/Kamek/KamekPulFile.cs b/translator/src/Translator.Core/Parsing/Kamek/KamekPulFile.cs index 75306fe..0cd46a9 100644 --- a/translator/src/Translator.Core/Parsing/Kamek/KamekPulFile.cs +++ b/translator/src/Translator.Core/Parsing/Kamek/KamekPulFile.cs @@ -33,7 +33,7 @@ public sealed class KamekPulFile if (!KamekChunk.HasMagic(data, 0)) { - throw new InvalidDataException("File is neither a combined Code.pul nor a raw Kamek v3 chunk."); + throw new InvalidDataException("File is neither a combined Code.pul nor a raw supported Kamek chunk."); } var rawChunk = KamekChunk.Parse(data, 0, expectedSize: 0, index: 0); diff --git a/translator/tests/Translator.Tests/KamekPulFileTests.cs b/translator/tests/Translator.Tests/KamekPulFileTests.cs index 528b7bb..7444651 100644 --- a/translator/tests/Translator.Tests/KamekPulFileTests.cs +++ b/translator/tests/Translator.Tests/KamekPulFileTests.cs @@ -49,6 +49,27 @@ public class KamekPulFileTests Assert.Equal(0x20u, pul.SelectRegion("E").BssSize); } + [Fact] + public void ParsesKamekV2CombinedChunks() + { + var p = BuildChunk(0x10, [0x4E, 0x80, 0x00, 0x20], + [BuildCommand(KamekCommandId.Branch, true, 0x8053369C, [0x100])], KamekChunk.Magic1V2); + var combined = new byte[0x10 + p.Length]; + WriteU32(combined, 0, (uint)p.Length); + p.CopyTo(combined, 0x10); + + var pul = KamekPulFile.Parse(combined); + + Assert.True(pul.IsCombined); + Assert.Equal(0x10u, pul.SelectRegion("P").BssSize); + Assert.Equal(KamekCommandId.Branch, pul.SelectRegion("P").Commands.Single().Id); + + var raw = KamekPulFile.Parse(p); + + Assert.False(raw.IsCombined); + Assert.Equal((uint)p.Length, raw.Chunks.Single().ChunkSize); + } + [Fact] public void EncodesPpcBranchLikeKamek() { @@ -83,18 +104,19 @@ public class KamekPulFileTests Assert.Equal(chunk.Commands.Count, chunk.CommandCounts.Values.Sum()); } - private static byte[] BuildChunk(uint bssSize, byte[] code, byte[][] commands) + private static byte[] BuildChunk(uint bssSize, byte[] code, byte[][] commands, + uint magic1 = KamekChunk.Magic1) { var commandSize = commands.Sum(c => c.Length); var chunkSize = KamekChunk.HeaderSize + code.Length + commandSize; var data = new byte[chunkSize]; WriteU32(data, 0x00, KamekChunk.Magic0); - WriteU32(data, 0x04, KamekChunk.Magic1); + WriteU32(data, 0x04, magic1); WriteU32(data, 0x08, bssSize); WriteU32(data, 0x0C, (uint)code.Length); WriteU32(data, 0x10, 0); WriteU32(data, 0x14, 0); - WriteU32(data, 0x18, (uint)chunkSize); + WriteU32(data, 0x18, magic1 == KamekChunk.Magic1V2 ? 0u : (uint)chunkSize); code.CopyTo(data, KamekChunk.HeaderSize); var offset = KamekChunk.HeaderSize + code.Length; diff --git a/translator/tests/Translator.Tests/ModDataPatchWriterTests.cs b/translator/tests/Translator.Tests/ModDataPatchWriterTests.cs index bc7d2a6..29da247 100644 --- a/translator/tests/Translator.Tests/ModDataPatchWriterTests.cs +++ b/translator/tests/Translator.Tests/ModDataPatchWriterTests.cs @@ -28,8 +28,10 @@ public sealed class ModDataPatchWriterTests var assemblyPath = Path.Combine(root, "cpp", "mod_data_patches_blobs.S"); var assembly = File.ReadAllText(assemblyPath); - Assert.Contains(".globl kModuleImage", assembly, StringComparison.Ordinal); - Assert.Contains(".globl kKamekCodeSha1Digest", assembly, StringComparison.Ordinal); + var symbolPrefix = OperatingSystem.IsMacOS() ? "_" : ""; + Assert.Contains($".globl {symbolPrefix}kModuleImage", assembly, StringComparison.Ordinal); + Assert.Contains($".globl {symbolPrefix}kKamekCodeSha1Digest", assembly, StringComparison.Ordinal); + Assert.DoesNotContain("__APPLE__", assembly, StringComparison.Ordinal); Assert.Contains(".incbin", assembly, StringComparison.Ordinal); var timestamps = new[] { cppPath, assemblyPath }