Added Android Meta Quest Support

This commit is contained in:
iChris4 committed 2026-09-16 22:06:34 +02:00
1 parent 49b8602543
commit e52227baf5
67 files changed
+5662 -176

No files matched your search

+9
View File
@@ -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/
+11 -9
View File
@@ -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.
+94
View File
@@ -0,0 +1,94 @@
# Builds the standalone Meta Quest APK on a Windows host.
#
# powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 [-Generated <dir>] [-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 <install root>'
}
$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'
}
+42
View File
@@ -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()
}
+23
View File
@@ -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 <dir>`; 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.
+111
View File
@@ -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 <path>] [-Data <extracted disc dir>]
# [-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/<stamp>/: 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/<package> 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 }
+124
View File
@@ -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"))
}
+61
View File
@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- The Khronos loader brokers the system OpenXR runtime through this permission. -->
<uses-permission android:name="org.khronos.openxr.permission.OPENXR_SYSTEM" />
<queries>
<provider android:authorities="org.khronos.openxr.runtime_broker;org.khronos.openxr.system_runtime_broker" />
<intent>
<action android:name="org.khronos.openxr.OpenXRRuntimeService" />
</intent>
<intent>
<action android:name="org.khronos.openxr.OpenXRApiLayerService" />
</intent>
</queries>
<uses-feature android:name="android.hardware.vulkan.level" android:required="true" android:version="1" />
<uses-feature android:name="android.hardware.vulkan.version" android:required="true" android:version="0x00401000" />
<uses-feature android:name="android.hardware.vr.headtracking" android:required="true" android:version="1" />
<uses-feature android:name="android.software.xr.api.openxr" android:required="true" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
<application
android:allowBackup="false"
android:appCategory="game"
android:hasFragileUserData="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.WiiCompiled.Quest">
<meta-data android:name="com.oculus.supportedDevices" android:value="quest2|quest3|quest3s|questpro" />
<meta-data android:name="com.oculus.vr.focusaware" android:value="true" />
<!-- Let the runtime move CPU budget to the GPU; Aurora's replay is GPU-bound. -->
<meta-data android:name="com.oculus.trade_cpu_for_gpu_amount" android:value="-1" />
<activity
android:name=".QuestActivity"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenLayout|screenSize|smallestScreenSize|touchscreen|uiMode|density"
android:excludeFromRecents="false"
android:exported="true"
android:launchMode="singleTask"
android:preferMinimalPostProcessing="true"
android:resizeableActivity="false"
android:screenOrientation="landscape">
<property
android:name="android.window.PROPERTY_XR_ACTIVITY_START_MODE"
android:value="XR_ACTIVITY_START_MODE_FULL_SPACE_UNMANAGED" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="com.oculus.intent.category.VR" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
+58
View File
@@ -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()
@@ -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 <aurora/android.h>
#include <jni.h>
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);
}
@@ -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/<package>/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<String> = 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"
}
}
@@ -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)
}
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Placeholder mark (a headset outline); no game artwork is used. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M30,40 h48 a8,8 0 0 1 8,8 v16 a8,8 0 0 1 -8,8 h-14 l-6,-8 h-8 l-6,8 h-14 a8,8 0 0 1 -8,-8 v-16 a8,8 0 0 1 8,-8 z" />
<path
android:fillColor="#1A237E"
android:pathData="M38,50 a6,6 0 1 0 12,0 a6,6 0 1 0 -12,0 z M58,50 a6,6 0 1 0 12,0 a6,6 0 1 0 -12,0 z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1A237E</color>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">WiiCompiled VR</string>
<string name="missing_game_data_title">Game data not found</string>
<string name="missing_game_data_message">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.</string>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Fullscreen, no action bar: the Android surface is only ever a mirror
behind the OpenXR session. -->
<style name="Theme.WiiCompiled.Quest" parent="android:Theme.Material.NoActionBar.Fullscreen">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
</resources>
+4
View File
@@ -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
}
+5
View File
@@ -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
Binary file not shown.
+8
View File
@@ -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
+248
View File
@@ -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" "$@"
+93
View File
@@ -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
+18
View File
@@ -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")
+31 -1
View File
@@ -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()
+4
View File
@@ -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)
+30
View File
@@ -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
+129
View File
@@ -0,0 +1,129 @@
#ifndef AURORA_VULKAN_INTEROP_H
#define AURORA_VULKAN_INTEROP_H
#ifdef __cplusplus
#include <cstdint>
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
+24
View File
@@ -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 <sys/system_properties.h>
#include <cstdlib>
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
+41 -4
View File
@@ -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<size_t>(vtxCount) * vtxSize); }
auto [buffer, range] = gfx::map_verts(static_cast<size_t>(vtxCount) * uploadStride);
u8* dst = buffer.data();
const u32 padding = uploadStride - vtxSize;
for (u32 i = 0; i < vtxCount; ++i) {
std::memcpy(dst, vertices + static_cast<size_t>(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<const u8*>(array.data);
if (uploadStride == array.stride || array.stride == 0)
LIKELY { return gfx::push_storage(data, array.size); }
const size_t count = (static_cast<size_t>(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<size_t>(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<const uint8_t*>(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;
}
}
+28 -3
View File
@@ -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 <absl/container/flat_hash_map.h>
#include <absl/container/flat_hash_set.h>
@@ -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<u8>(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<u8>(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<u8>(padded_upload_stride(vtxOffset));
if (primitive == GX_LINES) {
config.shaderConfig.lineMode = 1;
} else if (primitive == GX_LINESTRIP) {
+6
View File
@@ -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
+35 -3
View File
@@ -17,6 +17,7 @@
#include <magic_enum.hpp>
#include <webgpu/webgpu_cpp.h>
#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");
+620
View File
@@ -0,0 +1,620 @@
#include <aurora/vulkan_interop.h>
#include "../internal.hpp"
#include "../stereo.hpp"
#include "gpu.hpp"
#if defined(__ANDROID__) && defined(WEBGPU_DAWN)
#include <android/hardware_buffer.h>
#include <magic_enum.hpp>
#include <poll.h>
#include <unistd.h>
#include <vulkan/vulkan_core.h>
#include <algorithm>
#include <array>
#include <cstdint>
#include <memory>
#include <mutex>
#include <unordered_map>
#include <utility>
#include <vector>
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<AuroraVulkanStereoRelease, AURORA_VULKAN_STEREO_MAX_TARGETS> 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<AuroraVulkanStereoRelease, AURORA_VULKAN_STEREO_MAX_TARGETS> 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<Import*, AURORA_VULKAN_STEREO_MAX_TARGETS> 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<uint64_t, 1> 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<Import*, AURORA_VULKAN_STEREO_MAX_TARGETS>& 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<AuroraVulkanStereoRelease, AURORA_VULKAN_STEREO_MAX_TARGETS>& 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<AuroraVulkanStereoRelease, AURORA_VULKAN_STEREO_MAX_TARGETS> 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<AuroraVulkanStereoRelease, AURORA_VULKAN_STEREO_MAX_TARGETS>&
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<AHardwareBuffer*, Import> m_imports;
std::array<PendingTarget, AURORA_VULKAN_STEREO_MAX_TARGETS> m_targets{};
std::array<Import*, AURORA_VULKAN_STEREO_MAX_TARGETS> 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<StereoBridge> g_bridge;
bool sink_encode(wgpu::CommandEncoder& encoder, const stereo::SinkFrame& frame,
void* userdata) noexcept {
return static_cast<StereoBridge*>(userdata)->Encode(encoder, frame);
}
void sink_submitted(const stereo::SinkFrame& frame, void* userdata) noexcept {
static_cast<StereoBridge*>(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<StereoBridge>(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
+39 -4
View File
@@ -7,6 +7,7 @@
#include "input.hpp"
#include "internal.hpp"
#include <aurora/android.h>
#include <aurora/aurora.h>
#include <aurora/event.h>
#include <aurora/gfx.h>
@@ -28,12 +29,11 @@
#if defined(SDL_PLATFORM_ANDROID)
#include <jni.h>
extern "C" void Android_LockActivityMutex(void);
extern "C" void Android_UnlockActivityMutex(void);
#endif
#include <algorithm>
#include <atomic>
#include <mutex>
#include <vector>
#include "dolphin/vi/vi_internal.hpp"
@@ -55,6 +55,10 @@ std::atomic_bool g_backgrounded = false;
std::atomic_bool g_nativeResizePending = false;
std::atomic<AuroraDisplayMode> 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
+4
View File
@@ -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();
+11
View File
@@ -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<ByteBuffer, Range> map_verts(size_t length) {
s_lastPushedVertices.assign(length, 0);
return {ByteBuffer{s_lastPushedVertices.data(), s_lastPushedVertices.size()}, Range{}};
}
std::pair<ByteBuffer, Range> map_storage(size_t length) {
static std::vector<uint8_t> 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));
+301
View File
@@ -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/<product>_<stamp>_pid<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 <name> <value>` 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 <n>:<button>` | Presses `a`, `b`, `x`, `y`, `start`, `up`, `down`, `left` or `right` for 12 XR frames each time `<n>` 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/<newest>.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.
+80 -6
View File
@@ -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()
+79 -7
View File
@@ -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/<x>.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
$<$<NOT:$<PLATFORM_ID:Android>>: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/<x>.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)
$<$<NOT:$<PLATFORM_ID:Android>>: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)
"$<TARGET_FILE_DIR:${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()
+24
View File
@@ -2,6 +2,7 @@
#include "settings_overlay.h"
#include "runtime_config.h"
#include "fiber_manager.h"
#include <aurora/aurora.h>
#include <aurora/event.h>
@@ -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
}
+5
View File
@@ -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
+13
View File
@@ -15,6 +15,8 @@
#ifdef _WIN32
#include <windows.h>
#else
#include <cerrno>
#include <cstdio>
#include <unistd.h>
#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);
+8 -3
View File
@@ -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<std::filesystem::path> 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
+41
View File
@@ -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 <string>
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__)
+66
View File
@@ -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 <array>
#include <cstdint>
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<uint32_t, kOpenXREyeCount> render_width{};
std::array<uint32_t, kOpenXREyeCount> render_height{};
bool expects_gpu_submission = false;
};
} // namespace mkw::vr
#endif // defined(MKW_ENABLE_OPENXR)
+4
View File
@@ -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
+9 -42
View File
@@ -4,6 +4,7 @@
#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32)
#include "vr/openxr_backend.h"
#include "vr/openxr_runtime.h"
#include <array>
@@ -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<uint32_t, kOpenXREyeCount> render_width{};
std::array<uint32_t, kOpenXREyeCount> render_height{};
bool expects_gpu_submission = false;
};
// Same-device Dawn/OpenXR D3D12 backend.
//
// Startup is deliberately split in two. QueryGraphicsRequirements() runs
+94
View File
@@ -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 <cstdint>
#include <string>
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)
+78
View File
@@ -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 <cstdint>
#include <memory>
#include <string>
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<Impl> m_impl;
};
} // namespace mkw::vr
#endif // defined(MKW_ENABLE_OPENXR) && defined(__ANDROID__)
+12
View File
@@ -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
+9
View File
@@ -33,6 +33,15 @@
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#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 <linux/memfd.h>
#include <sys/syscall.h>
static int memfd_create(const char* name, unsigned int flags) {
return static_cast<int>(syscall(__NR_memfd_create, name, flags));
}
#endif
#endif
namespace GuestFlat {
+2 -1
View File
@@ -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<int64_t>(static_cast<int64_t>(elapsed), static_cast<int64_t>(kMaxPollDeltaMicros));
}
} // namespace
+37
View File
@@ -70,6 +70,13 @@
#include <aurora/aurora.h>
#include <aurora/gfx.h>
#include <dolphin/gx/GXAurora.h>
#if defined(__ANDROID__)
#include <android/log.h>
// 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 <SDL3/SDL_main.h>
#endif
#include <dolphin/vi.h>
// 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<char, 4096> 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<unsigned int>(buffer.size()));
@@ -275,6 +287,21 @@ void PumpTranscriptPipe(ProcessTranscriptState& state, int readFd, int mirrorFd)
}
WriteProcessTranscriptChunk(state, buffer.data(), static_cast<size_t>(bytesRead));
#if defined(__ANDROID__)
pendingLine.append(buffer.data(), static_cast<size_t>(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);
+3 -3
View File
@@ -16,7 +16,7 @@
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Foundation.Collections.h>
#include <winrt/Windows.Media.Control.h>
#elif defined(__linux__)
#elif defined(__linux__) && !defined(__ANDROID__)
#include <dlfcn.h>
#include <string>
@@ -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
+15
View File
@@ -46,6 +46,14 @@ std::optional<std::filesystem::path> 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/<package>/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;
}
+171
View File
@@ -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 <SDL3/SDL_system.h>
#include <jni.h>
#include <sys/syscall.h>
#include <unistd.h>
#define XR_USE_PLATFORM_ANDROID
#include <openxr/openxr_platform.h>
#include <mutex>
#include <sstream>
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<JNIEnv*>(SDL_GetAndroidJNIEnv());
auto activity = static_cast<jobject>(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<PFN_xrVoidFunction*>(&initialize_loader));
if (XR_FAILED(result) || initialize_loader == nullptr) {
if (error != nullptr) {
std::ostringstream message;
message << "xrInitializeLoaderKHR is unavailable (" << static_cast<int>(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<const XrLoaderInitInfoBaseHeaderKHR*>(&loader_init));
if (XR_FAILED(result)) {
if (error != nullptr) {
std::ostringstream message;
message << "xrInitializeLoaderKHR failed (" << static_cast<int>(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<uint32_t>(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__)
+450
View File
@@ -0,0 +1,450 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#if defined(MKW_ENABLE_OPENXR)
#include "vr/openxr_input.h"
#include <SDL3/SDL_gamepad.h>
#include <SDL3/SDL_joystick.h>
#include <SDL3/SDL_stdinc.h>
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <sstream>
#include <vector>
#if defined(__ANDROID__)
#include <sys/system_properties.h>
#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 <sequence>:<button>
// 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<Sint16>(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<Sint16>(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<Spec, 8> 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<Binding>& bindings, bool required) {
XrPath profile_path = XR_NULL_PATH;
if (!Check(xrStringToPath(m_runtime->Instance(), profile, &profile_path), profile)) {
return false;
}
std::vector<XrActionSuggestedBinding> 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<uint32_t>(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<Binding> 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<Binding> 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<SDL_Joystick*>(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<SDL_Joystick*>(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<const XrHapticBaseHeader*>(&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)
+184 -63
View File
@@ -27,12 +27,24 @@
#include <thread>
#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 <time.h>
#define XR_USE_TIMESPEC
#include <openxr/openxr_platform.h>
#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<OpenXRRuntime>(logger_);
backend_ = std::make_unique<OpenXRD3D12Backend>(logger_);
backend_ = std::make_unique<GraphicsBackend>(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<OpenXRInput>(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<std::chrono::nanoseconds>(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<std::chrono::nanoseconds>(now.time_since_epoch()).count();
const auto delta = static_cast<int64_t>(
(static_cast<double>(display_counter.QuadPart) - static_cast<double>(counter.QuadPart)) *
1.0e9 / static_cast<double>(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<int64_t>(display_spec.tv_sec) * 1'000'000'000ll + display_spec.tv_nsec;
const int64_t monotonic_ns = static_cast<int64_t>(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<uint64_t>(now_ns + delta) : 0;
}
@@ -810,7 +926,8 @@ private:
OpenXRLogCallback logger_;
std::unique_ptr<OpenXRRuntime> runtime_;
std::unique_ptr<OpenXRD3D12Backend> backend_;
std::unique_ptr<GraphicsBackend> backend_;
std::unique_ptr<OpenXRInput> 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<PublishedFrame*> 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
}
+1
View File
@@ -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;
File diff suppressed because it is too large. Load diff
+39 -6
View File
@@ -166,7 +166,7 @@ return command switch
{
"help" or "--help" or "-h" or "-?" => ShowGlobalHelp(),
"info" or "--info" or "--version" => RunInfo(),
"generate-data-init" => RunGenerateDataInit(),
"generate-data-init" => RunGenerateDataInit(tail),
"translate-recursive" => RunTranslateRecursive(tail),
"translate-mod" => RunTranslateMod(tail),
"emit-build-shards" => RunEmitBuildShards(tail),
@@ -1647,6 +1647,10 @@ void PreserveTranslateModFailureDiagnostics(string stagingDirectory, string publ
int RunTranslateModCore(string[] argsTail, string? outputDirectoryOverride)
{
if (ParseTargetOsOption(argsTail) is not { } modTargetOs)
{
return 1;
}
// Wall-clock accounting mirroring RunTranslateRecursive's LogPhase output.
var modPhaseStart = DateTime.Now;
var modLastPhaseSeconds = 0d;
@@ -1964,7 +1968,8 @@ int RunTranslateModCore(string[] argsTail, string? outputDirectoryOverride)
"cpp",
"mod_data_patches_blobs"),
profile?.Riivolution?.Xml,
riivolutionOptions);
riivolutionOptions,
targetOs: modTargetOs);
if (emitCpp && cppFailureCount == 0)
{
@@ -2943,9 +2948,13 @@ int RunEmitBaseManifest(string[] argsTail)
return 0;
}
int RunGenerateDataInit()
int RunGenerateDataInit(string[] argsTail)
{
var loadedProject = RequireProject();
if (ParseTargetOsOption(argsTail) is not { } dataInitTargetOs)
{
return 1;
}
var output = loadedProject.Output.DataInitializer;
var runtimeConfigOutput = loadedProject.Output.RuntimeConfig;
var dolPath = loadedProject.Inputs.Dol.Path;
@@ -2978,7 +2987,9 @@ int RunGenerateDataInit()
relImage,
output,
loadedProject.Identity.DisplayName,
relPath is null ? "rel_module" : Path.GetFileNameWithoutExtension(relPath));
relPath is null ? "rel_module" : Path.GetFileNameWithoutExtension(relPath),
targetOs: dataInitTargetOs);
Console.WriteLine($"[translator] Blob assembly flavour: {dataInitTargetOs}");
// Emit the guest symbol table for crash-report symbolization. The function
// map is already the project's authoritative name source; the runtime only
@@ -3712,7 +3723,7 @@ static (string? Positional, CommandOption[] Options)? CommandSpec(string command
{
"info" or "--info" or "--version" =>
(null, Array.Empty<CommandOption>()),
"generate-data-init" => (null, Array.Empty<CommandOption>()),
"generate-data-init" => (null, new CommandOption[] { new("--target-os", "windows|macos|linux|android") }),
"translate-recursive" => ("<start_addr>", new CommandOption[]
{
new("--outdir", "path"),
@@ -3736,7 +3747,8 @@ static (string? Positional, CommandOption[] Options)? CommandSpec(string command
new("--threads", "N"),
new("--retro-wfc-payload", "path-or-url"),
new("--skip-retro-wfc"),
new("--emit-cpp")
new("--emit-cpp"),
new("--target-os", "windows|macos|linux|android")
}),
"check-base-mod-awareness" => (null, new CommandOption[]
{
@@ -3950,6 +3962,27 @@ static int ParseInt(string text) =>
? int.Parse(text[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture)
: int.Parse(text, CultureInfo.InvariantCulture);
/// <summary>
/// The object-file flavour for hand-written blob assembly. Absent, the translator's own host is
/// assumed (the historical behaviour); a cross-compiled product names its target explicitly.
/// Null means the value was present but unrecognised, and the error has already been printed.
/// </summary>
static AssemblyTargetOs? ParseTargetOsOption(string[] argsTail)
{
var text = OptionValue(argsTail, "--target-os");
if (text is null)
{
return AssemblyTargetOsSyntax.Host();
}
if (AssemblyTargetOsSyntax.TryParse(text, out var target))
{
return target;
}
Console.Error.WriteLine(
$"[translator] --target-os '{text}' is not recognised; expected one of {AssemblyTargetOsSyntax.AcceptedNames}.");
return null;
}
static string? OptionValue(string[] argsTail, string name)
{
var index = Array.IndexOf(argsTail, name);
@@ -24,7 +24,8 @@ public static class DataSectionGenerator
string outputPath,
string projectName = "PowerPC DOL",
string relName = "rel_module",
string? blobReferenceDirectory = null)
string? blobReferenceDirectory = null,
AssemblyTargetOs? targetOs = null)
{
var sections = new List<DataSectionEntry>();
@@ -65,7 +66,8 @@ public static class DataSectionGenerator
sections,
blobDirectory,
blobReferenceDirectory ?? blobDirectory,
blobAssemblyPath);
blobAssemblyPath,
targetOs ?? AssemblyTargetOsSyntax.Host());
WriteSourceFile(sections, outputPath, dol.BssAddress, dol.BssSize, projectName);
}
@@ -90,7 +92,8 @@ public static class DataSectionGenerator
List<DataSectionEntry> sections,
string blobDirectory,
string blobReferenceDirectory,
string assemblyPath)
string assemblyPath,
AssemblyTargetOs targetOs)
{
var blobs = sections.Select(section => new AssemblyBlob(
$"{section.Name}.bin",
@@ -103,6 +106,7 @@ public static class DataSectionGenerator
blobDirectory,
blobReferenceDirectory,
blobs,
targetOs,
"// AUTO-GENERATED - DO NOT EDIT",
"// Binary DOL/REL section payloads for data_sections_init.cpp.");
}
@@ -16,21 +16,18 @@ internal static class AssemblyBlobWriter
string blobDirectory,
string blobReferenceDirectory,
IReadOnlyList<AssemblyBlob> blobs,
AssemblyTargetOs targetOs,
params string[] headerLines)
{
Directory.CreateDirectory(blobDirectory);
var expectedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var assembly = new StringBuilder();
foreach (var header in headerLines) assembly.AppendLine(header);
// PE/COFF (Windows), Mach-O (macOS), and ELF (Linux) spell a read-only data section
// differently in GNU-as syntax. The build always targets
// whichever platform the translator itself runs on (there is no cross-compilation
// support), so that's what this picks the section syntax from.
assembly.AppendLine(OperatingSystem.IsWindows()
? ".section .rdata,\"dr\""
: OperatingSystem.IsMacOS()
? ".section __TEXT,__const"
: ".section .rodata,\"a\",@progbits");
// PE/COFF (Windows), Mach-O (macOS), and ELF (Linux/Android) spell a read-only data
// section differently in GNU-as syntax. The caller names the object-file flavour the
// product is assembled for; it defaults to the translator's own host, and a cross-compiled
// product (the Android build generated on a Windows host) passes its target explicitly.
assembly.AppendLine(AssemblyTargetOsSyntax.SectionDirective(targetOs));
assembly.AppendLine();
foreach (var blob in blobs)
@@ -41,10 +38,7 @@ internal static class AssemblyBlobWriter
var hash = ChecksumUtilities.Sha256Hex(blob.Data.Span);
assembly.AppendLine($"// {blob.Comment}; sha256={hash}");
assembly.AppendLine(".p2align 4");
// C/C++ external symbols carry a leading underscore in Mach-O,
// unlike ELF and COFF. The generated C++ still names the symbol
// without that ABI decoration, so emit the platform spelling here.
var assemblySymbol = OperatingSystem.IsMacOS() ? $"_{blob.Symbol}" : blob.Symbol;
var assemblySymbol = AssemblyTargetOsSyntax.SymbolName(targetOs, blob.Symbol);
assembly.AppendLine($".globl {assemblySymbol}");
assembly.AppendLine($"{assemblySymbol}:");
var referencePath = Path.Combine(blobReferenceDirectory, blob.FileName);
@@ -56,7 +50,7 @@ internal static class AssemblyBlobWriter
{
if (!expectedFiles.Contains(Path.GetFullPath(stalePath))) File.Delete(stalePath);
}
if (!OperatingSystem.IsWindows() && !OperatingSystem.IsMacOS())
if (AssemblyTargetOsSyntax.EmitsGnuStackNote(targetOs))
{
// Absence of a .note.GNU-stack section makes the linker assume the oldest, most
// conservative default for this object (an executable stack) and warn about it; this
@@ -64,7 +58,7 @@ internal static class AssemblyBlobWriter
// object linked into the binary already does (the norm on modern toolchains, just not
// producible without an explicit section since this file is hand-assembled, not
// compiler-emitted).
assembly.AppendLine(".section .note.GNU-stack,\"\",@progbits");
assembly.AppendLine(AssemblyTargetOsSyntax.GnuStackNoteDirective);
}
FileOutput.WriteTextIfChanged(assemblyPath, assembly.ToString());
}
@@ -0,0 +1,83 @@
using System;
namespace Translator.Core.IO;
/// <summary>
/// The object-file flavour the hand-written blob assembly is assembled into. PE/COFF (Windows),
/// Mach-O (macOS) and ELF (Linux and Android) spell a read-only data section, external symbol
/// decoration and the stack-note trailer differently in GNU-as syntax. Historically the writer
/// keyed this off the operating system the translator itself ran on; a cross-compiled product
/// (the Quest build is generated on a Windows host and assembled by the Android NDK) must be able
/// to ask for another flavour explicitly.
/// </summary>
public enum AssemblyTargetOs
{
Windows,
MacOS,
Linux,
}
public static class AssemblyTargetOsSyntax
{
/// <summary>The flavour matching the host the translator is running on.</summary>
public static AssemblyTargetOs Host()
{
if (OperatingSystem.IsWindows()) return AssemblyTargetOs.Windows;
if (OperatingSystem.IsMacOS()) return AssemblyTargetOs.MacOS;
return AssemblyTargetOs.Linux;
}
/// <summary>
/// Parses a user-facing name. "android" is accepted as a spelling of the ELF flavour so a
/// build script can name the platform it is actually producing.
/// </summary>
public static bool TryParse(string? text, out AssemblyTargetOs target)
{
switch (text?.Trim().ToLowerInvariant())
{
case "windows":
case "win32":
case "mingw":
target = AssemblyTargetOs.Windows;
return true;
case "macos":
case "darwin":
target = AssemblyTargetOs.MacOS;
return true;
case "linux":
case "android":
case "elf":
target = AssemblyTargetOs.Linux;
return true;
default:
target = default;
return false;
}
}
public const string AcceptedNames = "windows, macos, linux, android";
/// <summary>The directive that opens the read-only payload section.</summary>
public static string SectionDirective(AssemblyTargetOs target) => target switch
{
AssemblyTargetOs.Windows => ".section .rdata,\"dr\"",
AssemblyTargetOs.MacOS => ".section __TEXT,__const",
_ => ".section .rodata,\"a\",@progbits",
};
/// <summary>
/// C/C++ external symbols carry a leading underscore in Mach-O, unlike ELF and COFF. The
/// generated C++ still names the symbol without that ABI decoration, so the assembly spelling
/// is produced here.
/// </summary>
public static string SymbolName(AssemblyTargetOs target, string symbol) =>
target == AssemblyTargetOs.MacOS ? "_" + symbol : symbol;
/// <summary>
/// Only ELF objects need an explicit non-executable-stack note: without one the linker assumes
/// the most conservative default (an executable stack) and warns.
/// </summary>
public static bool EmitsGnuStackNote(AssemblyTargetOs target) => target == AssemblyTargetOs.Linux;
public const string GnuStackNoteDirective = ".section .note.GNU-stack,\"\",@progbits";
}
@@ -26,7 +26,8 @@ public static class ModDataPatchWriter
uint? retroWfcInitializerSuccessReturnValue = null,
string? publishedBlobDirectory = null,
string? riivolutionXml = null,
IReadOnlyList<ModRiivolutionOption>? riivolutionOptions = null)
IReadOnlyList<ModRiivolutionOption>? riivolutionOptions = null,
AssemblyTargetOs? targetOs = null)
{
var blobAssemblyPath = Path.Combine(
Path.GetDirectoryName(path) ?? ".",
@@ -46,7 +47,8 @@ public static class ModDataPatchWriter
blobAssemblyPath,
blobDirectory,
publishedBlobDirectory ?? blobDirectory,
blobs);
blobs,
targetOs ?? AssemblyTargetOsSyntax.Host());
var sb = new StringBuilder();
var emitRetroWfcInitializer =
@@ -216,7 +218,8 @@ public static class ModDataPatchWriter
string assemblyPath,
string blobDirectory,
string blobReferenceDirectory,
IReadOnlyList<ModDataBlob> blobs)
IReadOnlyList<ModDataBlob> blobs,
AssemblyTargetOs targetOs)
{
var assemblyBlobs = blobs.Select(blob => new AssemblyBlob(
blob.FileName,
@@ -229,6 +232,7 @@ public static class ModDataPatchWriter
blobDirectory,
blobReferenceDirectory,
assemblyBlobs,
targetOs,
"// Generated by translator translate-mod.",
"// Binary payloads referenced by mod_data_patches.cpp.");
}
@@ -0,0 +1,94 @@
using System;
using System.IO;
using System.Linq;
using Translator.Core.CodeGen;
using Translator.Core.IO;
using Xunit;
namespace Translator.Tests;
public class AssemblyTargetOsTests
{
// AppendLine uses the host newline, so compare whole lines rather than LF-delimited substrings.
private static string[] Lines(string text) =>
text.Split('\n').Select(line => line.TrimEnd('\r')).ToArray();
private static string GenerateBlobAssembly(AssemblyTargetOs targetOs, string tag)
{
var dol = SyntheticDolFactory.Create(
0x80004000,
sections: [SyntheticDolFactory.Data(5, 0x80008000, 1, 2, 3, 4)]);
var tempDir = Path.Combine(Path.GetTempPath(), $"mkw_blob_target_{tag}_{Guid.NewGuid():N}");
try
{
var output = Path.Combine(tempDir, "data_sections_init.cpp");
DataSectionGenerator.Generate(dol, rel: null, output, targetOs: targetOs);
return File.ReadAllText(Path.Combine(tempDir, "data_sections_init_blobs.S"));
}
finally
{
if (Directory.Exists(tempDir)) Directory.Delete(tempDir, recursive: true);
}
}
[Theory]
[InlineData("windows", AssemblyTargetOs.Windows)]
[InlineData("MacOS", AssemblyTargetOs.MacOS)]
[InlineData("linux", AssemblyTargetOs.Linux)]
[InlineData("android", AssemblyTargetOs.Linux)]
public void ParsesUserFacingNames(string text, AssemblyTargetOs expected)
{
Assert.True(AssemblyTargetOsSyntax.TryParse(text, out var parsed));
Assert.Equal(expected, parsed);
}
[Theory]
[InlineData("")]
[InlineData("quest")]
[InlineData(null)]
public void RejectsUnknownNames(string? text)
{
Assert.False(AssemblyTargetOsSyntax.TryParse(text, out _));
}
[Fact]
public void HostFlavourMatchesTheRunningOperatingSystem()
{
var expected = OperatingSystem.IsWindows() ? AssemblyTargetOs.Windows
: OperatingSystem.IsMacOS() ? AssemblyTargetOs.MacOS
: AssemblyTargetOs.Linux;
Assert.Equal(expected, AssemblyTargetOsSyntax.Host());
}
[Fact]
public void ElfFlavourEmitsRodataUndecoratedSymbolsAndStackNote()
{
var assembly = GenerateBlobAssembly(AssemblyTargetOs.Linux, "elf");
var lines = Lines(assembly);
Assert.Contains(".section .rodata,\"a\",@progbits", lines);
Assert.Contains(".globl kData__data", lines);
Assert.DoesNotContain(".rdata", assembly, StringComparison.Ordinal);
Assert.DoesNotContain("__TEXT,__const", assembly, StringComparison.Ordinal);
Assert.Contains(".section .note.GNU-stack,\"\",@progbits", lines);
}
[Fact]
public void WindowsFlavourEmitsRdataWithoutStackNote()
{
var assembly = GenerateBlobAssembly(AssemblyTargetOs.Windows, "win");
var lines = Lines(assembly);
Assert.Contains(".section .rdata,\"dr\"", lines);
Assert.Contains(".globl kData__data", lines);
Assert.DoesNotContain(".note.GNU-stack", assembly, StringComparison.Ordinal);
}
[Fact]
public void MacOsFlavourDecoratesSymbols()
{
var assembly = GenerateBlobAssembly(AssemblyTargetOs.MacOS, "mac");
var lines = Lines(assembly);
Assert.Contains(".section __TEXT,__const", lines);
Assert.Contains(".globl _kData__data", lines);
Assert.DoesNotContain(".note.GNU-stack", assembly, StringComparison.Ordinal);
}
}