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 0000000..f8e1ee3
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
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 :` | Presses `a`, `b`, `x`, `y`, `start`, `up`, `down`, `left` or `right` for 12 XR frames each time `` changes |
+
+The injector makes headset tests possible with nobody wearing the headset.
+Keep the display awake, drive the menus, then take a compositor screenshot:
+
+```powershell
+adb shell am broadcast -a com.oculus.vrpowermanager.prox_close
+adb shell setprop debug.wiicompiled.inject 1:a # title -> licence; bump the number per press
+adb shell am startservice -n com.oculus.metacam/.capture.CaptureService -a TAKE_SCREENSHOT
+adb pull /sdcard/Oculus/Screenshots/.jpg
+```
+
+From a cold start, five `a` presses about 5 s apart, starting once the title
+screen is up, reach Grand Prix character select. A value left over from an
+earlier run is ignored on the first read. Presses only land while the XR
+session is `FOCUSED`.
+
+Open measurement: the attract race advanced 603 game frames in about 14 s,
+roughly 43 FPS against the game's 60, with an optimized build (`-O3`,
+translated code `-O2`, `-mcpu=cortex-a77`). Profiling on the XR2 Gen 2 is the
+first performance task.
+
+Verified on device since: the menus on the virtual screen, controller input
+(the user has driven races), and an immersive Grand Prix start with all 12
+racers rendering correctly. Not yet verified: stereo comfort and scale,
+lifecycle (Quest menu, guardian, sleep), and a full race to the finish.
+When diagnosing a new device, the session log should show, in order: the loader log line
+(`OpenXR Android loader initialized`), the requirements line (which binding
+extension was negotiated), `OpenXR Vulkan swapchains ready`, the session
+state reaching `FOCUSED`, `presentation=virtual-screen` for the menus, and
+`first immersive packet consumed` on race entry. A black headset with a working
+Android mirror points at the AHardwareBuffer copy (check for `vkImportSemaphoreFdKHR`
+or `EndAccess` errors); a black mirror too points at Aurora itself.
+
+## Known gaps and next steps
+
+- **Device bring-up.** Run on a Quest 3, capture logcat, fix what the runtime
+ rejects. Likely first candidates: the exact `XR_KHR_vulkan_enable2` device
+ extension negotiation, Dawn's begin/end layout reporting for AHardwareBuffer
+ imports, and swapchain format choice (`R8G8B8A8_SRGB` is expected).
+- **Performance.** The desktop product targets x86-64-v3; nothing has been
+ profiled on the XR2. Expect shader compilation stalls on first run (Aurora's
+ pipeline cache is bundled) and start with `render_scale` below 1.0 if the
+ compositor reports missed frames. `XR_FB_foveation` is not used yet.
+- **Lifecycle.** Backgrounding (the Quest menu, guardian) pauses the session
+ through the ordinary `STOPPING`/`READY` events; SDL's Android surface loss is
+ handled by Aurora's existing Android paths. Neither has been exercised.
+- **Input.** D-pad (trick inputs) is not bound; remap in `Config.toml` or bind
+ the thumbstick directions in a follow-up. Haptics are wired but nothing calls
+ them yet.
+- **Retro Rewind on device** needs the mod's translation and its extracted
+ content pushed next to `DATA`, exactly like the desktop product.
+- **Release signing and store packaging** are out of scope; `Build-Quest.ps1`
+ produces debug-signed APKs for sideloading.
diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt
index 8735e13..ee6d43c 100644
--- a/runtime/CMakeLists.txt
+++ b/runtime/CMakeLists.txt
@@ -13,9 +13,15 @@ elseif(APPLE AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm64|ARM64)$")
set(MKW_PLATFORM_MACOS TRUE)
elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64|aarch64|arm64|ARM64)$")
set(MKW_PLATFORM_LINUX TRUE)
+elseif(CMAKE_SYSTEM_NAME STREQUAL "Android" AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(aarch64|arm64|ARM64)$")
+ # Android arm64 through the NDK toolchain file (Meta Quest standalone).
+ # Bionic is Linux underneath, so the libco fibers, the mmap guest memory
+ # and the POSIX networking all follow the Linux branch; only paths, the
+ # SDL entry point and the OpenXR/Vulkan binding are Android-specific.
+ set(MKW_PLATFORM_ANDROID TRUE)
else()
message(FATAL_ERROR
- "WiiCompiled supports 64-bit LLVM-MinGW Clang on Windows, native Linux x86_64/aarch64, or Apple Clang on macOS arm64")
+ "WiiCompiled supports 64-bit LLVM-MinGW Clang on Windows, native Linux x86_64/aarch64, Apple Clang on macOS arm64, or the Android NDK on arm64-v8a")
endif()
if(NOT CMAKE_BUILD_TYPE STREQUAL "Release")
message(FATAL_ERROR "WiiCompiled only supports Release builds")
@@ -66,7 +72,7 @@ set_target_properties(mkw_pugixml PROPERTIES UNITY_BUILD OFF)
# Vendored from upstream (higan-emu/libco @ e18e09d, 2019-10-16, ISC license; valgrind.h is
# separately BSD-style licensed, see third_party/libco/LICENSE). Windows keeps native Fibers
# and macOS uses the project's x18-safe AArch64 assembly backend, so this target is Linux-only.
-if(MKW_PLATFORM_LINUX)
+if(MKW_PLATFORM_LINUX OR MKW_PLATFORM_ANDROID)
add_library(mkw_libco STATIC third_party/libco/libco.c)
add_library(mkw::libco ALIAS mkw_libco)
target_include_directories(mkw_libco PUBLIC third_party/libco)
@@ -98,9 +104,24 @@ if(NOT MKW_NATIVE_PREBUILT_DIR)
"/(adhoc|bench[123]|datatest|dlltest|fipsalgt|fipstest|pch|regtest[1234]|simple|test|validat[0-9]+)\\.cpp$")
list(FILTER MKW_CRYPTOPP_SOURCES EXCLUDE REGEX
"(_avx|_ppc|_simd|_sse)\\.cpp$")
+ if(MKW_PLATFORM_ANDROID)
+ # cpu.cpp reads the ARM feature bits through the NDK's cpu-features
+ # library, which the NDK ships as source rather than a prebuilt (see the
+ # comment above its #include "cpu-features.h"). Compiling it here is the
+ # arrangement upstream's own setenv-android.sh sets up.
+ set(MKW_ANDROID_CPU_FEATURES_DIR "${CMAKE_ANDROID_NDK}/sources/android/cpufeatures")
+ if(NOT EXISTS "${MKW_ANDROID_CPU_FEATURES_DIR}/cpu-features.c")
+ message(FATAL_ERROR
+ "Crypto++ needs the NDK cpu-features source: ${MKW_ANDROID_CPU_FEATURES_DIR}")
+ endif()
+ list(APPEND MKW_CRYPTOPP_SOURCES "${MKW_ANDROID_CPU_FEATURES_DIR}/cpu-features.c")
+ endif()
add_library(mkw_cryptopp STATIC ${MKW_CRYPTOPP_SOURCES})
add_library(mkw::cryptopp ALIAS mkw_cryptopp)
target_include_directories(mkw_cryptopp SYSTEM PUBLIC third_party)
+ if(MKW_PLATFORM_ANDROID)
+ target_include_directories(mkw_cryptopp SYSTEM PRIVATE "${MKW_ANDROID_CPU_FEATURES_DIR}")
+ endif()
target_compile_features(mkw_cryptopp PUBLIC cxx_std_17)
target_compile_definitions(mkw_cryptopp PUBLIC ${MKW_CRYPTOPP_INTERFACE_DEFINITIONS})
target_compile_options(mkw_cryptopp PRIVATE -w)
@@ -117,7 +138,7 @@ set(MKW_CPPWINRT_INCLUDE_DIR "" CACHE PATH "Optional self-contained C++/WinRT in
# available to explicit developer builds without making a nonfunctional SDK
# fetch part of every normal build.
set(MKW_ENABLE_OPENXR_DEFAULT OFF)
-if(MKW_PLATFORM_WINDOWS)
+if(MKW_PLATFORM_WINDOWS OR MKW_PLATFORM_ANDROID)
set(MKW_ENABLE_OPENXR_DEFAULT ON)
endif()
option(MKW_ENABLE_OPENXR "Build OpenXR VR support" ${MKW_ENABLE_OPENXR_DEFAULT})
@@ -149,6 +170,18 @@ else()
if(NOT EXISTS ${MKW_AURORA_DIR}/CMakeLists.txt)
message(FATAL_ERROR "Requested aurora-main but ${MKW_AURORA_DIR} is missing")
endif()
+ if(MKW_PLATFORM_ANDROID)
+ # encounter/dawn-build publishes the same release for android-aarch64;
+ # the provider pins its digest and maps the asset name. SDL3 is built
+ # from source as a shared library because SDLActivity loads libSDL3.so
+ # before libmain.so; a Gradle build may instead pass
+ # -DAURORA_SDL3_PROVIDER=system to use the prefab from the SDL3 AAR.
+ set(AURORA_DAWN_PROVIDER package CACHE STRING "" FORCE)
+ set(AURORA_SDL3_LINKAGE shared CACHE STRING "" FORCE)
+ if(NOT AURORA_SDL3_PROVIDER OR AURORA_SDL3_PROVIDER STREQUAL "auto")
+ set(AURORA_SDL3_PROVIDER vendor CACHE STRING "" FORCE)
+ endif()
+ endif()
set(DAWN_ENABLE_D3D11 OFF CACHE BOOL "" FORCE)
if(MKW_PLATFORM_WINDOWS)
set(DAWN_ENABLE_D3D12 ON CACHE BOOL "" FORCE)
@@ -190,8 +223,13 @@ else()
# should add it to the Dawn targets the same way.
# Keep SDL3 lean when aurora is enabled.
- set(SDL_SHARED OFF CACHE BOOL "" FORCE)
- set(SDL_STATIC ON CACHE BOOL "" FORCE)
+ if(MKW_PLATFORM_ANDROID)
+ set(SDL_SHARED ON CACHE BOOL "" FORCE)
+ set(SDL_STATIC OFF CACHE BOOL "" FORCE)
+ else()
+ set(SDL_SHARED OFF CACHE BOOL "" FORCE)
+ set(SDL_STATIC ON CACHE BOOL "" FORCE)
+ endif()
set(SDL_TEST OFF CACHE BOOL "" FORCE)
# MinGW/WSL can choke on SDL precompiled headers; disable PCH for aurora/SDL.
@@ -250,7 +288,7 @@ endif()
set(MKW_OPENXR_TARGET "")
if(MKW_ENABLE_OPENXR)
- if(NOT MKW_PLATFORM_WINDOWS AND NOT MKW_PLATFORM_LINUX)
+ if(NOT MKW_PLATFORM_WINDOWS AND NOT MKW_PLATFORM_LINUX AND NOT MKW_PLATFORM_ANDROID)
message(WARNING "OpenXR is not wired for this platform; disabling MKW_ENABLE_OPENXR")
set(MKW_ENABLE_OPENXR OFF)
else()
@@ -332,6 +370,15 @@ set_target_properties(mkw_platform PROPERTIES UNITY_BUILD OFF)
# Keep these independent from Aurora's BUILD_TESTING option: they validate the
# project's host-platform contracts, not Aurora's third-party test suite.
+# A cross-compiled Android tree cannot run them, so it does not build them.
+if(MKW_PLATFORM_ANDROID)
+ set(MKW_BUILD_TESTS_DEFAULT OFF)
+else()
+ set(MKW_BUILD_TESTS_DEFAULT ON)
+endif()
+option(MKW_BUILD_TESTS "Build the runtime host-contract test executables" ${MKW_BUILD_TESTS_DEFAULT})
+unset(MKW_BUILD_TESTS_DEFAULT)
+if(MKW_BUILD_TESTS)
enable_testing()
add_executable(mkw_frame_interpolation_pacing_tests tests/frame_interpolation_pacing_tests.cpp)
target_include_directories(mkw_frame_interpolation_pacing_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
@@ -443,6 +490,7 @@ if(MKW_PLATFORM_MACOS)
target_compile_features(mkw_macos_guest_flat_memory_tests PRIVATE cxx_std_17)
add_test(NAME mkw_macos_guest_flat_memory_tests COMMAND mkw_macos_guest_flat_memory_tests)
endif()
+endif() # MKW_BUILD_TESTS
# The translator emits the complete, content-addressed source graph. Consuming
# this one manifest keeps configure independent of the 28k generated function
@@ -493,6 +541,32 @@ else()
mkw::pugixml mkw::toml11 mkw::cryptopp)
set_target_properties(mkw_macos_native_compile PROPERTIES UNITY_BUILD OFF)
endif()
+ if(MKW_PLATFORM_ANDROID)
+ # Compile-only audit of the native runtime for arm64 Android, including
+ # the OpenXR Vulkan backend and the Android platform glue, without a
+ # translated game. Same exclusions as the macOS audit: those two files
+ # include the translator-emitted RuntimeConfig.h.
+ set(MKW_ANDROID_NATIVE_AUDIT_SOURCES ${SOURCES})
+ list(REMOVE_ITEM MKW_ANDROID_NATIVE_AUDIT_SOURCES
+ "${CMAKE_CURRENT_LIST_DIR}/src/abi_bridge.cpp"
+ "${CMAKE_CURRENT_LIST_DIR}/src/hle/os/os_alarm.cpp")
+ add_library(mkw_android_native_compile OBJECT ${MKW_ANDROID_NATIVE_AUDIT_SOURCES})
+ target_include_directories(mkw_android_native_compile PRIVATE
+ "${CMAKE_CURRENT_LIST_DIR}/include" "${CMAKE_CURRENT_LIST_DIR}/src"
+ "${CMAKE_CURRENT_LIST_DIR}/.." "${CMAKE_CURRENT_LIST_DIR}/../aurora-main/include")
+ target_compile_features(mkw_android_native_compile PRIVATE cxx_std_20)
+ target_compile_definitions(mkw_android_native_compile PRIVATE TARGET_PC)
+ target_link_libraries(mkw_android_native_compile PRIVATE
+ aurora::gx aurora::pad aurora::si aurora::vi aurora::mtx
+ mkw::pugixml mkw::toml11 mkw::cryptopp mkw::libco)
+ if(MKW_ENABLE_OPENXR)
+ target_link_libraries(mkw_android_native_compile PRIVATE ${MKW_OPENXR_TARGET})
+ endif()
+ # Same precompiled header as the product objects: several sources rely on
+ # it for abi_bridge.h / ppc_runtime.h rather than including them directly.
+ target_precompile_headers(mkw_android_native_compile PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include/mkw_pch.h")
+ set_target_properties(mkw_android_native_compile PROPERTIES UNITY_BUILD OFF)
+ endif()
add_custom_target(mkw_platform_paths_check DEPENDS mkw_platform)
message(STATUS "Translated product targets disabled (MKW_BUILD_PRODUCTS=OFF)")
endif()
diff --git a/runtime/cmake/PublicProducts.cmake b/runtime/cmake/PublicProducts.cmake
index 97abf1f..8388a12 100644
--- a/runtime/cmake/PublicProducts.cmake
+++ b/runtime/cmake/PublicProducts.cmake
@@ -4,15 +4,38 @@
# functions are compiled once into mkw_base_shared; only callers whose direct
# ABI differs between profiles receive small base/RR variants.
-set(DATA_INIT_FILE "${MKW_RUNTIME_SOURCE_DIR}/../generated/data_sections_init.cpp")
-set(DATA_INIT_BLOB_ASM "${MKW_RUNTIME_SOURCE_DIR}/../generated/data_sections_init_blobs.S")
+# The translator's output tree. It sits next to runtime/ in the installer's
+# build workspace; a build configured straight from a checkout (the Android
+# app, the test build dirs) names the workspace copy instead.
+set(MKW_GENERATED_DIR "${MKW_RUNTIME_SOURCE_DIR}/../generated" CACHE PATH
+ "Translator output directory holding data_sections_init.cpp and RuntimeConfig.h")
+get_filename_component(MKW_GENERATED_PARENT_DIR "${MKW_GENERATED_DIR}" DIRECTORY)
+
+set(DATA_INIT_FILE "${MKW_GENERATED_DIR}/data_sections_init.cpp")
+set(DATA_INIT_BLOB_ASM "${MKW_GENERATED_DIR}/data_sections_init_blobs.S")
+if(MKW_PLATFORM_ANDROID AND EXISTS "${DATA_INIT_BLOB_ASM}")
+ # The Windows installer generates the blob assembly for PE/COFF. The payload
+ # is byte-identical on every platform (the .S only wraps .incbin
+ # directives), so rewrite the section syntax for ELF here rather than
+ # requiring a second translation pass for the Quest build. A tree generated
+ # with `generate-data-init --target-os android` passes through unchanged.
+ file(READ "${DATA_INIT_BLOB_ASM}" MKW_ANDROID_BLOB_ASM_TEXT)
+ string(REPLACE ".section .rdata,\"dr\"" ".section .rodata,\"a\",@progbits"
+ MKW_ANDROID_BLOB_ASM_ELF "${MKW_ANDROID_BLOB_ASM_TEXT}")
+ if(NOT MKW_ANDROID_BLOB_ASM_ELF STREQUAL MKW_ANDROID_BLOB_ASM_TEXT)
+ string(APPEND MKW_ANDROID_BLOB_ASM_ELF "\n.section .note.GNU-stack,\"\",@progbits\n")
+ set(DATA_INIT_BLOB_ASM "${CMAKE_CURRENT_BINARY_DIR}/data_sections_init_blobs_android.S")
+ file(WRITE "${DATA_INIT_BLOB_ASM}" "${MKW_ANDROID_BLOB_ASM_ELF}")
+ message(STATUS "Rewrote the PE/COFF blob assembly for ELF: ${DATA_INIT_BLOB_ASM}")
+ endif()
+endif()
if(EXISTS "${DATA_INIT_FILE}")
list(APPEND SOURCES "${DATA_INIT_FILE}")
endif()
# Crash-report symbolization table emitted by generate-data-init. The stub
# (deliberately outside the globbed src/ tree so it is never picked up twice)
# keeps link succeeding when the generated table has not been produced yet.
-set(GUEST_SYMBOL_TABLE_FILE "${MKW_RUNTIME_SOURCE_DIR}/../generated/guest_symbol_table.cpp")
+set(GUEST_SYMBOL_TABLE_FILE "${MKW_GENERATED_DIR}/guest_symbol_table.cpp")
if(EXISTS "${GUEST_SYMBOL_TABLE_FILE}")
list(APPEND SOURCES "${GUEST_SYMBOL_TABLE_FILE}")
else()
@@ -45,6 +68,7 @@ function(mkw_configure_object_target target)
"${MKW_RUNTIME_SOURCE_DIR}/src"
# Workspace root, so translator output is spelled "generated/.h"
# instead of a ../ chain whose depth depends on the includer.
+ "${MKW_GENERATED_PARENT_DIR}"
"${MKW_RUNTIME_SOURCE_DIR}/.."
"${MKW_RUNTIME_SOURCE_DIR}/../aurora-main/include")
target_compile_definitions(${target} PRIVATE
@@ -76,8 +100,10 @@ endfunction()
add_library(mkw_runtime_common OBJECT ${SOURCES})
mkw_configure_object_target(mkw_runtime_common)
target_compile_features(mkw_runtime_common PRIVATE cxx_std_20)
+# On Android SDL owns the entry point: main.cpp includes SDL_main.h so that
+# SDLActivity's nativeRunMain finds SDL_main inside libmain.so.
target_compile_definitions(mkw_runtime_common PRIVATE
- SDL_MAIN_HANDLED
+ $<$>:SDL_MAIN_HANDLED>
_DISABLE_STRING_ANNOTATION _DISABLE_VECTOR_ANNOTATION)
target_link_libraries(mkw_runtime_common PRIVATE
aurora::gx aurora::pad aurora::si aurora::vi aurora::mtx)
@@ -91,6 +117,10 @@ elseif(MKW_PLATFORM_LINUX)
# ${CMAKE_DL_LIBS} for music_attenuation.cpp's dlopen of libdbus-1 (MPRIS
# media monitoring). Empty string on glibc >= 2.34 where dl* is in libc.
target_link_libraries(mkw_runtime_common PRIVATE mkw::libco ${CMAKE_DL_LIBS})
+elseif(MKW_PLATFORM_ANDROID)
+ # libvulkan for the OpenXR-side device, android/log for AHardwareBuffer,
+ # JNI and logcat.
+ target_link_libraries(mkw_runtime_common PRIVATE mkw::libco android log vulkan ${CMAKE_DL_LIBS})
endif()
if(MKW_CPPWINRT_INCLUDE_DIR)
if(NOT EXISTS "${MKW_CPPWINRT_INCLUDE_DIR}/winrt/base.h")
@@ -167,6 +197,26 @@ if(MKW_HAVE_RETRO_REWIND)
target_precompile_headers(mkw_retro_sensitive REUSE_FROM mkw_base_shared)
endif()
+ if(MKW_PLATFORM_ANDROID)
+ # Same PE/COFF-to-ELF rewrite as the base data blobs above.
+ set(MKW_ANDROID_RETRO_EXTRA_SOURCES)
+ foreach(source IN LISTS MKW_RETRO_EXTRA_SOURCES)
+ if(source MATCHES "\\.S$")
+ file(READ "${source}" MKW_ANDROID_RETRO_BLOB_TEXT)
+ string(REPLACE ".section .rdata,\"dr\"" ".section .rodata,\"a\",@progbits"
+ MKW_ANDROID_RETRO_BLOB_ELF "${MKW_ANDROID_RETRO_BLOB_TEXT}")
+ if(NOT MKW_ANDROID_RETRO_BLOB_ELF STREQUAL MKW_ANDROID_RETRO_BLOB_TEXT)
+ string(APPEND MKW_ANDROID_RETRO_BLOB_ELF "\n.section .note.GNU-stack,\"\",@progbits\n")
+ get_filename_component(source_name "${source}" NAME_WE)
+ set(source "${CMAKE_CURRENT_BINARY_DIR}/${source_name}_android.S")
+ file(WRITE "${source}" "${MKW_ANDROID_RETRO_BLOB_ELF}")
+ endif()
+ endif()
+ list(APPEND MKW_ANDROID_RETRO_EXTRA_SOURCES "${source}")
+ endforeach()
+ set(MKW_RETRO_EXTRA_SOURCES ${MKW_ANDROID_RETRO_EXTRA_SOURCES})
+ endif()
+
set(MKW_RETRO_TRANSLATED_SOURCES ${MKW_RETRO_MOD_SHARDS} ${MKW_RETRO_EXTRA_SOURCES})
set(MKW_RETRO_BLOB_OBJECTS)
foreach(source IN LISTS MKW_RETRO_EXTRA_SOURCES)
@@ -192,10 +242,12 @@ function(mkw_configure_product target)
"${MKW_RUNTIME_SOURCE_DIR}/src"
# Workspace root, so translator output is spelled "generated/.h"
# instead of a ../ chain whose depth depends on the includer.
+ "${MKW_GENERATED_PARENT_DIR}"
"${MKW_RUNTIME_SOURCE_DIR}/.."
"${MKW_RUNTIME_SOURCE_DIR}/../aurora-main/include")
target_compile_definitions(${target} PRIVATE
- SDL_MAIN_HANDLED _DISABLE_STRING_ANNOTATION _DISABLE_VECTOR_ANNOTATION TARGET_PC)
+ $<$>:SDL_MAIN_HANDLED>
+ _DISABLE_STRING_ANNOTATION _DISABLE_VECTOR_ANNOTATION TARGET_PC)
target_compile_features(${target} PRIVATE cxx_std_20)
mkw_apply_common_compile_options(${target})
# The dispatch-table and registration shards compile inside the product target itself and
@@ -244,6 +296,8 @@ function(mkw_configure_product target)
# here for the same reason: music_attenuation.cpp's dlopen(libdbus-1) lives in those
# objects (empty string on glibc >= 2.34, where dl* is in libc).
target_link_libraries(${target} PRIVATE mkw::libco ${CMAKE_DL_LIBS})
+ elseif(MKW_PLATFORM_ANDROID)
+ target_link_libraries(${target} PRIVATE mkw::libco android log vulkan ${CMAKE_DL_LIBS})
endif()
if(MKW_PLATFORM_WINDOWS)
foreach(runtime_dll libc++.dll libunwind.dll)
@@ -296,7 +350,15 @@ function(mkw_configure_product target)
"$/initial_pipeline_cache.db")
endfunction()
-add_executable(WiiCompiled "${MKW_BASE_PRODUCT_SOURCE}" ${MKW_BASE_REGISTRATION_SOURCES})
+# Android ships each product as the shared library SDLActivity loads; the base
+# game keeps SDL's conventional "main" name and Retro Rewind gets its own so a
+# dual-flavour app can bundle both without a name clash.
+if(MKW_PLATFORM_ANDROID)
+ add_library(WiiCompiled SHARED "${MKW_BASE_PRODUCT_SOURCE}" ${MKW_BASE_REGISTRATION_SOURCES})
+ set_target_properties(WiiCompiled PROPERTIES OUTPUT_NAME main)
+else()
+ add_executable(WiiCompiled "${MKW_BASE_PRODUCT_SOURCE}" ${MKW_BASE_REGISTRATION_SOURCES})
+endif()
mkw_configure_product(WiiCompiled)
target_precompile_headers(WiiCompiled PRIVATE
"${MKW_RUNTIME_SOURCE_DIR}/include/mkw_pch.h")
@@ -305,7 +367,12 @@ if(TARGET mkw_base_sensitive)
endif()
if(MKW_HAVE_RETRO_REWIND)
- add_executable(RetroRewind "${MKW_RETRO_REWIND_PRODUCT_SOURCE}" ${MKW_RETRO_REGISTRATION_SOURCES})
+ if(MKW_PLATFORM_ANDROID)
+ add_library(RetroRewind SHARED "${MKW_RETRO_REWIND_PRODUCT_SOURCE}" ${MKW_RETRO_REGISTRATION_SOURCES})
+ set_target_properties(RetroRewind PROPERTIES OUTPUT_NAME main_retro_rewind)
+ else()
+ add_executable(RetroRewind "${MKW_RETRO_REWIND_PRODUCT_SOURCE}" ${MKW_RETRO_REGISTRATION_SOURCES})
+ endif()
mkw_configure_product(RetroRewind)
target_precompile_headers(RetroRewind REUSE_FROM WiiCompiled)
if(TARGET mkw_retro_sensitive)
@@ -327,6 +394,11 @@ endif()
# tuning rather than leaving target-specific performance on the table.
if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64)$")
set(MKW_BASELINE_ARCH_FLAG -march=x86-64-v3)
+elseif(MKW_PLATFORM_ANDROID)
+ # Cross-compiled, so "native" would describe the build host. Cortex-A77 is
+ # the Snapdragon XR2 Gen 1 (Quest 2) core; Quest 3 / Pro are supersets.
+ set(MKW_ANDROID_CPU "cortex-a77" CACHE STRING "AArch64 -mcpu target for the Android products")
+ set(MKW_BASELINE_ARCH_FLAG -mcpu=${MKW_ANDROID_CPU})
elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "^(aarch64|arm64|ARM64)$")
set(MKW_BASELINE_ARCH_FLAG -mcpu=native)
else()
diff --git a/runtime/include/aurora_events.h b/runtime/include/aurora_events.h
index ddb2df8..ac0b3c2 100644
--- a/runtime/include/aurora_events.h
+++ b/runtime/include/aurora_events.h
@@ -2,6 +2,7 @@
#include "settings_overlay.h"
#include "runtime_config.h"
+#include "fiber_manager.h"
#include
#include
@@ -136,7 +137,30 @@ inline bool BeginAuroraFrame() {
return true;
}
+#if defined(__ANDROID__)
+inline std::atomic_bool g_androidAuroraPollPending{false};
+#endif
+
// Poll Aurora events and update cached window/framebuffer dimensions.
inline void UpdateAuroraAndProcessEvents() {
+#if defined(__ANDROID__)
+ // aurora_update() pumps SDL, which can call into Java; ART only tolerates
+ // that on the thread's real stack. A guest thread on its own fiber stack
+ // leaves the poll for the scheduler to service after the next switch.
+ if (!Fiber::GuestFiberManager::IsOnSchedulerFiber()) {
+ g_androidAuroraPollPending.store(true, std::memory_order_release);
+ return;
+ }
+ g_androidAuroraPollPending.store(false, std::memory_order_release);
+#endif
ProcessAuroraEvents(aurora_update());
}
+
+inline void ServicePendingAuroraEventsOnScheduler() {
+#if defined(__ANDROID__)
+ if (Fiber::GuestFiberManager::IsOnSchedulerFiber() &&
+ g_androidAuroraPollPending.exchange(false, std::memory_order_acq_rel)) {
+ ProcessAuroraEvents(aurora_update());
+ }
+#endif
+}
diff --git a/runtime/include/fiber_manager.h b/runtime/include/fiber_manager.h
index cad48c4..df67e0e 100644
--- a/runtime/include/fiber_manager.h
+++ b/runtime/include/fiber_manager.h
@@ -59,6 +59,11 @@ public:
// Check if fiber system is initialized
static bool IsInitialized();
+
+ // True when the caller runs on the scheduler's own host context, i.e. the
+ // original thread stack (also true before the fiber system exists). Code
+ // that may enter Java through JNI on Android must only run there.
+ static bool IsOnSchedulerFiber();
// Create a fiber for a guest thread (called from OSCreateThread HLE).
// OSCreateThread's stackSize/priority are not passed: the host fiber models
diff --git a/runtime/include/nand_settings.h b/runtime/include/nand_settings.h
index 055f4c2..d983033 100644
--- a/runtime/include/nand_settings.h
+++ b/runtime/include/nand_settings.h
@@ -15,6 +15,8 @@
#ifdef _WIN32
#include
#else
+#include
+#include
#include
#endif
@@ -202,6 +204,17 @@ inline bool Ensure(const std::filesystem::path& root, std::string& error,
published = MoveFileExW(temporary.c_str(), path.c_str(), MOVEFILE_WRITE_THROUGH) != 0;
#else
published = ::link(temporary.c_str(), path.c_str()) == 0;
+#if defined(__ANDROID__)
+ // Android's shared storage (the FUSE-backed Android/data tree) has no
+ // hard links; on a Quest 3 link() fails with EACCES, not EPERM, so the
+ // errno is not a usable signal. The no-replace publish exists for
+ // competing desktop launchers; an Android app runs one process, so an
+ // existence check followed by an atomic rename keeps the "never
+ // overwrite" guarantee. A real permission problem fails the rename too.
+ if (!published && !std::filesystem::exists(path)) {
+ published = ::rename(temporary.c_str(), path.c_str()) == 0;
+ }
+#endif
#endif
}
std::filesystem::remove(temporary, ec);
diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h
index 28ebd8a..f3ff254 100644
--- a/runtime/include/runtime_config.h
+++ b/runtime/include/runtime_config.h
@@ -131,7 +131,9 @@ inline std::filesystem::path PathFromUtf8(std::string_view text) {
}
inline constexpr const char* kConfigFileName = "Config.toml";
-#ifdef _WIN32
+#if defined(_WIN32) || defined(__ANDROID__)
+// The Quest activity (android/.../QuestActivity.kt) creates this directory
+// under the app's external files dir and writes Config.toml into it.
inline constexpr const char* kApplicationDirectoryName = "WiiCompiledOpenXRVR";
#else
inline constexpr const char* kApplicationDirectoryName = "WiiCompiled";
@@ -269,7 +271,10 @@ inline std::optional ExecutableDirectory() {
}
buffer.resize(buffer.size() * 2);
}
-#elif defined(__APPLE__)
+#elif defined(__APPLE__) || defined(__ANDROID__)
+ // Android has no executable next to which assets could sit; the platform
+ // layer answers with the directory the activity unpacked the bundled
+ // runtime resources into, so the adjacent-file lookups below keep working.
return RuntimePlatform::ExecutableDirectory();
#else
// /proc/self/exe is a Linux-specific magic symlink to the running executable; readlink()
@@ -328,7 +333,7 @@ inline std::filesystem::path ApplicationDataDirectory() {
CoTaskMemFree(rawPath);
return directory;
}
-#elif defined(__APPLE__)
+#elif defined(__APPLE__) || defined(__ANDROID__)
return RuntimePlatform::ApplicationDataDirectory(kApplicationDirectoryName);
#else
// XDG Base Directory spec equivalent of FOLDERID_LocalAppData: $XDG_DATA_HOME if set and
diff --git a/runtime/include/vr/openxr_android.h b/runtime/include/vr/openxr_android.h
new file mode 100644
index 0000000..b46098e
--- /dev/null
+++ b/runtime/include/vr/openxr_android.h
@@ -0,0 +1,41 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#if defined(MKW_ENABLE_OPENXR) && defined(__ANDROID__)
+
+#include "vr/openxr_runtime.h"
+
+#include
+
+namespace mkw::vr {
+
+// Android-specific OpenXR bootstrap.
+//
+// The Khronos loader on Android must be told which JavaVM and Context it lives
+// in before any other OpenXR entry point is called (xrInitializeLoaderKHR), and
+// xrCreateInstance must chain an XrInstanceCreateInfoAndroidKHR naming the
+// activity. Both come from SDL's Android glue (the SDLActivity that hosts the
+// runtime), so no extra JNI surface is needed. DolphinXR found that the loader
+// context must be the *activity*, not the application context: with a plain
+// application context the Quest runtime never leaves XR_SESSION_STATE_IDLE.
+//
+// Call order: OpenXRAndroidInitializeLoader() once, before OpenXRRuntime::Initialize;
+// then pass OpenXRAndroidInstanceCreateNext() as OpenXRConfig::instance_create_next.
+bool OpenXRAndroidInitializeLoader(OpenXRLogCallback logger, std::string* error);
+const void* OpenXRAndroidInstanceCreateNext();
+
+// Optional XR_KHR_android_thread_settings hint for the calling thread. Failure
+// is not an error: some Quest runtime builds advertise the extension but reject
+// particular thread types, so the caller only logs the outcome.
+enum class OpenXRAndroidThreadType {
+ ApplicationMain,
+ ApplicationWorker,
+ RendererMain,
+ RendererWorker,
+};
+bool OpenXRAndroidRegisterThread(OpenXRRuntime& runtime, OpenXRAndroidThreadType type);
+
+} // namespace mkw::vr
+
+#endif // defined(MKW_ENABLE_OPENXR) && defined(__ANDROID__)
diff --git a/runtime/include/vr/openxr_backend.h b/runtime/include/vr/openxr_backend.h
new file mode 100644
index 0000000..a4e1d2d
--- /dev/null
+++ b/runtime/include/vr/openxr_backend.h
@@ -0,0 +1,66 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#if defined(MKW_ENABLE_OPENXR)
+
+#include "vr/openxr_runtime.h"
+
+#include
+#include
+
+namespace mkw::vr {
+
+// Backend-neutral frame vocabulary shared by every graphics binding.
+//
+// The OpenXR pacing thread in openxr_integration.cpp is written against these
+// types and the method surface documented on OpenXRD3D12Backend; each concrete
+// backend (D3D12 on Windows, Vulkan on Android) implements that same surface so
+// the pacing, retained-layer and policy logic is compiled once for both.
+
+enum class OpenXRFrameMode {
+ ImmersiveProjection,
+ VirtualScreen,
+};
+
+enum class OpenXRBeginStatus {
+ Ready,
+ SessionNotRunning,
+ ExitRequested,
+ Error,
+};
+
+enum class OpenXRSubmissionStatus {
+ Success,
+ Failed,
+ Timeout,
+ ShuttingDown,
+};
+
+struct OpenXRPresentation {
+ OpenXRFrameMode mode = OpenXRFrameMode::ImmersiveProjection;
+
+ // Used only by VirtualScreen.
+ float quad_distance_meters = 2.0f;
+ float quad_width_meters = 2.4f;
+
+ // When quad_anchored is set, the quad is placed at quad_pose in the
+ // application reference space and stays put as the player looks around.
+ // Otherwise it falls back to being head-locked in XR_VIEW_SPACE, centered
+ // straight ahead at -Z, which is what happens until tracking has produced a
+ // head pose good enough to anchor against.
+ bool quad_anchored = false;
+ XrPosef quad_pose{{0.0f, 0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 0.0f}};
+};
+
+struct OpenXRBackendFrame {
+ OpenXRFrame xr_frame;
+ OpenXRPresentation presentation;
+ std::array render_width{};
+ std::array render_height{};
+ bool expects_gpu_submission = false;
+};
+
+} // namespace mkw::vr
+
+#endif // defined(MKW_ENABLE_OPENXR)
diff --git a/runtime/include/vr/openxr_config.h b/runtime/include/vr/openxr_config.h
index fd5e455..3d85432 100644
--- a/runtime/include/vr/openxr_config.h
+++ b/runtime/include/vr/openxr_config.h
@@ -46,6 +46,10 @@ struct OpenXRConfig {
// Destruction never waits indefinitely for a runtime to acknowledge an exit.
uint32_t shutdown_timeout_ms = 500;
+
+ // Platform structure chained into XrInstanceCreateInfo::next (Android needs
+ // XrInstanceCreateInfoAndroidKHR). Must outlive Initialize(); null elsewhere.
+ const void* instance_create_next = nullptr;
};
} // namespace mkw::vr
diff --git a/runtime/include/vr/openxr_d3d12.h b/runtime/include/vr/openxr_d3d12.h
index 47a75ef..a6401cb 100644
--- a/runtime/include/vr/openxr_d3d12.h
+++ b/runtime/include/vr/openxr_d3d12.h
@@ -4,6 +4,7 @@
#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32)
+#include "vr/openxr_backend.h"
#include "vr/openxr_runtime.h"
#include
@@ -13,24 +14,14 @@
namespace mkw::vr {
-enum class OpenXRD3D12FrameMode {
- ImmersiveProjection,
- VirtualScreen,
-};
-
-enum class OpenXRD3D12BeginStatus {
- Ready,
- SessionNotRunning,
- ExitRequested,
- Error,
-};
-
-enum class OpenXRD3D12SubmissionStatus {
- Success,
- Failed,
- Timeout,
- ShuttingDown,
-};
+// The frame vocabulary is shared with the Vulkan backend (vr/openxr_backend.h).
+// These aliases keep the D3D12 spelling that the replay tests and the original
+// integration code were written against.
+using OpenXRD3D12FrameMode = OpenXRFrameMode;
+using OpenXRD3D12BeginStatus = OpenXRBeginStatus;
+using OpenXRD3D12SubmissionStatus = OpenXRSubmissionStatus;
+using OpenXRD3D12Presentation = OpenXRPresentation;
+using OpenXRD3D12Frame = OpenXRBackendFrame;
struct OpenXRD3D12GraphicsRequirements {
uint32_t adapter_luid_low = 0;
@@ -38,30 +29,6 @@ struct OpenXRD3D12GraphicsRequirements {
uint32_t minimum_feature_level = 0;
};
-struct OpenXRD3D12Presentation {
- OpenXRD3D12FrameMode mode = OpenXRD3D12FrameMode::ImmersiveProjection;
-
- // Used only by VirtualScreen.
- float quad_distance_meters = 2.0f;
- float quad_width_meters = 2.4f;
-
- // When quad_anchored is set, the quad is placed at quad_pose in the
- // application reference space and stays put as the player looks around.
- // Otherwise it falls back to being head-locked in XR_VIEW_SPACE, centered
- // straight ahead at -Z, which is what happens until tracking has produced a
- // head pose good enough to anchor against.
- bool quad_anchored = false;
- XrPosef quad_pose{{0.0f, 0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 0.0f}};
-};
-
-struct OpenXRD3D12Frame {
- OpenXRFrame xr_frame;
- OpenXRD3D12Presentation presentation;
- std::array render_width{};
- std::array render_height{};
- bool expects_gpu_submission = false;
-};
-
// Same-device Dawn/OpenXR D3D12 backend.
//
// Startup is deliberately split in two. QueryGraphicsRequirements() runs
diff --git a/runtime/include/vr/openxr_input.h b/runtime/include/vr/openxr_input.h
new file mode 100644
index 0000000..0a69ca5
--- /dev/null
+++ b/runtime/include/vr/openxr_input.h
@@ -0,0 +1,94 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#if defined(MKW_ENABLE_OPENXR)
+
+#include "vr/openxr_runtime.h"
+
+#include
+#include
+
+namespace mkw::vr {
+
+// OpenXR action-based controller input, surfaced to the rest of the runtime as
+// one ordinary SDL gamepad.
+//
+// Quest Touch controllers are not visible to SDL's joystick layer (the OS does
+// not expose them as HID gamepads), so a standalone headset build would have no
+// input at all. Rather than adding a second input path through the PAD/WPAD
+// HLE, this module syncs an OpenXR action set on the pacing thread and feeds a
+// virtual SDL joystick (SDL_AttachVirtualJoystick) that Aurora's existing
+// controller code opens, maps and assigns to player 1 exactly like a physical
+// pad. Every binding the settings overlay already offers keeps working.
+//
+// Mapping (Oculus Touch profile; the same actions are also bound for
+// khr/simple_controller so an unknown runtime still gets A/menu):
+// right A / B -> gamepad South / East (GameCube A / B)
+// left X / Y -> gamepad West / North (GameCube X / Y)
+// index triggers -> left / right trigger axes
+// grip squeezes -> left / right shoulder buttons
+// left / right thumbstick-> left / right stick axes, clicks -> stick buttons
+// left menu -> Start
+//
+// Lifetime: OpenXRInputCreate after the session exists (attaches the action
+// set, which OpenXR permits once per session), OpenXRInputSync once per
+// xrWaitFrame when the session is focused, OpenXRInputDestroy before the
+// session is destroyed. All three run on the XR pacing thread; SDL's virtual
+// joystick setters are internally locked, so the game thread may read the pad
+// concurrently.
+class OpenXRInput final {
+public:
+ explicit OpenXRInput(OpenXRLogCallback logger = {});
+ ~OpenXRInput();
+
+ OpenXRInput(const OpenXRInput&) = delete;
+ OpenXRInput& operator=(const OpenXRInput&) = delete;
+
+ // Creates the action set/actions/spaces and attaches them to the session.
+ // Returns false (with LastError set) if the runtime rejects the action set;
+ // the caller continues without controller input rather than failing VR.
+ bool Create(OpenXRRuntime& runtime);
+ void Destroy();
+
+ // xrSyncActions + state reads, then publishes to the virtual gamepad.
+ // predicted_display_time is the frame's XrTime for pose-based lookups.
+ void Sync(XrTime predicted_display_time);
+
+ // Rumble for the given hand (0 = left, 1 = right); amplitude 0..1.
+ void ApplyHaptic(uint32_t hand, float amplitude, XrDuration duration);
+
+ bool IsCreated() const noexcept { return m_created; }
+ bool HasVirtualGamepad() const noexcept { return m_joystick_id != 0; }
+ const std::string& LastError() const noexcept { return m_last_error; }
+
+private:
+ bool CreateActions();
+ bool SuggestBindings();
+ bool AttachVirtualGamepad();
+ void DetachVirtualGamepad();
+ bool Check(XrResult result, const char* operation);
+ void Log(OpenXRLogLevel level, const std::string& message) const noexcept;
+
+ OpenXRLogCallback m_logger;
+ OpenXRRuntime* m_runtime = nullptr;
+ XrActionSet m_action_set = XR_NULL_HANDLE;
+ XrAction m_thumbstick = XR_NULL_HANDLE;
+ XrAction m_thumbstick_click = XR_NULL_HANDLE;
+ XrAction m_trigger = XR_NULL_HANDLE;
+ XrAction m_squeeze = XR_NULL_HANDLE;
+ XrAction m_button_primary = XR_NULL_HANDLE; // A / X
+ XrAction m_button_secondary = XR_NULL_HANDLE; // B / Y
+ XrAction m_menu = XR_NULL_HANDLE;
+ XrAction m_haptic = XR_NULL_HANDLE;
+ XrPath m_hand_paths[2]{};
+ uint32_t m_joystick_id = 0; // SDL_JoystickID; 0 when detached
+ void* m_joystick = nullptr; // SDL_Joystick*
+ bool m_created = false;
+ bool m_logged_sync_failure = false;
+ std::string m_last_error;
+};
+
+} // namespace mkw::vr
+
+#endif // defined(MKW_ENABLE_OPENXR)
diff --git a/runtime/include/vr/openxr_vulkan.h b/runtime/include/vr/openxr_vulkan.h
new file mode 100644
index 0000000..2b23399
--- /dev/null
+++ b/runtime/include/vr/openxr_vulkan.h
@@ -0,0 +1,78 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#if defined(MKW_ENABLE_OPENXR) && defined(__ANDROID__)
+
+#include "vr/openxr_backend.h"
+#include "vr/openxr_runtime.h"
+
+#include
+#include
+#include
+
+namespace mkw::vr {
+
+struct OpenXRVulkanGraphicsRequirements {
+ // XR_MAKE_VERSION-encoded bounds reported by xrGetVulkanGraphicsRequirements2KHR.
+ XrVersion min_api_version = 0;
+ XrVersion max_api_version = 0;
+ // Which binding extension was negotiated: XR_KHR_vulkan_enable2 when the
+ // runtime offers it, otherwise the original XR_KHR_vulkan_enable.
+ bool uses_enable2 = false;
+};
+
+// Two-device Dawn/OpenXR Vulkan backend for Android (Meta Quest).
+//
+// Method surface and threading rules are identical to OpenXRD3D12Backend so the
+// pacing thread in openxr_integration.cpp is shared. The difference is behind
+// BindAurora: instead of binding Dawn's device to the session, this backend
+// creates its own VkInstance/VkDevice through the OpenXR runtime, owns a small
+// ring of AHardwareBuffers per eye, and hands them to Aurora's Vulkan stereo
+// bridge (aurora/vulkan_interop.h). When Aurora reports a completed copy, the
+// backend copies the buffer into the acquired XrSwapchain image on the
+// session's queue, so the compositor sees ordinary same-queue rendering.
+//
+// QueryGraphicsRequirements() runs after OpenXRRuntime::Initialize() and before
+// aurora_initialize(); BindAurora() runs afterwards, while Aurora's frame worker
+// is idle. Everything from BeginFrame() through FinishFrame() belongs to one XR
+// pacing thread; the Aurora callback only records a small copy on this
+// backend's private queue and publishes a token.
+class OpenXRVulkanBackend final {
+public:
+ explicit OpenXRVulkanBackend(OpenXRLogCallback logger = {});
+ ~OpenXRVulkanBackend();
+
+ OpenXRVulkanBackend(const OpenXRVulkanBackend&) = delete;
+ OpenXRVulkanBackend& operator=(const OpenXRVulkanBackend&) = delete;
+ OpenXRVulkanBackend(OpenXRVulkanBackend&&) = delete;
+ OpenXRVulkanBackend& operator=(OpenXRVulkanBackend&&) = delete;
+
+ bool QueryGraphicsRequirements(OpenXRRuntime& runtime);
+ bool BindAurora(OpenXRRuntime& runtime);
+
+ OpenXRBeginStatus BeginFrame(const OpenXRPresentation& presentation, OpenXRBackendFrame& frame);
+ OpenXRSubmissionStatus WaitForSubmission(const OpenXRBackendFrame& frame,
+ uint32_t timeout_ms = UINT32_MAX);
+ bool TryCancelPendingFrame(OpenXRBackendFrame& frame);
+ bool RepeatFrame(const OpenXRBackendFrame& frame);
+ bool FinishFrame(OpenXRBackendFrame& frame, bool submit_layer);
+
+ // Drains this backend's own queue before tearing down. Returns false only
+ // when the private device could not be waited on, in which case the caller
+ // retains the backend and runtime for the process lifetime.
+ bool Shutdown();
+
+ bool IsBound() const;
+ const OpenXRVulkanGraphicsRequirements& GraphicsRequirements() const;
+ int64_t SwapchainFormat() const;
+ const std::string& LastError() const;
+
+private:
+ class Impl;
+ std::unique_ptr m_impl;
+};
+
+} // namespace mkw::vr
+
+#endif // defined(MKW_ENABLE_OPENXR) && defined(__ANDROID__)
diff --git a/runtime/src/fiber_manager.cpp b/runtime/src/fiber_manager.cpp
index 59ff4eb..7348d06 100644
--- a/runtime/src/fiber_manager.cpp
+++ b/runtime/src/fiber_manager.cpp
@@ -1,4 +1,7 @@
#include "fiber_manager.h"
+#if defined(__ANDROID__)
+#include "aurora_events.h"
+#endif
#include "memory.h"
#include "abi_bridge.h"
#include "hle_stubs.h"
@@ -219,6 +222,10 @@ void GuestFiberManager::Shutdown() {
s_initialized = false;
}
+bool GuestFiberManager::IsOnSchedulerFiber() {
+ return !s_initialized || s_schedulerFiber == nullptr || HostContext::IsCurrent(s_schedulerFiber);
+}
+
bool GuestFiberManager::IsInitialized() {
return s_initialized;
}
@@ -414,6 +421,11 @@ void GuestFiberManager::SwitchToThread(uint32_t guestThreadAddr, CpuContext* cpu
// Switch to the target fiber (the target fiber will load its own context)
HostContext::Switch(fiberHandle);
+#if defined(__ANDROID__)
+ // A guest thread running on its own stack may have deferred an SDL event
+ // poll to the scheduler stack; service it now if that is where we are.
+ ServicePendingAuroraEventsOnScheduler();
+#endif
// When we return here, the fiber that issued SwitchToThread has resumed.
// That does not automatically mean the previous guest thread became runnable
diff --git a/runtime/src/guest_flat_memory.cpp b/runtime/src/guest_flat_memory.cpp
index 06d1898..9cab9f6 100644
--- a/runtime/src/guest_flat_memory.cpp
+++ b/runtime/src/guest_flat_memory.cpp
@@ -33,6 +33,15 @@
#include
#include
#include
+#if defined(__ANDROID__) && __ANDROID_API__ < 30
+// Bionic only declares memfd_create() from API 30, but every Android kernel
+// this build can run on (4.x on the Quest line) has had the syscall since 3.17.
+#include
+#include
+static int memfd_create(const char* name, unsigned int flags) {
+ return static_cast(syscall(__NR_memfd_create, name, flags));
+}
+#endif
#endif
namespace GuestFlat {
diff --git a/runtime/src/hle/audio/audio.cpp b/runtime/src/hle/audio/audio.cpp
index 1be5008..d4645d1 100644
--- a/runtime/src/hle/audio/audio.cpp
+++ b/runtime/src/hle/audio/audio.cpp
@@ -509,7 +509,8 @@ int64_t ConsumeAudioPollDeltaMicros()
// kMaxBlocksPerTick per pass. Never return early on a zero delta: two scheduler passes
// can land in the same microsecond and the backlog still needs servicing.
constexpr int64_t kMaxPollDeltaMicros = 100'000;
- return std::min(elapsed, kMaxPollDeltaMicros);
+ // int64_t is `long` on LP64 Android while the clock rep is `long long`; pick one type.
+ return std::min(static_cast(elapsed), static_cast(kMaxPollDeltaMicros));
}
} // namespace
diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp
index 068c6bf..da14bf7 100644
--- a/runtime/src/main.cpp
+++ b/runtime/src/main.cpp
@@ -70,6 +70,13 @@
#include
#include
#include
+
+#if defined(__ANDROID__)
+#include
+// Renames main() to SDL_main(), which SDLActivity's nativeRunMain resolves
+// from libmain.so with dlsym once the Java side has set up the surface.
+#include
+#endif
#include
// Defined in `runtime/src/hle/vi.cpp` (used by GX/VI HLE).
@@ -245,6 +252,11 @@ void WriteProcessTranscriptChunk(ProcessTranscriptState& state, const char* data
void PumpTranscriptPipe(ProcessTranscriptState& state, int readFd, int mirrorFd) {
std::array buffer{};
+#if defined(__ANDROID__)
+ // Android sends stdout/stderr to /dev/null, so the mirror fd shows nothing;
+ // forward complete lines to logcat as well (tag WiiCompiled).
+ std::string pendingLine;
+#endif
for (;;) {
#if defined(_WIN32)
const int bytesRead = _read(readFd, buffer.data(), static_cast(buffer.size()));
@@ -275,6 +287,21 @@ void PumpTranscriptPipe(ProcessTranscriptState& state, int readFd, int mirrorFd)
}
WriteProcessTranscriptChunk(state, buffer.data(), static_cast(bytesRead));
+#if defined(__ANDROID__)
+ pendingLine.append(buffer.data(), static_cast(bytesRead));
+ size_t lineStart = 0;
+ for (size_t newline = pendingLine.find('\n'); newline != std::string::npos;
+ newline = pendingLine.find('\n', lineStart)) {
+ const std::string line = pendingLine.substr(lineStart, newline - lineStart);
+ __android_log_write(ANDROID_LOG_INFO, "WiiCompiled", line.c_str());
+ lineStart = newline + 1;
+ }
+ pendingLine.erase(0, lineStart);
+ if (pendingLine.size() > 3500) {
+ __android_log_write(ANDROID_LOG_INFO, "WiiCompiled", pendingLine.c_str());
+ pendingLine.clear();
+ }
+#endif
}
}
@@ -1352,6 +1379,16 @@ int RuntimeMain(int argc, char** argv) {
const std::string auroraCachePath = RuntimeConfigFile::PathToUtf8(rendererCacheDirectory);
auroraConfig.userPath = auroraUserPath.c_str();
auroraConfig.cachePath = auroraCachePath.c_str();
+#if defined(__ANDROID__)
+ // Aurora defaults resourcesPath to SDL_GetBasePath(), which is empty on
+ // Android; the transferable pipeline cache lives with the other bundled
+ // resources the activity unpacked.
+ std::string auroraResourcesPath;
+ if (const auto resources = RuntimeConfigFile::ExecutableDirectory()) {
+ auroraResourcesPath = RuntimeConfigFile::PathToUtf8(*resources);
+ auroraConfig.resourcesPath = auroraResourcesPath.c_str();
+ }
+#endif
auroraConfig.logCallback = &RuntimeAuroraLogCallback;
auroraConfig.logLevel = LOG_DEBUG;
const bool configWidescreen = RuntimeConfigFile::WidescreenEnabled(true);
diff --git a/runtime/src/music_attenuation.cpp b/runtime/src/music_attenuation.cpp
index 3ce2b82..e048b31 100644
--- a/runtime/src/music_attenuation.cpp
+++ b/runtime/src/music_attenuation.cpp
@@ -16,7 +16,7 @@
#include
#include
#include
-#elif defined(__linux__)
+#elif defined(__linux__) && !defined(__ANDROID__)
#include
#include
@@ -196,7 +196,7 @@ void MonitorWindowsMediaSessions() noexcept {
<< error.message().c_str() << std::endl;
}
}
-#elif defined(__linux__)
+#elif defined(__linux__) && !defined(__ANDROID__)
// For linux - watches MPRIS players on the D-Bus session bus and drives the
// same g_externalMediaPlaying / g_mediaControl* atomics.
@@ -453,7 +453,7 @@ void StartMonitor() noexcept {
// The process owns this monitor for its remaining lifetime. Keeping it
// detached avoids shutdown ordering between WinRT and static audio state.
std::thread(MonitorWindowsMediaSessions).detach();
-#elif defined(__linux__)
+#elif defined(__linux__) && !defined(__ANDROID__)
// Detached so there is no shutdown ordering to manage against static audio state.
std::thread(MonitorLinuxMprisSessions).detach();
#else
diff --git a/runtime/src/platform/host_platform.cpp b/runtime/src/platform/host_platform.cpp
index c0caa95..4a8876b 100644
--- a/runtime/src/platform/host_platform.cpp
+++ b/runtime/src/platform/host_platform.cpp
@@ -46,6 +46,14 @@ std::optional ExecutableDirectory() noexcept {
std::error_code ec;
const auto resolved = std::filesystem::weakly_canonical(path, ec);
return (ec ? std::filesystem::path(path) : resolved).parent_path();
+#elif defined(__ANDROID__)
+ // Bundled read-only runtime resources (wii_bootstrap/, dsp_coef.bin,
+ // initial_pipeline_cache.db) unpacked from the APK by the activity. Stands
+ // in for the executable directory the desktop builds look next to.
+ if (const char* resources = std::getenv("MKW_ANDROID_RESOURCES_DIR"); resources && *resources) {
+ return std::filesystem::path(resources);
+ }
+ return std::nullopt;
#else
return std::nullopt;
#endif
@@ -66,6 +74,13 @@ std::filesystem::path ApplicationDataDirectory(std::string_view applicationName)
if (const passwd* user = getpwuid(getuid()); user && user->pw_dir && *user->pw_dir) {
return std::filesystem::path(user->pw_dir) / "Library" / "Application Support" / applicationName;
}
+#elif defined(__ANDROID__)
+ // The app's external files directory (Android/data//files) so a
+ // user can push game data and Config.toml with adb or a file browser; the
+ // activity falls back to the private files directory when there is none.
+ if (const char* data = std::getenv("MKW_ANDROID_DATA_DIR"); data && *data) {
+ return std::filesystem::path(data) / applicationName;
+ }
#endif
return std::filesystem::current_path() / applicationName;
}
diff --git a/runtime/src/vr/openxr_android.cpp b/runtime/src/vr/openxr_android.cpp
new file mode 100644
index 0000000..a88659a
--- /dev/null
+++ b/runtime/src/vr/openxr_android.cpp
@@ -0,0 +1,171 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#if defined(MKW_ENABLE_OPENXR) && defined(__ANDROID__)
+
+#include "vr/openxr_android.h"
+
+#include
+#include
+#include
+#include
+
+#define XR_USE_PLATFORM_ANDROID
+#include
+
+#include
+#include
+
+namespace mkw::vr {
+namespace {
+
+std::mutex g_mutex;
+JavaVM* g_vm = nullptr;
+jobject g_activity = nullptr; // global reference, never released: it lives as long as the process
+bool g_loader_initialized = false;
+XrInstanceCreateInfoAndroidKHR g_instance_create{XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR};
+
+void Emit(const OpenXRLogCallback& logger, OpenXRLogLevel level, const std::string& message) noexcept {
+ if (!logger) {
+ return;
+ }
+ try {
+ logger(level, message);
+ } catch (...) {
+ }
+}
+
+bool ResolveAndroidObjectsLocked(std::string* error) {
+ if (g_vm != nullptr && g_activity != nullptr) {
+ return true;
+ }
+ // SDL attaches the calling thread to the VM on demand and hands back the
+ // activity as a local reference the caller owns.
+ auto* env = static_cast(SDL_GetAndroidJNIEnv());
+ auto activity = static_cast(SDL_GetAndroidActivity());
+ if (env == nullptr || activity == nullptr) {
+ if (error != nullptr) {
+ *error = "SDL has not published the Android activity yet";
+ }
+ return false;
+ }
+ JavaVM* vm = nullptr;
+ if (env->GetJavaVM(&vm) != JNI_OK || vm == nullptr) {
+ env->DeleteLocalRef(activity);
+ if (error != nullptr) {
+ *error = "JNIEnv::GetJavaVM failed";
+ }
+ return false;
+ }
+ g_vm = vm;
+ g_activity = env->NewGlobalRef(activity);
+ env->DeleteLocalRef(activity);
+ if (g_activity == nullptr) {
+ if (error != nullptr) {
+ *error = "could not pin the Android activity";
+ }
+ return false;
+ }
+ return true;
+}
+
+} // namespace
+
+bool OpenXRAndroidInitializeLoader(OpenXRLogCallback logger, std::string* error) {
+ std::lock_guard lock(g_mutex);
+ if (g_loader_initialized) {
+ return true;
+ }
+ if (!ResolveAndroidObjectsLocked(error)) {
+ return false;
+ }
+
+ PFN_xrInitializeLoaderKHR initialize_loader = nullptr;
+ XrResult result = xrGetInstanceProcAddr(
+ XR_NULL_HANDLE, "xrInitializeLoaderKHR",
+ reinterpret_cast(&initialize_loader));
+ if (XR_FAILED(result) || initialize_loader == nullptr) {
+ if (error != nullptr) {
+ std::ostringstream message;
+ message << "xrInitializeLoaderKHR is unavailable (" << static_cast(result) << ')';
+ *error = message.str();
+ }
+ return false;
+ }
+
+ // The activity is deliberately used as the loader context (an Activity is
+ // a Context). Meta's runtime keys activity readiness off it; with a plain
+ // application context the session parks in IDLE forever (DolphinXR finding).
+ XrLoaderInitInfoAndroidKHR loader_init{XR_TYPE_LOADER_INIT_INFO_ANDROID_KHR};
+ loader_init.applicationVM = g_vm;
+ loader_init.applicationContext = g_activity;
+ result = initialize_loader(reinterpret_cast(&loader_init));
+ if (XR_FAILED(result)) {
+ if (error != nullptr) {
+ std::ostringstream message;
+ message << "xrInitializeLoaderKHR failed (" << static_cast(result) << ')';
+ *error = message.str();
+ }
+ return false;
+ }
+
+ g_instance_create.next = nullptr;
+ g_instance_create.applicationVM = g_vm;
+ g_instance_create.applicationActivity = g_activity;
+ g_loader_initialized = true;
+ Emit(logger, OpenXRLogLevel::Info, "OpenXR Android loader initialized against the SDL activity");
+ return true;
+}
+
+const void* OpenXRAndroidInstanceCreateNext() {
+ std::lock_guard lock(g_mutex);
+ return g_loader_initialized ? &g_instance_create : nullptr;
+}
+
+bool OpenXRAndroidRegisterThread(OpenXRRuntime& runtime, OpenXRAndroidThreadType type) {
+ if (!runtime.HasSession()) {
+ return false;
+ }
+ const auto& extensions = runtime.EnabledExtensions();
+ bool enabled = false;
+ for (const auto& extension : extensions) {
+ if (extension == XR_KHR_ANDROID_THREAD_SETTINGS_EXTENSION_NAME) {
+ enabled = true;
+ break;
+ }
+ }
+ if (!enabled) {
+ return false;
+ }
+ PFN_xrSetAndroidApplicationThreadKHR set_thread = nullptr;
+ if (!runtime.LoadFunction("xrSetAndroidApplicationThreadKHR", &set_thread) || set_thread == nullptr) {
+ return false;
+ }
+ XrAndroidThreadTypeKHR xr_type = XR_ANDROID_THREAD_TYPE_APPLICATION_WORKER_KHR;
+ switch (type) {
+ case OpenXRAndroidThreadType::ApplicationMain:
+ xr_type = XR_ANDROID_THREAD_TYPE_APPLICATION_MAIN_KHR;
+ break;
+ case OpenXRAndroidThreadType::ApplicationWorker:
+ xr_type = XR_ANDROID_THREAD_TYPE_APPLICATION_WORKER_KHR;
+ break;
+ case OpenXRAndroidThreadType::RendererMain:
+ xr_type = XR_ANDROID_THREAD_TYPE_RENDERER_MAIN_KHR;
+ break;
+ case OpenXRAndroidThreadType::RendererWorker:
+ xr_type = XR_ANDROID_THREAD_TYPE_RENDERER_WORKER_KHR;
+ break;
+ }
+ const auto thread_id = static_cast(syscall(SYS_gettid));
+ XrResult result = set_thread(runtime.Session(), xr_type, thread_id);
+ if (XR_FAILED(result) && type == OpenXRAndroidThreadType::RendererWorker) {
+ // Some Quest runtime builds advertise the extension but reject the
+ // renderer-worker type; the renderer-main hint still raises priority.
+ result = set_thread(runtime.Session(), XR_ANDROID_THREAD_TYPE_RENDERER_MAIN_KHR, thread_id);
+ }
+ runtime.ObserveResult(result);
+ return XR_SUCCEEDED(result);
+}
+
+} // namespace mkw::vr
+
+#endif // defined(MKW_ENABLE_OPENXR) && defined(__ANDROID__)
diff --git a/runtime/src/vr/openxr_input.cpp b/runtime/src/vr/openxr_input.cpp
new file mode 100644
index 0000000..e3474ba
--- /dev/null
+++ b/runtime/src/vr/openxr_input.cpp
@@ -0,0 +1,450 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#if defined(MKW_ENABLE_OPENXR)
+
+#include "vr/openxr_input.h"
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#if defined(__ANDROID__)
+#include
+#endif
+
+namespace mkw::vr {
+namespace {
+
+#if defined(__ANDROID__)
+// Debug-only remote button presses for headset experiments driven over adb, so
+// a menu can be reached without someone wearing the headset:
+// adb shell setprop debug.wiicompiled.inject :
+// A new sequence number holds the button for kInjectHoldFrames XR frames.
+// Buttons: a, b, x, y, start, up, down, left, right. The property is unset in
+// normal use, so this costs one property read every few frames.
+constexpr uint32_t kInjectHoldFrames = 12;
+constexpr uint32_t kInjectPollFrames = 4;
+
+struct InjectedPress {
+ long sequence = -1;
+ std::string button;
+ uint32_t frames_left = 0;
+ uint32_t poll_countdown = 0;
+};
+
+InjectedPress& Injection() {
+ static InjectedPress press;
+ return press;
+}
+
+void PollInjection() {
+ InjectedPress& press = Injection();
+ if (press.frames_left > 0) {
+ --press.frames_left;
+ }
+ if (press.poll_countdown > 0) {
+ --press.poll_countdown;
+ return;
+ }
+ press.poll_countdown = kInjectPollFrames;
+ char value[PROP_VALUE_MAX]{};
+ if (__system_property_get("debug.wiicompiled.inject", value) <= 0) {
+ return;
+ }
+ char* end = nullptr;
+ const long sequence = std::strtol(value, &end, 10);
+ if (end == value || *end != ':' || sequence == press.sequence) {
+ return;
+ }
+ const bool first_read = press.sequence < 0;
+ press.sequence = sequence;
+ if (first_read) {
+ return; // A value left over from an earlier run is not a new press.
+ }
+ press.button = end + 1;
+ press.frames_left = kInjectHoldFrames;
+}
+
+bool Injected(const char* button) {
+ const InjectedPress& press = Injection();
+ return press.frames_left > 0 && press.button == button;
+}
+#else
+void PollInjection() {}
+bool Injected(const char*) { return false; }
+#endif
+
+constexpr uint32_t kHandCount = 2;
+
+struct Binding {
+ XrAction* action;
+ const char* path;
+};
+
+Sint16 ToAxis(float value) noexcept {
+ const float clamped = std::clamp(value, -1.0f, 1.0f);
+ return static_cast(std::lround(clamped * 32767.0f));
+}
+
+// Trigger axes are reported by SDL gamepads on the positive half only.
+Sint16 ToTrigger(float value) noexcept {
+ const float clamped = std::clamp(value, 0.0f, 1.0f);
+ return static_cast(std::lround(clamped * 32767.0f));
+}
+
+} // namespace
+
+OpenXRInput::OpenXRInput(OpenXRLogCallback logger) : m_logger(std::move(logger)) {}
+
+OpenXRInput::~OpenXRInput() {
+ Destroy();
+}
+
+bool OpenXRInput::Create(OpenXRRuntime& runtime) {
+ m_last_error.clear();
+ if (m_created) {
+ return true;
+ }
+ if (!runtime.IsInitialized() || !runtime.HasSession()) {
+ m_last_error = "OpenXR input needs an initialized runtime with a session";
+ return false;
+ }
+ m_runtime = &runtime;
+
+ if (!Check(xrStringToPath(runtime.Instance(), "/user/hand/left", &m_hand_paths[0]),
+ "xrStringToPath(/user/hand/left)") ||
+ !Check(xrStringToPath(runtime.Instance(), "/user/hand/right", &m_hand_paths[1]),
+ "xrStringToPath(/user/hand/right)")) {
+ Destroy();
+ return false;
+ }
+ if (!CreateActions() || !SuggestBindings()) {
+ Destroy();
+ return false;
+ }
+
+ XrSessionActionSetsAttachInfo attach{XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO};
+ attach.countActionSets = 1;
+ attach.actionSets = &m_action_set;
+ if (!Check(xrAttachSessionActionSets(runtime.Session(), &attach), "xrAttachSessionActionSets")) {
+ Destroy();
+ return false;
+ }
+ m_created = true;
+ if (!AttachVirtualGamepad()) {
+ Log(OpenXRLogLevel::Warning,
+ "SDL refused the virtual gamepad; OpenXR controllers will not reach the game");
+ }
+ Log(OpenXRLogLevel::Info, "OpenXR controller actions attached");
+ return true;
+}
+
+bool OpenXRInput::CreateActions() {
+ XrActionSetCreateInfo set_info{XR_TYPE_ACTION_SET_CREATE_INFO};
+ std::strncpy(set_info.actionSetName, "mkw_gameplay", XR_MAX_ACTION_SET_NAME_SIZE - 1);
+ std::strncpy(set_info.localizedActionSetName, "Gameplay", XR_MAX_LOCALIZED_ACTION_SET_NAME_SIZE - 1);
+ set_info.priority = 0;
+ if (!Check(xrCreateActionSet(m_runtime->Instance(), &set_info, &m_action_set), "xrCreateActionSet")) {
+ return false;
+ }
+
+ struct Spec {
+ XrAction* action;
+ const char* name;
+ const char* localized;
+ XrActionType type;
+ };
+ const std::array specs{{
+ {&m_thumbstick, "thumbstick", "Thumbstick", XR_ACTION_TYPE_VECTOR2F_INPUT},
+ {&m_thumbstick_click, "thumbstick_click", "Thumbstick Click", XR_ACTION_TYPE_BOOLEAN_INPUT},
+ {&m_trigger, "trigger", "Trigger", XR_ACTION_TYPE_FLOAT_INPUT},
+ {&m_squeeze, "squeeze", "Grip", XR_ACTION_TYPE_FLOAT_INPUT},
+ {&m_button_primary, "button_primary", "A / X", XR_ACTION_TYPE_BOOLEAN_INPUT},
+ {&m_button_secondary, "button_secondary", "B / Y", XR_ACTION_TYPE_BOOLEAN_INPUT},
+ {&m_menu, "menu", "Menu", XR_ACTION_TYPE_BOOLEAN_INPUT},
+ {&m_haptic, "haptic", "Haptic", XR_ACTION_TYPE_VIBRATION_OUTPUT},
+ }};
+ for (const Spec& spec : specs) {
+ XrActionCreateInfo info{XR_TYPE_ACTION_CREATE_INFO};
+ info.actionType = spec.type;
+ std::strncpy(info.actionName, spec.name, XR_MAX_ACTION_NAME_SIZE - 1);
+ std::strncpy(info.localizedActionName, spec.localized, XR_MAX_LOCALIZED_ACTION_NAME_SIZE - 1);
+ info.countSubactionPaths = kHandCount;
+ info.subactionPaths = m_hand_paths;
+ if (!Check(xrCreateAction(m_action_set, &info, spec.action), spec.name)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+bool OpenXRInput::SuggestBindings() {
+ const auto suggest = [&](const char* profile, const std::vector& bindings, bool required) {
+ XrPath profile_path = XR_NULL_PATH;
+ if (!Check(xrStringToPath(m_runtime->Instance(), profile, &profile_path), profile)) {
+ return false;
+ }
+ std::vector suggested;
+ suggested.reserve(bindings.size());
+ for (const Binding& binding : bindings) {
+ XrPath path = XR_NULL_PATH;
+ if (XR_FAILED(xrStringToPath(m_runtime->Instance(), binding.path, &path))) {
+ continue;
+ }
+ suggested.push_back({*binding.action, path});
+ }
+ XrInteractionProfileSuggestedBinding info{XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING};
+ info.interactionProfile = profile_path;
+ info.countSuggestedBindings = static_cast(suggested.size());
+ info.suggestedBindings = suggested.data();
+ const XrResult result = xrSuggestInteractionProfileBindings(m_runtime->Instance(), &info);
+ m_runtime->ObserveResult(result);
+ if (XR_FAILED(result)) {
+ std::ostringstream message;
+ message << "xrSuggestInteractionProfileBindings(" << profile << ") failed (" << result << ')';
+ if (required) {
+ m_last_error = message.str();
+ Log(OpenXRLogLevel::Error, m_last_error);
+ return false;
+ }
+ Log(OpenXRLogLevel::Warning, message.str());
+ }
+ return true;
+ };
+
+ // Meta Quest Touch controllers (Quest 2 / 3 / Pro all expose this profile).
+ const std::vector touch{
+ {&m_thumbstick, "/user/hand/left/input/thumbstick"},
+ {&m_thumbstick, "/user/hand/right/input/thumbstick"},
+ {&m_thumbstick_click, "/user/hand/left/input/thumbstick/click"},
+ {&m_thumbstick_click, "/user/hand/right/input/thumbstick/click"},
+ {&m_trigger, "/user/hand/left/input/trigger/value"},
+ {&m_trigger, "/user/hand/right/input/trigger/value"},
+ {&m_squeeze, "/user/hand/left/input/squeeze/value"},
+ {&m_squeeze, "/user/hand/right/input/squeeze/value"},
+ {&m_button_primary, "/user/hand/left/input/x/click"},
+ {&m_button_primary, "/user/hand/right/input/a/click"},
+ {&m_button_secondary, "/user/hand/left/input/y/click"},
+ {&m_button_secondary, "/user/hand/right/input/b/click"},
+ {&m_menu, "/user/hand/left/input/menu/click"},
+ {&m_haptic, "/user/hand/left/output/haptic"},
+ {&m_haptic, "/user/hand/right/output/haptic"},
+ };
+ if (!suggest("/interaction_profiles/oculus/touch_controller", touch, true)) {
+ return false;
+ }
+ // Minimal fallback so an unfamiliar runtime still offers a select and a menu.
+ const std::vector simple{
+ {&m_button_primary, "/user/hand/right/input/select/click"},
+ {&m_button_secondary, "/user/hand/left/input/select/click"},
+ {&m_menu, "/user/hand/left/input/menu/click"},
+ {&m_haptic, "/user/hand/left/output/haptic"},
+ {&m_haptic, "/user/hand/right/output/haptic"},
+ };
+ suggest("/interaction_profiles/khr/simple_controller", simple, false);
+ return true;
+}
+
+bool OpenXRInput::AttachVirtualGamepad() {
+ SDL_VirtualJoystickDesc desc;
+ SDL_INIT_INTERFACE(&desc);
+ desc.type = SDL_JOYSTICK_TYPE_GAMEPAD;
+ desc.naxes = SDL_GAMEPAD_AXIS_COUNT;
+ desc.nbuttons = SDL_GAMEPAD_BUTTON_COUNT;
+ desc.button_mask = (1u << SDL_GAMEPAD_BUTTON_SOUTH) | (1u << SDL_GAMEPAD_BUTTON_EAST) |
+ (1u << SDL_GAMEPAD_BUTTON_WEST) | (1u << SDL_GAMEPAD_BUTTON_NORTH) |
+ (1u << SDL_GAMEPAD_BUTTON_START) | (1u << SDL_GAMEPAD_BUTTON_LEFT_STICK) |
+ (1u << SDL_GAMEPAD_BUTTON_RIGHT_STICK) |
+ (1u << SDL_GAMEPAD_BUTTON_LEFT_SHOULDER) |
+ (1u << SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER);
+ desc.axis_mask = (1u << SDL_GAMEPAD_AXIS_LEFTX) | (1u << SDL_GAMEPAD_AXIS_LEFTY) |
+ (1u << SDL_GAMEPAD_AXIS_RIGHTX) | (1u << SDL_GAMEPAD_AXIS_RIGHTY) |
+ (1u << SDL_GAMEPAD_AXIS_LEFT_TRIGGER) | (1u << SDL_GAMEPAD_AXIS_RIGHT_TRIGGER);
+ desc.name = "OpenXR Touch Controllers";
+ const SDL_JoystickID id = SDL_AttachVirtualJoystick(&desc);
+ if (id == 0) {
+ m_last_error = std::string("SDL_AttachVirtualJoystick failed: ") + SDL_GetError();
+ Log(OpenXRLogLevel::Warning, m_last_error);
+ return false;
+ }
+ SDL_Joystick* joystick = SDL_OpenJoystick(id);
+ if (joystick == nullptr) {
+ m_last_error = std::string("SDL_OpenJoystick failed: ") + SDL_GetError();
+ Log(OpenXRLogLevel::Warning, m_last_error);
+ SDL_DetachVirtualJoystick(id);
+ return false;
+ }
+ m_joystick_id = id;
+ m_joystick = joystick;
+ return true;
+}
+
+void OpenXRInput::DetachVirtualGamepad() {
+ if (m_joystick != nullptr) {
+ SDL_CloseJoystick(static_cast(m_joystick));
+ m_joystick = nullptr;
+ }
+ if (m_joystick_id != 0) {
+ SDL_DetachVirtualJoystick(m_joystick_id);
+ m_joystick_id = 0;
+ }
+}
+
+void OpenXRInput::Destroy() {
+ DetachVirtualGamepad();
+ if (m_action_set != XR_NULL_HANDLE) {
+ // Destroying the set destroys every action created from it.
+ xrDestroyActionSet(m_action_set);
+ m_action_set = XR_NULL_HANDLE;
+ }
+ m_thumbstick = m_thumbstick_click = m_trigger = m_squeeze = XR_NULL_HANDLE;
+ m_button_primary = m_button_secondary = m_menu = m_haptic = XR_NULL_HANDLE;
+ m_hand_paths[0] = m_hand_paths[1] = XR_NULL_PATH;
+ m_created = false;
+ m_runtime = nullptr;
+}
+
+void OpenXRInput::Sync(XrTime) {
+ if (!m_created || m_runtime == nullptr || !m_runtime->IsSessionFocused()) {
+ return;
+ }
+ XrActiveActionSet active{m_action_set, XR_NULL_PATH};
+ XrActionsSyncInfo sync{XR_TYPE_ACTIONS_SYNC_INFO};
+ sync.countActiveActionSets = 1;
+ sync.activeActionSets = &active;
+ const XrResult result = xrSyncActions(m_runtime->Session(), &sync);
+ m_runtime->ObserveResult(result);
+ if (XR_FAILED(result)) {
+ if (!m_logged_sync_failure) {
+ m_logged_sync_failure = true;
+ std::ostringstream message;
+ message << "xrSyncActions failed (" << result << ')';
+ Log(OpenXRLogLevel::Warning, message.str());
+ }
+ return;
+ }
+ if (m_joystick == nullptr) {
+ return;
+ }
+ auto* joystick = static_cast(m_joystick);
+
+ const auto boolean = [&](XrAction action, uint32_t hand) {
+ XrActionStateGetInfo info{XR_TYPE_ACTION_STATE_GET_INFO};
+ info.action = action;
+ info.subactionPath = m_hand_paths[hand];
+ XrActionStateBoolean state{XR_TYPE_ACTION_STATE_BOOLEAN};
+ return XR_SUCCEEDED(xrGetActionStateBoolean(m_runtime->Session(), &info, &state)) &&
+ state.isActive == XR_TRUE && state.currentState == XR_TRUE;
+ };
+ const auto scalar = [&](XrAction action, uint32_t hand) {
+ XrActionStateGetInfo info{XR_TYPE_ACTION_STATE_GET_INFO};
+ info.action = action;
+ info.subactionPath = m_hand_paths[hand];
+ XrActionStateFloat state{XR_TYPE_ACTION_STATE_FLOAT};
+ if (XR_FAILED(xrGetActionStateFloat(m_runtime->Session(), &info, &state)) ||
+ state.isActive != XR_TRUE) {
+ return 0.0f;
+ }
+ return state.currentState;
+ };
+ const auto vector = [&](XrAction action, uint32_t hand) {
+ XrActionStateGetInfo info{XR_TYPE_ACTION_STATE_GET_INFO};
+ info.action = action;
+ info.subactionPath = m_hand_paths[hand];
+ XrActionStateVector2f state{XR_TYPE_ACTION_STATE_VECTOR2F};
+ if (XR_FAILED(xrGetActionStateVector2f(m_runtime->Session(), &info, &state)) ||
+ state.isActive != XR_TRUE) {
+ return XrVector2f{0.0f, 0.0f};
+ }
+ return state.currentState;
+ };
+
+ PollInjection();
+ XrVector2f left = vector(m_thumbstick, 0);
+ const XrVector2f right = vector(m_thumbstick, 1);
+ if (Injected("up")) {
+ left.y = 1.0f;
+ } else if (Injected("down")) {
+ left.y = -1.0f;
+ } else if (Injected("left")) {
+ left.x = -1.0f;
+ } else if (Injected("right")) {
+ left.x = 1.0f;
+ }
+ // OpenXR thumbsticks report +Y up; SDL gamepads report +Y down.
+ SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_LEFTX, ToAxis(left.x));
+ SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_LEFTY, ToAxis(-left.y));
+ SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_RIGHTX, ToAxis(right.x));
+ SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_RIGHTY, ToAxis(-right.y));
+ SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_LEFT_TRIGGER, ToTrigger(scalar(m_trigger, 0)));
+ SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, ToTrigger(scalar(m_trigger, 1)));
+
+ SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_SOUTH,
+ boolean(m_button_primary, 1) || Injected("a"));
+ SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_EAST,
+ boolean(m_button_secondary, 1) || Injected("b"));
+ SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_WEST,
+ boolean(m_button_primary, 0) || Injected("x"));
+ SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_NORTH,
+ boolean(m_button_secondary, 0) || Injected("y"));
+ SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_START, boolean(m_menu, 0) || Injected("start"));
+ SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_LEFT_STICK, boolean(m_thumbstick_click, 0));
+ SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_RIGHT_STICK, boolean(m_thumbstick_click, 1));
+ SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, scalar(m_squeeze, 0) > 0.5f);
+ SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, scalar(m_squeeze, 1) > 0.5f);
+}
+
+void OpenXRInput::ApplyHaptic(uint32_t hand, float amplitude, XrDuration duration) {
+ if (!m_created || m_runtime == nullptr || hand >= kHandCount || m_haptic == XR_NULL_HANDLE) {
+ return;
+ }
+ XrHapticVibration vibration{XR_TYPE_HAPTIC_VIBRATION};
+ vibration.amplitude = std::clamp(amplitude, 0.0f, 1.0f);
+ vibration.duration = duration;
+ vibration.frequency = XR_FREQUENCY_UNSPECIFIED;
+ XrHapticActionInfo info{XR_TYPE_HAPTIC_ACTION_INFO};
+ info.action = m_haptic;
+ info.subactionPath = m_hand_paths[hand];
+ if (vibration.amplitude <= 0.0f) {
+ xrStopHapticFeedback(m_runtime->Session(), &info);
+ return;
+ }
+ xrApplyHapticFeedback(m_runtime->Session(), &info,
+ reinterpret_cast(&vibration));
+}
+
+bool OpenXRInput::Check(XrResult result, const char* operation) {
+ if (m_runtime != nullptr) {
+ m_runtime->ObserveResult(result);
+ }
+ if (XR_SUCCEEDED(result)) {
+ return true;
+ }
+ std::ostringstream message;
+ message << operation << " failed (" << result << ')';
+ m_last_error = message.str();
+ Log(OpenXRLogLevel::Error, m_last_error);
+ return false;
+}
+
+void OpenXRInput::Log(OpenXRLogLevel level, const std::string& message) const noexcept {
+ if (!m_logger) {
+ return;
+ }
+ try {
+ m_logger(level, message);
+ } catch (...) {
+ }
+}
+
+} // namespace mkw::vr
+
+#endif // defined(MKW_ENABLE_OPENXR)
diff --git a/runtime/src/vr/openxr_integration.cpp b/runtime/src/vr/openxr_integration.cpp
index ccd7542..7f63b12 100644
--- a/runtime/src/vr/openxr_integration.cpp
+++ b/runtime/src/vr/openxr_integration.cpp
@@ -27,12 +27,24 @@
#include
#if defined(MKW_ENABLE_OPENXR)
+#include "vr/openxr_backend.h"
+#include "vr/openxr_input.h"
#include "vr/openxr_runtime.h"
#if defined(_WIN32)
#include "vr/openxr_d3d12.h"
+#define MKW_OPENXR_GRAPHICS_BACKEND 1
+#elif defined(__ANDROID__)
+#include "vr/openxr_android.h"
+#include "vr/openxr_vulkan.h"
+#include
+#define XR_USE_TIMESPEC
+#include
+#define MKW_OPENXR_GRAPHICS_BACKEND 1
#else
-#include "vr/openxr_vulkan_backend.h"
+#define MKW_OPENXR_GRAPHICS_BACKEND 0
#endif
+#else
+#define MKW_OPENXR_GRAPHICS_BACKEND 0
#endif
namespace mkw::vr {
@@ -40,6 +52,14 @@ namespace {
inline constexpr float kDegreesToRadians = 0.01745329252f;
+// A standalone headset has no desktop to fall back to, so VR is on unless the
+// user's configuration turns it off. Desktop builds keep the opt-in default.
+#if defined(__ANDROID__)
+inline constexpr bool kVrEnabledDefault = true;
+#else
+inline constexpr bool kVrEnabledDefault = false;
+#endif
+
void ConfigurePolicy(bool enabled) noexcept {
MkwVRPolicyReset();
MkwVRPolicyConfig config{};
@@ -54,7 +74,15 @@ void ConfigurePolicy(bool enabled) noexcept {
MkwVRFirstPersonApplyConfiguredSettings();
}
-#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32)
+#if MKW_OPENXR_GRAPHICS_BACKEND
+
+#if defined(_WIN32)
+using GraphicsBackend = OpenXRD3D12Backend;
+inline constexpr const char* kGraphicsBackendName = "D3D12";
+#else
+using GraphicsBackend = OpenXRVulkanBackend;
+inline constexpr const char* kGraphicsBackendName = "Vulkan";
+#endif
struct Quaternion {
float x = 0.0f;
@@ -240,17 +268,16 @@ public:
last_error_.clear();
}
if (graphics_retained_) {
- SetError("OpenXR cannot be restarted after an unfenceable D3D12 submission");
+ SetError(std::string("OpenXR cannot be restarted after an unfenceable ") +
+ kGraphicsBackendName + " submission");
return OpenXRStartupResult::Unavailable;
}
- requested_ = RuntimeConfigFile::VrEnabled(false);
+ requested_ = RuntimeConfigFile::VrEnabled(kVrEnabledDefault);
ConfigurePolicy(requested_);
if (!requested_) {
return OpenXRStartupResult::Disabled;
}
- if (aurora_config.desiredBackend != BACKEND_AUTO &&
- aurora_config.desiredBackend != BACKEND_D3D12) {
- SetError("OpenXR currently requires the D3D12 graphics backend on Windows");
+ if (!BackendMatchesConfiguredGraphicsApi(aurora_config)) {
return OpenXRStartupResult::Unavailable;
}
@@ -259,27 +286,56 @@ public:
level == OpenXRLogLevel::Warning ? "warning" : "info";
RT_LOG(RT_TAG_RUNTIME) << "[openxr::" << name << "] " << message << std::endl;
};
+#if defined(__ANDROID__)
+ {
+ std::string loader_error;
+ if (!OpenXRAndroidInitializeLoader(logger_, &loader_error)) {
+ SetError("OpenXR Android loader initialization failed: " + loader_error);
+ return OpenXRStartupResult::Unavailable;
+ }
+ }
+#endif
runtime_ = std::make_unique(logger_);
- backend_ = std::make_unique(logger_);
+ backend_ = std::make_unique(logger_);
OpenXRConfig config{};
config.application_name = aurora_config.appName != nullptr ? aurora_config.appName
: "WiiCompiled";
config.engine_name = "Aurora";
config.resolution_scale = RuntimeConfigFile::VrRenderScale(1.0f);
+#if defined(_WIN32)
config.required_extensions = {"XR_KHR_D3D12_enable"};
- config.optional_extensions = {"XR_KHR_win32_convert_performance_counter_time", "XR_FB_display_refresh_rate"};
+ config.optional_extensions = {"XR_KHR_win32_convert_performance_counter_time",
+ "XR_FB_display_refresh_rate"};
+#else
+ // Either Vulkan binding extension is acceptable; the backend picks
+ // whichever the runtime enabled, preferring enable2.
+ config.required_extensions = {"XR_KHR_android_create_instance"};
+ config.optional_extensions = {"XR_KHR_vulkan_enable2", "XR_KHR_vulkan_enable",
+ "XR_KHR_convert_timespec_time",
+ "XR_KHR_android_thread_settings",
+ "XR_FB_display_refresh_rate"};
+ config.instance_create_next = OpenXRAndroidInstanceCreateNext();
+#endif
if (!runtime_->Initialize(config)) {
SetError("OpenXR instance initialization failed: " + runtime_->LastError().message);
ResetPreparedObjects();
return OpenXRStartupResult::Unavailable;
}
const auto& extensions = runtime_->EnabledExtensions();
- if (std::find(extensions.begin(), extensions.end(),
- "XR_KHR_win32_convert_performance_counter_time") != extensions.end()) {
+ const auto has_extension = [&](const char* name) {
+ return std::find(extensions.begin(), extensions.end(), name) != extensions.end();
+ };
+#if defined(_WIN32)
+ if (has_extension("XR_KHR_win32_convert_performance_counter_time")) {
runtime_->LoadFunction("xrConvertTimeToWin32PerformanceCounterKHR", &convert_display_time_);
}
- if (std::find(extensions.begin(), extensions.end(), "XR_FB_display_refresh_rate") != extensions.end()) {
+#else
+ if (has_extension("XR_KHR_convert_timespec_time")) {
+ runtime_->LoadFunction("xrConvertTimeToTimespecTimeKHR", &convert_display_time_);
+ }
+#endif
+ if (has_extension("XR_FB_display_refresh_rate")) {
runtime_->LoadFunction("xrGetDisplayRefreshRateFB", &get_display_refresh_rate_);
}
interpolation_available_.store(convert_display_time_ != nullptr, std::memory_order_release);
@@ -289,12 +345,7 @@ public:
return OpenXRStartupResult::Unavailable;
}
- const auto& requirements = backend_->GraphicsRequirements();
- aurora_config.desiredBackend = BACKEND_D3D12;
- aurora_config.xrInterop = true;
- aurora_config.hasD3D12AdapterLuid = true;
- aurora_config.d3d12AdapterLuidLow = requirements.adapter_luid_low;
- aurora_config.d3d12AdapterLuidHigh = requirements.adapter_luid_high;
+ ApplyGraphicsRequirements(aurora_config);
prepared_ = true;
return OpenXRStartupResult::Prepared;
}
@@ -303,8 +354,9 @@ public:
if (!prepared_ || runtime_ == nullptr || backend_ == nullptr) {
return !requested_;
}
- if (active_backend != BACKEND_D3D12) {
- SetError("Aurora could not create the OpenXR-required D3D12 backend");
+ if (active_backend != kRequiredAuroraBackend) {
+ SetError(std::string("Aurora could not create the OpenXR-required ") +
+ kGraphicsBackendName + " backend");
ResetPreparedObjects();
return false;
}
@@ -313,6 +365,12 @@ public:
ResetPreparedObjects();
return false;
}
+ input_ = std::make_unique(logger_);
+ if (!input_->Create(*runtime_)) {
+ RT_LOG(RT_TAG_RUNTIME) << "OpenXR controller input unavailable: " << input_->LastError()
+ << std::endl;
+ input_.reset();
+ }
stop_.store(false, std::memory_order_release);
{
@@ -334,7 +392,8 @@ public:
ResetPreparedObjects();
return false;
}
- RT_LOG(RT_TAG_RUNTIME) << "OpenXR asynchronous D3D12 presentation started" << std::endl;
+ RT_LOG(RT_TAG_RUNTIME) << "OpenXR asynchronous " << kGraphicsBackendName
+ << " presentation started" << std::endl;
return true;
}
@@ -426,17 +485,51 @@ private:
AuroraStereoFrame frame{};
};
+#if defined(_WIN32)
+ static constexpr AuroraBackend kRequiredAuroraBackend = BACKEND_D3D12;
+#else
+ static constexpr AuroraBackend kRequiredAuroraBackend = BACKEND_VULKAN;
+#endif
+
+ bool BackendMatchesConfiguredGraphicsApi(const AuroraConfig& aurora_config) {
+ if (aurora_config.desiredBackend == BACKEND_AUTO ||
+ aurora_config.desiredBackend == kRequiredAuroraBackend) {
+ return true;
+ }
+ SetError(std::string("OpenXR requires the ") + kGraphicsBackendName +
+ " graphics backend on this platform");
+ return false;
+ }
+
+ void ApplyGraphicsRequirements(AuroraConfig& aurora_config) {
+ aurora_config.desiredBackend = kRequiredAuroraBackend;
+ aurora_config.xrInterop = true;
+#if defined(_WIN32)
+ const auto& requirements = backend_->GraphicsRequirements();
+ aurora_config.hasD3D12AdapterLuid = true;
+ aurora_config.d3d12AdapterLuidLow = requirements.adapter_luid_low;
+ aurora_config.d3d12AdapterLuidHigh = requirements.adapter_luid_high;
+#endif
+ }
+
void ResetPreparedObjects() {
ShutdownOrRetainGraphicsObjects();
+ input_.reset();
backend_.reset();
runtime_.reset();
prepared_ = false;
}
bool ShutdownOrRetainGraphicsObjects() noexcept {
+ if (input_ != nullptr) {
+ // Actions belong to the session and must go before it does.
+ input_->Destroy();
+ input_.reset();
+ }
if (backend_ != nullptr && !backend_->Shutdown()) {
RT_LOG(RT_TAG_RUNTIME)
- << "OpenXR D3D12 queue completion is unknown; retaining the backend, "
+ << "OpenXR " << kGraphicsBackendName
+ << " queue completion is unknown; retaining the backend, "
"runtime, session, and graphics resources until process exit"
<< std::endl;
(void)backend_.release();
@@ -468,6 +561,11 @@ private:
}
void PacingThread() noexcept {
+#if defined(__ANDROID__)
+ if (runtime_ != nullptr) {
+ OpenXRAndroidRegisterThread(*runtime_, OpenXRAndroidThreadType::RendererMain);
+ }
+#endif
bool fatal = false;
bool presentation_logged = false;
VRPresentationMode logged_presentation = VRPresentationMode::Desktop;
@@ -489,7 +587,7 @@ private:
}
}
if (events == OpenXREventStatus::ExitRequested) {
- SetError("OpenXR runtime requested session exit; continuing on the desktop mirror");
+ SetError("OpenXR runtime requested session exit; continuing on the mirror output");
break;
}
if (events == OpenXREventStatus::Error) {
@@ -525,10 +623,10 @@ private:
<< ", bindings=0x" << std::hex << policy.available_bindings
<< std::dec << std::endl;
}
- OpenXRD3D12Presentation presentation{};
+ OpenXRPresentation presentation{};
const bool immersive = policy.presentation == VRPresentationMode::ImmersiveRace;
- presentation.mode = immersive ? OpenXRD3D12FrameMode::ImmersiveProjection
- : OpenXRD3D12FrameMode::VirtualScreen;
+ presentation.mode = immersive ? OpenXRFrameMode::ImmersiveProjection
+ : OpenXRFrameMode::VirtualScreen;
presentation.quad_distance_meters = policy.config.hud_distance_meters;
presentation.quad_width_meters = policy.config.hud_width_meters;
@@ -537,23 +635,26 @@ private:
const uint32_t interpolation_target = frame_interpolation_fps_.load(std::memory_order_relaxed);
SetInterpolationActive(immersive && FrameInterpolationAvailable() && interpolation_target != 0);
- OpenXRD3D12Frame frame{};
- const OpenXRD3D12BeginStatus begin = backend_->BeginFrame(presentation, frame);
- if (begin == OpenXRD3D12BeginStatus::SessionNotRunning) {
+ OpenXRBackendFrame frame{};
+ const OpenXRBeginStatus begin = backend_->BeginFrame(presentation, frame);
+ if (begin == OpenXRBeginStatus::SessionNotRunning) {
MkwVRPolicySetSessionActive(false);
continue;
}
- if (begin == OpenXRD3D12BeginStatus::ExitRequested) {
- SetError("OpenXR runtime requested session exit; continuing on the desktop mirror");
+ if (begin == OpenXRBeginStatus::ExitRequested) {
+ SetError("OpenXR runtime requested session exit; continuing on the mirror output");
break;
}
- if (begin == OpenXRD3D12BeginStatus::Error) {
+ if (begin == OpenXRBeginStatus::Error) {
SetError(backend_->LastError());
fatal = true;
break;
}
UpdateFrameTiming(frame.xr_frame);
+ if (input_ != nullptr) {
+ input_->Sync(frame.xr_frame.predicted_display_time);
+ }
// Both of these read this frame's located head pose and must run
// before FinishFrame submits a layer built from it.
ServiceRecenterRequest();
@@ -588,16 +689,16 @@ private:
}
aurora_notify_stereo_frame();
- OpenXRD3D12SubmissionStatus submission = OpenXRD3D12SubmissionStatus::Timeout;
+ OpenXRSubmissionStatus submission = OpenXRSubmissionStatus::Timeout;
bool canceled_before_encode = false;
const auto cancel_after =
std::chrono::steady_clock::now() + std::chrono::milliseconds(50);
while (!stop_.load(std::memory_order_acquire) &&
- submission == OpenXRD3D12SubmissionStatus::Timeout) {
+ submission == OpenXRSubmissionStatus::Timeout) {
// Fresh rendering wakes us immediately. A 50 ms keep-alive
// protects stalls without issuing eager repeats during GPU work.
submission = backend_->WaitForSubmission(frame, 50);
- if (submission == OpenXRD3D12SubmissionStatus::Timeout) {
+ if (submission == OpenXRSubmissionStatus::Timeout) {
// A pause, minimized window, or guest stall may leave no GX
// frame to consume this packet. Withdraw it, then cancel the
// matching bridge target only if Encode has not taken ownership.
@@ -632,12 +733,13 @@ private:
if (fatal) {
break;
}
- const bool submit = submission == OpenXRD3D12SubmissionStatus::Success;
+ const bool submit = submission == OpenXRSubmissionStatus::Success;
if (!backend_->FinishFrame(frame, submit)) {
SetError(backend_->LastError());
fatal = true;
} else if (!submit) {
- SetError("Aurora's D3D12 stereo copy failed; continuing on the desktop mirror");
+ SetError(std::string("Aurora's ") + kGraphicsBackendName +
+ " stereo copy failed; continuing on the mirror output");
fatal = true;
} else {
++timing_submissions_;
@@ -665,7 +767,7 @@ private:
ShutdownOrRetainGraphicsObjects();
}
- void BuildPublishedFrame(const OpenXRD3D12Frame& source, bool immersive,
+ void BuildPublishedFrame(const OpenXRBackendFrame& source, bool immersive,
float units_per_meter, uint64_t content_tag) noexcept {
ApplyPendingReferenceSpaceChange(source.xr_frame);
auto& destination = published_frame_.frame;
@@ -722,8 +824,8 @@ private:
// Anchors the menu screen in the application space and holds it there. The
// pose is captured once, from the first frame whose head pose is good enough
// to place it, and released again by a recenter or an origin change.
- void UpdateVirtualScreenPose(OpenXRD3D12Frame& frame) noexcept {
- if (frame.presentation.mode != OpenXRD3D12FrameMode::VirtualScreen) {
+ void UpdateVirtualScreenPose(OpenXRBackendFrame& frame) noexcept {
+ if (frame.presentation.mode != OpenXRFrameMode::VirtualScreen) {
return;
}
constexpr XrViewStateFlags kPoseUsable =
@@ -775,17 +877,31 @@ private:
}
}
+ // Converts the compositor's predicted display time onto the runtime's
+ // steady clock, which is what Aurora's interpolation deadlines are paced by.
uint64_t DisplayTimeNanos(XrTime display_time) noexcept {
if (convert_display_time_ == nullptr) return 0;
+ const auto now = std::chrono::steady_clock::now();
+ const auto now_ns = std::chrono::duration_cast(now.time_since_epoch()).count();
+#if defined(_WIN32)
LARGE_INTEGER display_counter{}, counter{}, frequency{};
if (XR_FAILED(convert_display_time_(runtime_->Instance(), display_time, &display_counter)) ||
!QueryPerformanceFrequency(&frequency) || frequency.QuadPart <= 0 ||
!QueryPerformanceCounter(&counter)) return 0;
- const auto now = std::chrono::steady_clock::now();
- const auto now_ns = std::chrono::duration_cast(now.time_since_epoch()).count();
const auto delta = static_cast(
(static_cast(display_counter.QuadPart) - static_cast(counter.QuadPart)) *
1.0e9 / static_cast(frequency.QuadPart));
+#else
+ // XR_KHR_convert_timespec_time yields CLOCK_MONOTONIC, the clock behind
+ // libc++'s steady_clock, so the delta is measured on that clock too.
+ timespec display_spec{};
+ timespec now_spec{};
+ if (XR_FAILED(convert_display_time_(runtime_->Instance(), display_time, &display_spec)) ||
+ clock_gettime(CLOCK_MONOTONIC, &now_spec) != 0) return 0;
+ const int64_t display_ns = static_cast(display_spec.tv_sec) * 1'000'000'000ll + display_spec.tv_nsec;
+ const int64_t monotonic_ns = static_cast(now_spec.tv_sec) * 1'000'000'000ll + now_spec.tv_nsec;
+ const int64_t delta = display_ns - monotonic_ns;
+#endif
return now_ns + delta > 0 ? static_cast(now_ns + delta) : 0;
}
@@ -810,7 +926,8 @@ private:
OpenXRLogCallback logger_;
std::unique_ptr runtime_;
- std::unique_ptr backend_;
+ std::unique_ptr backend_;
+ std::unique_ptr input_;
std::thread pacing_thread_;
std::atomic_bool stop_{false};
std::atomic_bool running_{false};
@@ -827,7 +944,11 @@ private:
std::chrono::steady_clock::time_point timing_start_ = std::chrono::steady_clock::now();
uint32_t timing_submissions_ = 0;
PFN_xrGetDisplayRefreshRateFB get_display_refresh_rate_ = nullptr;
+#if defined(_WIN32)
using ConvertDisplayTime = XrResult (XRAPI_PTR*)(XrInstance, XrTime, LARGE_INTEGER*);
+#else
+ using ConvertDisplayTime = XrResult (XRAPI_PTR*)(XrInstance, XrTime, struct timespec*);
+#endif
ConvertDisplayTime convert_display_time_ = nullptr;
std::atomic published_{nullptr};
PublishedFrame published_frame_{};
@@ -849,7 +970,7 @@ private:
bool graphics_retained_ = false;
};
-#endif // defined(MKW_ENABLE_OPENXR) && defined(_WIN32)
+#endif // MKW_OPENXR_GRAPHICS_BACKEND
} // namespace
@@ -857,32 +978,32 @@ OpenXRStartupResult OpenXRPrepareAurora(AuroraConfig& config) {
#if !defined(MKW_ENABLE_OPENXR)
(void)config;
ConfigurePolicy(false);
- return RuntimeConfigFile::VrEnabled(false) ? OpenXRStartupResult::Unavailable
- : OpenXRStartupResult::Disabled;
-#elif defined(_WIN32)
+ return RuntimeConfigFile::VrEnabled(kVrEnabledDefault) ? OpenXRStartupResult::Unavailable
+ : OpenXRStartupResult::Disabled;
+#elif MKW_OPENXR_GRAPHICS_BACKEND
return OpenXRIntegration::Get().Prepare(config);
#else
- ConfigurePolicy(RuntimeConfigFile::VrEnabled(false));
- if (!RuntimeConfigFile::VrEnabled(false)) {
+ (void)config;
+ ConfigurePolicy(RuntimeConfigFile::VrEnabled(kVrEnabledDefault));
+ if (!RuntimeConfigFile::VrEnabled(kVrEnabledDefault)) {
return OpenXRStartupResult::Disabled;
}
- const OpenXRVulkanCapabilityInfo capability = OpenXRVulkanBackend::DawnInteropCapability();
- RT_LOG(RT_TAG_RUNTIME) << "OpenXR Vulkan unavailable: " << capability.reason << std::endl;
+ RT_LOG(RT_TAG_RUNTIME) << "OpenXR is not wired to a graphics backend on this platform" << std::endl;
return OpenXRStartupResult::Unavailable;
#endif
}
bool OpenXRStartAfterAurora(AuroraBackend active_backend) {
-#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32)
+#if MKW_OPENXR_GRAPHICS_BACKEND
return OpenXRIntegration::Get().Start(active_backend);
#else
(void)active_backend;
- return !RuntimeConfigFile::VrEnabled(false);
+ return !RuntimeConfigFile::VrEnabled(kVrEnabledDefault);
#endif
}
void OpenXRShutdownBeforeAurora() noexcept {
-#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32)
+#if MKW_OPENXR_GRAPHICS_BACKEND
OpenXRIntegration::Get().Shutdown();
#else
MkwVRPolicySetSessionActive(false);
@@ -890,13 +1011,13 @@ void OpenXRShutdownBeforeAurora() noexcept {
}
void OpenXRServiceProducerFrameBoundary() noexcept {
-#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32)
+#if MKW_OPENXR_GRAPHICS_BACKEND
OpenXRIntegration::Get().ServiceProducerFrameBoundary();
#endif
}
bool OpenXRIsRunning() noexcept {
-#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32)
+#if MKW_OPENXR_GRAPHICS_BACKEND
return OpenXRIntegration::Get().IsRunning();
#else
return false;
@@ -904,13 +1025,13 @@ bool OpenXRIsRunning() noexcept {
}
void OpenXRRequestRecenter() noexcept {
-#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32)
+#if MKW_OPENXR_GRAPHICS_BACKEND
OpenXRIntegration::Get().RequestRecenter();
#endif
}
void OpenXRSetLeanBackDegrees(float degrees) noexcept {
-#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32)
+#if MKW_OPENXR_GRAPHICS_BACKEND
OpenXRIntegration::Get().SetLeanBackDegrees(degrees);
#else
(void)degrees;
@@ -918,7 +1039,7 @@ void OpenXRSetLeanBackDegrees(float degrees) noexcept {
}
void OpenXRSetFrameInterpolationFps(uint32_t target) noexcept {
-#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32)
+#if MKW_OPENXR_GRAPHICS_BACKEND
OpenXRIntegration::Get().SetFrameInterpolationFps(target);
#else
(void)target;
@@ -926,7 +1047,7 @@ void OpenXRSetFrameInterpolationFps(uint32_t target) noexcept {
}
OpenXRFrameTiming OpenXRGetFrameTiming() noexcept {
-#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32)
+#if MKW_OPENXR_GRAPHICS_BACKEND
return OpenXRIntegration::Get().FrameTiming();
#else
return {};
@@ -934,7 +1055,7 @@ OpenXRFrameTiming OpenXRGetFrameTiming() noexcept {
}
bool OpenXRFrameInterpolationAvailable() noexcept {
-#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32)
+#if MKW_OPENXR_GRAPHICS_BACKEND
return OpenXRIntegration::Get().FrameInterpolationAvailable();
#else
return false;
@@ -944,10 +1065,10 @@ bool OpenXRFrameInterpolationAvailable() noexcept {
std::string OpenXRLastError() {
#if !defined(MKW_ENABLE_OPENXR)
return "this build was compiled without OpenXR support";
-#elif defined(_WIN32)
+#elif MKW_OPENXR_GRAPHICS_BACKEND
return OpenXRIntegration::Get().LastError();
#else
- return OpenXRVulkanBackend::DawnInteropCapability().reason;
+ return "OpenXR is not wired to a graphics backend on this platform";
#endif
}
diff --git a/runtime/src/vr/openxr_runtime.cpp b/runtime/src/vr/openxr_runtime.cpp
index b2364c8..05626ca 100644
--- a/runtime/src/vr/openxr_runtime.cpp
+++ b/runtime/src/vr/openxr_runtime.cpp
@@ -215,6 +215,7 @@ bool OpenXRRuntime::CreateInstance() {
}
XrInstanceCreateInfo create_info{XR_TYPE_INSTANCE_CREATE_INFO};
+ create_info.next = m_config.instance_create_next;
CopyOpenXRName(create_info.applicationInfo.applicationName,
XR_MAX_APPLICATION_NAME_SIZE, m_config.application_name);
create_info.applicationInfo.applicationVersion = m_config.application_version;
diff --git a/runtime/src/vr/openxr_vulkan.cpp b/runtime/src/vr/openxr_vulkan.cpp
new file mode 100644
index 0000000..bba67ef
--- /dev/null
+++ b/runtime/src/vr/openxr_vulkan.cpp
@@ -0,0 +1,1603 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#if defined(MKW_ENABLE_OPENXR) && defined(__ANDROID__)
+
+// OpenXR's Vulkan structures are selected when openxr_platform.h is parsed.
+#define VK_USE_PLATFORM_ANDROID_KHR
+#define XR_USE_GRAPHICS_API_VULKAN
+#define XR_USE_PLATFORM_ANDROID
+
+#include "vr/openxr_vulkan.h"
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace mkw::vr {
+namespace {
+
+// Two buffers per eye: Dawn writes one while this backend's queue copies the
+// other into the compositor image. The sync-fd handshake orders the two
+// devices on each buffer, so a deeper ring only adds latency.
+constexpr uint32_t kSlotCount = 2;
+// Copy command buffers in flight before the oldest fence is waited on.
+constexpr uint32_t kSubmissionRingSize = 3;
+constexpr uint64_t kFenceTimeoutNanos = 2'000'000'000ull;
+
+int CopyFamily(VkFormat 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 SameCopyFamily(VkFormat left, VkFormat right) noexcept {
+ const int family = CopyFamily(left);
+ return family != 0 && family == CopyFamily(right);
+}
+
+VkFormat SrgbSibling(VkFormat format) noexcept {
+ switch (format) {
+ case VK_FORMAT_R8G8B8A8_UNORM:
+ case VK_FORMAT_R8G8B8A8_SRGB:
+ return VK_FORMAT_R8G8B8A8_SRGB;
+ case VK_FORMAT_B8G8R8A8_UNORM:
+ case VK_FORMAT_B8G8R8A8_SRGB:
+ return VK_FORMAT_B8G8R8A8_SRGB;
+ default:
+ return VK_FORMAT_UNDEFINED;
+ }
+}
+
+const char* BeginStatusOperation(OpenXRFrameStatus status) noexcept {
+ switch (status) {
+ case OpenXRFrameStatus::Ready:
+ return "ready";
+ case OpenXRFrameStatus::SessionNotRunning:
+ return "session is not running";
+ case OpenXRFrameStatus::ExitRequested:
+ return "runtime requested exit";
+ case OpenXRFrameStatus::Error:
+ return "xrWaitFrame failed";
+ }
+ return "unknown frame status";
+}
+
+std::vector SplitExtensionList(const std::string& text) {
+ std::vector names;
+ std::istringstream stream(text);
+ std::string name;
+ while (stream >> name) {
+ names.push_back(name);
+ }
+ return names;
+}
+
+void CloseFd(int& fd) noexcept {
+ if (fd >= 0) {
+ ::close(fd);
+ }
+ fd = -1;
+}
+
+} // namespace
+
+class OpenXRVulkanBackend::Impl final {
+public:
+ explicit Impl(OpenXRLogCallback logger) : logger_(std::move(logger)) {}
+
+ ~Impl() { Shutdown(); }
+
+ struct EyeSwapchain {
+ XrSwapchain handle = XR_NULL_HANDLE;
+ uint32_t width = 0;
+ uint32_t height = 0;
+ std::vector images;
+ uint32_t acquired_index = 0;
+ bool acquired = false;
+ bool waited = false;
+ bool release_forbidden = false;
+ };
+
+ // One AHardwareBuffer shared with Dawn, imported into this backend's own
+ // device. Dawn writes it, this device reads it into the compositor image.
+ struct EyeSlot {
+ AHardwareBuffer* buffer = nullptr;
+ VkImage image = VK_NULL_HANDLE;
+ VkDeviceMemory memory = VK_NULL_HANDLE;
+ VkFormat format = VK_FORMAT_UNDEFINED;
+ // Waits on Dawn's release fence (temporary sync-fd import).
+ VkSemaphore wait_semaphore = VK_NULL_HANDLE;
+ // Signalled by this device's copy and exported as the fence Dawn waits on.
+ VkSemaphore signal_semaphore = VK_NULL_HANDLE;
+ VkImageLayout layout = VK_IMAGE_LAYOUT_UNDEFINED;
+ int pending_acquire_fd = -1;
+ uint32_t width = 0;
+ uint32_t height = 0;
+ };
+
+ struct Submission {
+ VkFence fence = VK_NULL_HANDLE;
+ VkCommandBuffer command_buffer = VK_NULL_HANDLE;
+ bool busy = false;
+ };
+
+ struct PendingCopy {
+ uint64_t token = 0;
+ uint32_t slot = 0;
+ uint32_t target_count = 0;
+ std::array swapchain_images{};
+ };
+
+ bool QueryGraphicsRequirements(OpenXRRuntime& runtime) {
+ ClearError();
+ if (!runtime.IsInitialized() || runtime.HasSession()) {
+ return Fail("OpenXR must own an instance, but no session, before querying Vulkan requirements");
+ }
+ if (requirements_queried_ && runtime_ != &runtime) {
+ return Fail("Vulkan graphics requirements were already queried from another OpenXR instance");
+ }
+
+ const auto& extensions = runtime.EnabledExtensions();
+ const bool enable2 = std::find(extensions.begin(), extensions.end(),
+ std::string(XR_KHR_VULKAN_ENABLE2_EXTENSION_NAME)) != extensions.end();
+ const bool enable1 = std::find(extensions.begin(), extensions.end(),
+ std::string(XR_KHR_VULKAN_ENABLE_EXTENSION_NAME)) != extensions.end();
+ if (!enable2 && !enable1) {
+ return Fail("the OpenXR runtime offers neither XR_KHR_vulkan_enable2 nor XR_KHR_vulkan_enable");
+ }
+
+ XrGraphicsRequirementsVulkan2KHR requirements{XR_TYPE_GRAPHICS_REQUIREMENTS_VULKAN2_KHR};
+ XrResult result = XR_ERROR_RUNTIME_FAILURE;
+ if (enable2) {
+ PFN_xrGetVulkanGraphicsRequirements2KHR get_requirements = nullptr;
+ if (!runtime.LoadFunction("xrGetVulkanGraphicsRequirements2KHR", &get_requirements) ||
+ get_requirements == nullptr) {
+ return Fail("OpenXR runtime did not expose xrGetVulkanGraphicsRequirements2KHR");
+ }
+ result = get_requirements(runtime.Instance(), runtime.SystemId(), &requirements);
+ } else {
+ PFN_xrGetVulkanGraphicsRequirementsKHR get_requirements = nullptr;
+ if (!runtime.LoadFunction("xrGetVulkanGraphicsRequirementsKHR", &get_requirements) ||
+ get_requirements == nullptr) {
+ return Fail("OpenXR runtime did not expose xrGetVulkanGraphicsRequirementsKHR");
+ }
+ result = get_requirements(runtime.Instance(), runtime.SystemId(), &requirements);
+ }
+ runtime.ObserveResult(result);
+ if (XR_FAILED(result)) {
+ std::ostringstream message;
+ message << "xrGetVulkanGraphicsRequirements failed (" << result << ')';
+ return Fail(message.str());
+ }
+
+ runtime_ = &runtime;
+ {
+ std::lock_guard lock(submission_mutex_);
+ shutting_down_ = false;
+ submission_unsafe_ = false;
+ }
+ requirements_ = {
+ requirements.minApiVersionSupported,
+ requirements.maxApiVersionSupported,
+ enable2,
+ };
+ requirements_queried_ = true;
+
+ std::ostringstream message;
+ message << "OpenXR Vulkan requirements: API "
+ << XR_VERSION_MAJOR(requirements_.min_api_version) << '.'
+ << XR_VERSION_MINOR(requirements_.min_api_version) << " to "
+ << XR_VERSION_MAJOR(requirements_.max_api_version) << '.'
+ << XR_VERSION_MINOR(requirements_.max_api_version) << " via "
+ << (enable2 ? XR_KHR_VULKAN_ENABLE2_EXTENSION_NAME : XR_KHR_VULKAN_ENABLE_EXTENSION_NAME);
+ Log(OpenXRLogLevel::Info, message.str());
+ return true;
+ }
+
+ bool BindAurora(OpenXRRuntime& runtime) {
+ ClearError();
+ if (!requirements_queried_ || runtime_ != &runtime || runtime.HasSession()) {
+ return Fail("QueryGraphicsRequirements must succeed on this OpenXR instance before BindAurora");
+ }
+ if (bound_) {
+ return Fail("OpenXR Vulkan backend is already bound");
+ }
+
+ AuroraVulkanNativeHandles handles{};
+ if (!aurora_vulkan_get_native_handles(&handles)) {
+ return Fail("Aurora did not report a Dawn Vulkan device");
+ }
+ if (!handles.sharedTextureMemoryAHardwareBuffer || !handles.sharedFenceSyncFd) {
+ return Fail("Aurora's Dawn device lacks AHardwareBuffer shared memory or sync-fd fences");
+ }
+ aurora_format_ = static_cast(handles.colorVkFormat);
+ if (CopyFamily(aurora_format_) == 2) {
+ return Fail("Aurora selected a BGRA colour format, which Android cannot share through AHardwareBuffer");
+ }
+ if (CopyFamily(aurora_format_) == 0) {
+ return Fail("Aurora's colour format cannot be copied into an OpenXR swapchain");
+ }
+
+ if (!CreateVulkanObjects()) {
+ DestroyVulkanObjects();
+ return false;
+ }
+
+ XrGraphicsBindingVulkan2KHR binding{XR_TYPE_GRAPHICS_BINDING_VULKAN2_KHR};
+ binding.instance = vk_instance_;
+ binding.physicalDevice = vk_physical_;
+ binding.device = vk_device_;
+ binding.queueFamilyIndex = queue_family_;
+ binding.queueIndex = 0;
+ if (!runtime.CreateSession(&binding)) {
+ DestroyVulkanObjects();
+ return Fail("OpenXR rejected the Vulkan device binding");
+ }
+ owns_session_ = true;
+
+ if (!SelectSwapchainFormat() || !CreateSwapchains() || !AllocateSlots()) {
+ DestroySwapchains();
+ DestroySlots();
+ runtime.DestroySession();
+ owns_session_ = false;
+ DestroyVulkanObjects();
+ return false;
+ }
+ if (runtime.ShouldExit()) {
+ DestroySwapchains();
+ DestroySlots();
+ runtime.DestroySession();
+ owns_session_ = false;
+ DestroyVulkanObjects();
+ return Fail("OpenXR session became loss-pending while creating Vulkan swapchains");
+ }
+ if (!aurora_vulkan_enable_stereo_bridge(&Impl::OnAuroraSubmitted, this)) {
+ DestroySwapchains();
+ DestroySlots();
+ runtime.DestroySession();
+ owns_session_ = false;
+ DestroyVulkanObjects();
+ return Fail("Aurora could not enable its AHardwareBuffer stereo bridge");
+ }
+ bridge_enabled_ = true;
+ bound_ = true;
+
+ std::ostringstream message;
+ message << "OpenXR Vulkan swapchains ready: VkFormat "
+ << static_cast(swapchain_format_) << ", eyes "
+ << eye_swapchains_[0].width << 'x' << eye_swapchains_[0].height << " / "
+ << eye_swapchains_[1].width << 'x' << eye_swapchains_[1].height
+ << ", shared buffers " << kSlotCount << " per eye";
+ Log(OpenXRLogLevel::Info, message.str());
+ return true;
+ }
+
+ OpenXRBeginStatus BeginFrame(const OpenXRPresentation& presentation, OpenXRBackendFrame& frame) {
+ frame = {};
+ frame.presentation = presentation;
+ if (!bound_ || runtime_ == nullptr) {
+ Fail("BeginFrame called before the Vulkan backend was bound");
+ return OpenXRBeginStatus::Error;
+ }
+ if (frame_active_) {
+ Fail("BeginFrame called while another OpenXR frame is active");
+ return OpenXRBeginStatus::Error;
+ }
+
+ const OpenXRFrameStatus status = runtime_->WaitFrame(frame.xr_frame);
+ if (status != OpenXRFrameStatus::Ready) {
+ if (status == OpenXRFrameStatus::Error) {
+ Fail(BeginStatusOperation(status));
+ }
+ switch (status) {
+ case OpenXRFrameStatus::SessionNotRunning:
+ return OpenXRBeginStatus::SessionNotRunning;
+ case OpenXRFrameStatus::ExitRequested:
+ return OpenXRBeginStatus::ExitRequested;
+ case OpenXRFrameStatus::Error:
+ return OpenXRBeginStatus::Error;
+ case OpenXRFrameStatus::Ready:
+ break;
+ }
+ }
+ if (!runtime_->BeginFrame(frame.xr_frame)) {
+ Fail("xrBeginFrame failed");
+ return OpenXRBeginStatus::Error;
+ }
+ frame_active_ = true;
+ active_frame_serial_ = frame.xr_frame.serial;
+ active_frame_ = frame.xr_frame;
+ render_session_serial_ = runtime_->SessionRunSerial();
+ render_space_serial_ = runtime_->LastReferenceSpaceChange().serial;
+
+ for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) {
+ frame.render_width[eye] = eye_swapchains_[eye].width;
+ frame.render_height[eye] = eye_swapchains_[eye].height;
+ }
+
+ if (!frame.xr_frame.should_render) {
+ return OpenXRBeginStatus::Ready;
+ }
+ if (!runtime_->LocateViews(frame.xr_frame)) {
+ Fail("xrLocateViews failed");
+ EndActiveFrameWithoutLayers(frame.xr_frame);
+ return OpenXRBeginStatus::Error;
+ }
+ active_frame_ = frame.xr_frame;
+ if (!frame.xr_frame.views_valid) {
+ return OpenXRBeginStatus::Ready;
+ }
+
+ const uint32_t target_count =
+ presentation.mode == OpenXRFrameMode::VirtualScreen ? 1u : kOpenXREyeCount;
+ if (target_count == 1) {
+ frame.render_width[1] = frame.render_width[0];
+ frame.render_height[1] = frame.render_height[0];
+ }
+
+ for (uint32_t eye = 0; eye < target_count; ++eye) {
+ if (!AcquireSwapchain(eye_swapchains_[eye])) {
+ ReleaseAcquiredSwapchains();
+ EndActiveFrameWithoutLayers(frame.xr_frame);
+ return OpenXRBeginStatus::Error;
+ }
+ }
+
+ std::array targets{};
+ const uint32_t slot = next_slot_;
+ {
+ std::lock_guard lock(vk_mutex_);
+ for (uint32_t eye = 0; eye < target_count; ++eye) {
+ EyeSlot& eye_slot = slots_[eye][slot];
+ targets[eye] = {
+ eye_slot.buffer,
+ eye_slot.width,
+ eye_slot.height,
+ static_cast(aurora_format_),
+ eye_slot.pending_acquire_fd,
+ static_cast(eye_slot.layout),
+ };
+ }
+ pending_copy_ = {frame.xr_frame.serial, slot, target_count, {}};
+ for (uint32_t eye = 0; eye < target_count; ++eye) {
+ pending_copy_.swapchain_images[eye] =
+ eye_swapchains_[eye].images[eye_swapchains_[eye].acquired_index].image;
+ }
+ }
+ {
+ std::lock_guard lock(submission_mutex_);
+ awaiting_token_ = frame.xr_frame.serial;
+ submitted_token_ = 0;
+ submission_arrived_ = false;
+ submission_success_ = false;
+ submission_unsafe_ = false;
+ }
+ if (!aurora_vulkan_set_stereo_targets(frame.xr_frame.serial, targets.data(), target_count)) {
+ {
+ std::lock_guard lock(submission_mutex_);
+ awaiting_token_ = 0;
+ }
+ ReleaseAcquiredSwapchains();
+ Fail("Aurora rejected the AHardwareBuffer stereo targets");
+ EndActiveFrameWithoutLayers(frame.xr_frame);
+ return OpenXRBeginStatus::Error;
+ }
+ {
+ // Aurora now owns the acquire descriptors it accepted.
+ std::lock_guard lock(vk_mutex_);
+ for (uint32_t eye = 0; eye < target_count; ++eye) {
+ slots_[eye][slot].pending_acquire_fd = -1;
+ }
+ }
+ next_slot_ = (slot + 1) % kSlotCount;
+ frame.expects_gpu_submission = true;
+ return OpenXRBeginStatus::Ready;
+ }
+
+ OpenXRSubmissionStatus WaitForSubmission(const OpenXRBackendFrame& frame, uint32_t timeout_ms) {
+ if (!frame.expects_gpu_submission) {
+ return OpenXRSubmissionStatus::Success;
+ }
+ std::unique_lock lock(submission_mutex_);
+ const auto ready = [&] {
+ return shutting_down_ ||
+ (submission_arrived_ && submitted_token_ == frame.xr_frame.serial);
+ };
+ if (timeout_ms == std::numeric_limits::max()) {
+ submission_cv_.wait(lock, ready);
+ } else if (!submission_cv_.wait_for(lock, std::chrono::milliseconds(timeout_ms), ready)) {
+ return OpenXRSubmissionStatus::Timeout;
+ }
+ if (shutting_down_) {
+ return OpenXRSubmissionStatus::ShuttingDown;
+ }
+ return submission_success_ ? OpenXRSubmissionStatus::Success
+ : OpenXRSubmissionStatus::Failed;
+ }
+
+ bool TryCancelPendingFrame(OpenXRBackendFrame& frame) {
+ if (!frame_active_ || !frame.expects_gpu_submission ||
+ frame.xr_frame.serial != active_frame_serial_) {
+ return false;
+ }
+ if (!aurora_vulkan_cancel_stereo_targets(frame.xr_frame.serial)) {
+ return false;
+ }
+ std::lock_guard lock(submission_mutex_);
+ awaiting_token_ = 0;
+ submitted_token_ = 0;
+ submission_arrived_ = false;
+ submission_success_ = false;
+ submission_unsafe_ = false;
+ frame.expects_gpu_submission = false;
+ return true;
+ }
+
+ bool FinishFrame(OpenXRBackendFrame& frame, bool submit_layer) {
+ if (!frame_active_ || runtime_ == nullptr ||
+ frame.xr_frame.serial != active_frame_serial_) {
+ return Fail("FinishFrame received a stale or inactive OpenXR frame token");
+ }
+
+ bool submission_unsafe = false;
+ {
+ std::lock_guard lock(submission_mutex_);
+ submission_unsafe = submission_arrived_ &&
+ submitted_token_ == frame.xr_frame.serial &&
+ submission_unsafe_;
+ }
+ if (submission_unsafe) {
+ AbandonAcquiredSwapchains();
+ Fail("Aurora's stereo submission failed after GPU work may have been queued");
+ }
+ bool release_ok = ReleaseAcquiredSwapchains();
+ const bool position_valid =
+ (frame.xr_frame.view_state_flags & XR_VIEW_STATE_POSITION_VALID_BIT) != 0;
+ const bool composition_pose_valid =
+ frame.presentation.mode == OpenXRFrameMode::VirtualScreen || position_valid;
+ const bool can_submit = submit_layer && release_ok && frame.xr_frame.should_render &&
+ frame.xr_frame.views_valid && frame.expects_gpu_submission &&
+ composition_pose_valid;
+ if (can_submit) {
+ // xrEndFrame references the most recently released image of a
+ // swapchain, so keep the displayed pair separate from the pair
+ // Aurora may write next.
+ std::swap(eye_swapchains_, retained_swapchains_);
+ retained_frame_ = frame;
+ retained_session_serial_ = render_session_serial_;
+ retained_space_serial_ = render_space_serial_;
+ have_retained_frame_ = true;
+ }
+ const bool end_ok = EndRetainedFrame();
+
+ frame_active_ = false;
+ active_frame_serial_ = 0;
+ active_frame_ = {};
+ frame.expects_gpu_submission = false;
+ {
+ std::lock_guard lock(submission_mutex_);
+ awaiting_token_ = 0;
+ submission_arrived_ = false;
+ submission_success_ = false;
+ submission_unsafe_ = false;
+ }
+ return release_ok && end_ok;
+ }
+
+ bool RepeatFrame(const OpenXRBackendFrame& frame) {
+ if (!frame_active_ || runtime_ == nullptr ||
+ frame.xr_frame.serial != active_frame_serial_) {
+ return Fail("RepeatFrame received a stale or inactive render token");
+ }
+ const bool end_ok = EndRetainedFrame();
+ frame_active_ = false;
+ if (!end_ok) {
+ return Fail("OpenXR could not resubmit the retained frame");
+ }
+ if (runtime_->PollEvents() != OpenXREventStatus::Continue ||
+ !runtime_->IsSessionRunning() || runtime_->ShouldExit()) {
+ return Fail("OpenXR session stopped while waiting for stereo rendering");
+ }
+ if (runtime_->WaitFrame(active_frame_) != OpenXRFrameStatus::Ready ||
+ !runtime_->BeginFrame(active_frame_)) {
+ return Fail("OpenXR could not start a retained-frame compositor cycle");
+ }
+ frame_active_ = true;
+ return true;
+ }
+
+ bool EndRetainedFrame() {
+ if (!runtime_->IsSessionRunning()) {
+ return true;
+ }
+ if (retained_session_serial_ != runtime_->SessionRunSerial() ||
+ retained_space_serial_ != runtime_->LastReferenceSpaceChange().serial) {
+ have_retained_frame_ = false;
+ }
+ if (!have_retained_frame_ || !active_frame_.should_render) {
+ return runtime_->EndFrameWithoutLayers(active_frame_);
+ }
+ const auto& frame = retained_frame_;
+ if (frame.presentation.mode == OpenXRFrameMode::VirtualScreen) {
+ XrCompositionLayerQuad quad{XR_TYPE_COMPOSITION_LAYER_QUAD};
+ quad.layerFlags = 0;
+ quad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
+ quad.subImage.swapchain = retained_swapchains_[0].handle;
+ quad.subImage.imageRect = {{0, 0},
+ {static_cast(retained_swapchains_[0].width),
+ static_cast(retained_swapchains_[0].height)}};
+ quad.subImage.imageArrayIndex = 0;
+ if (frame.presentation.quad_anchored) {
+ quad.space = runtime_->AppSpace();
+ quad.pose = frame.presentation.quad_pose;
+ } else {
+ quad.space = runtime_->ViewSpace();
+ quad.pose.orientation = {0.0f, 0.0f, 0.0f, 1.0f};
+ quad.pose.position = {
+ 0.0f, 0.0f, -std::max(0.25f, frame.presentation.quad_distance_meters)};
+ }
+ quad.size.width = std::max(0.25f, frame.presentation.quad_width_meters);
+ quad.size.height = quad.size.width * static_cast(retained_swapchains_[0].height) /
+ static_cast(retained_swapchains_[0].width);
+ const XrCompositionLayerBaseHeader* layers[] = {
+ reinterpret_cast(&quad)};
+ return runtime_->EndFrame(active_frame_, layers, 1);
+ }
+ std::array views{};
+ for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) {
+ views[eye] = {XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW};
+ views[eye].pose.orientation = frame.xr_frame.views[eye].pose.orientation;
+ views[eye].pose.position = frame.xr_frame.views[eye].pose.position;
+ views[eye].fov = frame.xr_frame.views[eye].fov;
+ views[eye].subImage.swapchain = retained_swapchains_[eye].handle;
+ views[eye].subImage.imageRect = {
+ {0, 0},
+ {static_cast(retained_swapchains_[eye].width),
+ static_cast(retained_swapchains_[eye].height)}};
+ views[eye].subImage.imageArrayIndex = 0;
+ }
+ XrCompositionLayerProjection projection{XR_TYPE_COMPOSITION_LAYER_PROJECTION};
+ projection.layerFlags = 0;
+ projection.space = runtime_->AppSpace();
+ projection.viewCount = kOpenXREyeCount;
+ projection.views = views.data();
+ const XrCompositionLayerBaseHeader* layers[] = {
+ reinterpret_cast(&projection)};
+ return runtime_->EndFrame(active_frame_, layers, 1);
+ }
+
+ bool Shutdown() {
+ if (shutdown_unsafe_) {
+ return false;
+ }
+ {
+ std::lock_guard lock(submission_mutex_);
+ shutting_down_ = true;
+ }
+ submission_cv_.notify_all();
+
+ bool bridge_drained = true;
+ if (bridge_enabled_) {
+ bridge_drained = aurora_vulkan_disable_stereo_bridge();
+ bridge_enabled_ = false;
+ }
+ bool device_idle = true;
+ if (vk_device_ != VK_NULL_HANDLE) {
+ std::lock_guard lock(vk_mutex_);
+ device_idle = vkDeviceWaitIdle(vk_device_) == VK_SUCCESS;
+ }
+ if (!bridge_drained || !device_idle) {
+ AbandonAcquiredSwapchains();
+ shutdown_unsafe_ = true;
+ Fail("Vulkan queue completion is unknown; retaining the OpenXR session and graphics owners");
+ return false;
+ }
+
+ AllowAcquiredSwapchainsAfterGpuDrain();
+ ReleaseAcquiredSwapchains();
+ if (frame_active_ && runtime_ != nullptr) {
+ if (runtime_->IsSessionRunning()) {
+ runtime_->EndFrameWithoutLayers(active_frame_);
+ }
+ frame_active_ = false;
+ active_frame_serial_ = 0;
+ active_frame_ = {};
+ }
+ DestroySwapchains();
+ DestroySlots();
+ if (owns_session_ && runtime_ != nullptr) {
+ runtime_->DestroySession();
+ owns_session_ = false;
+ }
+ DestroyVulkanObjects();
+ bound_ = false;
+ requirements_queried_ = false;
+ runtime_ = nullptr;
+ return true;
+ }
+
+ bool IsBound() const { return bound_; }
+ const OpenXRVulkanGraphicsRequirements& GraphicsRequirements() const { return requirements_; }
+ int64_t SwapchainFormat() const { return static_cast(swapchain_format_); }
+ const std::string& LastError() const { return last_error_; }
+
+private:
+ // ---- Vulkan device owned by the OpenXR side ------------------------------
+
+ bool CreateVulkanObjects() {
+ // Vulkan 1.1 brings external memory/semaphores and dedicated allocation
+ // into core; every Quest ships at least that. XR_KHR_vulkan_enable
+ // runtimes have been seen reporting max 1.0 while accepting 1.1.
+ uint32_t api_version = VK_API_VERSION_1_1;
+ if (requirements_.max_api_version != 0 &&
+ XR_VERSION_MAJOR(requirements_.max_api_version) == 1 &&
+ XR_VERSION_MINOR(requirements_.max_api_version) == 0 && requirements_.uses_enable2) {
+ api_version = VK_API_VERSION_1_1;
+ }
+
+ VkApplicationInfo application{VK_STRUCTURE_TYPE_APPLICATION_INFO};
+ application.pApplicationName = "WiiCompiled";
+ application.applicationVersion = 1;
+ application.pEngineName = "Aurora OpenXR bridge";
+ application.engineVersion = 1;
+ application.apiVersion = api_version;
+
+ std::vector instance_extension_storage;
+ std::vector instance_extensions;
+ if (!requirements_.uses_enable2) {
+ PFN_xrGetVulkanInstanceExtensionsKHR get_instance_extensions = nullptr;
+ if (!runtime_->LoadFunction("xrGetVulkanInstanceExtensionsKHR", &get_instance_extensions) ||
+ get_instance_extensions == nullptr) {
+ return Fail("xrGetVulkanInstanceExtensionsKHR is unavailable");
+ }
+ uint32_t length = 0;
+ get_instance_extensions(runtime_->Instance(), runtime_->SystemId(), 0, &length, nullptr);
+ std::string text(length, '\0');
+ if (length != 0) {
+ get_instance_extensions(runtime_->Instance(), runtime_->SystemId(), length, &length, text.data());
+ }
+ instance_extension_storage = SplitExtensionList(text);
+ }
+ for (const std::string& name : instance_extension_storage) {
+ instance_extensions.push_back(name.c_str());
+ }
+
+ VkInstanceCreateInfo instance_info{VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO};
+ instance_info.pApplicationInfo = &application;
+ instance_info.enabledExtensionCount = static_cast(instance_extensions.size());
+ instance_info.ppEnabledExtensionNames = instance_extensions.data();
+
+ if (requirements_.uses_enable2) {
+ PFN_xrCreateVulkanInstanceKHR create_instance = nullptr;
+ if (!runtime_->LoadFunction("xrCreateVulkanInstanceKHR", &create_instance) ||
+ create_instance == nullptr) {
+ return Fail("xrCreateVulkanInstanceKHR is unavailable");
+ }
+ XrVulkanInstanceCreateInfoKHR info{XR_TYPE_VULKAN_INSTANCE_CREATE_INFO_KHR};
+ info.systemId = runtime_->SystemId();
+ info.createFlags = 0;
+ info.pfnGetInstanceProcAddr = &vkGetInstanceProcAddr;
+ info.vulkanCreateInfo = &instance_info;
+ info.vulkanAllocator = nullptr;
+ VkResult vk_result = VK_SUCCESS;
+ const XrResult result = create_instance(runtime_->Instance(), &info, &vk_instance_, &vk_result);
+ ObserveResult(result);
+ if (XR_FAILED(result) || vk_result != VK_SUCCESS || vk_instance_ == VK_NULL_HANDLE) {
+ std::ostringstream message;
+ message << "xrCreateVulkanInstanceKHR failed (" << result << ", VkResult " << vk_result << ')';
+ return Fail(message.str());
+ }
+ } else {
+ const VkResult vk_result = vkCreateInstance(&instance_info, nullptr, &vk_instance_);
+ if (vk_result != VK_SUCCESS || vk_instance_ == VK_NULL_HANDLE) {
+ std::ostringstream message;
+ message << "vkCreateInstance failed (VkResult " << vk_result << ')';
+ return Fail(message.str());
+ }
+ }
+
+ if (requirements_.uses_enable2) {
+ PFN_xrGetVulkanGraphicsDevice2KHR get_device = nullptr;
+ if (!runtime_->LoadFunction("xrGetVulkanGraphicsDevice2KHR", &get_device) || get_device == nullptr) {
+ return Fail("xrGetVulkanGraphicsDevice2KHR is unavailable");
+ }
+ XrVulkanGraphicsDeviceGetInfoKHR info{XR_TYPE_VULKAN_GRAPHICS_DEVICE_GET_INFO_KHR};
+ info.systemId = runtime_->SystemId();
+ info.vulkanInstance = vk_instance_;
+ const XrResult result = get_device(runtime_->Instance(), &info, &vk_physical_);
+ ObserveResult(result);
+ if (XR_FAILED(result) || vk_physical_ == VK_NULL_HANDLE) {
+ return Fail("xrGetVulkanGraphicsDevice2KHR failed");
+ }
+ } else {
+ PFN_xrGetVulkanGraphicsDeviceKHR get_device = nullptr;
+ if (!runtime_->LoadFunction("xrGetVulkanGraphicsDeviceKHR", &get_device) || get_device == nullptr) {
+ return Fail("xrGetVulkanGraphicsDeviceKHR is unavailable");
+ }
+ const XrResult result =
+ get_device(runtime_->Instance(), runtime_->SystemId(), vk_instance_, &vk_physical_);
+ ObserveResult(result);
+ if (XR_FAILED(result) || vk_physical_ == VK_NULL_HANDLE) {
+ return Fail("xrGetVulkanGraphicsDeviceKHR failed");
+ }
+ }
+
+ uint32_t family_count = 0;
+ vkGetPhysicalDeviceQueueFamilyProperties(vk_physical_, &family_count, nullptr);
+ std::vector families(family_count);
+ vkGetPhysicalDeviceQueueFamilyProperties(vk_physical_, &family_count, families.data());
+ queue_family_ = UINT32_MAX;
+ for (uint32_t index = 0; index < family_count; ++index) {
+ if ((families[index].queueFlags & VK_QUEUE_GRAPHICS_BIT) != 0 && families[index].queueCount > 0) {
+ queue_family_ = index;
+ break;
+ }
+ }
+ if (queue_family_ == UINT32_MAX) {
+ return Fail("the OpenXR physical device exposes no graphics queue family");
+ }
+
+ // Extensions this backend needs on top of whatever the runtime adds.
+ // Everything promoted to core in 1.1 is still requested by name when
+ // the driver lists it, which keeps a 1.0-only report working too.
+ uint32_t available_count = 0;
+ vkEnumerateDeviceExtensionProperties(vk_physical_, nullptr, &available_count, nullptr);
+ std::vector available(available_count);
+ vkEnumerateDeviceExtensionProperties(vk_physical_, nullptr, &available_count, available.data());
+ const auto has_extension = [&](const char* name) {
+ return std::any_of(available.begin(), available.end(), [&](const VkExtensionProperties& e) {
+ return std::strcmp(e.extensionName, name) == 0;
+ });
+ };
+ const std::array mandatory{
+ VK_ANDROID_EXTERNAL_MEMORY_ANDROID_HARDWARE_BUFFER_EXTENSION_NAME,
+ VK_KHR_EXTERNAL_SEMAPHORE_FD_EXTENSION_NAME,
+ };
+ const std::array desirable{
+ VK_EXT_QUEUE_FAMILY_FOREIGN_EXTENSION_NAME,
+ VK_KHR_SAMPLER_YCBCR_CONVERSION_EXTENSION_NAME,
+ VK_KHR_EXTERNAL_MEMORY_EXTENSION_NAME,
+ VK_KHR_EXTERNAL_SEMAPHORE_EXTENSION_NAME,
+ VK_KHR_DEDICATED_ALLOCATION_EXTENSION_NAME,
+ VK_KHR_GET_MEMORY_REQUIREMENTS_2_EXTENSION_NAME,
+ VK_KHR_BIND_MEMORY_2_EXTENSION_NAME,
+ VK_KHR_MAINTENANCE1_EXTENSION_NAME,
+ };
+ std::vector device_extension_storage;
+ for (const char* name : mandatory) {
+ if (!has_extension(name)) {
+ return Fail(std::string("the OpenXR physical device lacks ") + name);
+ }
+ device_extension_storage.emplace_back(name);
+ }
+ for (const char* name : desirable) {
+ if (has_extension(name)) {
+ device_extension_storage.emplace_back(name);
+ }
+ }
+ if (!requirements_.uses_enable2) {
+ PFN_xrGetVulkanDeviceExtensionsKHR get_device_extensions = nullptr;
+ if (!runtime_->LoadFunction("xrGetVulkanDeviceExtensionsKHR", &get_device_extensions) ||
+ get_device_extensions == nullptr) {
+ return Fail("xrGetVulkanDeviceExtensionsKHR is unavailable");
+ }
+ uint32_t length = 0;
+ get_device_extensions(runtime_->Instance(), runtime_->SystemId(), 0, &length, nullptr);
+ std::string text(length, '\0');
+ if (length != 0) {
+ get_device_extensions(runtime_->Instance(), runtime_->SystemId(), length, &length, text.data());
+ }
+ for (const std::string& name : SplitExtensionList(text)) {
+ if (std::find(device_extension_storage.begin(), device_extension_storage.end(), name) ==
+ device_extension_storage.end()) {
+ device_extension_storage.push_back(name);
+ }
+ }
+ }
+ std::vector device_extensions;
+ for (const std::string& name : device_extension_storage) {
+ device_extensions.push_back(name.c_str());
+ }
+
+ const float priority = 1.0f;
+ VkDeviceQueueCreateInfo queue_info{VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO};
+ queue_info.queueFamilyIndex = queue_family_;
+ queue_info.queueCount = 1;
+ queue_info.pQueuePriorities = &priority;
+ VkPhysicalDeviceFeatures features{};
+ VkDeviceCreateInfo device_info{VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO};
+ device_info.queueCreateInfoCount = 1;
+ device_info.pQueueCreateInfos = &queue_info;
+ device_info.enabledExtensionCount = static_cast(device_extensions.size());
+ device_info.ppEnabledExtensionNames = device_extensions.data();
+ device_info.pEnabledFeatures = &features;
+
+ if (requirements_.uses_enable2) {
+ PFN_xrCreateVulkanDeviceKHR create_device = nullptr;
+ if (!runtime_->LoadFunction("xrCreateVulkanDeviceKHR", &create_device) || create_device == nullptr) {
+ return Fail("xrCreateVulkanDeviceKHR is unavailable");
+ }
+ XrVulkanDeviceCreateInfoKHR info{XR_TYPE_VULKAN_DEVICE_CREATE_INFO_KHR};
+ info.systemId = runtime_->SystemId();
+ info.createFlags = 0;
+ info.pfnGetInstanceProcAddr = &vkGetInstanceProcAddr;
+ info.vulkanPhysicalDevice = vk_physical_;
+ info.vulkanCreateInfo = &device_info;
+ info.vulkanAllocator = nullptr;
+ VkResult vk_result = VK_SUCCESS;
+ const XrResult result = create_device(runtime_->Instance(), &info, &vk_device_, &vk_result);
+ ObserveResult(result);
+ if (XR_FAILED(result) || vk_result != VK_SUCCESS || vk_device_ == VK_NULL_HANDLE) {
+ std::ostringstream message;
+ message << "xrCreateVulkanDeviceKHR failed (" << result << ", VkResult " << vk_result << ')';
+ return Fail(message.str());
+ }
+ } else {
+ const VkResult vk_result = vkCreateDevice(vk_physical_, &device_info, nullptr, &vk_device_);
+ if (vk_result != VK_SUCCESS || vk_device_ == VK_NULL_HANDLE) {
+ std::ostringstream message;
+ message << "vkCreateDevice failed (VkResult " << vk_result << ')';
+ return Fail(message.str());
+ }
+ }
+ vkGetDeviceQueue(vk_device_, queue_family_, 0, &vk_queue_);
+
+ pfn_import_semaphore_fd_ = reinterpret_cast(
+ vkGetDeviceProcAddr(vk_device_, "vkImportSemaphoreFdKHR"));
+ pfn_get_semaphore_fd_ = reinterpret_cast(
+ vkGetDeviceProcAddr(vk_device_, "vkGetSemaphoreFdKHR"));
+ pfn_get_ahb_properties_ = reinterpret_cast(
+ vkGetDeviceProcAddr(vk_device_, "vkGetAndroidHardwareBufferPropertiesANDROID"));
+ if (pfn_import_semaphore_fd_ == nullptr || pfn_get_semaphore_fd_ == nullptr ||
+ pfn_get_ahb_properties_ == nullptr) {
+ return Fail("the OpenXR Vulkan device did not resolve the sync-fd or AHardwareBuffer entry points");
+ }
+
+ VkCommandPoolCreateInfo pool_info{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO};
+ pool_info.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
+ pool_info.queueFamilyIndex = queue_family_;
+ if (vkCreateCommandPool(vk_device_, &pool_info, nullptr, &command_pool_) != VK_SUCCESS) {
+ return Fail("vkCreateCommandPool failed");
+ }
+ for (Submission& submission : submissions_) {
+ VkCommandBufferAllocateInfo allocate{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO};
+ allocate.commandPool = command_pool_;
+ allocate.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
+ allocate.commandBufferCount = 1;
+ VkFenceCreateInfo fence_info{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO};
+ if (vkAllocateCommandBuffers(vk_device_, &allocate, &submission.command_buffer) != VK_SUCCESS ||
+ vkCreateFence(vk_device_, &fence_info, nullptr, &submission.fence) != VK_SUCCESS) {
+ return Fail("could not allocate the OpenXR copy command buffers");
+ }
+ }
+ return true;
+ }
+
+ void DestroyVulkanObjects() {
+ std::lock_guard lock(vk_mutex_);
+ if (vk_device_ != VK_NULL_HANDLE) {
+ vkDeviceWaitIdle(vk_device_);
+ for (Submission& submission : submissions_) {
+ if (submission.fence != VK_NULL_HANDLE) {
+ vkDestroyFence(vk_device_, submission.fence, nullptr);
+ }
+ submission = {};
+ }
+ if (command_pool_ != VK_NULL_HANDLE) {
+ vkDestroyCommandPool(vk_device_, command_pool_, nullptr);
+ command_pool_ = VK_NULL_HANDLE;
+ }
+ vkDestroyDevice(vk_device_, nullptr);
+ vk_device_ = VK_NULL_HANDLE;
+ }
+ if (vk_instance_ != VK_NULL_HANDLE) {
+ vkDestroyInstance(vk_instance_, nullptr);
+ vk_instance_ = VK_NULL_HANDLE;
+ }
+ vk_physical_ = VK_NULL_HANDLE;
+ vk_queue_ = VK_NULL_HANDLE;
+ queue_family_ = UINT32_MAX;
+ pfn_import_semaphore_fd_ = nullptr;
+ pfn_get_semaphore_fd_ = nullptr;
+ pfn_get_ahb_properties_ = nullptr;
+ }
+
+ // ---- Shared eye buffers ---------------------------------------------------
+
+ bool AllocateSlots() {
+ std::lock_guard lock(vk_mutex_);
+ for (uint32_t eye = 0; eye < kOpenXREyeCount; ++eye) {
+ for (uint32_t slot = 0; slot < kSlotCount; ++slot) {
+ if (!AllocateSlot(slots_[eye][slot], eye_swapchains_[eye].width, eye_swapchains_[eye].height)) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ bool AllocateSlot(EyeSlot& slot, uint32_t width, uint32_t height) {
+ AHardwareBuffer_Desc desc{};
+ desc.width = width;
+ desc.height = height;
+ desc.layers = 1;
+ desc.format = CopyFamily(aurora_format_) == 3 ? AHARDWAREBUFFER_FORMAT_R16G16B16A16_FLOAT
+ : AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM;
+ // SAMPLED lets Dawn read (copy source) and FRAMEBUFFER lets it write
+ // (copy destination / render attachment); both sides transfer.
+ desc.usage = AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE | AHARDWAREBUFFER_USAGE_GPU_FRAMEBUFFER;
+ if (AHardwareBuffer_allocate(&desc, &slot.buffer) != 0 || slot.buffer == nullptr) {
+ return Fail("AHardwareBuffer_allocate failed for an eye buffer");
+ }
+ slot.width = width;
+ slot.height = height;
+
+ VkAndroidHardwareBufferFormatPropertiesANDROID format_properties{
+ VK_STRUCTURE_TYPE_ANDROID_HARDWARE_BUFFER_FORMAT_PROPERTIES_ANDROID};
+ VkAndroidHardwareBufferPropertiesANDROID properties{
+ VK_STRUCTURE_TYPE_ANDROID_HARDWARE_BUFFER_PROPERTIES_ANDROID};
+ properties.pNext = &format_properties;
+ if (pfn_get_ahb_properties_(vk_device_, slot.buffer, &properties) != VK_SUCCESS) {
+ return Fail("vkGetAndroidHardwareBufferPropertiesANDROID failed");
+ }
+ if (format_properties.format == VK_FORMAT_UNDEFINED ||
+ !SameCopyFamily(format_properties.format, aurora_format_)) {
+ return Fail("the AHardwareBuffer's Vulkan format does not match Aurora's colour format");
+ }
+ slot.format = format_properties.format;
+
+ VkExternalMemoryImageCreateInfo external{VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_IMAGE_CREATE_INFO};
+ external.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_ANDROID_HARDWARE_BUFFER_BIT_ANDROID;
+ VkImageCreateInfo image_info{VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO};
+ image_info.pNext = &external;
+ image_info.imageType = VK_IMAGE_TYPE_2D;
+ image_info.format = slot.format;
+ image_info.extent = {width, height, 1};
+ image_info.mipLevels = 1;
+ image_info.arrayLayers = 1;
+ image_info.samples = VK_SAMPLE_COUNT_1_BIT;
+ image_info.tiling = VK_IMAGE_TILING_OPTIMAL;
+ image_info.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT;
+ image_info.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
+ image_info.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
+ if (vkCreateImage(vk_device_, &image_info, nullptr, &slot.image) != VK_SUCCESS) {
+ return Fail("vkCreateImage failed for an imported eye buffer");
+ }
+
+ uint32_t memory_type = UINT32_MAX;
+ for (uint32_t bit = 0; bit < 32; ++bit) {
+ if ((properties.memoryTypeBits & (1u << bit)) != 0) {
+ memory_type = bit;
+ break;
+ }
+ }
+ if (memory_type == UINT32_MAX) {
+ return Fail("the AHardwareBuffer reports no usable memory type");
+ }
+ VkImportAndroidHardwareBufferInfoANDROID import_info{
+ VK_STRUCTURE_TYPE_IMPORT_ANDROID_HARDWARE_BUFFER_INFO_ANDROID};
+ import_info.buffer = slot.buffer;
+ VkMemoryDedicatedAllocateInfo dedicated{VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO};
+ dedicated.pNext = &import_info;
+ dedicated.image = slot.image;
+ VkMemoryAllocateInfo allocate{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO};
+ allocate.pNext = &dedicated;
+ allocate.allocationSize = properties.allocationSize;
+ allocate.memoryTypeIndex = memory_type;
+ if (vkAllocateMemory(vk_device_, &allocate, nullptr, &slot.memory) != VK_SUCCESS) {
+ return Fail("vkAllocateMemory failed while importing an eye buffer");
+ }
+ if (vkBindImageMemory(vk_device_, slot.image, slot.memory, 0) != VK_SUCCESS) {
+ return Fail("vkBindImageMemory failed for an imported eye buffer");
+ }
+
+ VkSemaphoreCreateInfo semaphore_info{VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO};
+ if (vkCreateSemaphore(vk_device_, &semaphore_info, nullptr, &slot.wait_semaphore) != VK_SUCCESS) {
+ return Fail("vkCreateSemaphore failed for the eye wait semaphore");
+ }
+ VkExportSemaphoreCreateInfo export_info{VK_STRUCTURE_TYPE_EXPORT_SEMAPHORE_CREATE_INFO};
+ export_info.handleTypes = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_SYNC_FD_BIT;
+ VkSemaphoreCreateInfo exportable{VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO};
+ exportable.pNext = &export_info;
+ if (vkCreateSemaphore(vk_device_, &exportable, nullptr, &slot.signal_semaphore) != VK_SUCCESS) {
+ return Fail("vkCreateSemaphore failed for the exportable eye semaphore");
+ }
+ slot.layout = VK_IMAGE_LAYOUT_UNDEFINED;
+ slot.pending_acquire_fd = -1;
+ return true;
+ }
+
+ void DestroySlots() {
+ std::lock_guard lock(vk_mutex_);
+ if (vk_device_ != VK_NULL_HANDLE) {
+ vkDeviceWaitIdle(vk_device_);
+ }
+ for (auto& eye : slots_) {
+ for (EyeSlot& slot : eye) {
+ CloseFd(slot.pending_acquire_fd);
+ if (vk_device_ != VK_NULL_HANDLE) {
+ if (slot.signal_semaphore != VK_NULL_HANDLE) {
+ vkDestroySemaphore(vk_device_, slot.signal_semaphore, nullptr);
+ }
+ if (slot.wait_semaphore != VK_NULL_HANDLE) {
+ vkDestroySemaphore(vk_device_, slot.wait_semaphore, nullptr);
+ }
+ if (slot.image != VK_NULL_HANDLE) {
+ vkDestroyImage(vk_device_, slot.image, nullptr);
+ }
+ if (slot.memory != VK_NULL_HANDLE) {
+ vkFreeMemory(vk_device_, slot.memory, nullptr);
+ }
+ }
+ if (slot.buffer != nullptr) {
+ AHardwareBuffer_release(slot.buffer);
+ }
+ slot = {};
+ }
+ }
+ }
+
+ // ---- The copy into the compositor image -----------------------------------
+
+ static void OnAuroraSubmitted(uint64_t token, bool success,
+ const AuroraVulkanStereoRelease* releases, uint32_t release_count,
+ void* userdata) {
+ auto* self = static_cast(userdata);
+ std::array owned{};
+ for (auto& release : owned) {
+ release = {-1, VK_IMAGE_LAYOUT_UNDEFINED};
+ }
+ for (uint32_t i = 0; i < release_count && i < owned.size(); ++i) {
+ owned[i] = releases[i];
+ }
+ if (self == nullptr) {
+ for (auto& release : owned) {
+ CloseFd(release.releaseFenceFd);
+ }
+ return;
+ }
+
+ bool copied = false;
+ {
+ std::lock_guard lock(self->vk_mutex_);
+ bool expected = false;
+ {
+ std::lock_guard submission_lock(self->submission_mutex_);
+ expected = token == self->awaiting_token_ && token == self->pending_copy_.token;
+ }
+ if (expected && success && release_count >= self->pending_copy_.target_count) {
+ copied = self->RecordAndSubmitCopyLocked(owned);
+ } else {
+ for (auto& release : owned) {
+ CloseFd(release.releaseFenceFd);
+ }
+ }
+ if (!expected) {
+ return;
+ }
+ }
+ {
+ std::lock_guard lock(self->submission_mutex_);
+ if (token != self->awaiting_token_) {
+ return;
+ }
+ self->submitted_token_ = token;
+ self->submission_success_ = copied;
+ self->submission_arrived_ = true;
+ // A failed Aurora submission may have queued a write into the shared
+ // buffer; a failed copy here may have queued a write into the XR
+ // image. Neither has a trustworthy completion marker.
+ self->submission_unsafe_ = !copied;
+ }
+ self->submission_cv_.notify_all();
+ }
+
+ bool RecordAndSubmitCopyLocked(std::array& releases) {
+ Submission* submission = AcquireSubmissionLocked();
+ if (submission == nullptr) {
+ for (auto& release : releases) {
+ CloseFd(release.releaseFenceFd);
+ }
+ return false;
+ }
+ const PendingCopy copy = pending_copy_;
+ VkCommandBuffer cmd = submission->command_buffer;
+ VkCommandBufferBeginInfo begin{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
+ begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
+ if (vkBeginCommandBuffer(cmd, &begin) != VK_SUCCESS) {
+ for (auto& release : releases) {
+ CloseFd(release.releaseFenceFd);
+ }
+ return Fail("vkBeginCommandBuffer failed for the eye copy");
+ }
+
+ std::array waits{};
+ std::array wait_stages{};
+ std::array signals{};
+ uint32_t wait_count = 0;
+ uint32_t signal_count = 0;
+ constexpr VkImageSubresourceRange kColorRange{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
+
+ for (uint32_t eye = 0; eye < copy.target_count; ++eye) {
+ EyeSlot& slot = slots_[eye][copy.slot];
+ AuroraVulkanStereoRelease& release = releases[eye];
+
+ if (release.releaseFenceFd >= 0) {
+ VkImportSemaphoreFdInfoKHR import{VK_STRUCTURE_TYPE_IMPORT_SEMAPHORE_FD_INFO_KHR};
+ import.semaphore = slot.wait_semaphore;
+ import.flags = VK_SEMAPHORE_IMPORT_TEMPORARY_BIT;
+ import.handleType = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_SYNC_FD_BIT;
+ import.fd = release.releaseFenceFd;
+ if (pfn_import_semaphore_fd_(vk_device_, &import) == VK_SUCCESS) {
+ // Ownership of the descriptor moved to Vulkan.
+ release.releaseFenceFd = -1;
+ waits[wait_count] = slot.wait_semaphore;
+ wait_stages[wait_count] = VK_PIPELINE_STAGE_TRANSFER_BIT;
+ ++wait_count;
+ } else {
+ CloseFd(release.releaseFenceFd);
+ vkEndCommandBuffer(cmd);
+ return Fail("vkImportSemaphoreFdKHR rejected Dawn's release fence");
+ }
+ }
+
+ // Dawn released the buffer with an ownership transfer whose old/new
+ // layouts it reported; the acquire here must repeat that pair before
+ // the image can be transitioned for reading.
+ VkImageLayout released = static_cast(release.releasedImageLayout);
+ if (released == VK_IMAGE_LAYOUT_UNDEFINED) {
+ released = VK_IMAGE_LAYOUT_GENERAL;
+ }
+ VkImageMemoryBarrier acquire{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
+ acquire.srcAccessMask = 0;
+ acquire.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
+ acquire.oldLayout = released;
+ acquire.newLayout = released;
+ acquire.srcQueueFamilyIndex = VK_QUEUE_FAMILY_EXTERNAL;
+ acquire.dstQueueFamilyIndex = queue_family_;
+ acquire.image = slot.image;
+ acquire.subresourceRange = kColorRange;
+ VkImageMemoryBarrier to_source = acquire;
+ to_source.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
+ to_source.oldLayout = released;
+ to_source.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
+ to_source.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
+ to_source.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
+ VkImageMemoryBarrier to_destination{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
+ to_destination.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
+ to_destination.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
+ to_destination.oldLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
+ to_destination.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
+ to_destination.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
+ to_destination.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
+ to_destination.image = copy.swapchain_images[eye];
+ to_destination.subresourceRange = kColorRange;
+ vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0,
+ nullptr, 0, nullptr, 1, &acquire);
+ const std::array pre{to_source, to_destination};
+ vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_TRANSFER_BIT,
+ VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr,
+ static_cast(pre.size()), pre.data());
+
+ VkImageCopy region{};
+ region.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
+ region.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
+ region.extent = {slot.width, slot.height, 1};
+ vkCmdCopyImage(cmd, slot.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, copy.swapchain_images[eye],
+ VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion);
+
+ // Back to the layout the compositor expects, and hand the shared
+ // buffer back to Dawn in GENERAL without a transition inside the
+ // ownership release so Dawn's acquire can mirror it exactly.
+ VkImageMemoryBarrier to_attachment = to_destination;
+ to_attachment.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
+ to_attachment.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
+ to_attachment.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
+ to_attachment.newLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
+ VkImageMemoryBarrier to_general = to_source;
+ to_general.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
+ to_general.dstAccessMask = 0;
+ to_general.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
+ to_general.newLayout = VK_IMAGE_LAYOUT_GENERAL;
+ const std::array post{to_attachment, to_general};
+ vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT,
+ VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_TRANSFER_BIT, 0,
+ 0, nullptr, 0, nullptr, static_cast(post.size()), post.data());
+ VkImageMemoryBarrier release_barrier = acquire;
+ release_barrier.srcAccessMask = 0;
+ release_barrier.dstAccessMask = 0;
+ release_barrier.oldLayout = VK_IMAGE_LAYOUT_GENERAL;
+ release_barrier.newLayout = VK_IMAGE_LAYOUT_GENERAL;
+ release_barrier.srcQueueFamilyIndex = queue_family_;
+ release_barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_EXTERNAL;
+ vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0,
+ nullptr, 0, nullptr, 1, &release_barrier);
+ slot.layout = VK_IMAGE_LAYOUT_GENERAL;
+ signals[signal_count++] = slot.signal_semaphore;
+ }
+ if (vkEndCommandBuffer(cmd) != VK_SUCCESS) {
+ return Fail("vkEndCommandBuffer failed for the eye copy");
+ }
+
+ VkSubmitInfo submit{VK_STRUCTURE_TYPE_SUBMIT_INFO};
+ submit.waitSemaphoreCount = wait_count;
+ submit.pWaitSemaphores = waits.data();
+ submit.pWaitDstStageMask = wait_stages.data();
+ submit.commandBufferCount = 1;
+ submit.pCommandBuffers = &cmd;
+ submit.signalSemaphoreCount = signal_count;
+ submit.pSignalSemaphores = signals.data();
+ if (vkQueueSubmit(vk_queue_, 1, &submit, submission->fence) != VK_SUCCESS) {
+ return Fail("vkQueueSubmit failed for the eye copy");
+ }
+ submission->busy = true;
+
+ // Exporting a sync fd from a binary semaphore resets it, so the same
+ // semaphore serves the next copy. Dawn waits on this before it writes
+ // the buffer again.
+ for (uint32_t eye = 0; eye < copy.target_count; ++eye) {
+ EyeSlot& slot = slots_[eye][copy.slot];
+ VkSemaphoreGetFdInfoKHR get_fd{VK_STRUCTURE_TYPE_SEMAPHORE_GET_FD_INFO_KHR};
+ get_fd.semaphore = slot.signal_semaphore;
+ get_fd.handleType = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_SYNC_FD_BIT;
+ int fd = -1;
+ CloseFd(slot.pending_acquire_fd);
+ if (pfn_get_semaphore_fd_(vk_device_, &get_fd, &fd) == VK_SUCCESS) {
+ slot.pending_acquire_fd = fd;
+ } else {
+ // Without the fence Dawn would write while the copy may still
+ // read; wait for this submission on the CPU instead.
+ vkWaitForFences(vk_device_, 1, &submission->fence, VK_TRUE, kFenceTimeoutNanos);
+ Log(OpenXRLogLevel::Warning, "vkGetSemaphoreFdKHR failed; the eye copy was waited on the CPU");
+ }
+ }
+ return true;
+ }
+
+ Submission* AcquireSubmissionLocked() {
+ Submission& submission = submissions_[next_submission_];
+ next_submission_ = (next_submission_ + 1) % kSubmissionRingSize;
+ if (submission.busy) {
+ if (vkWaitForFences(vk_device_, 1, &submission.fence, VK_TRUE, kFenceTimeoutNanos) != VK_SUCCESS) {
+ Fail("a previous eye copy did not complete in time");
+ return nullptr;
+ }
+ submission.busy = false;
+ }
+ vkResetFences(vk_device_, 1, &submission.fence);
+ if (vkResetCommandBuffer(submission.command_buffer, 0) != VK_SUCCESS) {
+ Fail("vkResetCommandBuffer failed");
+ return nullptr;
+ }
+ return &submission;
+ }
+
+ // ---- OpenXR swapchains ----------------------------------------------------
+
+ bool SelectSwapchainFormat() {
+ const auto& formats = runtime_->SwapchainFormats();
+ // Aurora's UNORM target holds gamma-encoded bytes; declaring the sRGB
+ // sibling makes the compositor decode them instead of treating them as
+ // linear light. vkCmdCopyImage between UNORM and SRGB siblings is legal.
+ const VkFormat srgb = SrgbSibling(aurora_format_);
+ if (srgb != VK_FORMAT_UNDEFINED &&
+ std::find(formats.begin(), formats.end(), static_cast(srgb)) != formats.end()) {
+ swapchain_format_ = srgb;
+ return true;
+ }
+ if (std::find(formats.begin(), formats.end(), static_cast(aurora_format_)) != formats.end()) {
+ swapchain_format_ = aurora_format_;
+ return true;
+ }
+ const auto compatible = std::find_if(formats.begin(), formats.end(), [&](int64_t format) {
+ return SameCopyFamily(aurora_format_, static_cast(format));
+ });
+ if (compatible == formats.end()) {
+ return Fail("OpenXR offered no swapchain format copy-compatible with Aurora's Vulkan colour format");
+ }
+ swapchain_format_ = static_cast(*compatible);
+ return true;
+ }
+
+ bool CreateSwapchains() {
+ return CreateSwapchainPair(eye_swapchains_) && CreateSwapchainPair(retained_swapchains_);
+ }
+
+ bool CreateSwapchainPair(std::array