From e52227baf599f4d33b2a79f41b0a853e40d98cc9 Mon Sep 17 00:00:00 2001 From: iChris4 Date: Wed, 16 Sep 2026 22:06:34 +0200 Subject: [PATCH] Added Android Meta Quest Support --- .gitignore | 9 + OPENXR.md | 20 +- android/Build-Quest.ps1 | 94 + android/Prepare-QuestDependencies.ps1 | 42 + android/README.md | 23 + android/Run-Quest.ps1 | 111 ++ android/app/build.gradle.kts | 124 ++ android/app/src/main/AndroidManifest.xml | 61 + android/app/src/main/cpp/CMakeLists.txt | 58 + .../app/src/main/cpp/quest_surface_jni.cpp | 19 + .../org/wiicompiled/quest/QuestActivity.kt | 128 ++ .../org/wiicompiled/quest/QuestSurface.kt | 38 + .../res/drawable/ic_launcher_foreground.xml | 14 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + android/app/src/main/res/values/colors.xml | 4 + android/app/src/main/res/values/strings.xml | 6 + android/app/src/main/res/values/themes.xml | 9 + android/build.gradle.kts | 4 + android/gradle.properties | 5 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45633 bytes .../gradle/wrapper/gradle-wrapper.properties | 8 + android/gradlew | 248 +++ android/gradlew.bat | 93 + android/settings.gradle.kts | 18 + aurora-main/cmake/AuroraDawnProvider.cmake | 32 +- aurora-main/cmake/aurora_core.cmake | 4 + aurora-main/include/aurora/android.h | 30 + aurora-main/include/aurora/vulkan_interop.h | 129 ++ aurora-main/lib/android_debug.hpp | 24 + aurora-main/lib/gx/command_processor.cpp | 45 +- aurora-main/lib/gx/gx.cpp | 31 +- aurora-main/lib/gx/gx.hpp | 6 + aurora-main/lib/webgpu/gpu.cpp | 38 +- aurora-main/lib/webgpu/vulkan_interop.cpp | 620 +++++++ aurora-main/lib/window.cpp | 43 +- aurora-main/lib/window.hpp | 4 + aurora-main/tests/gx_test_stubs.cpp | 11 + docs/quest-port.md | 301 ++++ runtime/CMakeLists.txt | 86 +- runtime/cmake/PublicProducts.cmake | 86 +- runtime/include/aurora_events.h | 24 + runtime/include/fiber_manager.h | 5 + runtime/include/nand_settings.h | 13 + runtime/include/runtime_config.h | 11 +- runtime/include/vr/openxr_android.h | 41 + runtime/include/vr/openxr_backend.h | 66 + runtime/include/vr/openxr_config.h | 4 + runtime/include/vr/openxr_d3d12.h | 51 +- runtime/include/vr/openxr_input.h | 94 + runtime/include/vr/openxr_vulkan.h | 78 + runtime/src/fiber_manager.cpp | 12 + runtime/src/guest_flat_memory.cpp | 9 + runtime/src/hle/audio/audio.cpp | 3 +- runtime/src/main.cpp | 37 + runtime/src/music_attenuation.cpp | 6 +- runtime/src/platform/host_platform.cpp | 15 + runtime/src/vr/openxr_android.cpp | 171 ++ runtime/src/vr/openxr_input.cpp | 450 +++++ runtime/src/vr/openxr_integration.cpp | 247 ++- runtime/src/vr/openxr_runtime.cpp | 1 + runtime/src/vr/openxr_vulkan.cpp | 1603 +++++++++++++++++ translator/src/Translator.Cli/Program.cs | 45 +- .../CodeGen/DataSectionGenerator.cs | 10 +- .../Translator.Core/IO/AssemblyBlobWriter.cs | 24 +- .../Translator.Core/IO/AssemblyTargetOs.cs | 83 + .../Mods/ModDataPatchWriter.cs | 10 +- .../Translator.Tests/AssemblyTargetOsTests.cs | 94 + 67 files changed, 5662 insertions(+), 176 deletions(-) create mode 100644 android/Build-Quest.ps1 create mode 100644 android/Prepare-QuestDependencies.ps1 create mode 100644 android/README.md create mode 100644 android/Run-Quest.ps1 create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/cpp/CMakeLists.txt create mode 100644 android/app/src/main/cpp/quest_surface_jni.cpp create mode 100644 android/app/src/main/java/org/wiicompiled/quest/QuestActivity.kt create mode 100644 android/app/src/main/java/org/wiicompiled/quest/QuestSurface.kt create mode 100644 android/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle.kts create mode 100644 aurora-main/include/aurora/android.h create mode 100644 aurora-main/include/aurora/vulkan_interop.h create mode 100644 aurora-main/lib/android_debug.hpp create mode 100644 aurora-main/lib/webgpu/vulkan_interop.cpp create mode 100644 docs/quest-port.md create mode 100644 runtime/include/vr/openxr_android.h create mode 100644 runtime/include/vr/openxr_backend.h create mode 100644 runtime/include/vr/openxr_input.h create mode 100644 runtime/include/vr/openxr_vulkan.h create mode 100644 runtime/src/vr/openxr_android.cpp create mode 100644 runtime/src/vr/openxr_input.cpp create mode 100644 runtime/src/vr/openxr_vulkan.cpp create mode 100644 translator/src/Translator.Core/IO/AssemblyTargetOs.cs create mode 100644 translator/tests/Translator.Tests/AssemblyTargetOsTests.cs diff --git a/.gitignore b/.gitignore index e9818b5..37dd411 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,12 @@ output.txt # Operating System .DS_Store + +# Android (Meta Quest) app: Gradle outputs and the downloaded SDL3 AAR +/android/.gradle/ +/android/.dependencies/ +/android/local.properties +/android/app/build/ +/android/app/.cxx/ +/android/app/libs/*.aar +/android/.runs/ diff --git a/OPENXR.md b/OPENXR.md index 323ce5c..c9c205c 100644 --- a/OPENXR.md +++ b/OPENXR.md @@ -255,12 +255,13 @@ ended up; its original viewport is folded into the projection instead. | Backend | Status | | --- | --- | | Windows D3D12 | Implemented: same-adapter, same-device asynchronous OpenXR submission. | -| Linux Vulkan | Capability-gated scaffold. The pinned Dawn package does not expose the complete native Vulkan instance/device/queue context needed for safe same-device OpenXR interop, so the runtime logs the limitation and falls back to desktop rendering. | +| Android Vulkan (Meta Quest) | Implemented and running on a Quest 3: the OpenXR side owns its own Vulkan device (`XR_KHR_vulkan_enable2`, `XR_KHR_vulkan_enable` fallback) and shares eyes with Dawn through `AHardwareBuffer`s ordered by sync-fd fences. Controllers arrive through OpenXR actions as a virtual SDL gamepad. See `docs/quest-port.md`. | +| Linux Vulkan | Not wired. The pinned Dawn package does not expose a native Vulkan device, and the AHardwareBuffer bridge is Android-only; a dma-buf/opaque-fd variant of the same design would cover desktop Linux. | | Other platforms | Not wired yet. | -The Vulkan path intentionally does not create an unrelated Vulkan device or use a CPU readback as -a workaround. It accepts a future explicit Dawn native context, including external queue locking, -so it can be enabled once Aurora exposes those handles safely. +Both bindings share `openxr_integration.cpp`: the pacing thread, policy evaluation, the +retained-layer protocol and the head-pose maths are compiled once against the neutral types in +`vr/openxr_backend.h`, and only the backend class differs per platform. ### Interpolation validation @@ -308,11 +309,12 @@ ends, including mid-frame flushes, so live setting changes cannot invalidate pen ## Current limitations - Only the project's supported PAL `RMCP01` translation has race instrumentation addresses. -- Motion-controller/Wii Remote emulation and OpenXR action bindings are not implemented yet; use - the existing game-controller input path. -- Dedicated Quest, Android, and Apple visionOS packaging is not implemented. The static recompilation - architecture avoids a runtime JIT, but each platform still needs an Aurora graphics bridge, - windowing/lifecycle work, and packaging. +- Wii Remote pointer/motion emulation from tracked controllers is not implemented. OpenXR action + bindings exist only on the Android build, where they present the Touch controllers as one + ordinary gamepad; on Windows use the existing game-controller input path. +- The Quest build (`android/`, `docs/quest-port.md`) runs on a Quest 3 through menus and races. + Lifecycle events and performance (about 43 game FPS) are still open. Apple visionOS packaging + is not implemented. - Scene-specific comfort options, culling fixes, replay/spectator classification, and a broader VR settings UI beyond the current enable/replay controls are future work. - The desktop window remains available as a mirror/fallback. diff --git a/android/Build-Quest.ps1 b/android/Build-Quest.ps1 new file mode 100644 index 0000000..77d3849 --- /dev/null +++ b/android/Build-Quest.ps1 @@ -0,0 +1,94 @@ +# Builds the standalone Meta Quest APK on a Windows host. +# +# powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 [-Generated ] [-Flavor base|retroRewind] +# [-Configuration debug|release] [-Install] +# +# -Generated names the translator output for the disc you own: the directory +# holding data_sections_init.cpp, RuntimeConfig.h and build_shards/shards.cmake. +# It defaults to the installer's build workspace next to this checkout. The +# Windows-generated tree is used as-is; runtime/cmake/PublicProducts.cmake +# rewrites its blob assembly for ELF at configure time. +# +# Prerequisites (see docs/quest-port.md): JDK 17, the Android SDK with NDK +# 29.0.14206865 and CMake 3.22.1, and android/Prepare-QuestDependencies.ps1 +# having staged the SDL3 AAR. +[CmdletBinding()] +param( + [string]$Generated = '', + [string]$Dependencies = '', + [string]$CMakeDir = '', + [ValidateSet('base', 'retroRewind')] [string]$Flavor = 'base', + [ValidateSet('debug', 'release')] [string]$Configuration = 'debug', + [switch]$Install +) +$ErrorActionPreference = 'Stop' + +$root = Split-Path -Parent $MyInvocation.MyCommand.Path +$repo = (Resolve-Path (Join-Path $root '..')).Path + +if (-not $Generated) { + $Generated = Join-Path $repo '.scratch\vr-build-workspace\BuildWorkspace\generated' +} +if (-not (Test-Path (Join-Path $Generated 'build_shards\shards.cmake'))) { + throw "No translated graph at $Generated (expected build_shards\shards.cmake). Run the translator first; see docs/quest-port.md." +} +if (-not (Test-Path (Join-Path $root 'app\libs\SDL3-3.4.4.aar'))) { + throw 'SDL3 AAR missing; run android/Prepare-QuestDependencies.ps1 first.' +} + +$javaHome = if ($env:JAVA_HOME) { $env:JAVA_HOME } else { 'C:\Program Files\Java\jdk-17' } +$sdkRoot = if ($env:ANDROID_HOME) { $env:ANDROID_HOME } elseif ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { Join-Path $env:LOCALAPPDATA 'Android\Sdk' } +if (-not (Test-Path (Join-Path $javaHome 'bin\java.exe'))) { throw "JDK 17 not found at $javaHome (set JAVA_HOME)" } +if (-not (Test-Path $sdkRoot)) { throw "Android SDK not found at $sdkRoot (set ANDROID_HOME)" } + +$env:JAVA_HOME = $javaHome +$env:ANDROID_HOME = $sdkRoot +$env:ANDROID_SDK_ROOT = $sdkRoot + +# aurora-main requires CMake 3.25+, newer than the SDK's bundled 3.22.1. Point +# the Android Gradle plugin at a system CMake through local.properties and put +# the SDK's Ninja on PATH, which a non-bundled CMake needs. +if (-not $CMakeDir) { + $cmakeExe = Get-Command cmake.exe -ErrorAction SilentlyContinue + if ($cmakeExe) { $CMakeDir = Split-Path -Parent (Split-Path -Parent $cmakeExe.Source) } +} +if (-not $CMakeDir -or -not (Test-Path (Join-Path $CMakeDir 'bin\cmake.exe'))) { + throw 'No CMake 3.25+ found; install one or pass -CMakeDir ' +} +$sdkCMake = Get-ChildItem -Path (Join-Path $sdkRoot 'cmake') -Directory | Sort-Object Name -Descending | Select-Object -First 1 +if ($sdkCMake) { $env:PATH = (Join-Path $sdkCMake.FullName 'bin') + ';' + $env:PATH } +$localProperties = @( + 'sdk.dir=' + $sdkRoot.Replace('\', '\\'), + 'cmake.dir=' + $CMakeDir.Replace('\', '\\') +) +Set-Content -Path (Join-Path $root 'local.properties') -Value $localProperties -Encoding ascii + +if (-not $Dependencies) { + $candidate = Join-Path $repo '.scratch\vr-build-workspace\BuildWorkspace\Dependencies' + if (Test-Path $candidate) { $Dependencies = $candidate } +} + +$variant = (Get-Culture).TextInfo.ToTitleCase($Configuration) +$task = "app:assemble$((Get-Culture).TextInfo.ToTitleCase($Flavor))$variant" +$gradleArgs = @( + '--project-dir', $root, + "-PmkwGeneratedDir=$Generated" +) +if ($Dependencies) { $gradleArgs += "-PmkwDependenciesDir=$Dependencies" } +$gradleArgs += $task +Write-Host "gradlew $($gradleArgs -join ' ')" +& (Join-Path $root 'gradlew.bat') @gradleArgs +if ($LASTEXITCODE -ne 0) { throw "Gradle failed ($LASTEXITCODE)" } + +$apkDir = Join-Path $root "app\build\outputs\apk\$Flavor\$Configuration" +$apk = Get-ChildItem -Path $apkDir -Filter '*.apk' | Select-Object -First 1 +if (-not $apk) { throw "No APK under $apkDir" } +Write-Host "APK: $($apk.FullName)" + +if ($Install) { + $adb = Join-Path $sdkRoot 'platform-tools\adb.exe' + & $adb install -r $apk.FullName + if ($LASTEXITCODE -ne 0) { throw "adb install failed ($LASTEXITCODE)" } + Write-Host 'Installed. Push the extracted disc with:' + Write-Host ' adb push DATA /sdcard/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/DATA' +} diff --git a/android/Prepare-QuestDependencies.ps1 b/android/Prepare-QuestDependencies.ps1 new file mode 100644 index 0000000..cbdac43 --- /dev/null +++ b/android/Prepare-QuestDependencies.ps1 @@ -0,0 +1,42 @@ +# Fetches the one dependency the Gradle project cannot resolve from a Maven +# repository: the SDL3 Android AAR (SDLActivity plus the matching libSDL3.so). +# Dawn, the OpenXR loader and every other native dependency are fetched and +# pinned by the runtime's CMake during the Gradle build. +# +# Usage: powershell -ExecutionPolicy Bypass -File android/Prepare-QuestDependencies.ps1 +# +# The AAR version must equal AURORA_SDL3_VERSION in aurora-main/CMakeLists.txt: +# the Java classes in the AAR and libSDL3.so must come from the same release. +$ErrorActionPreference = 'Stop' + +$sdlVersion = '3.4.4' +$sdlArchiveSha256 = 'da67b5a43442e449511399c65aa86b724419f92850cf36a2a8c7de72eb992bc0' +$root = Split-Path -Parent $MyInvocation.MyCommand.Path +$libs = Join-Path $root 'app\libs' +$cache = Join-Path $root '.dependencies' +New-Item -ItemType Directory -Force $libs, $cache | Out-Null + +$archive = Join-Path $cache "SDL3-devel-$sdlVersion-android.zip" +if (-not (Test-Path $archive)) { + $url = "https://github.com/libsdl-org/SDL/releases/download/release-$sdlVersion/SDL3-devel-$sdlVersion-android.zip" + Write-Host "Downloading $url" + Invoke-WebRequest -Uri $url -OutFile $archive +} +$actual = (Get-FileHash -Algorithm SHA256 $archive).Hash.ToLowerInvariant() +if ($actual -ne $sdlArchiveSha256) { + Remove-Item $archive + throw "SDL3 Android archive digest mismatch: $actual" +} + +Add-Type -AssemblyName System.IO.Compression.FileSystem +$zip = [System.IO.Compression.ZipFile]::OpenRead($archive) +try { + $entry = $zip.Entries | Where-Object { $_.Name -eq "SDL3-$sdlVersion.aar" } | Select-Object -First 1 + if (-not $entry) { throw "SDL3-$sdlVersion.aar not found in the archive" } + $target = Join-Path $libs "SDL3-$sdlVersion.aar" + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $target, $true) + Write-Host "SDL3 AAR staged at $target" +} +finally { + $zip.Dispose() +} diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..4f0bc2e --- /dev/null +++ b/android/README.md @@ -0,0 +1,23 @@ +# WiiCompiled VR for Meta Quest (Android) + +Standalone Android/OpenXR build of the Mario Kart Wii recompilation for Quest 2, +Quest 3, Quest 3S and Quest Pro. The full design, build walkthrough and current +status live in [docs/quest-port.md](../docs/quest-port.md); this directory only +holds the Gradle project and its two helper scripts. + +```powershell +powershell -ExecutionPolicy Bypass -File android/Prepare-QuestDependencies.ps1 # stages the SDL3 3.4.4 AAR once +powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Install # base game, debug-signed, installs over adb +powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Flavor retroRewind # Retro Rewind product +``` + +The translated game (the translator's `generated/` tree for **your own** +PAL `RMCP01` disc) is passed with `-Generated `; it defaults to the +installer workspace next to this checkout. No game data is ever part of the +APK: push the extracted disc afterwards to + +``` +/sdcard/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/DATA +``` + +`Config.toml`, saves and logs live in the same `WiiCompiledOpenXRVR` directory. diff --git a/android/Run-Quest.ps1 b/android/Run-Quest.ps1 new file mode 100644 index 0000000..47670d3 --- /dev/null +++ b/android/Run-Quest.ps1 @@ -0,0 +1,111 @@ +# Installs the built APK on the connected Quest, places the game data, launches +# the activity and collects diagnostics for one session. +# +# powershell -ExecutionPolicy Bypass -File android/Run-Quest.ps1 [-Apk ] [-Data ] +# [-Seconds 60] [-NoLaunch] [-SkipInstall] +# +# -Data names the extracted disc partition (the directory holding sys/, files/, +# disc/ ...). It is pushed once to a staging folder, then moved into the app's +# data directory; both steps are skipped when DATA is already in place. +# Diagnostics land in android/.runs//: logcat (UTF-8) and the runtime's +# own per-run log folder pulled from the device. +# +# Android storage rules this script works within: +# * Never create directories under Android/data/ through adb before +# the app has run: they would belong to the shell user and the app could +# not write its Config.toml, NAND or logs there. The app creates its own +# WiiCompiledOpenXRVR directory on first launch, so this script launches it +# once before placing DATA. +# * Files adb places stay owned by the shell user, so DATA is made +# world-readable (chmod -R a+rX). The game only ever reads it. +# * run-as cannot reach shared storage (SELinux), so it is not used here. +[CmdletBinding()] +param( + [string]$Apk = '', + [string]$Data = '', + [int]$Seconds = 60, + [switch]$NoLaunch, + [switch]$SkipInstall +) +$ErrorActionPreference = 'Stop' + +$root = Split-Path -Parent $MyInvocation.MyCommand.Path +$sdkRoot = if ($env:ANDROID_HOME) { $env:ANDROID_HOME } elseif ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { Join-Path $env:LOCALAPPDATA 'Android\Sdk' } +$adb = Join-Path $sdkRoot 'platform-tools\adb.exe' +if (-not (Test-Path $adb)) { throw "adb not found at $adb" } + +$package = 'org.wiicompiled.quest' +$activity = "$package/.QuestActivity" +$appDir = "/sdcard/Android/data/$package/files/WiiCompiledOpenXRVR" +$stageDir = '/sdcard/Download/WiiCompiledQuestStaging' + +function Invoke-Adb { param([string[]]$Arguments) & $adb @Arguments; if ($LASTEXITCODE -ne 0) { throw "adb $($Arguments -join ' ') failed ($LASTEXITCODE)" } } +# adb shell output carries a trailing CR, so trim before comparing. +function Test-DevicePath { param([string]$Path, [string]$Kind = 'd') ((& $adb shell "test -$Kind '$Path' && echo yes") | Out-String).Trim() -eq 'yes' } + +$devices = (& $adb devices) -match "device$" +if (-not $devices) { throw 'No device in "device" state; check the Quest is connected and USB debugging is allowed.' } + +if (-not $SkipInstall) { + if (-not $Apk) { + $Apk = Get-ChildItem -Path (Join-Path $root 'app\build\outputs\apk') -Recurse -Filter '*.apk' | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 | ForEach-Object FullName + } + if (-not $Apk -or -not (Test-Path $Apk)) { throw 'No APK found; run Build-Quest.ps1 first or pass -Apk' } + Write-Host "Installing $Apk" + Invoke-Adb @('install', '-r', '-g', $Apk) +} + +if (-not (Test-DevicePath "$appDir/DATA")) { + if (-not (Test-DevicePath $appDir)) { + Write-Host 'First launch so the app creates its own data directory' + Invoke-Adb @('shell', 'am', 'start', '-W', '-n', $activity) + for ($i = 0; $i -lt 30 -and -not (Test-DevicePath $appDir); ++$i) { Start-Sleep -Seconds 1 } + & $adb shell am force-stop $package + if (-not (Test-DevicePath $appDir)) { throw "The app did not create $appDir" } + } + if (-not (Test-DevicePath "$stageDir/DATA") -and $Data) { + Write-Host "Pushing $Data to the staging folder (a few minutes)" + Invoke-Adb @('shell', 'mkdir', '-p', $stageDir) + Invoke-Adb @('push', $Data, "$stageDir/DATA") + } + if (Test-DevicePath "$stageDir/DATA") { + Write-Host 'Moving staged DATA into the app directory' + Invoke-Adb @('shell', "mv '$stageDir/DATA' '$appDir/DATA'") + } else { + Write-Warning 'No DATA on the device and -Data not given; the app will show its missing-data dialog.' + } +} +if (Test-DevicePath "$appDir/DATA") { + # Idempotent; also repairs a tree placed by hand. + Invoke-Adb @('shell', "chmod -R a+rX '$appDir/DATA'") +} + +if ($NoLaunch) { return } + +$stamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$runDir = Join-Path $root ".runs\$stamp" +New-Item -ItemType Directory -Force $runDir | Out-Null + +& $adb logcat -c +Write-Host "Launching $activity" +Invoke-Adb @('shell', 'am', 'start', '-n', $activity) +Start-Sleep -Seconds $Seconds +$logcat = Join-Path $runDir 'logcat.txt' +# Windows PowerShell's '>' writes UTF-16; keep the capture greppable. +& $adb logcat -d -v time | Out-File -FilePath $logcat -Encoding utf8 +Write-Host "logcat: $logcat" + +$running = ((& $adb shell pidof $package) | Out-String).Trim() +if ($running) { Write-Host "Process still running (pid $running)" } else { Write-Warning 'Process is not running' } + +$latest = ((& $adb shell "ls -t '$appDir/Logs' 2>/dev/null | head -1") | Out-String).Trim() +if ($latest) { + $pulled = Join-Path $runDir 'runtime-log' + & $adb pull "$appDir/Logs/$latest" $pulled | Out-Null + Write-Host "runtime log: $pulled" +} + +Write-Host '--- app log (WiiCompiled / WiiCompiledQuest / SDL / crashes) ---' +Select-String -Path $logcat -Pattern '[VDIWEF]/(WiiCompiled|WiiCompiledQuest|SDL|AndroidRuntime|DEBUG|libc) *\(' | + Select-Object -Last 120 | ForEach-Object { $_.Line } diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..ddbc40d --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,124 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +// Translator output for the disc the user owns (data_sections_init.cpp, +// RuntimeConfig.h, build_shards/shards.cmake). Never checked in. +val mkwGeneratedDir = providers.gradleProperty("mkwGeneratedDir").orNull +// Optional: a directory of already-fetched dependency sources (the installer's +// BuildWorkspace/Dependencies) so the native configure does not download them. +val mkwDependenciesDir = providers.gradleProperty("mkwDependenciesDir").orNull +// Optional: a directory holding a second SDL3 AAR/prefab is not needed; the +// AAR in app/libs is produced by Prepare-QuestDependencies.ps1. +val mkwRepoRoot = rootProject.file("..").canonicalFile + +// The runtime's read-only assets travel inside the APK and are unpacked by +// QuestActivity into the app's private storage on first launch. +val runtimeResources = layout.buildDirectory.dir("generated/assets/runtimeResources") +val prepareRuntimeResources by tasks.registering(Copy::class) { + val assets = File(mkwRepoRoot, "runtime/assets") + from(File(assets, "wii")) { into("runtime_resources/wii_bootstrap") } + from(File(assets, "dsp/dsp_coef.bin")) { into("runtime_resources") } + from(File(assets, "pipeline/initial_pipeline_cache.db")) { into("runtime_resources") } + into(runtimeResources) +} + +android { + namespace = "org.wiicompiled.quest" + compileSdk = 36 + ndkVersion = "29.0.14206865" + + defaultConfig { + applicationId = "org.wiicompiled.quest" + // Quest 2 ships Android 10 (API 29); AHardwareBuffer/Vulkan 1.1 need 26+. + minSdk = 29 + targetSdk = 34 + versionCode = 1 + versionName = "0.1.0-quest" + + ndk { + abiFilters += "arm64-v8a" + } + externalNativeBuild { + cmake { + arguments += listOf( + "-DANDROID_STL=c++_shared", + "-DANDROID_PLATFORM=android-29", + "-DMKW_REPO_ROOT=${mkwRepoRoot.path.replace('\\', '/')}", + ) + if (mkwGeneratedDir != null) { + arguments += "-DMKW_GENERATED_DIR=${File(mkwGeneratedDir).canonicalPath.replace('\\', '/')}" + } + if (mkwDependenciesDir != null) { + arguments += "-DMKW_DEPENDENCIES_DIR=${File(mkwDependenciesDir).canonicalPath.replace('\\', '/')}" + } + } + } + } + + flavorDimensions += "profile" + productFlavors { + create("base") { + dimension = "profile" + buildConfigField("String", "MAIN_LIBRARY", "\"main\"") + buildConfigField("String", "PROFILE", "\"base\"") + externalNativeBuild { cmake { targets += "WiiCompiled" } } + } + create("retroRewind") { + dimension = "profile" + applicationIdSuffix = ".retrorewind" + versionNameSuffix = "-retro-rewind" + buildConfigField("String", "MAIN_LIBRARY", "\"main_retro_rewind\"") + buildConfigField("String", "PROFILE", "\"retro_rewind\"") + externalNativeBuild { cmake { targets += "RetroRewind" } } + } + } + + externalNativeBuild { + cmake { + // aurora-main needs CMake 3.25+, newer than the SDK's bundled 3.22.1; + // Build-Quest.ps1 points cmake.dir in local.properties at a system CMake. + path = file("src/main/cpp/CMakeLists.txt") + } + } + + buildFeatures { + prefab = true + buildConfig = true + } + + sourceSets.named("main") { + assets.srcDir(runtimeResources) + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + packaging { + jniLibs { + useLegacyPackaging = false + } + } + lint { + disable += setOf("ChromeOsAbiSupport", "DiscouragedApi") + } +} + +tasks.named("preBuild") { + dependsOn(prepareRuntimeResources) +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +dependencies { + // SDLActivity and libSDL3.so; the AAR is downloaded by Prepare-QuestDependencies.ps1. + implementation(files("libs/SDL3-3.4.4.aar")) +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d471f74 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..0a3aef7 --- /dev/null +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,58 @@ +cmake_minimum_required(VERSION 3.22) +project(wiicompiled_quest LANGUAGES C CXX ASM) + +# Gradle drives this file. It hands the repository's runtime/ tree to CMake +# with the Android-specific choices the desktop build never needs: +# * SDL3 comes from the AAR's prefab module (SDLActivity must load the same +# libSDL3.so the Java classes were built against); +# * the products are shared libraries (see runtime/cmake/PublicProducts.cmake); +# * the translated graph is the one the Windows installer generated, whose +# blob assembly PublicProducts.cmake rewrites for ELF. + +if(NOT ANDROID OR NOT CMAKE_ANDROID_ARCH_ABI STREQUAL "arm64-v8a") + message(FATAL_ERROR "The Quest build supports Android arm64-v8a only") +endif() +if(NOT DEFINED MKW_REPO_ROOT OR NOT EXISTS "${MKW_REPO_ROOT}/runtime/CMakeLists.txt") + message(FATAL_ERROR "MKW_REPO_ROOT must name the WiiCompiled checkout") +endif() + +# Translated PowerPC is Release-only by policy (see runtime/CMakeLists.txt); +# a debug-signed APK still carries a release-compiled game. +set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE) + +# Reuse already-fetched dependency sources when the caller has them (the +# installer's BuildWorkspace/Dependencies); FetchContent honours these +# per-dependency overrides and skips the download. +if(DEFINED MKW_DEPENDENCIES_DIR AND IS_DIRECTORY "${MKW_DEPENDENCIES_DIR}") + file(GLOB _mkw_dependency_dirs LIST_DIRECTORIES true "${MKW_DEPENDENCIES_DIR}/*") + foreach(_dir IN LISTS _mkw_dependency_dirs) + if(IS_DIRECTORY "${_dir}") + get_filename_component(_name "${_dir}" NAME) + if(NOT _name MATCHES "^(dawn_prebuilt|native_prebuilt|cppwinrt|libusb)$") + string(TOUPPER "${_name}" _upper) + set("FETCHCONTENT_SOURCE_DIR_${_upper}" "${_dir}" CACHE PATH "" FORCE) + endif() + endif() + endforeach() +endif() + +find_package(SDL3 REQUIRED CONFIG) +set(AURORA_SDL3_PROVIDER system CACHE STRING "" FORCE) +set(AURORA_SDL3_LINKAGE shared CACHE STRING "" FORCE) +set(MKW_ENABLE_OPENXR ON CACHE BOOL "" FORCE) +set(MKW_BUILD_PRODUCTS ON CACHE BOOL "" FORCE) +set(MKW_BUILD_TESTS OFF CACHE BOOL "" FORCE) +if(DEFINED MKW_GENERATED_DIR) + set(MKW_TRANSLATED_SHARD_MANIFEST "${MKW_GENERATED_DIR}/build_shards/shards.cmake" + CACHE FILEPATH "" FORCE) +endif() + +add_subdirectory("${MKW_REPO_ROOT}/runtime" wiicompiled-runtime) + +# App-owned JNI glue lives in each product library SDLActivity loads. +foreach(product IN ITEMS WiiCompiled RetroRewind) + if(TARGET ${product}) + target_sources(${product} PRIVATE "${CMAKE_CURRENT_LIST_DIR}/quest_surface_jni.cpp") + target_include_directories(${product} PRIVATE "${MKW_REPO_ROOT}/aurora-main/include") + endif() +endforeach() diff --git a/android/app/src/main/cpp/quest_surface_jni.cpp b/android/app/src/main/cpp/quest_surface_jni.cpp new file mode 100644 index 0000000..033eb47 --- /dev/null +++ b/android/app/src/main/cpp/quest_surface_jni.cpp @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// JNI side of org.wiicompiled.quest.QuestSurface. Compiled into the product +// library (libmain.so) so the natives resolve once SDLActivity has loaded it, +// which happens before the activity creates its surface. + +#include + +#include + +extern "C" JNIEXPORT void JNICALL +Java_org_wiicompiled_quest_QuestSurface_nativeBeginSurfaceMutation(JNIEnv*, jobject) { + aurora_android_begin_surface_mutation(); +} + +extern "C" JNIEXPORT void JNICALL +Java_org_wiicompiled_quest_QuestSurface_nativeEndSurfaceMutation(JNIEnv*, jobject, jboolean ready) { + aurora_android_end_surface_mutation(ready == JNI_TRUE); +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/QuestActivity.kt b/android/app/src/main/java/org/wiicompiled/quest/QuestActivity.kt new file mode 100644 index 0000000..393cf41 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/QuestActivity.kt @@ -0,0 +1,128 @@ +package org.wiicompiled.quest + +import android.app.AlertDialog +import android.content.Context +import android.os.Bundle +import android.system.Os +import android.util.Log +import java.io.File +import java.io.FileOutputStream +import org.libsdl.app.SDLActivity +import org.libsdl.app.SDLSurface + +/** + * The one activity of the standalone Quest build. + * + * SDLActivity owns the surface, the Java-side event pump and the JNI plumbing + * the OpenXR loader needs (the runtime fetches the JavaVM and this activity + * through SDL_GetAndroidJNIEnv/SDL_GetAndroidActivity). What this subclass + * adds is everything the native runtime cannot discover on its own: + * + * - the data directory the player fills with the extracted disc + * (Android/data//files/WiiCompiled/DATA) and where Config.toml, + * saves and logs live; + * - the bundled read-only runtime resources, unpacked from the APK once; + * - a Config.toml with VR enabled and the disc path filled in. + * + * The paths are handed over through the environment before SDLActivity's own + * onCreate loads the native libraries, because the runtime resolves them from + * static initialisers where SDL's JNI helpers are not yet usable. + */ +class QuestActivity : SDLActivity() { + + override fun getLibraries(): Array = arrayOf("SDL3", BuildConfig.MAIN_LIBRARY) + + override fun createSDLSurface(context: Context): SDLSurface = QuestSurface(context) + + override fun onCreate(savedInstanceState: Bundle?) { + val dataRoot = (getExternalFilesDir(null) ?: filesDir) + val gameRoot = File(dataRoot, APP_DIRECTORY) + gameRoot.mkdirs() + val resources = unpackRuntimeResources() + + Os.setenv("MKW_ANDROID_DATA_DIR", dataRoot.absolutePath, true) + Os.setenv("MKW_ANDROID_RESOURCES_DIR", resources.absolutePath, true) + ensureConfig(gameRoot) + + super.onCreate(savedInstanceState) + + if (!File(gameRoot, DISC_DIRECTORY).isDirectory && !mBrokenLibraries) { + Log.w(TAG, "No extracted disc found under ${gameRoot.absolutePath}") + AlertDialog.Builder(this) + .setTitle(getString(R.string.missing_game_data_title)) + .setMessage(getString(R.string.missing_game_data_message, File(gameRoot, DISC_DIRECTORY).absolutePath)) + .setCancelable(false) + .setPositiveButton(android.R.string.ok) { _, _ -> finish() } + .show() + } + } + + /** + * Copies runtime_resources/ from the APK assets into private storage the + * first time this build runs. A stamp file keyed on the version code keeps + * every later launch to one existence check. + */ + private fun unpackRuntimeResources(): File { + val target = File(filesDir, "runtime_resources") + val stamp = File(target, ".version") + val expected = "${BuildConfig.VERSION_CODE}:${BuildConfig.VERSION_NAME}" + if (stamp.isFile && stamp.readText() == expected) { + return target + } + target.deleteRecursively() + target.mkdirs() + copyAssetTree("runtime_resources", target) + stamp.writeText(expected) + Log.i(TAG, "Unpacked runtime resources into ${target.absolutePath}") + return target + } + + private fun copyAssetTree(assetPath: String, destination: File) { + val entries = assets.list(assetPath) ?: emptyArray() + if (entries.isEmpty()) { + // A leaf: copy the file. + assets.open(assetPath).use { input -> + FileOutputStream(destination).use { output -> input.copyTo(output) } + } + return + } + destination.mkdirs() + for (entry in entries) { + copyAssetTree("$assetPath/$entry", File(destination, entry)) + } + } + + /** + * Writes a first Config.toml pointing at the disc directory with VR on. + * An existing file is left alone: the settings overlay edits it in place. + */ + private fun ensureConfig(gameRoot: File) { + val config = File(gameRoot, "Config.toml") + if (config.isFile) { + return + } + val disc = File(gameRoot, DISC_DIRECTORY).absolutePath + config.writeText( + """ + # WiiCompiled Quest configuration. Edit with adb pull/push or the in-game overlay. + [paths] + dvd_root = "$disc" + + [video] + widescreen = true + resolution_multiplier = 1.0 + + [vr] + enabled = true + render_scale = 1.0 + """.trimIndent() + "\n", + ) + } + + companion object { + private const val TAG = "WiiCompiledQuest" + // Must match kApplicationDirectoryName in runtime/include/runtime_config.h. + private const val APP_DIRECTORY = "WiiCompiledOpenXRVR" + private const val DISC_DIRECTORY = "DATA" + } +} diff --git a/android/app/src/main/java/org/wiicompiled/quest/QuestSurface.kt b/android/app/src/main/java/org/wiicompiled/quest/QuestSurface.kt new file mode 100644 index 0000000..67fc565 --- /dev/null +++ b/android/app/src/main/java/org/wiicompiled/quest/QuestSurface.kt @@ -0,0 +1,38 @@ +package org.wiicompiled.quest + +import android.content.Context +import android.view.SurfaceHolder +import org.libsdl.app.SDLSurface + +/** + * Stock SDLSurface plus the two calls Aurora needs around every surface change. + * + * Aurora only presents while the surface is known to be usable and must never + * touch an ANativeWindow that SDL is replacing or destroying. SDL keeps its own + * activity mutex internal to libSDL3.so, so this subclass brackets SDL's + * handling instead: begin takes Aurora's surface lock and pauses presentation, + * end releases it and reports whether SDL left the surface ready. + */ +class QuestSurface(context: Context) : SDLSurface(context) { + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + nativeBeginSurfaceMutation() + try { + super.surfaceChanged(holder, format, width, height) + } finally { + nativeEndSurfaceMutation(mIsSurfaceReady) + } + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + nativeBeginSurfaceMutation() + try { + super.surfaceDestroyed(holder) + } finally { + nativeEndSurfaceMutation(false) + } + } + + private external fun nativeBeginSurfaceMutation() + private external fun nativeEndSurfaceMutation(ready: Boolean) +} diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..c5d04da --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..ba2528e --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #1A237E + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..01f2cfc --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + WiiCompiled VR + Game data not found + Copy the extracted DATA directory of your own PAL RMCP01 Mario Kart Wii disc to:\n\n%1$s\n\nFor example with adb: adb push DATA /sdcard/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/DATA\n\nThen start the app again. + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..3c450f5 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..34f518e --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.13.2" apply false + id("org.jetbrains.kotlin.android") version "2.2.21" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..5299c5f --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 +org.gradle.caching=true +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..f8e1ee3125fe0768e9a76ee977ac089eb657005e GIT binary patch literal 45633 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�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 literal 0 HcmV?d00001 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c99f974 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78 +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100644 index 0000000..adff685 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..0d3da68 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "WiiCompiledQuest" +include(":app") diff --git a/aurora-main/cmake/AuroraDawnProvider.cmake b/aurora-main/cmake/AuroraDawnProvider.cmake index 002344b..44d570c 100644 --- a/aurora-main/cmake/AuroraDawnProvider.cmake +++ b/aurora-main/cmake/AuroraDawnProvider.cmake @@ -50,6 +50,8 @@ if (_aurora_dawn_provider STREQUAL "auto") set(_has_package TRUE) elseif (APPLE AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm64|x86_64)$") set(_has_package TRUE) + elseif (CMAKE_SYSTEM_NAME STREQUAL "Android" AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(aarch64|arm64)$") + set(_has_package TRUE) endif () if (_has_package) @@ -161,6 +163,12 @@ elseif (_aurora_dawn_provider STREQUAL "package") set(AURORA_DAWN_PACKAGE_URL_HASH "SHA256=7785373d569b3b0237918ec9c523239f7d0667857c5ea8242e3cdfde95e6aeab") endif () + if (NOT AURORA_DAWN_PACKAGE_URL_HASH + AND AURORA_DAWN_VERSION STREQUAL "v20260603.191052" + AND _dawn_system STREQUAL "android" AND _dawn_arch STREQUAL "aarch64") + set(AURORA_DAWN_PACKAGE_URL_HASH + "SHA256=27d910dee1201fd1e5b6ac567f0ba2306ebf2135e9f40b6929976c365d38b09b") + endif () endif () message(STATUS "aurora: Fetching prebuilt Dawn package from ${AURORA_DAWN_PACKAGE_URL}") @@ -196,6 +204,25 @@ elseif (_aurora_dawn_provider STREQUAL "package") # Static Dawn packages may link Threads::Threads find_package(Threads QUIET) + if (CMAKE_SYSTEM_NAME STREQUAL "Android") + # The android-aarch64 package records the absolute path of the liblog.so + # its CI sysroot linked against. Rewrite that to the logical library name + # so the NDK on this machine resolves it. + foreach (_targets_file + "${_dawn_pkg_dir}/lib/cmake/Dawn/DawnTargets.cmake" + "${_dawn_pkg_dir}/lib64/cmake/Dawn/DawnTargets.cmake") + if (EXISTS "${_targets_file}") + file(READ "${_targets_file}" _dawn_targets_text) + string(REGEX REPLACE "[^;\"]*/sysroot/usr/lib/aarch64-linux-android/[0-9]+/liblog\\.so" "log" + _dawn_targets_sanitized "${_dawn_targets_text}") + if (NOT _dawn_targets_sanitized STREQUAL _dawn_targets_text) + file(WRITE "${_targets_file}" "${_dawn_targets_sanitized}") + message(STATUS "aurora: Rewrote the Dawn package's absolute liblog.so reference") + endif () + endif () + endforeach () + endif () + # Find DawnConfig.cmake in the package set(_dawn_cmake_found FALSE) foreach (_cmake_path @@ -206,7 +233,10 @@ elseif (_aurora_dawn_provider STREQUAL "package") ) if (EXISTS "${_cmake_path}/DawnConfig.cmake") set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL ON) - find_package(Dawn REQUIRED CONFIG PATHS "${_cmake_path}" NO_DEFAULT_PATH) + # NO_CMAKE_FIND_ROOT_PATH: cross toolchains (the Android NDK) root package + # discovery inside their sysroot; the extracted package is a verified host + # path this one lookup must be allowed to see. + find_package(Dawn REQUIRED CONFIG PATHS "${_cmake_path}" NO_DEFAULT_PATH NO_CMAKE_FIND_ROOT_PATH) set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL OFF) set(_dawn_cmake_found TRUE) break() diff --git a/aurora-main/cmake/aurora_core.cmake b/aurora-main/cmake/aurora_core.cmake index eb5e90a..3811afc 100644 --- a/aurora-main/cmake/aurora_core.cmake +++ b/aurora-main/cmake/aurora_core.cmake @@ -40,6 +40,10 @@ if (AURORA_ENABLE_GX) if (CMAKE_SYSTEM_NAME STREQUAL Windows) target_sources(aurora_core PRIVATE lib/webgpu/d3d12_interop.cpp) endif () + # Android/Vulkan counterpart: the AHardwareBuffer stereo bridge. The file + # compiles to C ABI stubs on every other platform so the runtime's OpenXR + # integration links everywhere. + target_sources(aurora_core PRIVATE lib/webgpu/vulkan_interop.cpp) target_link_libraries(aurora_core PRIVATE dawn::webgpu_dawn) if (DAWN_ENABLE_VULKAN) target_compile_definitions(aurora_core PRIVATE DAWN_ENABLE_BACKEND_VULKAN) diff --git a/aurora-main/include/aurora/android.h b/aurora-main/include/aurora/android.h new file mode 100644 index 0000000..c3f6025 --- /dev/null +++ b/aurora-main/include/aurora/android.h @@ -0,0 +1,30 @@ +#ifndef AURORA_ANDROID_H +#define AURORA_ANDROID_H + +#ifdef __cplusplus +extern "C" { +#else +#include "stdbool.h" +#endif + +/* + * Android surface lifecycle bridge. + * + * Aurora only presents while the Android surface is known to be usable, and + * must never touch an ANativeWindow the Java side is tearing down. Stock SDL3 + * keeps its own activity mutex internal to libSDL3.so, so the host app brackets + * every surface change instead: an SDLSurface subclass calls begin before + * delegating surfaceChanged/surfaceDestroyed to SDL and end afterwards, with + * `ready` saying whether SDL left the surface usable. Between the two calls + * Aurora's surface lock is held and presentation is paused. + * + * Call from the Java UI thread only. The pair must always balance. + */ +void aurora_android_begin_surface_mutation(void); +void aurora_android_end_surface_mutation(bool ready); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/aurora-main/include/aurora/vulkan_interop.h b/aurora-main/include/aurora/vulkan_interop.h new file mode 100644 index 0000000..ac2e34b --- /dev/null +++ b/aurora-main/include/aurora/vulkan_interop.h @@ -0,0 +1,129 @@ +#ifndef AURORA_VULKAN_INTEROP_H +#define AURORA_VULKAN_INTEROP_H + +#ifdef __cplusplus +#include +extern "C" { +#else +#include "stdbool.h" +#include "stdint.h" +#endif + +/** + * Android/Vulkan counterpart of aurora/d3d12_interop.h. + * + * Dawn's Vulkan device cannot be bound to an OpenXR session (the pinned Dawn + * package exposes no VkDevice/VkQueue and would not enable the runtime's + * required extensions anyway), so the OpenXR side owns a second VkDevice + * created through XR_KHR_vulkan_enable2. The two devices meet on + * AHardwareBuffer-backed images: Aurora imports each buffer as Dawn shared + * texture memory and copies an eye into it inside the frame worker's command + * buffer; the OpenXR side imports the same buffer on its own device and copies + * it into the acquired XrSwapchain image. Ordering across the two devices uses + * Android sync file descriptors (Dawn's SharedFenceSyncFD). + * + * Every handle in this API is a plain C value so the runtime never includes + * Dawn's C++ headers. + */ + +enum { AURORA_VULKAN_STEREO_MAX_TARGETS = 2 }; + +/** + * Borrowed facts about Aurora's Dawn Vulkan device. colorVkFormat is the + * VkFormat enum value matching Aurora's single-sample eye output. + */ +typedef struct { + int64_t colorVkFormat; + bool sharedTextureMemoryAHardwareBuffer; + bool sharedFenceSyncFd; +} AuroraVulkanNativeHandles; + +/** + * One AHardwareBuffer the next Aurora stereo sink must copy an eye into. The + * buffer is imported into Dawn on first use and the import is cached for as + * long as the bridge lives, so callers should recycle a small ring of buffers + * rather than allocating per frame. + * + * acquireFenceFd is a sync file descriptor Dawn waits on before writing (the + * OpenXR side's previous copy out of this buffer), or -1 when the buffer has no + * pending reader. Ownership of the descriptor transfers to Aurora on a + * successful aurora_vulkan_set_stereo_targets call; on failure the caller + * still owns it. + * + * acquireImageLayout is the VkImageLayout the buffer's image currently holds + * (VK_IMAGE_LAYOUT_UNDEFINED when the contents may be discarded). It must + * equal the layout the OpenXR side's release barrier left the image in. + */ +typedef struct { + struct AHardwareBuffer* buffer; + uint32_t width; + uint32_t height; + int64_t vkFormat; + int acquireFenceFd; + int32_t acquireImageLayout; +} AuroraVulkanStereoTarget; + +/** + * Per-target result handed to the submitted callback. releaseFenceFd is a + * sync file descriptor that signals once Aurora's copy into the buffer has + * completed on Dawn's queue (-1 if Dawn reported no fence); ownership passes to + * the callee. releasedImageLayout is the VkImageLayout Dawn's release barrier + * left the image in; the OpenXR side's acquire barrier must start from it. + */ +typedef struct { + int releaseFenceFd; + int32_t releasedImageLayout; +} AuroraVulkanStereoRelease; + +/** + * Fired when Aurora either finishes or abandons the stereo sink. `success` + * guarantees that the copies were submitted and that every release entry is + * valid. The callback runs on Aurora's frame worker while its queue-submit + * mutex is held: it may record and submit work on the OpenXR side's own Vulkan + * queue, but must not wait for the GPU or re-enter Aurora. + */ +typedef void (*AuroraVulkanStereoSubmittedCallback)(uint64_t frameToken, bool success, + const AuroraVulkanStereoRelease* releases, + uint32_t releaseCount, void* userdata); + +/** Returns false unless the active Aurora backend is Dawn Vulkan. */ +bool aurora_vulkan_get_native_handles(AuroraVulkanNativeHandles* handles); + +/** + * Installs the internal AHardwareBuffer stereo sink. Call while Aurora's frame + * worker is idle, after aurora_initialize(). Fails when the Dawn device was not + * created with the AHardwareBuffer shared-memory and sync-fd fence features. + */ +bool aurora_vulkan_enable_stereo_bridge(AuroraVulkanStereoSubmittedCallback submitted, + void* userdata); + +/** + * Publishes the buffer(s) for frameToken. Immersive projection frames supply + * two targets; virtual-screen quad frames supply one. Exactly one frame may be + * pending at a time. + */ +bool aurora_vulkan_set_stereo_targets(uint64_t frameToken, + const AuroraVulkanStereoTarget* targets, + uint32_t targetCount); + +/** + * Withdraws frameToken only while its targets have not been encoded. Semantics + * match aurora_d3d12_cancel_stereo_targets: false means the worker already + * owns encoded work and the submitted callback remains the completion + * authority. A successful cancellation closes the acquire descriptors it was + * given and fires no callback. + */ +bool aurora_vulkan_cancel_stereo_targets(uint64_t frameToken); + +/** + * Removes the sink and releases the cached Dawn imports. The worker must be + * idle. Returns false only when Dawn could not be drained, in which case the + * bridge is retained for the process lifetime. + */ +bool aurora_vulkan_disable_stereo_bridge(); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/aurora-main/lib/android_debug.hpp b/aurora-main/lib/android_debug.hpp new file mode 100644 index 0000000..87ae21c --- /dev/null +++ b/aurora-main/lib/android_debug.hpp @@ -0,0 +1,24 @@ +#pragma once + +// Android-only diagnostic switches read from system properties, so an on-device +// experiment can change renderer behaviour with `adb shell setprop` and an app +// restart instead of a rebuild. debug.* properties are writable by the adb shell +// and readable by apps. Values are read once per process by each caller. + +#if defined(__ANDROID__) +#include + +#include + +namespace aurora::android_debug { + +inline int property_int(const char* name, int fallback) noexcept { + char value[PROP_VALUE_MAX]{}; + if (__system_property_get(name, value) <= 0) { + return fallback; + } + return std::atoi(value); +} + +} // namespace aurora::android_debug +#endif diff --git a/aurora-main/lib/gx/command_processor.cpp b/aurora-main/lib/gx/command_processor.cpp index 7a28be0..7fb3096 100644 --- a/aurora-main/lib/gx/command_processor.cpp +++ b/aurora-main/lib/gx/command_processor.cpp @@ -1830,6 +1830,41 @@ static void handle_draw_overrun(u8 cmd, u16 vtxCount, u32 vtxSize, u32 totalVtxB Log.warn("stopping FIFO decode at truncated draw: need {} bytes at pos {}, have {}", totalVtxBytes, pos, size); } +// Uploads a draw's GX vertices with the stride populate_pipeline_config gave the shader. When that stride is padded +// (Android, see padded_upload_stride), each vertex is copied with zeroed trailing bytes; attribute offsets inside the +// vertex are unchanged. Every padded upload is a multiple of 4 bytes, so consecutive draws stay contiguous for merging. +static gfx::Range push_draw_vertices(const u8* vertices, u32 vtxCount, u32 vtxSize) { + const u32 uploadStride = padded_upload_stride(vtxSize); + if (uploadStride == vtxSize) + LIKELY { return gfx::push_verts(vertices, static_cast(vtxCount) * vtxSize); } + auto [buffer, range] = gfx::map_verts(static_cast(vtxCount) * uploadStride); + u8* dst = buffer.data(); + const u32 padding = uploadStride - vtxSize; + for (u32 i = 0; i < vtxCount; ++i) { + std::memcpy(dst, vertices + static_cast(i) * vtxSize, vtxSize); + std::memset(dst + vtxSize, 0, padding); + dst += uploadStride; + } + return range; +} + +// Uploads an indexed vertex array with elements `uploadStride` bytes apart (see padded_upload_stride). A padded upload +// copies each element and zeroes its trailing bytes; a trailing partial element is copied as far as the array goes. +static gfx::Range push_vertex_array(const AttrArray& array, u32 uploadStride) { + const auto* data = static_cast(array.data); + if (uploadStride == array.stride || array.stride == 0) + LIKELY { return gfx::push_storage(data, array.size); } + const size_t count = (static_cast(array.size) + array.stride - 1) / array.stride; + auto [buffer, range] = gfx::map_storage(count * uploadStride); + u8* dst = buffer.data(); + std::memset(dst, 0, count * uploadStride); + for (size_t i = 0; i < count; ++i) { + const size_t start = i * array.stride; + std::memcpy(dst + i * uploadStride, data + start, std::min(array.stride, array.size - start)); + } + return range; +} + // Draw command handler - parses vertices inline and caches results static u32 calculate_last_vtx_size(GXVtxFmt fmt) { u32 vtxSize = 0; @@ -2248,7 +2283,7 @@ bool submit_raw_draw(GXPrimitive prim, GXVtxFmt fmt, const uint8_t* vertices, ui // This entry point bypasses process(), so it owns the renderer lock itself. std::lock_guard gpuLock(aurora::renderer_gpu_mutex()); - const gfx::Range vertRange = gfx::push_verts(vertices, vertexBytes); + const gfx::Range vertRange = push_draw_vertices(vertices, vtxCount, vtxSize); const bool interpolationIdentityActive = frame_interpolation_identity_needed(); const PnMtxUsage matrixUsage = interpolationIdentityActive ? pn_mtx_usage(vertices, vtxCount, vtxSize) : PnMtxUsage{}; handle_draw_unmerged(prim, fmt, vtxCount, vertRange, matrixUsage.mask, matrixUsage.topologySignature, @@ -2283,7 +2318,7 @@ static bool handle_draw(u8 cmd, const u8* data, u32& pos, u32 size, bool bigEndi // Push raw vertex data to buffer const uint8_t* vertices = data + pos; - gfx::Range vertRange = gfx::push_verts(vertices, totalVtxBytes); + gfx::Range vertRange = push_draw_vertices(vertices, vtxCount, vtxSize); pos += totalVtxBytes; // Try to merge with previous draw call @@ -2350,12 +2385,14 @@ static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, g continue; } auto& array = g_gxState.arrays[i]; - if (array.cachedRange.size > 0) { + const u32 uploadStride = padded_upload_stride(array.stride); + if (array.cachedRange.size > 0 && array.cachedStride == uploadStride) { ranges.vaRanges[i - GX_VA_POS] = array.cachedRange; } else { - const auto range = gfx::push_storage(static_cast(array.data), array.size); + const auto range = push_vertex_array(array, uploadStride); ranges.vaRanges[i - GX_VA_POS] = range; array.cachedRange = range; + array.cachedStride = uploadStride; } } diff --git a/aurora-main/lib/gx/gx.cpp b/aurora-main/lib/gx/gx.cpp index 15952a6..b58e540 100644 --- a/aurora-main/lib/gx/gx.cpp +++ b/aurora-main/lib/gx/gx.cpp @@ -11,6 +11,9 @@ #include "../gfx/texture_convert.hpp" #include "../gfx/texture_replacement.hpp" #include "gx_fmt.hpp" +#if defined(__ANDROID__) +#include "../android_debug.hpp" +#endif #include #include @@ -1753,6 +1756,28 @@ static u8 index_attr_size(GXAttr attr, GXCompCnt cnt, GXAttrType type) noexcept return indexSize; } +// The Quest 3's Adreno 740 Vulkan driver decodes the wrong bytes when the vertex shader's +// `ubuf.vtx_start + vidx * stride + offset` uses a stride that is not a multiple of 4. GX packs vertices byte-tight, +// so every skinned character (a 1-byte PNMTXIDX first, stride 7) exploded and textured menu panels smeared. Rewriting +// the WGSL byte helpers with constant shifts or integer division did not help; padding each uploaded vertex to a +// 4-byte stride did, with attribute offsets inside the vertex unchanged (device-tested 2026-09-16). Indexed array +// reads (`array_start + index * stride`, e.g. 6-byte S16 normals) get the same padding. KartPad reports the same +// character corruption on Adreno 750. `adb shell setprop debug.wiicompiled.vtxpad 0` turns it off. +u32 padded_upload_stride(u32 packedStride) noexcept { +#if defined(__ANDROID__) + static const bool pad = [] { + const bool enabled = android_debug::property_int("debug.wiicompiled.vtxpad", 1) != 0; + Log.info("Vertex stride padding for Adreno: {}", enabled ? "on" : "off"); + return enabled; + }(); + const u32 padded = (packedStride + 3u) & ~3u; + // ShaderConfig::vtxStride is a u8. + return pad && padded <= 255u ? padded : packedStride; +#else + return packedStride; +#endif +} + void populate_pipeline_config(PipelineConfig& config, GXPrimitive primitive, GXVtxFmt fmt) noexcept { ZoneScoped; @@ -1790,12 +1815,12 @@ void populate_pipeline_config(PipelineConfig& config, GXPrimitive primitive, GXV break; } case GX_INDEX8: - mapping.stride = g_gxState.arrays[i].stride; + mapping.stride = static_cast(padded_upload_stride(g_gxState.arrays[i].stride)); mapping.le = g_gxState.arrays[i].le; vtxOffset += index_attr_size(attr, attrFmt.cnt, type); break; case GX_INDEX16: - mapping.stride = g_gxState.arrays[i].stride; + mapping.stride = static_cast(padded_upload_stride(g_gxState.arrays[i].stride)); mapping.le = g_gxState.arrays[i].le; vtxOffset += index_attr_size(attr, attrFmt.cnt, type); break; @@ -1803,7 +1828,7 @@ void populate_pipeline_config(PipelineConfig& config, GXPrimitive primitive, GXV Log.fatal("populate_pipeline_config: Invalid vertex type {}", type); } } - config.shaderConfig.vtxStride = vtxOffset; + config.shaderConfig.vtxStride = static_cast(padded_upload_stride(vtxOffset)); if (primitive == GX_LINES) { config.shaderConfig.lineMode = 1; } else if (primitive == GX_LINESTRIP) { diff --git a/aurora-main/lib/gx/gx.hpp b/aurora-main/lib/gx/gx.hpp index bbf6b07..778c23e 100644 --- a/aurora-main/lib/gx/gx.hpp +++ b/aurora-main/lib/gx/gx.hpp @@ -285,6 +285,8 @@ struct AttrArray { u8 stride; bool le = true; gfx::Range cachedRange; + // Element stride of the cached upload, which differs from `stride` when the upload is padded. + u32 cachedStride = 0; }; inline bool operator==(const AttrArray& lhs, const AttrArray& rhs) { return lhs.data == rhs.data && lhs.size == rhs.size && lhs.stride == rhs.stride && lhs.le == rhs.le; @@ -771,4 +773,8 @@ void notify_copy_texture_created() noexcept; u8 comp_type_size(GXAttr attr, GXCompType type) noexcept; u8 comp_cnt_count(GXAttr attr, GXCompCnt cnt) noexcept; +// Bytes per uploaded vertex or vertex-array element, and so the stride the shader multiplies an index by, for GX +// data packed at `packedStride` bytes. Equal to `packedStride` except on Android, which pads to a multiple of 4 +// (see gx.cpp). +u32 padded_upload_stride(u32 packedStride) noexcept; } // namespace aurora::gx diff --git a/aurora-main/lib/webgpu/gpu.cpp b/aurora-main/lib/webgpu/gpu.cpp index 06c9ec7..1e98e59 100644 --- a/aurora-main/lib/webgpu/gpu.cpp +++ b/aurora-main/lib/webgpu/gpu.cpp @@ -17,6 +17,7 @@ #include #include +#include "../android_debug.hpp" #include "../gfx/common.hpp" #include "../internal.hpp" #include "../window.hpp" @@ -170,6 +171,19 @@ wgpu::TextureFormat best_surface_format() { if (g_surfaceCapabilities.formatCount == 0) { return wgpu::TextureFormat::Undefined; } +#if defined(__ANDROID__) + // The OpenXR bridge shares eyes through AHardwareBuffers, and Android has no + // BGRA AHardwareBuffer format, so an XR interop build must render RGBA8 to + // keep the eye copy legal. Only the preference order changes; a surface + // without RGBA8 still falls through to the generic choice below. + if (g_config.xrInterop) { + for (size_t i = 0; i < g_surfaceCapabilities.formatCount; ++i) { + if (to_linear(g_surfaceCapabilities.formats[i]) == wgpu::TextureFormat::RGBA8Unorm) { + return wgpu::TextureFormat::RGBA8Unorm; + } + } + } +#endif for (size_t i = 0; i < g_surfaceCapabilities.formatCount; ++i) { const auto format = to_linear(g_surfaceCapabilities.formats[i]); if (format == wgpu::TextureFormat::RGBA8Unorm || format == wgpu::TextureFormat::BGRA8Unorm) { @@ -552,7 +566,7 @@ bool initialize(AuroraBackend auroraBackend) { }); if (g_config.xrInterop) { instanceDescriptor.nextInChain = &instanceToggles; - Log.info("Enabling Dawn unsafe APIs for OpenXR D3D12 resource interop"); + Log.info("Enabling Dawn unsafe APIs for OpenXR resource interop"); } #endif #if defined(WEBGPU_DAWN) && !defined(__MINGW32__) @@ -700,6 +714,15 @@ bool initialize(AuroraBackend auroraBackend) { feature == wgpu::FeatureName::SharedFenceDXGISharedHandle)) { requiredFeatures.push_back(feature); } +#endif +#if defined(WEBGPU_DAWN) && defined(__ANDROID__) + // The OpenXR side shares eyes through AHardwareBuffers ordered by sync + // file descriptors (lib/webgpu/vulkan_interop.cpp). + if (g_config.xrInterop && g_backendType == wgpu::BackendType::Vulkan && + (feature == wgpu::FeatureName::SharedTextureMemoryAHardwareBuffer || + feature == wgpu::FeatureName::SharedFenceSyncFD)) { + requiredFeatures.push_back(feature); + } #endif } if (!implicitDeviceSynchronizationSupported) { @@ -731,8 +754,17 @@ bool initialize(AuroraBackend auroraBackend) { /* clang-format on */ }; #ifdef NDEBUG - enableToggles.push_back("skip_validation"); - enableToggles.push_back("disable_robustness"); +#if defined(__ANDROID__) + // `adb shell setprop debug.wiicompiled.validation 1` keeps WebGPU validation + // and robustness on for a diagnostic run. + const bool keepValidation = android_debug::property_int("debug.wiicompiled.validation", 0) == 1; + Log.info("Android WebGPU validation: {}", keepValidation ? "on" : "off"); + if (!keepValidation) +#endif + { + enableToggles.push_back("skip_validation"); + enableToggles.push_back("disable_robustness"); + } #endif if (g_backendType == wgpu::BackendType::Vulkan) { enableToggles.push_back("vulkan_monolithic_pipeline_cache"); diff --git a/aurora-main/lib/webgpu/vulkan_interop.cpp b/aurora-main/lib/webgpu/vulkan_interop.cpp new file mode 100644 index 0000000..e642cb4 --- /dev/null +++ b/aurora-main/lib/webgpu/vulkan_interop.cpp @@ -0,0 +1,620 @@ +#include + +#include "../internal.hpp" +#include "../stereo.hpp" +#include "gpu.hpp" + +#if defined(__ANDROID__) && defined(WEBGPU_DAWN) + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace aurora::vulkan_interop { +namespace { + +Module Log("aurora::vulkan_interop"); + +int64_t to_vk_format(wgpu::TextureFormat format) noexcept { + switch (format) { + case wgpu::TextureFormat::RGBA8Unorm: + return VK_FORMAT_R8G8B8A8_UNORM; + case wgpu::TextureFormat::RGBA8UnormSrgb: + return VK_FORMAT_R8G8B8A8_SRGB; + case wgpu::TextureFormat::BGRA8Unorm: + return VK_FORMAT_B8G8R8A8_UNORM; + case wgpu::TextureFormat::BGRA8UnormSrgb: + return VK_FORMAT_B8G8R8A8_SRGB; + case wgpu::TextureFormat::RGBA16Float: + return VK_FORMAT_R16G16B16A16_SFLOAT; + default: + return VK_FORMAT_UNDEFINED; + } +} + +// vkCmdCopyImage and WebGPU CopyTextureToTexture both require the two formats +// to be the same modulo sRGB encoding, so the bridge only accepts a target +// whose VkFormat sits in the same family as Aurora's eye output. +int copy_family(int64_t format) noexcept { + switch (format) { + case VK_FORMAT_R8G8B8A8_UNORM: + case VK_FORMAT_R8G8B8A8_SRGB: + return 1; + case VK_FORMAT_B8G8R8A8_UNORM: + case VK_FORMAT_B8G8R8A8_SRGB: + return 2; + case VK_FORMAT_R16G16B16A16_SFLOAT: + return 3; + default: + return 0; + } +} + +bool same_copy_family(int64_t left, int64_t right) noexcept { + const int family = copy_family(left); + return family != 0 && family == copy_family(right); +} + +void close_fd(int& fd) noexcept { + if (fd >= 0) { + ::close(fd); + } + fd = -1; +} + +// A sync file descriptor becomes readable once its fence signals, so a plain +// poll() is a CPU-side wait that needs no libsync. +void wait_sync_fd(int fd) noexcept { + if (fd < 0) { + return; + } + pollfd request{.fd = fd, .events = POLLIN, .revents = 0}; + while (::poll(&request, 1, 1000) == 0) { + Log.warn("Waiting on a Dawn release fence took over a second"); + } +} + +bool device_supports_bridge() noexcept { + return webgpu::g_device && webgpu::g_backendType == wgpu::BackendType::Vulkan && + webgpu::g_device.HasFeature(wgpu::FeatureName::SharedTextureMemoryAHardwareBuffer) && + webgpu::g_device.HasFeature(wgpu::FeatureName::SharedFenceSyncFD); +} + +// One AHardwareBuffer imported into Dawn. The import is created on first use +// and kept until the bridge is disabled; the ring the OpenXR side recycles is +// tiny (two buffers per eye), so this never grows beyond a handful of entries. +struct Import { + AHardwareBuffer* buffer = nullptr; + wgpu::SharedTextureMemory memory; + wgpu::Texture texture; + wgpu::TextureFormat format = wgpu::TextureFormat::Undefined; + uint32_t width = 0; + uint32_t height = 0; + bool initialized = false; + bool accessBegun = false; +}; + +struct PendingTarget { + AHardwareBuffer* buffer = nullptr; + uint32_t width = 0; + uint32_t height = 0; + int64_t vkFormat = VK_FORMAT_UNDEFINED; + int acquireFenceFd = -1; + int32_t acquireImageLayout = VK_IMAGE_LAYOUT_UNDEFINED; +}; + +class StereoBridge final { +public: + StereoBridge(AuroraVulkanStereoSubmittedCallback callback, void* userdata) noexcept + : m_callback(callback), m_userdata(userdata) {} + + ~StereoBridge() { ReleaseImportsLocked(); } + + bool Initialize() noexcept { + if (!device_supports_bridge()) { + Log.error("Dawn Vulkan device lacks SharedTextureMemoryAHardwareBuffer or SharedFenceSyncFD"); + return false; + } + m_auroraFormat = webgpu::g_graphicsConfig.surfaceConfiguration.format; + return to_vk_format(m_auroraFormat) != VK_FORMAT_UNDEFINED; + } + + bool PrepareForDestruction() noexcept { + std::lock_guard lock(m_mutex); + // Dawn's queue is drained by aurora_quiesce_frame_worker() before this is + // reached; all that can be outstanding here is a begun access whose + // EndAccess never ran because the frame was abandoned. + for (auto& [buffer, import] : m_imports) { + if (import.accessBegun) { + wgpu::SharedTextureMemoryEndAccessState end{}; + import.memory.EndAccess(import.texture, &end); + import.accessBegun = false; + } + } + ReleaseImportsLocked(); + return true; + } + + bool SetTargets(uint64_t token, const AuroraVulkanStereoTarget* targets, + uint32_t targetCount) noexcept { + if (token == 0 || targets == nullptr || targetCount == 0 || + targetCount > AURORA_VULKAN_STEREO_MAX_TARGETS) { + return false; + } + std::lock_guard lock(m_mutex); + if (m_framePending || m_encoded) { + return false; + } + const int64_t auroraFormat = to_vk_format(m_auroraFormat); + for (uint32_t eye = 0; eye < targetCount; ++eye) { + const auto& target = targets[eye]; + if (target.buffer == nullptr || target.width == 0 || target.height == 0 || + !same_copy_family(target.vkFormat, auroraFormat)) { + return false; + } + } + for (uint32_t eye = 0; eye < targetCount; ++eye) { + m_targets[eye] = { + .buffer = targets[eye].buffer, + .width = targets[eye].width, + .height = targets[eye].height, + .vkFormat = targets[eye].vkFormat, + .acquireFenceFd = targets[eye].acquireFenceFd, + .acquireImageLayout = targets[eye].acquireImageLayout, + }; + } + for (uint32_t eye = targetCount; eye < m_targets.size(); ++eye) { + m_targets[eye] = {}; + } + m_frameToken = token; + m_targetCount = targetCount; + m_framePending = true; + return true; + } + + bool Encode(wgpu::CommandEncoder& encoder, const stereo::SinkFrame& frame) noexcept { + std::lock_guard lock(m_mutex); + if (!m_framePending || m_encoded || frame.frameToken != m_frameToken) { + return false; + } + if (EncodeLocked(encoder, frame)) { + m_encoded = true; + return true; + } + PublishAndClearFrameLocked(frame.frameToken, false); + return false; + } + + void Submitted(const stereo::SinkFrame& frame) noexcept { + std::lock_guard lock(m_mutex); + if (!m_framePending || !m_encoded || frame.frameToken != m_frameToken) { + return; + } + std::array releases{}; + const bool success = EndAccessLocked(releases); + NotifyLocked(frame.frameToken, success, releases); + ClearFrameLocked(); + } + + void CancelPending() noexcept { + std::lock_guard lock(m_mutex); + if (!m_framePending) { + return; + } + const uint64_t token = m_frameToken; + std::array releases{}; + if (m_encoded) { + EndAccessLocked(releases); + for (auto& release : releases) { + close_fd(release.releaseFenceFd); + } + } + PublishAndClearFrameLocked(token, false); + } + + bool CancelBeforeEncode(uint64_t token) noexcept { + std::unique_lock lock(m_mutex, std::try_to_lock); + if (!lock.owns_lock()) { + return false; + } + if (token == 0 || !m_framePending || m_encoded || token != m_frameToken) { + return false; + } + ClearFrameLocked(); + return true; + } + +private: + Import* EnsureImport(uint32_t eye, const stereo::EyeImage& source) noexcept { + const auto& target = m_targets[eye]; + if (source.texture == nullptr || source.format != m_auroraFormat || + source.size.width != target.width || source.size.height != target.height) { + Log.error("Stereo eye {} does not match its OpenXR Vulkan target ({}x{} vs {}x{})", eye, + source.size.width, source.size.height, target.width, target.height); + return nullptr; + } + if (auto found = m_imports.find(target.buffer); found != m_imports.end()) { + auto& import = found->second; + if (import.width == target.width && import.height == target.height && + import.format == source.format) { + return &import; + } + Log.error("AHardwareBuffer for eye {} was re-used with a different geometry", eye); + return nullptr; + } + + Import import; + import.buffer = target.buffer; + AHardwareBuffer_acquire(import.buffer); + + wgpu::SharedTextureMemoryAHardwareBufferDescriptor ahb{}; + ahb.handle = target.buffer; + const wgpu::SharedTextureMemoryDescriptor memoryDescriptor{ + .nextInChain = &ahb, + .label = eye == 0 ? "OpenXR left eye AHardwareBuffer" : "OpenXR right eye AHardwareBuffer", + }; + import.memory = webgpu::g_device.ImportSharedTextureMemory(&memoryDescriptor); + if (!import.memory) { + Log.error("Dawn rejected the AHardwareBuffer import for eye {}", eye); + AHardwareBuffer_release(import.buffer); + return nullptr; + } + wgpu::SharedTextureMemoryProperties properties{}; + if (import.memory.GetProperties(&properties) != wgpu::Status::Success || + properties.size.width != target.width || properties.size.height != target.height || + (properties.usage & wgpu::TextureUsage::CopyDst) == wgpu::TextureUsage::None) { + Log.error("Dawn reported incompatible AHardwareBuffer properties for eye {}", eye); + AHardwareBuffer_release(import.buffer); + return nullptr; + } + if (properties.format != source.format) { + // The OpenXR side allocates R8G8B8A8_UNORM buffers because that is the only + // 8-bit RGBA AHardwareBuffer format; gpu.cpp steers Aurora to RGBA8Unorm + // under xrInterop so this mismatch only happens on a driver with no such + // surface format, where a copy could never be legal anyway. + Log.error("AHardwareBuffer format {} does not match Aurora's {} for eye {}", + magic_enum::enum_name(properties.format), magic_enum::enum_name(source.format), eye); + AHardwareBuffer_release(import.buffer); + return nullptr; + } + const wgpu::TextureDescriptor textureDescriptor{ + .label = eye == 0 ? "OpenXR left eye shared texture" : "OpenXR right eye shared texture", + .usage = wgpu::TextureUsage::CopyDst, + .dimension = wgpu::TextureDimension::e2D, + .size = {target.width, target.height, 1}, + .format = source.format, + .mipLevelCount = 1, + .sampleCount = 1, + }; + import.texture = import.memory.CreateTexture(&textureDescriptor); + if (!import.texture) { + Log.error("Dawn could not wrap the AHardwareBuffer for eye {}", eye); + AHardwareBuffer_release(import.buffer); + return nullptr; + } + import.format = source.format; + import.width = target.width; + import.height = target.height; + const auto [inserted, ok] = m_imports.emplace(target.buffer, std::move(import)); + return ok ? &inserted->second : nullptr; + } + + bool EncodeLocked(wgpu::CommandEncoder& encoder, const stereo::SinkFrame& frame) noexcept { + std::array imports{}; + for (uint32_t eye = 0; eye < m_targetCount; ++eye) { + imports[eye] = EnsureImport(eye, frame.eyes[eye]); + if (imports[eye] == nullptr) { + return false; + } + } + for (uint32_t eye = 0; eye < m_targetCount; ++eye) { + auto& target = m_targets[eye]; + auto& import = *imports[eye]; + if (import.accessBegun) { + Log.error("AHardwareBuffer for eye {} is still under a previous access", eye); + RollbackAccesses(imports, eye); + return false; + } + // The OpenXR side's release barrier leaves the image in acquireImageLayout; + // Dawn's acquire barrier must repeat exactly that old/new pair, then it + // transitions to its own copy-destination layout. A never-written buffer + // has undefined contents and layout, which Dawn treats as uninitialized. + const bool haveContents = import.initialized && + target.acquireImageLayout != VK_IMAGE_LAYOUT_UNDEFINED; + wgpu::SharedTextureMemoryVkImageLayoutBeginState layout{}; + layout.oldLayout = haveContents ? target.acquireImageLayout : VK_IMAGE_LAYOUT_UNDEFINED; + layout.newLayout = haveContents ? target.acquireImageLayout : VK_IMAGE_LAYOUT_GENERAL; + wgpu::SharedFence acquireFence; + if (target.acquireFenceFd >= 0) { + wgpu::SharedFenceSyncFDDescriptor syncFd{}; + syncFd.handle = target.acquireFenceFd; + const wgpu::SharedFenceDescriptor fenceDescriptor{ + .nextInChain = &syncFd, + .label = "OpenXR eye copy-out fence", + }; + // Dawn duplicates the descriptor; the bridge still owns and closes its copy. + acquireFence = webgpu::g_device.ImportSharedFence(&fenceDescriptor); + close_fd(target.acquireFenceFd); + if (!acquireFence) { + Log.error("Dawn could not import the OpenXR copy-out fence for eye {}", eye); + RollbackAccesses(imports, eye); + return false; + } + } + const std::array fences{acquireFence}; + // A sync file descriptor is binary; Dawn requires signaled value 1 for + // SyncFD fences ("signaled value (0) was not 1" otherwise). + const std::array values{1}; + wgpu::SharedTextureMemoryBeginAccessDescriptor begin{}; + begin.nextInChain = &layout; + begin.concurrentRead = false; + begin.initialized = haveContents; + if (acquireFence) { + begin.fenceCount = 1; + begin.fences = fences.data(); + begin.signaledValueCount = 1; + begin.signaledValues = values.data(); + } + if (import.memory.BeginAccess(import.texture, &begin) != wgpu::Status::Success) { + Log.error("Dawn BeginAccess failed for stereo eye {}", eye); + RollbackAccesses(imports, eye); + return false; + } + import.accessBegun = true; + } + for (uint32_t eye = 0; eye < m_targetCount; ++eye) { + const auto& import = *imports[eye]; + const wgpu::TexelCopyTextureInfo source{ + .texture = *frame.eyes[eye].texture, + .mipLevel = 0, + .origin = {}, + .aspect = wgpu::TextureAspect::All, + }; + const wgpu::TexelCopyTextureInfo destination{ + .texture = import.texture, + .mipLevel = 0, + .origin = {}, + .aspect = wgpu::TextureAspect::All, + }; + const wgpu::Extent3D extent{import.width, import.height, 1}; + encoder.CopyTextureToTexture(&source, &destination, &extent); + m_encodedImports[eye] = imports[eye]; + } + return true; + } + + void RollbackAccesses(const std::array& imports, + uint32_t count) noexcept { + for (uint32_t eye = 0; eye < count; ++eye) { + if (imports[eye] != nullptr && imports[eye]->accessBegun) { + wgpu::SharedTextureMemoryEndAccessState end{}; + imports[eye]->memory.EndAccess(imports[eye]->texture, &end); + imports[eye]->initialized = end.initialized; + imports[eye]->accessBegun = false; + } + } + for (auto& target : m_targets) { + close_fd(target.acquireFenceFd); + } + } + + bool EndAccessLocked( + std::array& releases) noexcept { + bool success = true; + for (auto& release : releases) { + release = {.releaseFenceFd = -1, .releasedImageLayout = VK_IMAGE_LAYOUT_UNDEFINED}; + } + for (uint32_t eye = 0; eye < m_targetCount; ++eye) { + Import* import = m_encodedImports[eye]; + if (import == nullptr || !import->accessBegun) { + success = false; + continue; + } + wgpu::SharedTextureMemoryVkImageLayoutEndState layout{}; + wgpu::SharedTextureMemoryEndAccessState end{}; + end.nextInChain = &layout; + if (import->memory.EndAccess(import->texture, &end) != wgpu::Status::Success) { + Log.error("Dawn EndAccess failed for stereo eye {}", eye); + success = false; + } else { + import->initialized = end.initialized; + releases[eye].releasedImageLayout = layout.newLayout; + for (size_t i = 0; i < end.fenceCount; ++i) { + wgpu::SharedFenceSyncFDExportInfo syncFd{}; + wgpu::SharedFenceExportInfo info{}; + info.nextInChain = &syncFd; + end.fences[i].ExportInfo(&info); + if (info.type == wgpu::SharedFenceType::SyncFD && syncFd.handle >= 0) { + // The fence keeps its descriptor; hand the caller an independent one. + const int duplicate = ::dup(syncFd.handle); + if (duplicate >= 0) { + if (releases[eye].releaseFenceFd >= 0) { + // Dawn normally returns exactly one fence per access. Both must + // be honoured and one descriptor cannot express two fences, so + // the earlier one is retired on the CPU before handing over the + // latest. + Log.warn("Dawn returned several release fences for eye {}; merging on the CPU", eye); + wait_sync_fd(releases[eye].releaseFenceFd); + close_fd(releases[eye].releaseFenceFd); + } + releases[eye].releaseFenceFd = duplicate; + } + } else { + Log.error("Dawn returned a non-sync-fd fence for eye {}", eye); + success = false; + } + } + } + import->accessBegun = false; + } + return success; + } + + void ReleaseImportsLocked() noexcept { + for (auto& [buffer, import] : m_imports) { + import.texture = nullptr; + import.memory = nullptr; + if (import.buffer != nullptr) { + AHardwareBuffer_release(import.buffer); + } + } + m_imports.clear(); + } + + void ClearFrameLocked() noexcept { + for (auto& target : m_targets) { + close_fd(target.acquireFenceFd); + target = {}; + } + m_encodedImports = {}; + m_frameToken = 0; + m_targetCount = 0; + m_framePending = false; + m_encoded = false; + } + + void PublishAndClearFrameLocked(uint64_t token, bool success) noexcept { + std::array releases{}; + for (auto& release : releases) { + release = {.releaseFenceFd = -1, .releasedImageLayout = VK_IMAGE_LAYOUT_UNDEFINED}; + } + NotifyLocked(token, success, releases); + ClearFrameLocked(); + } + + void NotifyLocked(uint64_t token, bool success, + const std::array& + releases) noexcept { + if (m_callback != nullptr) { + m_callback(token, success, releases.data(), m_targetCount, m_userdata); + } else { + for (auto release : releases) { + close_fd(release.releaseFenceFd); + } + } + } + + std::mutex m_mutex; + std::unordered_map m_imports; + std::array m_targets{}; + std::array m_encodedImports{}; + wgpu::TextureFormat m_auroraFormat = wgpu::TextureFormat::Undefined; + AuroraVulkanStereoSubmittedCallback m_callback = nullptr; + void* m_userdata = nullptr; + uint64_t m_frameToken = 0; + uint32_t m_targetCount = 0; + bool m_framePending = false; + bool m_encoded = false; +}; + +std::unique_ptr g_bridge; + +bool sink_encode(wgpu::CommandEncoder& encoder, const stereo::SinkFrame& frame, + void* userdata) noexcept { + return static_cast(userdata)->Encode(encoder, frame); +} + +void sink_submitted(const stereo::SinkFrame& frame, void* userdata) noexcept { + static_cast(userdata)->Submitted(frame); +} + +} // namespace +} // namespace aurora::vulkan_interop + +bool aurora_vulkan_get_native_handles(AuroraVulkanNativeHandles* handles) { + if (handles == nullptr) { + return false; + } + *handles = {}; + using namespace aurora::vulkan_interop; + if (!aurora::webgpu::g_device || aurora::webgpu::g_backendType != wgpu::BackendType::Vulkan) { + return false; + } + const int64_t format = to_vk_format(aurora::webgpu::g_graphicsConfig.surfaceConfiguration.format); + if (format == VK_FORMAT_UNDEFINED) { + return false; + } + *handles = { + .colorVkFormat = format, + .sharedTextureMemoryAHardwareBuffer = + aurora::webgpu::g_device.HasFeature(wgpu::FeatureName::SharedTextureMemoryAHardwareBuffer), + .sharedFenceSyncFd = aurora::webgpu::g_device.HasFeature(wgpu::FeatureName::SharedFenceSyncFD), + }; + return true; +} + +bool aurora_vulkan_enable_stereo_bridge(AuroraVulkanStereoSubmittedCallback submitted, + void* userdata) { + using namespace aurora::vulkan_interop; + if (g_bridge || submitted == nullptr) { + return false; + } + auto bridge = std::make_unique(submitted, userdata); + if (!bridge->Initialize()) { + return false; + } + aurora::stereo::set_sink(sink_encode, sink_submitted, bridge.get()); + g_bridge = std::move(bridge); + return true; +} + +bool aurora_vulkan_set_stereo_targets(uint64_t frameToken, + const AuroraVulkanStereoTarget* targets, + uint32_t targetCount) { + using namespace aurora::vulkan_interop; + return g_bridge && g_bridge->SetTargets(frameToken, targets, targetCount); +} + +bool aurora_vulkan_cancel_stereo_targets(uint64_t frameToken) { + using namespace aurora::vulkan_interop; + return g_bridge && g_bridge->CancelBeforeEncode(frameToken); +} + +bool aurora_vulkan_disable_stereo_bridge() { + using namespace aurora::vulkan_interop; + if (!g_bridge) { + return true; + } + aurora::stereo::set_sink(nullptr, nullptr, nullptr); + g_bridge->CancelPending(); + if (!g_bridge->PrepareForDestruction()) { + (void)g_bridge.release(); + return false; + } + g_bridge.reset(); + return true; +} + +#else + +bool aurora_vulkan_get_native_handles(AuroraVulkanNativeHandles* handles) { + if (handles != nullptr) { + *handles = {}; + } + return false; +} + +bool aurora_vulkan_enable_stereo_bridge(AuroraVulkanStereoSubmittedCallback, void*) { + return false; +} + +bool aurora_vulkan_set_stereo_targets(uint64_t, const AuroraVulkanStereoTarget*, uint32_t) { + return false; +} + +bool aurora_vulkan_cancel_stereo_targets(uint64_t) { return false; } + +bool aurora_vulkan_disable_stereo_bridge() { return true; } + +#endif diff --git a/aurora-main/lib/window.cpp b/aurora-main/lib/window.cpp index 86649c9..aafcd1d 100644 --- a/aurora-main/lib/window.cpp +++ b/aurora-main/lib/window.cpp @@ -7,6 +7,7 @@ #include "input.hpp" #include "internal.hpp" +#include #include #include #include @@ -28,12 +29,11 @@ #if defined(SDL_PLATFORM_ANDROID) #include -extern "C" void Android_LockActivityMutex(void); -extern "C" void Android_UnlockActivityMutex(void); #endif #include #include +#include #include #include "dolphin/vi/vi_internal.hpp" @@ -55,6 +55,10 @@ std::atomic_bool g_backgrounded = false; std::atomic_bool g_nativeResizePending = false; std::atomic g_displayMode{AURORA_DISPLAY_MODE_WINDOWED}; #if defined(SDL_PLATFORM_ANDROID) +// SDL's own activity mutex (Android_LockActivityMutex) is internal and not +// exported from libSDL3.so, so SurfaceLock owns an equivalent one. Recursive +// because a surface-recreation path can re-enter through refresh_surface(). +std::recursive_mutex g_surfaceMutex; std::atomic_bool g_surfaceReady = false; #else std::atomic_bool g_surfaceReady = true; @@ -582,10 +586,19 @@ bool is_presentable() noexcept { } void pump_events() noexcept { +#if defined(SDL_PLATFORM_ANDROID) + // Guest fibers run on libco stacks inside the SDL thread. SDL's Android pump + // reaches Java (joystick polling, HIDAPI), and ART binds JNI transitions to + // the thread's real stack, so pumping here from a fiber can crash the VM. + // Events are pumped only by the host through aurora_update(), which the + // runtime calls on the scheduler's own stack (KartPad found this on device). + return; +#else if (g_window != nullptr) { SDL_SyncWindow(g_window); } SDL_PumpEvents(); +#endif } bool native_resize_pending() noexcept { return g_nativeResizePending.load(std::memory_order_acquire); } @@ -605,15 +618,29 @@ bool native_window_size_matches(uint32_t width, uint32_t height) noexcept { void set_surface_ready(bool ready) noexcept { g_surfaceReady.store(ready, std::memory_order_release); } +#if defined(SDL_PLATFORM_ANDROID) +// Held across the Java side's surface change so no present can race an +// ANativeWindow SDL is destroying or replacing (see aurora/android.h). +void begin_surface_mutation() noexcept { + g_surfaceMutex.lock(); + set_surface_ready(false); +} + +void end_surface_mutation(bool ready) noexcept { + set_surface_ready(ready); + g_surfaceMutex.unlock(); +} +#endif + SurfaceLock::SurfaceLock() noexcept { #if defined(SDL_PLATFORM_ANDROID) - Android_LockActivityMutex(); + g_surfaceMutex.lock(); #endif } SurfaceLock::~SurfaceLock() { #if defined(SDL_PLATFORM_ANDROID) - Android_UnlockActivityMutex(); + g_surfaceMutex.unlock(); #endif } @@ -815,4 +842,12 @@ extern "C" JNIEXPORT void JNICALL Java_org_libsdl_app_SDLSurface_auroraNativeSet jboolean ready) { aurora::window::set_surface_ready(ready == JNI_TRUE); } + +void aurora_android_begin_surface_mutation(void) { + aurora::window::begin_surface_mutation(); +} + +void aurora_android_end_surface_mutation(bool ready) { + aurora::window::end_surface_mutation(ready); +} #endif diff --git a/aurora-main/lib/window.hpp b/aurora-main/lib/window.hpp index 22d249f..8293abb 100644 --- a/aurora-main/lib/window.hpp +++ b/aurora-main/lib/window.hpp @@ -44,6 +44,10 @@ void pump_events() noexcept; bool native_resize_pending() noexcept; bool native_window_size_matches(uint32_t width, uint32_t height) noexcept; void set_surface_ready(bool ready) noexcept; +#if defined(__ANDROID__) +void begin_surface_mutation() noexcept; +void end_surface_mutation(bool ready) noexcept; +#endif void set_title(const char* title); void set_fullscreen(bool fullscreen); bool get_fullscreen(); diff --git a/aurora-main/tests/gx_test_stubs.cpp b/aurora-main/tests/gx_test_stubs.cpp index 6a1fa2f..2da7beb 100644 --- a/aurora-main/tests/gx_test_stubs.cpp +++ b/aurora-main/tests/gx_test_stubs.cpp @@ -194,6 +194,8 @@ void populate_pipeline_config(PipelineConfig& config, GXPrimitive primitive, GXV GXBindGroups build_bind_groups(const ShaderInfo& info) noexcept { return {}; } void resolve_sampled_textures(const ShaderInfo& info) noexcept {} u8 color_channel(GXChannelID id) noexcept { return 0; } +// Desktop behaviour: vertices upload with their packed GX stride. +u32 padded_upload_stride(u32 packedStride) noexcept { return packedStride; } u8 comp_type_size(GXAttr attr, GXCompType type) noexcept { if (!s_useRealVertexFormatHelpers) { return 0; @@ -282,6 +284,15 @@ Range push_verts(const uint8_t* data, size_t length) { s_lastPushedVertices.assign(data, data + length); return {}; } +std::pair map_verts(size_t length) { + s_lastPushedVertices.assign(length, 0); + return {ByteBuffer{s_lastPushedVertices.data(), s_lastPushedVertices.size()}, Range{}}; +} +std::pair map_storage(size_t length) { + static std::vector storage; + storage.assign(length, 0); + return {ByteBuffer{storage.data(), storage.size()}, Range{}}; +} Range push_indices(const uint8_t* data, size_t length) { CHECK(length % sizeof(uint16_t) == 0, "unaligned test index upload"); s_lastPushedIndices.resize(length / sizeof(uint16_t)); diff --git a/docs/quest-port.md b/docs/quest-port.md new file mode 100644 index 0000000..16f34f9 --- /dev/null +++ b/docs/quest-port.md @@ -0,0 +1,301 @@ +# WiiCompiled VR on Meta Quest (standalone Android) + +This document is the design and build reference for the native Quest build. It +complements `OPENXR.md`, which remains the specification for the presentation +policy, the virtual screen, the first-person camera and frame interpolation: +all of that is shared, unchanged, between the Windows D3D12 product and the +Quest Vulkan product. What differs is everything below the stereo replay: the +graphics binding, the app shell and the platform glue. + +## Sources of the design + +- **KartPad** (`kartpad-main/`, the `kartpad-android` runtime branch of the + WiiCompiled fork) proved that the translated game runs on Android arm64 with + Aurora on Dawn/Vulkan under SDL3's `SDLActivity`. Its lessons carried over: + the products are shared libraries SDL loads, the activity exports its + directories through the environment before native code runs, the + Windows-generated blob assembly needs its section syntax rewritten for ELF, + and Dawn's android-aarch64 prebuilt package needs one path rewritten. + KartPad's Android fiber/JNI split (never calling Java-backed SDL APIs from a + guest fiber stack) is respected here by keeping every OpenXR call on the + dedicated pacing thread, which is a real `std::thread`. +- **DolphinXR** (`Dolphin-OpenXR-2/`, quest flavour) supplied the Quest-side + specifics: the loader must be initialised with the *activity* as its + context or the session never leaves `IDLE`; `XrInstanceCreateInfoAndroidKHR` + must be chained on instance creation; the manifest needs the Khronos runtime + broker queries, the `OPENXR_SYSTEM` permission, the `com.oculus.intent.category.VR` + intent category and the `XR_ACTIVITY_START_MODE_FULL_SPACE_UNMANAGED` + property; `XR_KHR_android_thread_settings` may reject the renderer-worker + type on some runtime builds. + +## Architecture + +### One runtime, two graphics bindings + +`runtime/src/vr/openxr_integration.cpp` owns the pacing thread, policy +evaluation, the retained-layer protocol and the head-pose maths. It is written +against the backend-neutral vocabulary in `runtime/include/vr/openxr_backend.h` +(`OpenXRPresentation`, `OpenXRBackendFrame`, `OpenXRBeginStatus`, +`OpenXRSubmissionStatus`) and selects one backend class at compile time: + +| Platform | Backend | Binding | +| --- | --- | --- | +| Windows | `OpenXRD3D12Backend` (`openxr_d3d12.cpp`) | Dawn's own D3D12 device and queue are bound to the session; eyes are copied on that queue. | +| Android | `OpenXRVulkanBackend` (`openxr_vulkan.cpp`) | The backend creates its **own** `VkInstance`/`VkDevice` through the OpenXR runtime; Dawn and that device meet on `AHardwareBuffer`s. | + +The D3D12 types keep their old names through aliases, so `openxr_d3d12_replay_tests` +and the desktop code did not change. + +### Why a second Vulkan device + +The pinned Dawn package (`v20260603.191052`) exposes only `VkInstance` from its +Vulkan backend, no `VkDevice`, `VkQueue` or queue family, and it will not enable +the device extensions the OpenXR runtime demands. Binding Dawn's device to the +session is therefore impossible without a patched Dawn. Instead: + +1. `xrCreateVulkanInstanceKHR` / `xrCreateVulkanDeviceKHR` (`XR_KHR_vulkan_enable2`, + with an `XR_KHR_vulkan_enable` fallback that queries the extension lists) + create a small Vulkan device the runtime is happy with. +2. Per eye, two `AHardwareBuffer`s (R8G8B8A8_UNORM, or RGBA16F when Aurora + renders float) are allocated and imported on that device + (`VK_ANDROID_external_memory_android_hardware_buffer`). +3. Aurora imports the same buffers as Dawn shared texture memory + (`SharedTextureMemoryAHardwareBuffer`) and, inside the frame worker's + command buffer, copies each replayed eye into the buffer + (`aurora-main/lib/webgpu/vulkan_interop.cpp`, the twin of + `d3d12_interop.cpp` and registered through the same stereo sink). +4. Ordering across the two devices uses Android sync file descriptors: + Dawn's `EndAccess` exports a `SharedFenceSyncFD` the OpenXR device waits on + before its `vkCmdCopyImage` into the acquired swapchain image, and the copy + signals an exportable semaphore whose sync fd Dawn waits on before it + writes that buffer again. Image layouts follow Vulkan's rule that a queue + family ownership release and acquire must repeat the same old/new layout + pair: Dawn reports its release layout, the OpenXR side acquires with it, + transitions for the copy, and hands the buffer back in `GENERAL` with a + transition-free release so Dawn's acquire can mirror it. +5. The copy runs on the queue bound to the session before + `xrReleaseSwapchainImage`, so the compositor sees ordinary same-queue work. + +The cost is one extra GPU copy per eye per frame, a few hundred microseconds +at Quest eye resolutions; the benefit is that stock Dawn is used unchanged +and the OpenXR device outlives Aurora's, which is exactly the failure DolphinXR +hit on Vulkan when a game's device was destroyed under the compositor. + +`gpu.cpp` steers Aurora to an RGBA8 surface format under `xrInterop` on Android +(there is no BGRA `AHardwareBuffer` format) and requests the two Dawn features +the bridge needs. + +### Controllers + +Quest Touch controllers are not HID gamepads, so `openxr_input.cpp` syncs an +OpenXR action set on the pacing thread and feeds a virtual SDL joystick +(`SDL_AttachVirtualJoystick`, type gamepad). Aurora opens it like any pad and +assigns it to player 1; every existing binding, dead zone and overlay setting +applies. Mapping: A/B → South/East, X/Y → West/North, index triggers → trigger +axes, grips → shoulders, thumbsticks → sticks (clicks → stick buttons), left +menu → Start. Bindings are suggested for `oculus/touch_controller` and +`khr/simple_controller`. + +### Android platform glue + +- `runtime/src/vr/openxr_android.cpp`: `xrInitializeLoaderKHR` with the + JavaVM and activity SDL already holds, the `XrInstanceCreateInfoAndroidKHR` + chain (`OpenXRConfig::instance_create_next`), and the optional thread hint. +- `runtime/src/platform/host_platform.cpp` / `runtime_config.h`: the activity + exports `MKW_ANDROID_DATA_DIR` (external files dir, user reachable) and + `MKW_ANDROID_RESOURCES_DIR` (unpacked `wii_bootstrap/`, `dsp_coef.bin`, + `initial_pipeline_cache.db`); the latter stands in for the executable + directory so the existing adjacent-file lookups work unchanged. +- `main.cpp` includes `SDL_main.h` on Android so `SDLActivity` finds + `SDL_main` in `libmain.so`, and passes the resources path to Aurora. +- Fibers use the vendored libco AArch64 backend (Bionic is Linux), guest memory + uses the Linux `mmap` path, the MPRIS media monitor is compiled out. +- **Surface readiness.** Aurora presents only while `g_surfaceReady` is set, and + on Android that flag starts false. Stock SDL3 exports neither its activity + mutex (`Android_LockActivityMutex`) nor a readiness hook, so the app's + `QuestSurface` subclass brackets SDL's `surfaceChanged`/`surfaceDestroyed` + with `aurora_android_begin/end_surface_mutation` (`aurora/android.h`), and + Aurora's `SurfaceLock` owns its own recursive mutex. This is KartPad's design. +- **JNI only on the real thread stack.** Guest threads run on libco stacks + inside the SDL thread, and SDL's Android event pump can reach Java (joystick + polling, HIDAPI). ART binds JNI transitions to the thread's real stack, so + Aurora's `pump_events` is a no-op on Android and `UpdateAuroraAndProcessEvents` + defers a poll made from a guest fiber until control is back on the scheduler + context (`GuestFiberManager::IsOnSchedulerFiber`). The default guest thread + shares the scheduler context, so most polls run immediately. Also KartPad's + finding, from device crashes. +- Time conversion for frame interpolation uses `XR_KHR_convert_timespec_time` + (CLOCK_MONOTONIC, the clock behind `steady_clock` on Bionic). + +### Build system + +- `runtime/CMakeLists.txt` recognises `CMAKE_SYSTEM_NAME=Android` on arm64 as + `MKW_PLATFORM_ANDROID`: OpenXR on by default, Dawn from the pinned + android-aarch64 package (digest pinned in `AuroraDawnProvider.cmake`, which + also rewrites the package's absolute `liblog.so` path and looks the package + up with `NO_CMAKE_FIND_ROOT_PATH` so the NDK sysroot rule does not hide it), + SDL3 built shared (or `-DAURORA_SDL3_PROVIDER=system` for the AAR prefab), + tests off, products built as `libmain.so` / `libmain_retro_rewind.so`, + `-mcpu=cortex-a77` (Quest 2's XR2 Gen 1; Quest 3/Pro are supersets). +- `runtime/cmake/PublicProducts.cmake` gains `MKW_GENERATED_DIR` so a build + configured from a checkout can name the translator output tree, and on + Android rewrites the PE/COFF `.section .rdata,"dr"` of a Windows-generated + blob `.S` into ELF `.rodata` plus a GNU-stack note. The translator itself + also learned `--target-os windows|macos|linux|android` for + `generate-data-init` and `translate-mod`, for pipelines that generate on + another host. +- `android/`: the Gradle project. `app/src/main/cpp/CMakeLists.txt` adds the + repository's `runtime/` as a subdirectory with those Android choices; + flavours `base` and `retroRewind` pick the product target and library name. + +## Building + +Prerequisites on the Windows host (all already present on the machine this +was developed on): JDK 17, Android SDK with platform 34+, NDK `29.0.14206865`, +SDK CMake `3.22.1`, `adb`; a translated graph for your own disc (the installer's +`BuildWorkspace/generated`, produced by the normal Windows pipeline). + +```powershell +powershell -ExecutionPolicy Bypass -File android/Prepare-QuestDependencies.ps1 # SDL3 3.4.4 AAR into android/app/libs +powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Install # base game, debug-signed +powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Flavor retroRewind # Retro Rewind (needs translate-mod output) +adb push DATA /sdcard/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/DATA +``` + +`Config.toml`, saves and per-run logs live next to `DATA` under +`WiiCompiledOpenXRVR`; the activity writes a first `Config.toml` with +`[vr] enabled = true` and `paths.dvd_root` set. Logs: `adb logcat -s SDL WiiCompiledQuest` +plus the `Logs/__pid/console.log` folder the runtime writes. + +A CMake-only cross-compile of the native runtime (no game) is the quick +compile check and needs no Gradle: + +```powershell +cmake -S runtime -B .scratch/android-audit-build -G Ninja ` + -DCMAKE_TOOLCHAIN_FILE=$env:LOCALAPPDATA/Android/Sdk/ndk/29.0.14206865/build/cmake/android.toolchain.cmake ` + -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-29 -DANDROID_STL=c++_shared ` + -DCMAKE_BUILD_TYPE=Release -DMKW_BUILD_PRODUCTS=OFF +cmake --build .scratch/android-audit-build --target mkw_android_native_compile aurora_core aurora_gx +``` + +## Validation status + +What has been verified on the development machine (September 2026): + +- Translator: `dotnet test` passes with the new `--target-os` tests (17/17 in + the touched suites). +- Windows: `mkw_openxr_replay_tests` and `mkw_vr_policy_tests` pass; the + `mkw_runtime_common` and `aurora_core` targets compile with the refactored + integration; the workspace product rebuild links `WiiCompiled.exe`. +- Android: the cross-compile audit passes. `mkw_android_native_compile`, + `aurora_core` and `aurora_gx` all build for `aarch64-none-linux-android29` + with NDK 29.0.14206865, which covers the whole native runtime including + `openxr_vulkan.cpp`, `openxr_android.cpp`, `openxr_input.cpp` and the Aurora + AHardwareBuffer bridge. Three Bionic portability fixes came out of it: the + `std::min` call in `hle/audio/audio.cpp` needed an explicit type (`int64_t` + is `long` on LP64 Android while the clock rep is `long long`), + `guest_flat_memory.cpp` needs a `__NR_memfd_create` shim below API 30, and + Crypto++'s `cpu.cpp` needs the NDK's `cpu-features` source compiled in. +- **Device, 2026-09-16: running on a Quest 3** (HorizonOS 14, API 34). The + runtime negotiates `XR_KHR_vulkan_enable2` (Vulkan 1.0 to 1.2), creates + 1680x1760 `R8G8B8A8_SRGB` (VkFormat 43) swapchains, attaches the controller + actions, and the session reaches `FOCUSED`. The game boots through the title + movies into the attract race, the policy switches to `immersive-race` with + all 189 perspective draws replayed per eye, the first projection layer is + submitted, and the menus return to the virtual screen. No WebGPU or OpenXR + errors over a 90 second session. + +Bring-up fixes that only a device could reveal: + +| Symptom | Cause | Fix | +| --- | --- | --- | +| Activity crashed with `EACCES` on `Config.toml` | `adb shell mkdir` had created the app's data directory, so the shell user owned it | Let the app create its own directory; `Run-Quest.ps1` launches once before placing DATA | +| DATA unreadable by the game | adb-placed files stay owned by the shell user and directories are `2770` | `chmod -R a+rX DATA` as the owning shell user; `run-as` cannot reach shared storage (SELinux) | +| "Cannot persist NAND setting.txt" | FUSE storage has no hard links; `link()` fails with `EACCES`, not `EPERM` | Android falls back to exists-check plus `rename` in `nand_settings.h` | +| `SharedFence ... signaled value (0) was not 1` | A sync fd is binary; Dawn expects value 1 | `vulkan_interop.cpp` passes 1 | +| Link error on `Android_LockActivityMutex` | SDL's activity mutex is not exported | Aurora-owned mutex plus the `QuestSurface` bracket (above) | +| Crypto++ `cpu-features.h` not found | The NDK ships cpu-features as source | Compiled into `mkw_cryptopp` on Android | +| Exploded racers and menu characters; smeared movie panels in the menus; then, once those were fixed, damaged eyes and slightly misplaced detail on characters | The Adreno 740 driver reads the wrong bytes when the shader multiplies an index by a stride that is not a multiple of 4. That covers the vertex fetch (`ubuf.vtx_start + vidx * stride + offset`) and indexed array reads (`array_start + index * stride`, e.g. 6-byte S16 normals). GX packs both byte-tight, so skinned models (a 1-byte `PNMTXIDX` first, stride 7) broke everywhere | Android pads every uploaded vertex and every indexed-array element to a 4-byte stride (`padded_upload_stride` in `lib/gx/gx.cpp`). Offsets inside a vertex or element are unchanged, and desktop is unchanged. **Fixed, headset-verified 2026-09-16** at character select and a Grand Prix start | + +How the explosion was isolated, so the next Adreno rendering bug starts further +ahead: + +- The CPU side was identical to Windows: a per-draw audit of palette indices + and matrices matched byte for byte. Menus reach the headset as the mono + desktop image, so stereo replay was not involved either. +- Shader-side rewrites did **not** help and were removed: constant-index palette + matrix selection, shift-free sign extension, replacing `extractBits`, and + byte helpers rewritten with constant shifts or integer division. The last two + made menus worse, which is what pointed away from any one helper. +- Moving every attribute to a 4-byte boundary on the CPU fixed the explosion. + Padding only the stride, with offsets still packed, fixed it just as well, + which narrows the fault to the `vidx * stride` term. Mario's eyes stayed + wrong under both, until indexed arrays got the same element padding. That + combined padding is the shipped fix. It costs one copy per vertex and per + array element. Peak uploads at a 12-racer race start were about 570 KB of + the 3 MB vertex buffer and 710 KB of the 8 MB storage buffer. +- KartPad's Android reports of corrupted drivers on Adreno 750 match this + symptom. That is plausible but not tested. + +Diagnostics that stay in the build, all read once at launch from system +properties. Set them with `adb shell setprop ` before starting +the app: + +| Property | Effect | +| --- | --- | +| `debug.wiicompiled.vtxpad 0` | Turns the stride padding off, to re-check a driver update | +| `debug.wiicompiled.validation 1` | Keeps WebGPU validation and robustness on in release builds | +| `debug.wiicompiled.inject :