diff --git a/.gitattributes b/.gitattributes index e69de29bb2..6b653bb123 100644 --- a/.gitattributes +++ b/.gitattributes @@ -0,0 +1 @@ +*.patch whitespace=-space-before-tab diff --git a/.gitignore b/.gitignore index 52052d7600..bb5760a3e9 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,9 @@ resign.keystore /app/modernXr/ +# Robolectric/debug container snapshots written relative to the app module. +/app/.container + # Steam bootstrap shim source — withheld out of respect for Valve, since it # encodes internal details of Steam's proprietary client that Valve does not # publish. See THIRD_PARTY_NOTICES ("Steam Client Bootstrap Shim"). diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..94b889950a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# Project agent instructions + +## Remote Quest ADB through Tailminal + +- A Quest 3 is attached by ADB to the tailnet host `lily`. Local `adb devices` + may be empty; check the remote ADB server before concluding that the headset + is unavailable. +- Tailminal is already installed on this workstation and running on `lily`. + It connects directly over Tailscale. Its operating guide is + . +- From PowerShell on this workstation, invoke the npm CMD launcher explicitly. + The `.ps1` wrapper has mishandled the `--` command boundary here: + + ```powershell + & "$env:APPDATA\npm\tailminal.cmd" exec lily --timeout-ms 30000 -- "adb devices -l" + ``` + +- Pass the entire remote command after `--` as one quoted argument. Prefer + one-shot `exec` calls with a finite `--timeout-ms` for unattended work: + + ```powershell + & "$env:APPDATA\npm\tailminal.cmd" exec lily --timeout-ms 30000 -- "adb shell getprop ro.product.model" + & "$env:APPDATA\npm\tailminal.cmd" exec lily --timeout-ms 30000 -- "adb shell tail -n 300 /sdcard/Android/data/app.gamenative/files/gamenativevr/launch.log" + & "$env:APPDATA\npm\tailminal.cmd" exec lily --timeout-ms 30000 -- "adb logcat -d -v threadtime -t 1000" + ``` + +- `tailminal hosts` depends on a local Tailminal server and may return no peers + when that server is stopped. Directly addressing the MagicDNS host `lily` + still works. If a direct command fails, check `tailscale status`, then retry + the Tailminal health/command path before treating the Quest as disconnected. +- Tailminal grants a full shell on the remote host. Keep commands scoped to the + requested task, prefer read-only diagnostics first, and do not install, + delete, reboot, or otherwise mutate the headset unless the user's request + authorizes it. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 399f57028a..182f6b3491 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -63,6 +63,7 @@ android { manifestPlaceholders["screenOrientation"] = "unspecified" buildConfigField("boolean", "XR_BUILD", "false") buildConfigField("boolean", "MODERN_XR", "false") + buildConfigField("boolean", "ANDROID_XR", "false") versionCode = 21 versionName = "1.2.0" @@ -159,6 +160,19 @@ android { buildConfigField("String", "PRODUCT_SKU", "\"$productSku\"") manifestPlaceholders["screenOrientation"] = "landscape" } + create("androidXr") { + dimension = "androidApi" + minSdk = 29 + targetSdk = 36 + ndk.abiFilters += listOf("arm64-v8a") + buildConfigField("boolean", "MODERN_ANDROID", "true") + buildConfigField("String", "PRELOAD_BIONIC_SO", "\"libredirect-bionic-wx.so\"") + buildConfigField("boolean", "XR_BUILD", "true") + buildConfigField("boolean", "MODERN_XR", "true") + buildConfigField("boolean", "ANDROID_XR", "true") + // Keep the launcher in Android XR Home Space. Only ImmersiveXrActivity is + // promoted to unmanaged Full Space by src/androidXr/AndroidManifest.xml. + } } buildTypes { @@ -274,6 +288,17 @@ android { setSrcDirs(listOf("src/modern/jniLibs", "build/generated/xrNative/modernXr")) } } + getByName("androidXr") { + // The platform launch contract differs, but the OpenXR renderer, Wine runtime + // payload and launch-readiness implementation are shared with modernXr. + java.srcDir("src/modernXr/java") + assets { + srcDirs("src/modern/assets", "src/main/assets", "build/generated/xrPayload/modernXr") + } + jniLibs { + setSrcDirs(listOf("src/modern/jniLibs", "build/generated/xrNative/modernXr")) + } + } getByName("debug") { assets.srcDir(copyDebugManifest) } @@ -326,6 +351,32 @@ android { tasks.register("stageOpenComposite") { enabled = hostCanRunXrPayloadScripts + val stageScript = rootProject.file("tools/stage-opencomposite.ps1") + val toolchainFingerprint = providers.exec { + commandLine( + "powershell", + "-ExecutionPolicy", + "Bypass", + "-File", + stageScript.absolutePath, + "-PrintToolchainFingerprint", + ) + }.standardOutput.asText.map { it.trim() } + inputs.files( + stageScript, + rootProject.file("tools/patches/opencomposite-gamenative-wine.patch"), + rootProject.file("tools/patches/opencomposite-gamenative-native-backend.patch"), + rootProject.file("tools/opencomposite-vulkan-x64.def"), + rootProject.file("app/src/main/windows/openvr_client/DrvGameNative.cpp"), + rootProject.file("app/src/main/windows/openvr_client/DrvGameNative.h"), + rootProject.file("app/src/main/windows/openxr_runtime/gamenative_control.c"), + rootProject.file("app/src/main/windows/openxr_runtime/gamenative_control.h"), + rootProject.file("app/src/main/windows/openxr_runtime/gamenative_openxr_unix.h"), + ) + inputs.property("openCompositeCommit", "a27e7e6a64bdcd1eff6b7fba1ea2ea34bcf1273d") + inputs.property("openCompositeBuildRevision", 4) + inputs.property("openCompositeToolchainFingerprint", toolchainFingerprint) + outputs.file(layout.buildDirectory.file("generated/xrPayload/modernXr/opencomposite_x64.dll")) commandLine( "powershell", "-ExecutionPolicy", @@ -352,7 +403,8 @@ android { } tasks.matching { - it.name.startsWith("mergeModernXr") && it.name.endsWith("Assets") + (it.name.startsWith("mergeModernXr") || it.name.startsWith("mergeAndroidXr")) && + it.name.endsWith("Assets") }.configureEach { dependsOn("prepareModernXrPayload") } @@ -443,6 +495,7 @@ dependencies { // Official Khronos OpenXR loader (Apache-2.0) for the Meta Quest immersive launch mode's // native module (app/src/main/cpp/xrimmersive) — not a Winlator/GameNativeXR dependency. "modernXrImplementation"("org.khronos.openxr:openxr_loader_for_android:1.1.61") + "androidXrImplementation"("org.khronos.openxr:openxr_loader_for_android:1.1.61") // Winlator implementation(libs.bundles.winlator) diff --git a/app/src/androidXr/AndroidManifest.xml b/app/src/androidXr/AndroidManifest.xml new file mode 100644 index 0000000000..ce3873408d --- /dev/null +++ b/app/src/androidXr/AndroidManifest.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/cpp/xrimmersive/jni_bridge.cpp b/app/src/main/cpp/xrimmersive/jni_bridge.cpp index 9e032a8b28..42cec855da 100644 --- a/app/src/main/cpp/xrimmersive/jni_bridge.cpp +++ b/app/src/main/cpp/xrimmersive/jni_bridge.cpp @@ -132,7 +132,7 @@ Java_app_gamenative_ui_screen_xr_XrNative_nativeWaitWindowsFrame( JNIEnv *env, jclass, jlong handlePtr, jlong afterSerial, jint timeoutMs, jlongArray outTiming, jfloatArray outViews, jfloatArray outInput, jintArray outFlags) { if (env->GetArrayLength(outTiming) < 11 || env->GetArrayLength(outViews) < 22 || - env->GetArrayLength(outInput) < 36 || env->GetArrayLength(outFlags) < 3) return JNI_FALSE; + env->GetArrayLength(outInput) < 36 || env->GetArrayLength(outFlags) < 4) return JNI_FALSE; xrimmersive::WindowsRuntimeSnapshot snapshot; { std::lock_guard lock(gHandleMutex); @@ -200,13 +200,18 @@ Java_app_gamenative_ui_screen_xr_XrNative_nativeWaitWindowsFrame( inputValues[base + 17] = aim.position.z; } env->SetFloatArrayRegion(outInput, 0, 36, inputValues); - const jint flags[3] = { + const bool leftActive = input.aimPoseValid[0] || input.gripPoseValid[0]; + const bool rightActive = input.aimPoseValid[1] || input.gripPoseValid[1]; + const jint flags[4] = { static_cast(snapshot.viewStateFlags), static_cast(input.buttons), - static_cast((input.aimPoseValid[0] && input.gripPoseValid[0] ? 1 : 0) | - (input.aimPoseValid[1] && input.gripPoseValid[1] ? 2 : 0)), + static_cast((leftActive ? 1 : 0) | (rightActive ? 2 : 0)), + static_cast((input.gripPoseValid[0] ? 1 : 0) | + (input.gripPoseValid[1] ? 2 : 0) | + (input.aimPoseValid[0] ? 4 : 0) | + (input.aimPoseValid[1] ? 8 : 0)), }; - env->SetIntArrayRegion(outFlags, 0, 3, flags); + env->SetIntArrayRegion(outFlags, 0, 4, flags); return JNI_TRUE; } diff --git a/app/src/main/java/app/gamenative/MainActivity.kt b/app/src/main/java/app/gamenative/MainActivity.kt index 97919fb51b..88d39cf766 100644 --- a/app/src/main/java/app/gamenative/MainActivity.kt +++ b/app/src/main/java/app/gamenative/MainActivity.kt @@ -40,6 +40,7 @@ import app.gamenative.mods.NexusDownloadLinkInbox import app.gamenative.mods.NexusIntegrationStatus import app.gamenative.mods.NexusPendingDownloadStore import app.gamenative.ui.screen.library.appscreen.BaseAppScreen +import app.gamenative.ui.screen.xr.ImmersiveSessionOwnership import app.gamenative.service.SteamService import app.gamenative.service.gog.GOGService import app.gamenative.service.epic.EpicService @@ -77,6 +78,7 @@ class MainActivity : ComponentActivity() { fun isHeadset(context: Context): Boolean = context.packageManager.hasSystemFeature("android.hardware.vr.headtracking") || + context.packageManager.hasSystemFeature("android.software.xr.api.openxr") || Build.MANUFACTURER.equals("Oculus", true) || Build.MANUFACTURER.equals("Meta", true) || Build.MANUFACTURER.equals("Pico", true) @@ -205,9 +207,16 @@ class MainActivity : ComponentActivity() { } // stale keepAlive from a prior crash/swipe — no container is actually running - if (SteamService.keepAlive && PluviaApp.xEnvironment == null) { + if (SteamService.keepAlive && PluviaApp.xEnvironment == null && + (!BuildConfig.ANDROID_XR || !ImmersiveSessionOwnership.isOwnedByImmersive()) + ) { Timber.w("onCreate: clearing stale keepAlive — no container running") PluviaApp.shutdownEnvironment() + } else if (BuildConfig.ANDROID_XR && SteamService.keepAlive && PluviaApp.xEnvironment == null) { + Timber.i( + "onCreate: preserving keepAlive during immersive handoff (%s)", + ImmersiveSessionOwnership.snapshot(), + ) } // Apply immersive mode based on user preference @@ -372,7 +381,12 @@ class MainActivity : ComponentActivity() { override fun onDestroy() { // emit before super so Compose DisposableEffects (which unregister // listeners during super.onDestroy's lifecycle transition) still fire - if (!isChangingConfigurations) { + val launcherShouldHandleDestruction = if (BuildConfig.ANDROID_XR) { + ImmersiveSessionOwnership.shouldLauncherHandleDestruction(isChangingConfigurations) + } else { + !isChangingConfigurations + } + if (launcherShouldHandleDestruction) { PluviaApp.events.emit(AndroidEvent.ActivityDestroyed) // if exit() didn't run (listener already unregistered, race, etc.) @@ -381,6 +395,12 @@ class MainActivity : ComponentActivity() { Timber.w("onDestroy: keepAlive still set after ActivityDestroyed — forcing cleanup") PluviaApp.shutdownEnvironment() } + } else { + Timber.i( + "onDestroy: launcher teardown skipped; changingConfig=%b immersive=%s", + isChangingConfigurations, + ImmersiveSessionOwnership.snapshot(), + ) } super.onDestroy() diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/GraphicsTab.kt b/app/src/main/java/app/gamenative/ui/component/dialog/GraphicsTab.kt index 4acec3fef8..6ea660c0a9 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/GraphicsTab.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GraphicsTab.kt @@ -195,6 +195,17 @@ fun GraphicsTabContent(state: ContainerConfigState, default: Boolean = false) { ) Text(text = "${config.xrRenderScale}%") } + if (!default) { + SettingsSwitch( + colors = settingsTileColorsAlt(), + title = { Text(text = stringResource(R.string.xr_open_composite)) }, + subtitle = { Text(text = stringResource(R.string.xr_open_composite_description)) }, + state = config.openCompositeEnabled, + onCheckedChange = { + state.config.value = config.copy(openCompositeEnabled = it) + }, + ) + } } SettingsListDropdown( colors = settingsTileColors(), diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveRuntimeViewModel.kt b/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveRuntimeViewModel.kt new file mode 100644 index 0000000000..4738cf38e5 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveRuntimeViewModel.kt @@ -0,0 +1,24 @@ +package app.gamenative.ui.screen.xr + +import android.content.Context +import androidx.lifecycle.ViewModel +import app.gamenative.ui.screen.xr.windows.WindowsVrRuntimeService + +/** Retains the Windows VR control server across Android activity configuration recreation. */ +internal class ImmersiveRuntimeViewModel : ViewModel() { + private var windowsVrRuntimeService: WindowsVrRuntimeService? = null + + fun windowsVrRuntimeService(context: Context): WindowsVrRuntimeService = + windowsVrRuntimeService ?: WindowsVrRuntimeService(context.applicationContext).also { + windowsVrRuntimeService = it + } + + fun closeWindowsVrRuntimeService() { + windowsVrRuntimeService?.close() + windowsVrRuntimeService = null + } + + override fun onCleared() { + closeWindowsVrRuntimeService() + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveSessionOwnership.kt b/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveSessionOwnership.kt new file mode 100644 index 0000000000..ec67c7b48a --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveSessionOwnership.kt @@ -0,0 +1,70 @@ +package app.gamenative.ui.screen.xr + +import timber.log.Timber + +/** + * Process-local ownership of a flat-launcher -> immersive-session handoff. + * + * Android XR may stop, recreate, or destroy the Home Space activity while the Full Space + * activity is starting. The flat activity must not interpret that transition as an app exit: + * [ImmersiveXrActivity] owns Wine/environment teardown until its final destruction. + */ +internal object ImmersiveSessionOwnership { + internal enum class Phase { + IDLE, + LAUNCHING, + ACTIVE, + } + + internal data class Snapshot( + val appId: String? = null, + val phase: Phase = Phase.IDLE, + ) + + private val lock = Any() + + @Volatile + private var state = Snapshot() + + fun beginLaunch(appId: String) { + synchronized(lock) { + state = Snapshot(appId, Phase.LAUNCHING) + } + Timber.i("Immersive ownership: launch handoff started for %s", appId) + } + + fun markActivityActive(appId: String) { + synchronized(lock) { + state = Snapshot(appId, Phase.ACTIVE) + } + Timber.i("Immersive ownership: activity owns session for %s", appId) + } + + fun release(appId: String?) { + var released = false + synchronized(lock) { + if (appId == null || state.appId == appId) { + state = Snapshot() + released = true + } + } + if (released) { + Timber.i("Immersive ownership: session released for %s", appId ?: "") + } else { + Timber.w("Immersive ownership: ignored release for non-owner %s", appId ?: "") + } + } + + fun isOwnedByImmersive(): Boolean = state.phase != Phase.IDLE + + fun shouldLauncherHandleDestruction(isChangingConfigurations: Boolean): Boolean = + !isChangingConfigurations && !isOwnedByImmersive() + + fun snapshot(): Snapshot = state + + internal fun resetForTest() { + synchronized(lock) { + state = Snapshot() + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveXrActivity.kt b/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveXrActivity.kt index 25a70ced10..de6834ec8a 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveXrActivity.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xr/ImmersiveXrActivity.kt @@ -1,5 +1,6 @@ package app.gamenative.ui.screen.xr +import android.app.Activity import android.content.Context import android.content.Intent import android.graphics.Bitmap @@ -46,6 +47,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope +import app.gamenative.BuildConfig +import app.gamenative.ui.screen.xr.windows.WindowsVrRuntimeConfig import kotlinx.coroutines.delay import app.gamenative.PluviaApp import app.gamenative.R @@ -66,7 +69,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber -/** Dedicated entry point for launching a game directly in Meta Quest immersive mode. */ +/** Shared OpenXR entry point for launching a game in headset immersive mode. */ @AndroidEntryPoint class ImmersiveXrActivity : androidx.activity.ComponentActivity() { @@ -99,20 +102,37 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { private const val EXTRA_QUAD_DISTANCE = "immersiveQuadDistance" private const val EXTRA_QUAD_SCALE = "immersiveQuadScale" private const val EXTRA_PASSTHROUGH_ENABLED = "immersivePassthroughEnabled" - private const val EXTRA_WINDOWS_VR_ENABLED = "windowsVrEnabled" - private const val EXTRA_WINDOWS_VR_OPEN_COMPOSITE = "windowsVrOpenCompositeEnabled" - fun start(context: Context, appId: String, isOffline: Boolean) { - val intent = Intent(context, ImmersiveXrActivity::class.java).apply { + internal fun createLaunchIntent( + context: Context, + appId: String, + isOffline: Boolean, + isAndroidXr: Boolean = BuildConfig.ANDROID_XR, + ): Intent = + Intent(context, ImmersiveXrActivity::class.java).apply { putExtra(EXTRA_APP_ID, appId) putExtra(EXTRA_IS_OFFLINE, isOffline) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + // Android XR keeps an Activity-context launch on the flat launcher's task so + // finish() returns to Home Space. Quest must retain its proven separate-task + // transition into an Activity carrying com.oculus.intent.category.VR. + if (!isAndroidXr || context !is Activity) { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + } + + fun start(context: Context, appId: String, isOffline: Boolean) { + if (BuildConfig.ANDROID_XR) ImmersiveSessionOwnership.beginLaunch(appId) + try { + context.startActivity(createLaunchIntent(context, appId, isOffline)) + } catch (t: Throwable) { + if (BuildConfig.ANDROID_XR) ImmersiveSessionOwnership.release(appId) + throw t } - context.startActivity(intent) } } private val viewModel: MainViewModel by viewModels() + private val immersiveRuntimeViewModel: ImmersiveRuntimeViewModel by viewModels() @Volatile private var backAction: (() -> Unit)? = null @@ -256,7 +276,12 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { return } currentAppId = appId - windowsVrRuntimeService = app.gamenative.ui.screen.xr.windows.WindowsVrRuntimeService(this) + if (BuildConfig.ANDROID_XR) { + ImmersiveSessionOwnership.markActivityActive(appId) + windowsVrRuntimeService = immersiveRuntimeViewModel.windowsVrRuntimeService(this) + } else { + windowsVrRuntimeService = app.gamenative.ui.screen.xr.windows.WindowsVrRuntimeService(this) + } PluviaApp.isActivityInForeground = true AppUtils.keepScreenOn(this) @@ -401,8 +426,9 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { quadScale = container.getExtra(EXTRA_QUAD_SCALE, ImmersiveControls.DEFAULT_SCALE.toString()) .toFloatOrNull() ?: ImmersiveControls.DEFAULT_SCALE passthroughEnabled = container.getExtra(EXTRA_PASSTHROUGH_ENABLED, "false").toBoolean() - windowsVrEnabled = container.getExtra(EXTRA_WINDOWS_VR_ENABLED, "true").toBoolean() - openCompositeEnabled = container.getExtra(EXTRA_WINDOWS_VR_OPEN_COMPOSITE, "false").toBoolean() + val windowsVrConfig = WindowsVrRuntimeConfig.from(container) + windowsVrEnabled = windowsVrConfig.enabled + openCompositeEnabled = windowsVrConfig.openCompositeEnabled windowsVrStatus = if (windowsVrEnabled) "Waiting for runtime" else "Disabled" applyQuadTransform() if (xrSessionHandle != 0L) { @@ -422,8 +448,8 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { container.putExtra(EXTRA_QUAD_DISTANCE, quadDistance.toString()) container.putExtra(EXTRA_QUAD_SCALE, quadScale.toString()) container.putExtra(EXTRA_PASSTHROUGH_ENABLED, passthroughEnabled.toString()) - container.putExtra(EXTRA_WINDOWS_VR_ENABLED, windowsVrEnabled.toString()) - container.putExtra(EXTRA_WINDOWS_VR_OPEN_COMPOSITE, openCompositeEnabled.toString()) + WindowsVrRuntimeConfig.setEnabled(container, windowsVrEnabled) + WindowsVrRuntimeConfig.setOpenCompositeEnabled(container, openCompositeEnabled) container.saveData() } } @@ -482,10 +508,30 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { } override fun onDestroy() { + val changingConfigurations = isChangingConfigurations + Timber.i( + "Immersive: onDestroy, changingConfig=%b finishing=%b appId=%s", + changingConfigurations, + isFinishing, + currentAppId, + ) stopXrSession() - windowsVrRuntimeService?.close() - windowsVrRuntimeService = null - PluviaApp.shutdownEnvironment() + if (BuildConfig.ANDROID_XR && changingConfigurations) { + // The replacement Activity receives the same ViewModel and control server. Keeping + // both alive prevents a display-density/configuration transition from killing Wine + // or disconnecting the Windows OpenXR/OpenVR runtime during startup. + windowsVrRuntimeService = null + Timber.i("Immersive: preserving Wine and Windows VR service across recreation") + } else { + if (BuildConfig.ANDROID_XR) { + immersiveRuntimeViewModel.closeWindowsVrRuntimeService() + ImmersiveSessionOwnership.release(currentAppId) + } else { + windowsVrRuntimeService?.close() + } + windowsVrRuntimeService = null + PluviaApp.shutdownEnvironment() + } super.onDestroy() } @@ -537,64 +583,65 @@ class ImmersiveXrActivity : androidx.activity.ComponentActivity() { var lastFedGamepad = false while (pollingActive.get()) { val winHandler = PluviaApp.xServerView?.getxServer()?.winHandler - if (winHandler != null) { - if (winHandler !== cachedWinHandler) { - cachedWinHandler = winHandler - cachedBridge = XrGamepadBridge(winHandler) - } - val quickMenuClicked = XrNative.nativePollSnapshot(xrSessionHandle, buttons, axes, handPoses, flags) - val stereoActive = XrNative.nativeIsWindowsStereoActive(xrSessionHandle) - if (stereoActive != flatPresentationSuspended) { - flatPresentationSuspended = stereoActive - windowsVrStatus = if (stereoActive) "Stereo active" else if (windowsVrEnabled) "Flat fallback" else "Disabled" - windowsVrRuntimeService?.onPresentationState(windowsVrStatus) - runOnUiThread { applyFlatPresentationGate(!stereoActive) } - } - if (flags[2] != lastPushedStartHeld) { - lastPushedStartHeld = flags[2] - val setter = quickMenuSetStartHeld - if (setter != null) runOnUiThread { setter(lastPushedStartHeld) } - } - if (flags[1]) { - xrPointerModeActive = !xrPointerModeActive - Timber.i("Immersive: XR pointer mode %s", if (xrPointerModeActive) "enabled" else "disabled") - lastLeftActionPressed = (axes[4] > POINTER_GRAB_PRESS_THRESHOLD) || - ((buttons[0] and (1 shl XrGamepadBridge.BUTTON_LB)) != 0) - lastRightActionPressed = (axes[5] > POINTER_GRAB_PRESS_THRESHOLD) || - ((buttons[0] and (1 shl XrGamepadBridge.BUTTON_RB)) != 0) - if (!xrPointerModeActive) { - pointerGrabHand = null - pointerCursorLeftValid = false - pointerCursorRightValid = false - } - } - val inMenuMode = quickMenuVisible || PluviaApp.isOverlayPaused - overlayPausedUi = PluviaApp.isOverlayPaused - if (wasInMenuNavigationMode && !inMenuMode) { - buttonSuppressMaskUntilRelease = buttons[0] - } - wasInMenuNavigationMode = inMenuMode - val feedGame = !xrPointerModeActive && !inMenuMode - if (!feedGame && lastFedGamepad) cachedBridge?.reset() - lastFedGamepad = feedGame - when { - xrPointerModeActive -> handlePointerMode(buttons[0], axes, handPoses, flags[0]) - inMenuMode && !flags[2] -> handleMenuNavigation(buttons[0], axes) - inMenuMode -> Unit - else -> { - buttonSuppressMaskUntilRelease = buttonSuppressMaskUntilRelease and buttons[0] - cachedBridge?.applySnapshot(buttons[0] and buttonSuppressMaskUntilRelease.inv(), axes) - } + if (winHandler == null) { + cachedWinHandler = null + cachedBridge = null + } else if (winHandler !== cachedWinHandler) { + cachedWinHandler = winHandler + cachedBridge = XrGamepadBridge(winHandler) + } + val quickMenuClicked = XrNative.nativePollSnapshot(xrSessionHandle, buttons, axes, handPoses, flags) + val stereoActive = XrNative.nativeIsWindowsStereoActive(xrSessionHandle) + if (stereoActive != flatPresentationSuspended) { + flatPresentationSuspended = stereoActive + windowsVrStatus = if (stereoActive) "Stereo active" else if (windowsVrEnabled) "Flat fallback" else "Disabled" + windowsVrRuntimeService?.onPresentationState(windowsVrStatus) + runOnUiThread { applyFlatPresentationGate(!stereoActive) } + } + if (flags[2] != lastPushedStartHeld) { + lastPushedStartHeld = flags[2] + val setter = quickMenuSetStartHeld + if (setter != null) runOnUiThread { setter(lastPushedStartHeld) } + } + if (flags[1]) { + xrPointerModeActive = !xrPointerModeActive + Timber.i("Immersive: XR pointer mode %s", if (xrPointerModeActive) "enabled" else "disabled") + lastLeftActionPressed = (axes[4] > POINTER_GRAB_PRESS_THRESHOLD) || + ((buttons[0] and (1 shl XrGamepadBridge.BUTTON_LB)) != 0) + lastRightActionPressed = (axes[5] > POINTER_GRAB_PRESS_THRESHOLD) || + ((buttons[0] and (1 shl XrGamepadBridge.BUTTON_RB)) != 0) + if (!xrPointerModeActive) { + pointerGrabHand = null + pointerCursorLeftValid = false + pointerCursorRightValid = false } - if (quickMenuClicked) { - Timber.i( - "Immersive: quick-menu chord fired, quickMenuToggle registered=%b buttons=0x%03x " + - "quickMenuVisible=%b isOverlayPaused=%b xrPointerModeActive=%b", - quickMenuToggle != null, buttons[0], quickMenuVisible, PluviaApp.isOverlayPaused, xrPointerModeActive, - ) - runOnUiThread { quickMenuToggle?.invoke() } + } + val inMenuMode = quickMenuVisible || PluviaApp.isOverlayPaused + overlayPausedUi = PluviaApp.isOverlayPaused + if (wasInMenuNavigationMode && !inMenuMode) { + buttonSuppressMaskUntilRelease = buttons[0] + } + wasInMenuNavigationMode = inMenuMode + val feedGame = winHandler != null && !xrPointerModeActive && !inMenuMode + if (!feedGame && lastFedGamepad) cachedBridge?.reset() + lastFedGamepad = feedGame + when { + xrPointerModeActive -> handlePointerMode(buttons[0], axes, handPoses, flags[0]) + inMenuMode && !flags[2] -> handleMenuNavigation(buttons[0], axes) + inMenuMode -> Unit + feedGame -> { + buttonSuppressMaskUntilRelease = buttonSuppressMaskUntilRelease and buttons[0] + cachedBridge?.applySnapshot(buttons[0] and buttonSuppressMaskUntilRelease.inv(), axes) } } + if (quickMenuClicked) { + Timber.i( + "Immersive: quick-menu chord fired, quickMenuToggle registered=%b buttons=0x%03x " + + "quickMenuVisible=%b isOverlayPaused=%b xrPointerModeActive=%b", + quickMenuToggle != null, buttons[0], quickMenuVisible, PluviaApp.isOverlayPaused, xrPointerModeActive, + ) + runOnUiThread { quickMenuToggle?.invoke() } + } try { Thread.sleep(POLL_INTERVAL_MS) } catch (e: InterruptedException) { diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrControlServer.kt b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrControlServer.kt index 1835ee10a1..e76801a804 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrControlServer.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrControlServer.kt @@ -11,6 +11,21 @@ import java.util.concurrent.Executors import java.util.concurrent.Semaphore import java.util.concurrent.atomic.AtomicBoolean +/** Translate the shared Xbox-layout bit mask into the compact per-hand mask consumed by the + * Windows OpenXR compatibility runtime: primary, secondary, stick-click, and menu. */ +internal fun openVrControllerButtons(buttons: Int, hand: Int): Int { + require(hand in 0..1) + val sourceBits = if (hand == 0) { + intArrayOf(2, 3, 8, 7) // X, Y, left-stick click, left menu/start. + } else { + intArrayOf(0, 1, 9, -1) // A, B, right-stick click; no right-hand menu button. + } + return sourceBits.foldIndexed(0) { outputBit, result, sourceBit -> + if (sourceBit >= 0 && buttons and (1 shl sourceBit) != 0) result or (1 shl outputBit) + else result + } +} + class WindowsVrControlServer( private val config: WindowsVrRuntimeConfig, private val diagnostics: WindowsVrDiagnostics, @@ -236,7 +251,11 @@ class WindowsVrControlServer( "${inputFields[field]}=${(snapshot.input[base + field] * 1_000_000f).toLong()}" } val active = (snapshot.flags[2] shr hand) and 1 - return "OK active=$active buttons=${snapshot.flags[1]} $rawValues $namedValues" + val poseFlags = snapshot.flags.getOrElse(3) { 0 } + val gripActive = (poseFlags shr hand) and 1 + val aimActive = (poseFlags shr (hand + 2)) and 1 + val buttons = openVrControllerButtons(snapshot.flags[1], hand) + return "OK active=$active gripActive=$gripActive aimActive=$aimActive buttons=$buttons $rawValues $namedValues" } private fun haptic(tokens: List): String { diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrPayloadManager.kt b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrPayloadManager.kt index 5b79ea1d8c..5bdd788c2c 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrPayloadManager.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrPayloadManager.kt @@ -74,45 +74,25 @@ class WindowsVrPayloadManager( return PreparedPayload(prefixDirectory, manifest) } - fun installOpenComposite(container: Container) { - val gameRoot = Container.drivesIterator(container.drives).asSequence() - .firstOrNull { it[0].equals("A", ignoreCase = true) } - ?.get(1) - ?.let(::File) - ?.canonicalFile - ?: error("OpenComposite requires the launched game's A: drive") - check(gameRoot.isDirectory) - val candidates = gameRoot.walkTopDown() - .onEnter { it.canonicalFile.path.startsWith(gameRoot.path + File.separator) || it.canonicalFile == gameRoot } - .take(20001) - .toList() - check(candidates.size <= 20000) { "OpenComposite scan exceeded 20000 files" } - val targets = candidates.filter { it.isFile && it.name.equals("openvr_api.dll", ignoreCase = true) } - check(targets.isNotEmpty()) { "No openvr_api.dll was found under the launched game" } - val adapter = context.assets.open("opencomposite_x64.dll").use { it.readBytes() } - val record = File(File(container.rootDir, ".wine/drive_c/gamenative-xr"), "opencomposite.targets") - val encodedTargets = targets.map { checkNotNull(it.parentFile).canonicalPath } - .distinct() - .joinToString("\n") { Base64.getUrlEncoder().withoutPadding().encodeToString(it.toByteArray()) } - writeIfChanged(record, encodedTargets.toByteArray()) - openCompositeRecord = record - targets.forEach { target -> - check(peMachine(target.readBytes()) == 0x8664) { "OpenVR library is not x64: ${target.path}" } - val directory = checkNotNull(target.parentFile).canonicalFile - val backup = File(directory, "openvr_api.dll.gamenative-original") - val owner = File(directory, "openvr_api.dll.gamenative-owner") - val ini = File(directory, "opencomposite.ini") - val iniBackup = File(directory, "opencomposite.ini.gamenative-original") - val iniMissing = File(directory, "opencomposite.ini.gamenative-missing") - check(!backup.exists() && !owner.exists()) - writeIfChanged(backup, target.readBytes()) - if (ini.isFile) writeIfChanged(iniBackup, ini.readBytes()) else writeIfChanged(iniMissing, byteArrayOf(1)) - writeIfChanged(owner, "2\n".toByteArray()) - writeIfChanged(target, adapter) - writeIfChanged(ini, "enableAudio=false\nlogEnabled=true\n".toByteArray()) - openCompositeDirectories += directory - diagnostics.record("opencomposite", "installed path=${target.path}") - } + fun installOpenCompositeRuntime(container: Container): String { + val payloadDirectory = File(container.rootDir, ".wine/drive_c/gamenative-xr") + val runtimeDirectory = File(payloadDirectory, "openvr") + val binDirectory = File(runtimeDirectory, "bin") + check(binDirectory.exists() || binDirectory.mkdirs()) + val client = File(binDirectory, "vrclient_x64.dll") + copyAssetIfChanged("opencomposite_x64.dll", client) + check(peMachine(client.readBytes()) == 0x8664) { "OpenComposite runtime is not x64" } + writeIfChanged(File(binDirectory, "version.txt"), byteArrayOf()) + writeIfChanged( + File(runtimeDirectory, "openvrpaths.vrpath"), + "{\"jsonid\":\"vrpathreg\",\"runtime\":[\"C:\\\\gamenative-xr\\\\openvr\"],\"version\":1}\n".toByteArray(), + ) + val windowsPath = "C:\\gamenative-xr\\openvr" + diagnostics.record( + "opencomposite", + "runtime installed path=${client.path} mode=VR_OVERRIDE backend=GameNative-direct", + ) + return windowsPath } fun restore() { diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrRuntimeConfig.kt b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrRuntimeConfig.kt index 67a474f475..e02c95b267 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrRuntimeConfig.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrRuntimeConfig.kt @@ -13,12 +13,23 @@ data class WindowsVrRuntimeConfig( val renderScalePercent: Int = 100, ) { companion object { + const val EXTRA_ENABLED = "windowsVrEnabled" + const val EXTRA_OPEN_COMPOSITE_ENABLED = "windowsVrOpenCompositeEnabled" + fun from(container: Container): WindowsVrRuntimeConfig { return WindowsVrRuntimeConfig( - enabled = container.getExtra("windowsVrEnabled", "true").toBoolean(), - openCompositeEnabled = container.getExtra("windowsVrOpenCompositeEnabled", "false").toBoolean(), + enabled = container.getExtra(EXTRA_ENABLED, "true").toBoolean(), + openCompositeEnabled = container.getExtra(EXTRA_OPEN_COMPOSITE_ENABLED, "false").toBoolean(), renderScalePercent = container.xrRenderScale.coerceIn(25, 100), ) } + + fun setEnabled(container: Container, enabled: Boolean) { + container.putExtra(EXTRA_ENABLED, enabled.toString()) + } + + fun setOpenCompositeEnabled(container: Container, enabled: Boolean) { + container.putExtra(EXTRA_OPEN_COMPOSITE_ENABLED, enabled.toString()) + } } } diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrRuntimeService.kt b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrRuntimeService.kt index f936b34889..248fee1408 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrRuntimeService.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrRuntimeService.kt @@ -42,7 +42,9 @@ class WindowsVrRuntimeService(context: Context) : Closeable { val payload = payloadManager.prepare(container) runtimeLogDirectory = payload.prefixDirectory listOf("runtime.log", "unix.log").forEach { payload.prefixDirectory.resolve(it).delete() } - if (active.openCompositeEnabled) payloadManager.installOpenComposite(container) + if (active.openCompositeEnabled) { + env.put("VR_OVERRIDE", payloadManager.installOpenCompositeRuntime(container)) + } env.put("XR_RUNTIME_JSON", active.runtimeManifest) env.put("GAMENATIVE_XR", "1") env.put("GAMENATIVE_XR_LOG", "1") @@ -69,7 +71,8 @@ class WindowsVrRuntimeService(context: Context) : Closeable { diagnostics.record( "environment", "runtime=${active.runtimeManifest} transport=${active.transportEndpoint} " + - "control=127.0.0.1:${active.controlPort}", + "control=127.0.0.1:${active.controlPort} " + + "openvr=${if (active.openCompositeEnabled) "GameNative-direct" else "disabled"}", ) diagnostics.record("effective-launch", WindowsVrEmulationDiagnostics.effective(container)) diagnostics.record("payload", payloadSummary(payload.prefixDirectory)) diff --git a/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrSnapshotProvider.kt b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrSnapshotProvider.kt index 0f29a6879a..e5758f7bee 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrSnapshotProvider.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xr/windows/WindowsVrSnapshotProvider.kt @@ -27,7 +27,7 @@ class WindowsVrSnapshotProvider { fun waitFrame(afterSerial: Long, timeoutMs: Int): WindowsVrRuntimeSnapshot? { val activeHandle = handle if (activeHandle == 0L) return null - val snapshot = WindowsVrRuntimeSnapshot(LongArray(11), FloatArray(22), FloatArray(36), IntArray(3)) + val snapshot = WindowsVrRuntimeSnapshot(LongArray(11), FloatArray(22), FloatArray(36), IntArray(4)) if (!XrNative.nativeWaitWindowsFrame( activeHandle, afterSerial, diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index 26131d87be..4803668448 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -10,6 +10,7 @@ import app.gamenative.service.SteamService import app.gamenative.service.amazon.AmazonService import app.gamenative.service.epic.EpicService import app.gamenative.service.gog.GOGService +import app.gamenative.ui.screen.xr.windows.WindowsVrRuntimeConfig import com.winlator.container.Container import com.winlator.container.ContainerData import com.winlator.container.ContainerManager @@ -306,6 +307,7 @@ object ContainerUtils { displayRenderer = container.displayRenderer, xrRefreshRate = container.xrRefreshRate, xrRenderScale = container.xrRenderScale, + openCompositeEnabled = WindowsVrRuntimeConfig.from(container).openCompositeEnabled, sfCompatMode = container.sfCompatMode, dxwrapper = container.dxWrapper, dxwrapperConfig = container.dxWrapperConfig, @@ -491,6 +493,7 @@ object ContainerUtils { container.displayRenderer = containerData.displayRenderer container.xrRefreshRate = containerData.xrRefreshRate container.xrRenderScale = containerData.xrRenderScale + WindowsVrRuntimeConfig.setOpenCompositeEnabled(container, containerData.openCompositeEnabled) container.sfCompatMode = containerData.sfCompatMode container.dxWrapper = containerData.dxwrapper container.dxWrapperConfig = containerData.dxwrapperConfig diff --git a/app/src/main/java/com/winlator/container/ContainerData.kt b/app/src/main/java/com/winlator/container/ContainerData.kt index b1b5e3f5e1..3f4a8cf8e0 100644 --- a/app/src/main/java/com/winlator/container/ContainerData.kt +++ b/app/src/main/java/com/winlator/container/ContainerData.kt @@ -21,6 +21,7 @@ data class ContainerData( val displayRenderer: String = Container.DEFAULT_DISPLAY_RENDERER, val xrRefreshRate: Int = 72, val xrRenderScale: Int = 100, + val openCompositeEnabled: Boolean = false, val sfCompatMode: Boolean = true, var dxwrapper: String = Container.DEFAULT_DXWRAPPER, val dxwrapperConfig: String = "", @@ -124,6 +125,7 @@ data class ContainerData( "displayRenderer" to state.displayRenderer, "xrRefreshRate" to state.xrRefreshRate, "xrRenderScale" to state.xrRenderScale, + "openCompositeEnabled" to state.openCompositeEnabled, "sfCompatMode" to state.sfCompatMode, "dxwrapper" to state.dxwrapper, "dxwrapperConfig" to state.dxwrapperConfig, @@ -196,6 +198,7 @@ data class ContainerData( displayRenderer = (savedMap["displayRenderer"] as? String) ?: "vulkan", xrRefreshRate = (savedMap["xrRefreshRate"] as? Int) ?: 72, xrRenderScale = (savedMap["xrRenderScale"] as? Int) ?: 100, + openCompositeEnabled = (savedMap["openCompositeEnabled"] as? Boolean) ?: false, sfCompatMode = (savedMap["sfCompatMode"] as? Boolean) ?: true, dxwrapper = savedMap["dxwrapper"] as String, dxwrapperConfig = savedMap["dxwrapperConfig"] as String, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1e080ea082..a42e6fa069 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2347,4 +2347,6 @@ AdjustsGradual gradually with a focus on stability VR Refresh Rate VR Render Scale + OpenComposite + Use OpenComposite for games that require SteamVR/OpenVR. Changes apply on the next game launch. diff --git a/app/src/main/windows/openvr_client/DrvGameNative.cpp b/app/src/main/windows/openvr_client/DrvGameNative.cpp new file mode 100644 index 0000000000..9386fd4627 --- /dev/null +++ b/app/src/main/windows/openvr_client/DrvGameNative.cpp @@ -0,0 +1,548 @@ +#include "stdafx.h" + +#include "DrvGameNative.h" + +#include "DrvOpenXR.h" +#include "XrBackend.h" +#include "logging.h" + +#include "gamenative_control.h" +#include "gamenative_openxr_unix.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using WineUnixCall = int(__stdcall*)(unsigned int, void*); + +struct DxgiVkInteropDevice; +struct DxgiVkInteropDevice1; + +struct DxgiVkInteropDeviceVtable { + HRESULT(__stdcall* QueryInterface)(DxgiVkInteropDevice*, REFIID, void**); + ULONG(__stdcall* AddRef)(DxgiVkInteropDevice*); + ULONG(__stdcall* Release)(DxgiVkInteropDevice*); + void(__stdcall* GetVulkanHandles)(DxgiVkInteropDevice*, uint64_t*, uint64_t*, uint64_t*); + void(__stdcall* GetSubmissionQueue)(DxgiVkInteropDevice*, uint64_t*, uint32_t*); + void(__stdcall* TransitionSurfaceLayout)(DxgiVkInteropDevice*, void*, const void*, uint32_t, uint32_t); + void(__stdcall* FlushRenderingCommands)(DxgiVkInteropDevice*); + void(__stdcall* LockSubmissionQueue)(DxgiVkInteropDevice*); + void(__stdcall* ReleaseSubmissionQueue)(DxgiVkInteropDevice*); +}; + +struct DxgiVkInteropDevice1Vtable : DxgiVkInteropDeviceVtable { + void(__stdcall* GetSubmissionQueue1)(DxgiVkInteropDevice*, uint64_t*, uint32_t*, uint32_t*); + HRESULT(__stdcall* CreateTexture2DFromVkImage)(DxgiVkInteropDevice*, const void*, uint64_t, ID3D11Texture2D**); +}; + +struct DxgiVkInteropDevice { + const DxgiVkInteropDeviceVtable* lpVtbl; +}; + +struct D3D11Texture2DDesc1 { + UINT Width; + UINT Height; + UINT MipLevels; + UINT ArraySize; + DXGI_FORMAT Format; + DXGI_SAMPLE_DESC SampleDesc; + D3D11_USAGE Usage; + UINT BindFlags; + UINT CPUAccessFlags; + UINT MiscFlags; + UINT TextureLayout; +}; + +constexpr GUID kIidDxgiVkInteropDevice1 = { + 0xe2ef5fa5, 0xdc21, 0x4af7, {0x90, 0xc4, 0xf6, 0x7e, 0xf6, 0xa0, 0x93, 0x24} +}; + +int64_t dxgiToVulkan(DXGI_FORMAT format) +{ + switch (format) { + case DXGI_FORMAT_R8G8B8A8_UNORM_SRGB: + return 43; // VK_FORMAT_R8G8B8A8_SRGB + case DXGI_FORMAT_R8G8B8A8_UNORM: + return 37; // VK_FORMAT_R8G8B8A8_UNORM + case DXGI_FORMAT_B8G8R8A8_UNORM_SRGB: + return 50; // VK_FORMAT_B8G8R8A8_SRGB + case DXGI_FORMAT_B8G8R8A8_UNORM: + return 44; // VK_FORMAT_B8G8R8A8_UNORM + default: + return 0; + } +} + +int64_t toMicro(float value) +{ + return static_cast(std::llround(static_cast(value) * 1000000.0)); +} + +class DirectD3D11Transport { +public: + DirectD3D11Transport() = default; + ~DirectD3D11Transport() { shutdown(); } + + bool store(vr::EVREye eye, const vr::Texture_t* texture, const vr::VRTextureBounds_t* bounds) + { + if (!texture || !texture->handle || texture->eType != vr::TextureType_DirectX) { + OOVR_LOGF("DrvGameNative: unsupported texture type %d", texture ? texture->eType : -1); + return false; + } + + auto* source = static_cast(texture->handle); + D3D11_TEXTURE2D_DESC sourceDesc{}; + source->GetDesc(&sourceDesc); + if (!ensureDevice(source)) return false; + + const unsigned eyeIndex = eye == vr::Eye_Right ? 1u : 0u; + D3D11_BOX sourceBox{}; + sourceBox.left = 0; + sourceBox.top = 0; + sourceBox.front = 0; + sourceBox.right = sourceDesc.Width; + sourceBox.bottom = sourceDesc.Height; + sourceBox.back = 1; + bool verticallyInverted = false; + if (bounds) { + const float uMin = std::clamp(std::min(bounds->uMin, bounds->uMax), 0.0f, 1.0f); + const float uMax = std::clamp(std::max(bounds->uMin, bounds->uMax), 0.0f, 1.0f); + const float vMin = std::clamp(std::min(bounds->vMin, bounds->vMax), 0.0f, 1.0f); + const float vMax = std::clamp(std::max(bounds->vMin, bounds->vMax), 0.0f, 1.0f); + sourceBox.left = static_cast(std::floor(uMin * sourceDesc.Width)); + sourceBox.right = static_cast(std::ceil(uMax * sourceDesc.Width)); + sourceBox.top = static_cast(std::floor(vMin * sourceDesc.Height)); + sourceBox.bottom = static_cast(std::ceil(vMax * sourceDesc.Height)); + verticallyInverted = bounds->vMin > bounds->vMax; + } + if (sourceBox.right <= sourceBox.left || sourceBox.bottom <= sourceBox.top) return false; + + const UINT width = sourceBox.right - sourceBox.left; + const UINT height = sourceBox.bottom - sourceBox.top; + EyeChain& chain = eyes_[eyeIndex]; + if (!ensureChain(chain, eyeIndex, width, height, sourceDesc.Format)) return false; + if (!acquire(chain)) return false; + + const UINT sourceArrayIndex = sourceDesc.ArraySize > 1 ? eyeIndex : 0; + const UINT sourceSubresource = D3D11CalcSubresource(0, sourceArrayIndex, sourceDesc.MipLevels); + ID3D11Texture2D* copySource = source; + UINT copySubresource = sourceSubresource; + if (sourceDesc.SampleDesc.Count > 1) { + if (!ensureResolve(chain, sourceDesc)) return false; + context_->ResolveSubresource(chain.resolve, sourceSubresource, source, sourceSubresource, sourceDesc.Format); + copySource = chain.resolve; + } + + context_->CopySubresourceRegion( + chain.images[chain.current], 0, 0, 0, 0, copySource, copySubresource, &sourceBox); + chain.ready = true; + chain.verticallyInverted = verticallyInverted; + if (verticallyInverted && !loggedInverted_) { + loggedInverted_ = true; + OOVR_LOG("DrvGameNative: vertically inverted bounds use the compatibility copy path"); + } + return true; + } + + bool submit(const gamenative_control_view views[2]) + { + if (!ready_ || !eyes_[0].ready || !eyes_[1].ready) return false; + + interop_->lpVtbl->FlushRenderingCommands(interop_); + interop_->lpVtbl->LockSubmissionQueue(interop_); + + gn_unix_submit_stereo_args args{}; + args.view_count = 2; + for (unsigned eye = 0; eye < 2; ++eye) { + EyeChain& chain = eyes_[eye]; + gn_unix_submit_view_args& view = args.views[eye]; + view.slot = chain.slot; + view.image_index = chain.current; + view.eye = eye; + view.array_index = 0; + view.rect_x = 0; + view.rect_y = 0; + view.rect_width = chain.width; + view.rect_height = chain.height; + for (unsigned i = 0; i < 4; ++i) { + view.orientation_micro[i] = views[eye].orientation[i]; + view.fov_micro[i] = views[eye].fov[i]; + } + for (unsigned i = 0; i < 3; ++i) view.position_micro[i] = views[eye].position[i]; + } + args.result = GN_UNIX_ERROR_UNAVAILABLE; + const bool ok = unixCall_(GN_UNIX_SUBMIT_STEREO, &args) == 0 && args.result == GN_UNIX_SUCCESS; + interop_->lpVtbl->ReleaseSubmissionQueue(interop_); + if (!ok) { + OOVR_LOGF("DrvGameNative: stereo submit failed result=%d", args.result); + return false; + } + for (EyeChain& chain : eyes_) { + chain.submitted[chain.current] = true; + chain.ready = false; + chain.next = (chain.current + 1) % chain.imageCount; + } + ++submittedFrames_; + if (submittedFrames_ == 1) { + OOVR_LOG("DrvGameNative: first device-local stereo frame submitted"); + } + return true; + } + + bool configured() const { return ready_; } + + bool locateViews(gamenative_control_view views[2], uint32_t* flags) + { + if (!views || !flags || !ensureBridge()) return false; + gn_unix_control_transact_args control{}; + strcpy_s(control.request, sizeof(control.request), "LOCATE_VIEWS"); + control.response_lines = 1; + control.result = GN_UNIX_ERROR_UNAVAILABLE; + if (unixCall_(GN_UNIX_CONTROL_TRANSACT, &control) != 0 || control.result != GN_UNIX_SUCCESS) { + OOVR_LOGF("DrvGameNative: fast view request failed result=%d", control.result); + return false; + } + int consumed = 0; + if (sscanf_s(control.response, "OK flags=%u %n", flags, &consumed) < 1 || consumed <= 0) return false; + const char* cursor = control.response + consumed; + for (uint32_t eye = 0; eye < 2; ++eye) { + int32_t* values[] = { + &views[eye].orientation[0], &views[eye].orientation[1], + &views[eye].orientation[2], &views[eye].orientation[3], + &views[eye].position[0], &views[eye].position[1], &views[eye].position[2], + &views[eye].fov[0], &views[eye].fov[1], &views[eye].fov[2], &views[eye].fov[3], + }; + for (int32_t* value : values) { + char* end = nullptr; + const long parsed = strtol(cursor, &end, 10); + if (end == cursor) return false; + *value = static_cast(parsed); + cursor = end; + while (*cursor == ' ') ++cursor; + } + } + return true; + } + +private: + struct EyeChain { + uint32_t slot = 0; + uint32_t width = 0; + uint32_t height = 0; + DXGI_FORMAT format = DXGI_FORMAT_UNKNOWN; + uint32_t imageCount = 0; + uint32_t current = 0; + uint32_t next = 0; + std::array images{}; + std::array submitted{}; + ID3D11Texture2D* resolve = nullptr; + bool ready = false; + bool verticallyInverted = false; + }; + + bool ensureBridge() + { + if (unixCall_) return true; + bridge_ = LoadLibraryA("gamenative_xr_unixbridge.dll"); + unixCall_ = bridge_ ? reinterpret_cast(GetProcAddress(bridge_, "gnWineUnixCall")) : nullptr; + if (!unixCall_) { + OOVR_LOGF("DrvGameNative: Wine unix bridge unavailable error=%lu", GetLastError()); + if (bridge_) FreeLibrary(bridge_); + bridge_ = nullptr; + return false; + } + gn_unix_init_args init{}; + init.abi_version = GN_UNIX_ABI_VERSION; + init.result = GN_UNIX_ERROR_UNAVAILABLE; + if (unixCall_(GN_UNIX_INIT, &init) != 0 || init.result != GN_UNIX_SUCCESS) { + OOVR_LOGF("DrvGameNative: unix ABI handshake failed abi=%u result=%d", GN_UNIX_ABI_VERSION, init.result); + FreeLibrary(bridge_); + bridge_ = nullptr; + unixCall_ = nullptr; + return false; + } + OOVR_LOGF("DrvGameNative: unix bridge ready ABI=%u control=unix-fast", GN_UNIX_ABI_VERSION); + return true; + } + + bool ensureDevice(ID3D11Texture2D* source) + { + ID3D11Device* sourceDevice = nullptr; + source->GetDevice(&sourceDevice); + if (!sourceDevice) return false; + if (device_ == sourceDevice && ready_) { + sourceDevice->Release(); + return true; + } + if (device_ && device_ != sourceDevice) shutdown(); + device_ = sourceDevice; + device_->GetImmediateContext(&context_); + + void* rawInterop = nullptr; + HRESULT hr = device_->QueryInterface(kIidDxgiVkInteropDevice1, &rawInterop); + if (FAILED(hr) || !rawInterop) { + OOVR_LOGF("DrvGameNative: IDXGIVkInteropDevice1 unavailable hr=0x%08x", static_cast(hr)); + shutdown(); + return false; + } + interop_ = static_cast(rawInterop); + const auto* interop1 = reinterpret_cast(interop_->lpVtbl); + + uint64_t instance = 0; + uint64_t physicalDevice = 0; + uint64_t device = 0; + uint64_t queue = 0; + uint32_t queueFamily = 0; + uint32_t queueIndex = 0; + interop_->lpVtbl->GetVulkanHandles(interop_, &instance, &physicalDevice, &device); + interop1->GetSubmissionQueue1(interop_, &queue, &queueIndex, &queueFamily); + + if (!ensureBridge()) { + shutdown(); + return false; + } + + gn_unix_vulkan_context_args vk{}; + vk.client_physical_device = physicalDevice; + vk.client_device = device; + vk.client_queue = queue; + vk.queue_family_index = queueFamily; + vk.queue_index = queueIndex; + vk.handles_are_host = 0; + vk.result = GN_UNIX_ERROR_UNAVAILABLE; + if (unixCall_(GN_UNIX_SET_VULKAN_CONTEXT, &vk) != 0 || vk.result != GN_UNIX_SUCCESS) { + OOVR_LOGF("DrvGameNative: Vulkan context rejected result=%d flags=0x%x", vk.result, vk.diagnostic_flags); + shutdown(); + return false; + } + ready_ = true; + OOVR_LOGF("DrvGameNative: direct transport ready ABI=%u queue=%u:%u", GN_UNIX_ABI_VERSION, queueFamily, queueIndex); + return true; + } + + bool ensureChain(EyeChain& chain, uint32_t eye, UINT width, UINT height, DXGI_FORMAT format) + { + if (chain.imageCount && chain.width == width && chain.height == height && chain.format == format) return true; + destroyChain(chain); + const int64_t vkFormat = dxgiToVulkan(format); + if (!vkFormat) { + OOVR_LOGF("DrvGameNative: unsupported DXGI format %d", format); + return false; + } + + chain.slot = 28u + eye; + gn_unix_create_swapchain_args create{}; + create.slot = chain.slot; + create.width = width; + create.height = height; + create.array_size = 1; + create.mip_count = 1; + create.sample_count = 1; + create.format = vkFormat; + // XrSwapchainUsageFlags: color attachment, transfer destination and sampled. + create.usage = 0x1u | 0x10u | 0x20u; + create.result = GN_UNIX_ERROR_UNAVAILABLE; + if (unixCall_(GN_UNIX_CREATE_SWAPCHAIN, &create) != 0 || + create.result != GN_UNIX_SUCCESS || create.image_count < 2 || create.image_count > GN_UNIX_MAX_IMAGES) { + OOVR_LOGF("DrvGameNative: eye %u transport swapchain failed result=%d", eye, create.result); + return false; + } + chain.imageCount = create.image_count; + + const auto* interop1 = reinterpret_cast(interop_->lpVtbl); + D3D11Texture2DDesc1 desc{}; + desc.Width = width; + desc.Height = height; + desc.MipLevels = 1; + desc.ArraySize = 1; + desc.Format = format; + desc.SampleDesc.Count = 1; + desc.Usage = D3D11_USAGE_DEFAULT; + desc.BindFlags = D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_RENDER_TARGET; + for (uint32_t i = 0; i < create.image_count; ++i) { + HRESULT hr = interop1->CreateTexture2DFromVkImage(interop_, &desc, create.images[i], &chain.images[i]); + if (FAILED(hr) || !chain.images[i]) { + OOVR_LOGF("DrvGameNative: wrapping eye %u image %u failed hr=0x%08x", eye, i, static_cast(hr)); + destroyChain(chain); + return false; + } + } + chain.width = width; + chain.height = height; + chain.format = format; + OOVR_LOGF("DrvGameNative: registered eye=%u images=%u size=%ux%u format=%d path=device-local-gpu-copy", + eye, chain.imageCount, width, height, format); + return true; + } + + bool ensureResolve(EyeChain& chain, const D3D11_TEXTURE2D_DESC& sourceDesc) + { + if (chain.resolve) return true; + D3D11_TEXTURE2D_DESC desc = sourceDesc; + desc.SampleDesc.Count = 1; + desc.SampleDesc.Quality = 0; + desc.Usage = D3D11_USAGE_DEFAULT; + desc.CPUAccessFlags = 0; + desc.MiscFlags = 0; + return SUCCEEDED(device_->CreateTexture2D(&desc, nullptr, &chain.resolve)) && chain.resolve; + } + + bool acquire(EyeChain& chain) + { + chain.current = chain.next; + if (!chain.submitted[chain.current]) return true; + gn_unix_acquire_image_args acquire{}; + acquire.slot = chain.slot; + acquire.image_index = chain.current; + acquire.timeout_ns = 500000000; + acquire.result = GN_UNIX_ERROR_UNAVAILABLE; + if (unixCall_(GN_UNIX_ACQUIRE_IMAGE, &acquire) != 0 || acquire.result != GN_UNIX_SUCCESS) { + OOVR_LOGF("DrvGameNative: acquire timeout/failure slot=%u image=%u result=%d", + chain.slot, chain.current, acquire.result); + return false; + } + chain.submitted[chain.current] = false; + return true; + } + + void destroyChain(EyeChain& chain) + { + if (chain.resolve) chain.resolve->Release(); + chain.resolve = nullptr; + for (ID3D11Texture2D*& image : chain.images) { + if (image) image->Release(); + image = nullptr; + } + if (chain.imageCount && unixCall_) { + gn_unix_destroy_swapchain_args destroy{}; + destroy.slot = chain.slot; + destroy.result = GN_UNIX_ERROR_UNAVAILABLE; + unixCall_(GN_UNIX_DESTROY_SWAPCHAIN, &destroy); + } + chain = EyeChain{}; + } + + void shutdown() + { + for (EyeChain& chain : eyes_) destroyChain(chain); + if (interop_) interop_->lpVtbl->Release(interop_); + interop_ = nullptr; + if (context_) context_->Release(); + context_ = nullptr; + if (device_) device_->Release(); + device_ = nullptr; + if (bridge_) FreeLibrary(bridge_); + bridge_ = nullptr; + unixCall_ = nullptr; + ready_ = false; + } + + ID3D11Device* device_ = nullptr; + ID3D11DeviceContext* context_ = nullptr; + DxgiVkInteropDevice* interop_ = nullptr; + HMODULE bridge_ = nullptr; + WineUnixCall unixCall_ = nullptr; + std::array eyes_{}; + uint64_t submittedFrames_ = 0; + bool ready_ = false; + bool loggedInverted_ = false; +}; + +class Backend final : public IBackend { +public: + explicit Backend(XrBackend* compatibility) + : compatibility_(compatibility) + { + OOVR_LOGF("DrvGameNative: selected backend protocol=%u graphics=direct-unixlib input=openxr-compat", GN_UNIX_ABI_VERSION); + } + + ~Backend() override { delete compatibility_; } + + std::shared_ptr GetPrimaryHMD() override { return compatibility_->GetPrimaryHMD(); } + std::shared_ptr GetDevice(vr::TrackedDeviceIndex_t index) override { return compatibility_->GetDevice(index); } + std::shared_ptr GetDeviceByHand(ITrackedDevice::TrackedDeviceType hand) override { return compatibility_->GetDeviceByHand(hand); } + void GetDeviceToAbsoluteTrackingPose(vr::ETrackingUniverseOrigin origin, float prediction, vr::TrackedDevicePose_t* poses, uint32_t count) override + { + compatibility_->GetDeviceToAbsoluteTrackingPose(origin, prediction, poses, count); + } + + void WaitForTrackingData() override + { + compatibility_->WaitForTrackingData(); + uint32_t flags = 0; + if (!transport_.locateViews(views_.data(), &flags)) { + OOVR_LOG("DrvGameNative: view snapshot unavailable after frame wait"); + } else { + viewFlags_ = flags; + } + } + + void StoreEyeTexture(vr::EVREye eye, const vr::Texture_t* texture, const vr::VRTextureBounds_t* bounds, + vr::EVRSubmitFlags submitFlags, bool isFirstEye) override + { + (void)submitFlags; + (void)isFirstEye; + submitted_[eye == vr::Eye_Right ? 1 : 0] = transport_.store(eye, texture, bounds); + } + + void SubmitFrames(bool showSkybox, bool postPresent) override + { + (void)showSkybox; + if (!postPresent && submitted_[0] && submitted_[1] && viewFlags_ != 0) { + transport_.submit(views_.data()); + } + submitted_[0] = submitted_[1] = false; + // End the control-only OpenXR compatibility frame with no graphics layers. + compatibility_->SubmitFrames(false, postPresent); + } + + openvr_enum_t SetSkyboxOverride(const vr::Texture_t* textures, uint32_t count) override + { + return compatibility_->SetSkyboxOverride(textures, count); + } + void ClearSkyboxOverride() override { compatibility_->ClearSkyboxOverride(); } + bool GetFrameTiming(OOVR_Compositor_FrameTiming* timing, uint32_t framesAgo) override + { + return compatibility_->GetFrameTiming(timing, framesAgo); + } + openvr_enum_t GetMirrorTextureD3D11(vr::EVREye eye, void* device, void** view) override + { + return compatibility_->GetMirrorTextureD3D11(eye, device, view); + } + void ReleaseMirrorTextureD3D11(void* view) override { compatibility_->ReleaseMirrorTextureD3D11(view); } + bool GetPlayAreaPoints(vr::HmdVector3_t* points, int* count) override { return compatibility_->GetPlayAreaPoints(points, count); } + bool AreBoundsVisible() override { return compatibility_->AreBoundsVisible(); } + void ForceBoundsVisible(bool visible) override { compatibility_->ForceBoundsVisible(visible); } + void PumpEvents() override { compatibility_->PumpEvents(); } + bool IsInputAvailable() override { return compatibility_->IsInputAvailable(); } + bool IsGraphicsConfigured() override { return transport_.configured(); } + void OnOverlayTexture(const vr::Texture_t* texture) override { compatibility_->OnOverlayTexture(texture); } + +private: + XrBackend* compatibility_; + DirectD3D11Transport transport_; + std::array views_{}; + std::array submitted_{}; + uint32_t viewFlags_ = 0; +}; + +} // namespace + +IBackend* DrvGameNative::CreateGameNativeBackend(const char* startupInfo) +{ + auto* compatibility = static_cast(DrvOpenXR::CreateOpenXRBackend(startupInfo)); + if (!compatibility) { + OOVR_LOG("DrvGameNative: failed to initialize OpenXR input compatibility layer"); + return nullptr; + } + return new Backend(compatibility); +} diff --git a/app/src/main/windows/openvr_client/DrvGameNative.h b/app/src/main/windows/openvr_client/DrvGameNative.h new file mode 100644 index 0000000000..929274c47f --- /dev/null +++ b/app/src/main/windows/openvr_client/DrvGameNative.h @@ -0,0 +1,12 @@ +#pragma once + +#include "Drivers/Backend.h" + +namespace DrvGameNative { + +// Creates the GameNative OpenVR backend. OpenComposite keeps its mature +// OpenXR-backed input compatibility session, while all eye images bypass the +// Windows OpenXR compositor and are submitted to GameNative's Wine unixlib. +IBackend* CreateGameNativeBackend(const char* startupInfo); + +} // namespace DrvGameNative diff --git a/app/src/main/windows/openxr_runtime/gamenative_control.h b/app/src/main/windows/openxr_runtime/gamenative_control.h index 24ece0eecf..a6cdccb05e 100644 --- a/app/src/main/windows/openxr_runtime/gamenative_control.h +++ b/app/src/main/windows/openxr_runtime/gamenative_control.h @@ -3,6 +3,10 @@ #include +#ifdef __cplusplus +extern "C" { +#endif + typedef struct gamenative_control_frame { uint64_t serial; int64_t display_time; @@ -39,4 +43,8 @@ int gamenative_control_locate_views(gamenative_control_view views[2], uint32_t * int gamenative_control_get_input(uint32_t hand, gamenative_control_input *input); int gamenative_control_haptic(uint32_t hand, int32_t amplitude, int64_t duration, int32_t frequency); +#ifdef __cplusplus +} +#endif + #endif diff --git a/app/src/main/windows/openxr_runtime/gamenative_openxr_runtime.c b/app/src/main/windows/openxr_runtime/gamenative_openxr_runtime.c index a836ee55c3..91c6bd3a51 100644 --- a/app/src/main/windows/openxr_runtime/gamenative_openxr_runtime.c +++ b/app/src/main/windows/openxr_runtime/gamenative_openxr_runtime.c @@ -530,6 +530,8 @@ typedef struct { typedef struct { int active; + int grip_active; + int aim_active; unsigned buttons; float trigger, squeeze, sx, sy; float grip[7]; @@ -1491,6 +1493,8 @@ static void gn_refresh_hand(int hand) { } h->active = (int)gn_parse_i64(response, "active", 0); + h->grip_active = (int)gn_parse_i64(response, "gripActive", h->active); + h->aim_active = (int)gn_parse_i64(response, "aimActive", h->active); h->buttons = (unsigned)gn_parse_i64(response, "buttons", 0); h->trigger = gn_parse_micro(response, "tr", 0.0f); h->squeeze = gn_parse_micro(response, "sq", 0.0f); @@ -2176,8 +2180,19 @@ static int gn_space_absolute_pose( int hand = action_space->hand >= 0 ? action_space->hand : gn_action_default_hand(action); int comp = action->component[hand]; XrPosef tracked; - if (!gn_hands[hand].active || (action->active_hands & (1u << hand)) == 0) return 0; - gn_write_pose(&tracked, comp == GN_COMP_AIM_POSE ? gn_hands[hand].aim : gn_hands[hand].grip); + /* OpenVR device tracking is independent from the game's active input action sets. + * Requiring active_hands here made OpenComposite expose a valid identity pose until its + * legacy actions were synced, which placed both controllers at the stage origin. */ + if (!gn_hands[hand].active) return 0; + if (comp == GN_COMP_AIM_POSE) { + if (!gn_hands[hand].aim_active) return 0; + gn_write_pose(&tracked, gn_hands[hand].aim); + } else if (comp == GN_COMP_GRIP_POSE) { + if (!gn_hands[hand].grip_active) return 0; + gn_write_pose(&tracked, gn_hands[hand].grip); + } else { + return 0; + } *pose = gn_pose_multiply(tracked, action_space->pose_in_action_space); for (int i = 0; i < 3; ++i) { linear_velocity[i] = gn_hand_linear_velocity[hand][i]; @@ -3227,7 +3242,10 @@ static int gn_resolve_action(const XrActionStateGetInfo* getInfo, int* out_hand, static int gn_action_is_active(XrAction action, int hand, int comp) { int action_idx = gn_action_index(action); if (action_idx < 0 || hand < 0 || hand > 1 || comp == GN_COMP_NONE) return 0; - return (gn_actions[action_idx].active_hands & (1u << hand)) != 0 && gn_hands[hand].active; + if ((gn_actions[action_idx].active_hands & (1u << hand)) == 0 || !gn_hands[hand].active) return 0; + if (comp == GN_COMP_GRIP_POSE) return gn_hands[hand].grip_active; + if (comp == GN_COMP_AIM_POSE) return gn_hands[hand].aim_active; + return 1; } static XrResult XRAPI_CALL gn_xrGetActionStateBoolean( diff --git a/app/src/test/java/app/gamenative/AndroidXrLaunchTest.kt b/app/src/test/java/app/gamenative/AndroidXrLaunchTest.kt new file mode 100644 index 0000000000..09b3d6bf2a --- /dev/null +++ b/app/src/test/java/app/gamenative/AndroidXrLaunchTest.kt @@ -0,0 +1,77 @@ +package app.gamenative + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.test.core.app.ApplicationProvider +import app.gamenative.ui.screen.xr.ImmersiveXrActivity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [35]) +class AndroidXrLaunchTest { + + @Test + fun androidXrOpenXrFeatureIsRecognizedAsHeadset() { + val packageManager = mock() + val context = mock() + whenever(context.packageManager).thenReturn(packageManager) + whenever(packageManager.hasSystemFeature("android.hardware.vr.headtracking")).thenReturn(false) + whenever(packageManager.hasSystemFeature("android.software.xr.api.openxr")).thenReturn(true) + + assertTrue(MainActivity.isHeadset(context)) + } + + @Test + fun androidXrActivityLaunchStaysOnFlatLauncherTask() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + + val intent = ImmersiveXrActivity.createLaunchIntent( + activity, + "STEAM_123", + false, + isAndroidXr = true, + ) + + assertFalse(intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0) + assertEquals(ImmersiveXrActivity::class.java.name, intent.component?.className) + } + + @Test + fun questActivityLaunchUsesNewTask() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + + val intent = ImmersiveXrActivity.createLaunchIntent( + activity, + "STEAM_123", + false, + isAndroidXr = false, + ) + + assertTrue(intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0) + } + + @Test + fun androidXrNonActivityLaunchUsesNewTask() { + val context = ApplicationProvider.getApplicationContext() + + val intent = ImmersiveXrActivity.createLaunchIntent( + context, + "STEAM_123", + true, + isAndroidXr = true, + ) + + assertTrue(intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0) + } +} diff --git a/app/src/test/java/app/gamenative/ui/screen/xr/ImmersiveSessionOwnershipTest.kt b/app/src/test/java/app/gamenative/ui/screen/xr/ImmersiveSessionOwnershipTest.kt new file mode 100644 index 0000000000..4d22009f8b --- /dev/null +++ b/app/src/test/java/app/gamenative/ui/screen/xr/ImmersiveSessionOwnershipTest.kt @@ -0,0 +1,62 @@ +package app.gamenative.ui.screen.xr + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class ImmersiveSessionOwnershipTest { + @Before + fun setUp() { + ImmersiveSessionOwnership.resetForTest() + } + + @After + fun tearDown() { + ImmersiveSessionOwnership.resetForTest() + } + + @Test + fun launcherCannotTearDownEnvironmentWhileImmersiveLaunchIsPending() { + ImmersiveSessionOwnership.beginLaunch("STEAM_123") + + assertFalse(ImmersiveSessionOwnership.shouldLauncherHandleDestruction(false)) + assertEquals(ImmersiveSessionOwnership.Phase.LAUNCHING, ImmersiveSessionOwnership.snapshot().phase) + } + + @Test + fun launcherCannotTearDownEnvironmentWhileImmersiveActivityIsActive() { + ImmersiveSessionOwnership.beginLaunch("STEAM_123") + ImmersiveSessionOwnership.markActivityActive("STEAM_123") + + assertFalse(ImmersiveSessionOwnership.shouldLauncherHandleDestruction(false)) + assertEquals(ImmersiveSessionOwnership.Phase.ACTIVE, ImmersiveSessionOwnership.snapshot().phase) + } + + @Test + fun launcherRegainsTeardownOwnershipAfterImmersiveSessionFinishes() { + ImmersiveSessionOwnership.beginLaunch("STEAM_123") + ImmersiveSessionOwnership.markActivityActive("STEAM_123") + ImmersiveSessionOwnership.release("STEAM_123") + + assertTrue(ImmersiveSessionOwnership.shouldLauncherHandleDestruction(false)) + assertEquals(ImmersiveSessionOwnership.Phase.IDLE, ImmersiveSessionOwnership.snapshot().phase) + } + + @Test + fun mismatchedActivityCannotReleaseAnotherGamesHandoff() { + ImmersiveSessionOwnership.beginLaunch("STEAM_123") + + ImmersiveSessionOwnership.release("STEAM_456") + + assertTrue(ImmersiveSessionOwnership.isOwnedByImmersive()) + assertEquals("STEAM_123", ImmersiveSessionOwnership.snapshot().appId) + } + + @Test + fun configurationRecreationNeverGivesLauncherTeardownOwnership() { + assertFalse(ImmersiveSessionOwnership.shouldLauncherHandleDestruction(true)) + } +} diff --git a/app/src/test/java/app/gamenative/ui/screen/xr/windows/WindowsVrControlServerTest.kt b/app/src/test/java/app/gamenative/ui/screen/xr/windows/WindowsVrControlServerTest.kt new file mode 100644 index 0000000000..670257653b --- /dev/null +++ b/app/src/test/java/app/gamenative/ui/screen/xr/windows/WindowsVrControlServerTest.kt @@ -0,0 +1,29 @@ +package app.gamenative.ui.screen.xr.windows + +import org.junit.Assert.assertEquals +import org.junit.Test + +class WindowsVrControlServerTest { + @Test + fun `left controller buttons map to OpenVR semantics`() { + val xboxButtons = (1 shl 2) or (1 shl 3) or (1 shl 8) or (1 shl 7) + + assertEquals(0b1111, openVrControllerButtons(xboxButtons, hand = 0)) + } + + @Test + fun `right controller buttons map independently`() { + val xboxButtons = (1 shl 0) or (1 shl 1) or (1 shl 9) or (1 shl 7) + + assertEquals(0b0111, openVrControllerButtons(xboxButtons, hand = 1)) + } + + @Test + fun `buttons from the other hand do not leak`() { + val leftOnly = (1 shl 2) or (1 shl 3) or (1 shl 8) + val rightOnly = (1 shl 0) or (1 shl 1) or (1 shl 9) + + assertEquals(0, openVrControllerButtons(leftOnly, hand = 1)) + assertEquals(0, openVrControllerButtons(rightOnly, hand = 0)) + } +} diff --git a/app/src/test/java/app/gamenative/ui/screen/xr/windows/WindowsVrRuntimeConfigTest.kt b/app/src/test/java/app/gamenative/ui/screen/xr/windows/WindowsVrRuntimeConfigTest.kt new file mode 100644 index 0000000000..d53263a217 --- /dev/null +++ b/app/src/test/java/app/gamenative/ui/screen/xr/windows/WindowsVrRuntimeConfigTest.kt @@ -0,0 +1,34 @@ +package app.gamenative.ui.screen.xr.windows + +import com.winlator.container.Container +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class WindowsVrRuntimeConfigTest { + + @Test + fun `defaults keep runtime enabled and OpenComposite disabled`() { + val config = WindowsVrRuntimeConfig.from(Container("STEAM_TEST")) + + assertTrue(config.enabled) + assertFalse(config.openCompositeEnabled) + } + + @Test + fun `accessors round trip container settings through canonical keys`() { + val container = Container("STEAM_TEST") + + WindowsVrRuntimeConfig.setEnabled(container, false) + WindowsVrRuntimeConfig.setOpenCompositeEnabled(container, true) + + val config = WindowsVrRuntimeConfig.from(container) + assertFalse(config.enabled) + assertTrue(config.openCompositeEnabled) + } +} diff --git a/docs/xr/native-openvr-runtime-plan.md b/docs/xr/native-openvr-runtime-plan.md new file mode 100644 index 0000000000..afbd32c13b --- /dev/null +++ b/docs/xr/native-openvr-runtime-plan.md @@ -0,0 +1,198 @@ +# GameNative native OpenVR runtime preparation + +## Objective + +Keep OpenComposite's mature OpenVR application ABI and compatibility logic, but replace its `DrvOpenXR` backend with a `DrvGameNative` backend. The new backend must connect to the same native XR host used by GameNative's Windows OpenXR runtime. It must not create a second Windows OpenXR session. + +The target paths are: + +```text +OpenXR game -> GameNative Windows OpenXR runtime ----\ + -> shared XR transport/core -> native Vulkan presenter -> headset OpenXR +OpenVR game -> OpenComposite ABI -> DrvGameNative --/ +``` + +This should make the OpenVR path comparable to the direct OpenXR path. A final native composition/blit may still be required when an application texture cannot itself be a headset-runtime swapchain image, but there must be no CPU image copy and no per-frame texture re-registration. + +## Prepared repositories + +- GameNative: `C:\Users\flori\Documents\Coding\gamenative` +- OpenComposite development checkout: `C:\Users\flori\Documents\Coding\OpenComposite-gamenative` +- OpenComposite upstream revision: `a27e7e6a64bdcd1eff6b7fba1ea2ea34bcf1273d` +- Baseline compatibility commit in the prepared checkout: `f0dde5f` +- Development branch: `gamenative-native-openvr` +- Upstream remote is named `upstream` and its push URL is disabled. Add a writable fork as `origin` before pushing development work. + +Recreate the checkout with: + +```powershell +powershell -ExecutionPolicy Bypass -File .\tools\prepare-native-openvr-dev.ps1 +``` + +Build the current developer checkout without changing APK payloads with: + +```powershell +powershell -ExecutionPolicy Bypass -File .\tools\build-native-openvr-dev.ps1 +``` + +The isolated output defaults to `app/build/native-openvr-dev/opencomposite_x64.dll`. Normal Gradle packaging continues to use the disposable, pinned `app/build/opencomposite-source` checkout. + +## Correct integration seam + +`OpenOVR/Drivers/Backend.h` defines `IBackend`, `ITrackedDevice`, and `IHMD`. `OCOVR/openvr_api.cpp` currently constructs `DrvOpenXR::CreateOpenXRBackend(...)`, while compositor calls are routed through `BackendManager`. Add a sibling `DrvGameNative` static library implementing `IBackend` and choose it in `VR_InitInternal2` for GameNative builds. + +Do not fork or rewrite the generated OpenVR interfaces. Retain: + +- OpenVR interface/version exports and generated stubs; +- application/action manifest parsing and legacy input compatibility; +- D3D11/Vulkan texture interpretation and bounds handling; +- controller/profile property emulation; +- known application-type compatibility behavior. + +Do not retain in the GameNative backend: + +- creation of a Windows OpenXR instance or session; +- OpenXR action sets owned by `DrvOpenXR`; +- OpenXR swapchains or compositor submission inside the Wine process; +- per-game replacement of Valve's public `openvr_api.dll` as the final deployment model. + +During bring-up, DLL replacement remains an acceptable loader mechanism. The final runtime should expose a SteamVR-compatible runtime registration and provide `vrclient_x64.dll` centrally. + +## Shared client/host protocol + +Extend the existing GameNative XR client/Unix bridge instead of creating an unrelated socket protocol. Version every structure and reject incompatible peers explicitly. + +### Control plane + +Use the existing local Unix-domain control channel only for low-frequency operations: + +- protocol handshake and feature negotiation; +- create/destroy client and scene session; +- register/unregister graphics device and submitted images; +- transfer external-memory and synchronization file descriptors; +- action manifest and binding setup; +- haptic requests, errors, and orderly shutdown. + +### Data plane + +Use shared memory for high-frequency state: + +- predicted display time and frame serial; +- HMD/controller poses and validity flags; +- buttons, touches, axes, skeletal state where available; +- session visibility/focus state; +- compositor timing counters and dropped/repeated-frame statistics. + +Use atomic sequence counters around snapshots. Never serialize poses or controller state through a request/response socket once the shared state page is mapped. + +### Graphics plane + +For each eye or array image: + +1. Import the Wine/DXVK Vulkan image into the native process once using external memory. +2. Cache the registration by stable image identity, dimensions, format, array layer, and generation. +3. Transfer file descriptors only during registration/recreation. +4. Per frame, publish image id, bounds, color space, pose/time serial, and a GPU-completion primitive. +5. Wait on the GPU primitive natively, perform only the required layout/format transition or composition pass, and signal completion before reuse. + +Never map image pixels on the CPU. Never allocate or import an `AHardwareBuffer` per frame. Prefer timeline semaphores when the Wine/Vulkan export path supports them; otherwise use sync FDs/fences with an explicit ownership protocol. + +## Work packages + +### 1. Backend selection and clean compile + +- Add `GAMENATIVE_BACKEND` CMake option. +- Add `DrvGameNative` target and a small backend factory header independent of OpenXR. +- Move the direct `DrvOpenXR::CreateOpenXRBackend` call behind the factory. +- Make `VR_IsHmdPresent` query the selected backend rather than constructing a temporary OpenXR instance. +- Keep upstream `DrvOpenXR` fully buildable for comparison. + +Acceptance: both stock OpenComposite and the GameNative variant compile; the latter loads and performs a versioned handshake without creating Windows OpenXR objects. + +### 2. Remove hidden `XrBackend` coupling + +Audit `BaseCompositor`, `BaseInput`, `BaseOverlay`, `BaseSystem`, and compositor implementations. Replace direct `XrBackend` calls with narrow `IBackend` capabilities. Important known calls include input-session restart and Vulkan physical-device lookup. Add capability methods rather than downcasts. + +Acceptance: `OCCore` has no include dependency on `DrvOpenXR/XrBackend.h` in a GameNative build. + +### 3. Tracking, display, and timing + +- Implement HMD and controller devices from the shared state page. +- Convert GameNative stage/local spaces to OpenVR standing/seated/raw universes consistently. +- Report actual floor-level standing origin; do not derive floor height from the current HMD pose. +- Source recommended size, eye transforms, FOV, IPD, refresh period, focus, and vsync timing from the native host. +- Make `WaitGetPoses` wait for the native predicted-frame serial, not a generic sleep. + +Acceptance: a scene application receives stable stereo poses and correct floor height before graphics submission. + +### 4. Device-local graphics submission + +- Start with D3D11/DXVK because it is the dominant GameNative path. +- Reuse the proven Wine Vulkan handle-unwrapping and Unix-bridge mechanisms. +- Register images once and keep them device-local. +- Support per-eye textures, texture arrays, bounds, sRGB/linear formats, and image recreation. +- Add Vulkan application submission after D3D11 is stable. + +Acceptance: eye images appear as native stereo projection layers, never as the immersive-mode flat panel; transport logs show zero CPU image copies. + +### 5. Input, haptics, and compatibility + +- Map OpenVR action manifests to the native input snapshot. +- Preserve legacy controller-state APIs. +- Implement Oculus Touch-compatible properties and paths while keeping other profiles graceful. +- Forward haptics through the host control plane. +- Support scene, overlay, utility, and background application types without allowing helper clients to steal the scene session. + +Acceptance: The Lab interactions work; background/utility clients do not crash or start competing sessions. + +### 6. Runtime registration and lifecycle + +- Package one central OpenVR runtime directory in GameNative. +- Generate `openvrpaths.vrpath`/registry-compatible discovery for the Wine prefix. +- Provide `vrclient_x64.dll` and required manifests centrally. +- Keep per-game DLL replacement only as an opt-in fallback. +- Tie scene ownership to immersive activity lifecycle and retain diagnostics after activity exit. + +Acceptance: an unmodified OpenVR game discovers the runtime as SteamVR-compatible and starts through GameNative immersive mode. + +### 7. Performance completion + +- Move the native presenter from GLES to Vulkan so import, synchronization, and headset submission share one graphics API. +- Stop flat X-server rendering and flat-panel image copies only after native stereo frames are confirmed active; restore them on disconnect/error. +- Replace generic 2:1 repetition with compositor-aware pacing driven by predicted display time and measured application cadence. +- Profile render, Wine/FEX, transport, and XR threads separately before changing affinities/priorities. +- Update FEX and its lower-memory JIT cache independently of the OpenVR backend. Do not add Beat Saber-specific TSO metadata. + +## Required diagnostics + +Every launch must log: + +- OpenVR application type and requested interface versions; +- selected backend and protocol versions/features; +- native host connection/session state; +- graphics API, device identity, image registration/recreation, format and dimensions; +- external-memory and synchronization path selected; +- frame serials, waits, drops, repeats, and GPU timeout/errors; +- input profile/action manifest status; +- orderly teardown reason. + +Logs must be written outside ephemeral activity state so they remain exportable after a crash. + +## Regression matrix + +- Beat Saber direct OpenXR: unchanged behavior and performance. +- Beat Saber OpenVR mode: stereo projection, controls, audio, correct floor. +- The Lab: multiple action sets, session recreation, overlays, and controller bindings. +- Vertigo 2: stable loading/menu/gameplay without positional flicker or GPU-memory growth. +- Bigscreen: application-type handling and media/audio behavior. +- Disconnect/crash: immersive menu recovers and the next launch does not inherit stale sessions or image handles. + +Record CPU/GPU frame time, image-copy count, imported-image count, memory growth, and end-to-end pose age for every comparison. + +## Guardrails + +- Keep the current GameNative OpenXR path working throughout development. +- Do not mix experimental device-local transport patches into the known-good branch without a feature gate. +- Do not create a second headset OpenXR session for OpenVR. +- Do not add title-specific behavior unless it represents a documented compatibility quirk with a safe default. +- Preserve GPLv3 notices and track upstream OpenComposite commits and local patches explicitly. diff --git a/tools/build-native-openvr-dev.ps1 b/tools/build-native-openvr-dev.ps1 new file mode 100644 index 0000000000..9e214e0b3a --- /dev/null +++ b/tools/build-native-openvr-dev.ps1 @@ -0,0 +1,19 @@ +param( + [string]$SourceDirectory, + [string]$BuildDirectory, + [string]$Destination +) + +$ErrorActionPreference = "Stop" +$repository = Split-Path -Parent $PSScriptRoot +$workspace = Split-Path -Parent $repository +$source = if ($SourceDirectory) { [IO.Path]::GetFullPath($SourceDirectory) } else { Join-Path $workspace "OpenComposite-gamenative" } +$build = if ($BuildDirectory) { [IO.Path]::GetFullPath($BuildDirectory) } else { Join-Path $repository "app\build\native-openvr-dev" } +$output = if ($Destination) { [IO.Path]::GetFullPath($Destination) } else { Join-Path $build "opencomposite_x64.dll" } +$stage = Join-Path $PSScriptRoot "stage-opencomposite.ps1" + +& powershell -ExecutionPolicy Bypass -File $stage -SourceDirectory $source -BuildDirectory $build -Destination $output +if ($LASTEXITCODE -ne 0) { throw "Native OpenVR developer build failed with exit code $LASTEXITCODE" } + +Write-Host "Native OpenVR developer payload ready: $output" +Write-Host "This isolated output is not packaged into the APK unless explicitly staged later." diff --git a/tools/opencomposite-vulkan-x64.def b/tools/opencomposite-vulkan-x64.def new file mode 100644 index 0000000000..8d09573e9b --- /dev/null +++ b/tools/opencomposite-vulkan-x64.def @@ -0,0 +1,21 @@ +LIBRARY vulkan-1.dll +EXPORTS + vkAllocateCommandBuffers + vkBeginCommandBuffer + vkCmdBlitImage + vkCmdCopyImage + vkCmdPipelineBarrier + vkCmdResolveImage + vkCreateCommandPool + vkCreateDevice + vkCreateInstance + vkDestroyCommandPool + vkDestroyDevice + vkDestroyInstance + vkEndCommandBuffer + vkEnumeratePhysicalDevices + vkFreeCommandBuffers + vkGetDeviceQueue + vkGetPhysicalDeviceProperties2 + vkGetPhysicalDeviceQueueFamilyProperties + vkQueueSubmit diff --git a/tools/patches/opencomposite-gamenative-native-backend.patch b/tools/patches/opencomposite-gamenative-native-backend.patch new file mode 100644 index 0000000000..7e8ee6be8c --- /dev/null +++ b/tools/patches/opencomposite-gamenative-native-backend.patch @@ -0,0 +1,108 @@ +diff --git a/CMakeLists.txt b/CMakeLists.txt +index 17fd03e..3acddf5 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -21,6 +21,8 @@ option(USE_SYSTEM_OPENXR "Try using system installation of OpenXR if available" + option(USE_SYSTEM_GLM "Try using system installation of glm if available" OFF) + option(OC_BACKTRACE "Print the backtrace on crash" OFF) + option(ERROR_ON_WARNING "Set all warnings to be errors" OFF) ++option(GAMENATIVE_BACKEND "Build the GameNative direct OpenVR backend" OFF) ++set(GAMENATIVE_SOURCE_DIR "" CACHE PATH "Path to the GameNative source checkout") + + # Directory for generated files, those being split headers and stubs + set(GENERATED_DIR ${CMAKE_BINARY_DIR}/generated) +@@ -250,6 +252,35 @@ endif () + target_compile_definitions(DrvOpenXR PRIVATE ${GRAPHICS_API_SUPPORT_FLAGS}) + source_group(Public REGULAR_EXPRESSION DrvOpenXR/pub/*) + ++if (GAMENATIVE_BACKEND) ++ if (NOT WIN32) ++ message(FATAL_ERROR "The GameNative OpenVR backend currently requires Windows") ++ endif () ++ if (NOT EXISTS "${GAMENATIVE_SOURCE_DIR}/app/src/main/windows/openvr_client/DrvGameNative.cpp") ++ message(FATAL_ERROR "GAMENATIVE_SOURCE_DIR does not contain the GameNative OpenVR backend") ++ endif () ++ set(GAMENATIVE_OPENVR_DIR "${GAMENATIVE_SOURCE_DIR}/app/src/main/windows/openvr_client") ++ set(GAMENATIVE_OPENXR_DIR "${GAMENATIVE_SOURCE_DIR}/app/src/main/windows/openxr_runtime") ++ add_library(DrvGameNative STATIC ++ ${GAMENATIVE_OPENVR_DIR}/DrvGameNative.cpp ++ ${GAMENATIVE_OPENVR_DIR}/DrvGameNative.h ++ ${GAMENATIVE_OPENXR_DIR}/gamenative_control.h ++ ${GAMENATIVE_OPENXR_DIR}/gamenative_openxr_unix.h ++ ) ++ target_include_directories(DrvGameNative PUBLIC ${GAMENATIVE_OPENVR_DIR}) ++ target_include_directories(DrvGameNative PRIVATE ++ ${GAMENATIVE_OPENXR_DIR} ++ DrvOpenXR ++ DrvOpenXR/pub ++ OpenOVR ++ OpenVRHeaders ++ ${CMAKE_BINARY_DIR} ++ ) ++ target_link_libraries(DrvGameNative PUBLIC OpenVR PRIVATE DrvOpenXR ws2_32 d3d11 dxgi) ++ target_compile_definitions(DrvGameNative PRIVATE ${GRAPHICS_API_SUPPORT_FLAGS}) ++ target_precompile_headers(DrvGameNative PRIVATE ${CMAKE_SOURCE_DIR}/OpenOVR/stdafx.h) ++endif () ++ + if (NOT (XR_VALIDATION_LAYER_PATH STREQUAL "")) + target_compile_definitions(DrvOpenXR PRIVATE -DXR_VALIDATION_LAYER_PATH=\"${XR_VALIDATION_LAYER_PATH}\") + endif () +@@ -519,6 +550,10 @@ if (NOT WIN32) + else () + # On Windows this isn't a problem since the linker knows how to figure this stuff out + target_link_libraries(OCOVR OCCore DrvOpenXR) ++ if (GAMENATIVE_BACKEND) ++ target_link_libraries(OCOVR DrvGameNative) ++ target_compile_definitions(OCOVR PRIVATE GAMENATIVE_BACKEND) ++ endif () + endif () + + # If we're on Linux, compile everything as position-independent +diff --git a/DrvOpenXR/DrvOpenXR.cpp b/DrvOpenXR/DrvOpenXR.cpp +index 208e612..261b82c 100644 +--- a/DrvOpenXR/DrvOpenXR.cpp ++++ b/DrvOpenXR/DrvOpenXR.cpp +@@ -346,15 +346,11 @@ void DrvOpenXR::SetupSession() + + void DrvOpenXR::ShutdownSession() + { +- BackendManager* instance = BackendManager::InstancePtr(); + // Is it already being shut down? + // Note that this is indirectly called by the XrBackend destructor, which will have + // already called this function in that case. +- if (instance) { +- auto* backend = (XrBackend*)instance->GetBackendInstance(); +- if (backend) +- backend->PrepareForSessionShutdown(); +- } ++ if (currentBackend) ++ currentBackend->PrepareForSessionShutdown(); + + delete xr_gbl; + xr_gbl = nullptr; +diff --git a/OCOVR/openvr_api.cpp b/OCOVR/openvr_api.cpp +index 3401468..6de5f66 100644 +--- a/OCOVR/openvr_api.cpp ++++ b/OCOVR/openvr_api.cpp +@@ -19,6 +19,9 @@ + // Specific to OCOVR + #include "Drivers/Backend.h" + #include "DrvOpenXR.h" ++#ifdef GAMENATIVE_BACKEND ++#include "DrvGameNative.h" ++#endif + + using namespace vr; + +@@ -280,7 +283,11 @@ VR_INTERFACE uint32_t VR_CALLTYPE VR_InitInternal2(EVRInitError* peError, EVRApp + running = true; + + // TODO seperate this from the rest of dllmain ++ #ifdef GAMENATIVE_BACKEND ++ IBackend* backend = DrvGameNative::CreateGameNativeBackend(pStartupInfo); ++ #else + IBackend* backend = DrvOpenXR::CreateOpenXRBackend(pStartupInfo); ++ #endif + if (!backend && peError) { + *peError = VRInitError_Init_Internal; + } else { diff --git a/tools/patches/opencomposite-gamenative-wine.patch b/tools/patches/opencomposite-gamenative-wine.patch new file mode 100644 index 0000000000..4d124c6791 --- /dev/null +++ b/tools/patches/opencomposite-gamenative-wine.patch @@ -0,0 +1,98 @@ +diff --git a/DrvOpenXR/DrvOpenXR.cpp b/DrvOpenXR/DrvOpenXR.cpp +index 8e3bd9c..208e612 100644 +--- a/DrvOpenXR/DrvOpenXR.cpp ++++ b/DrvOpenXR/DrvOpenXR.cpp +@@ -28,8 +28,9 @@ std::string GetExeName() + { + char exePath[MAX_PATH + 1] = { 0 }; + DWORD len = GetModuleFileNameA(NULL, exePath, MAX_PATH); +- PathStripPathA(exePath); +- return { exePath }; ++ std::string path(exePath, len); ++ auto separator = path.find_last_of("\\/"); ++ return separator == std::string::npos ? path : path.substr(separator + 1); + } + #else + #include // basename +diff --git a/DrvOpenXR/XrHMD.cpp b/DrvOpenXR/XrHMD.cpp +index ffeab79..d2fe55c 100644 +--- a/DrvOpenXR/XrHMD.cpp ++++ b/DrvOpenXR/XrHMD.cpp +@@ -9,6 +9,7 @@ + #include "../OpenOVR/Reimpl/BaseSystem.h" + #include "../OpenOVR/convert.h" + #include "generated/static_bases.gen.h" ++#include + #include + #include + +diff --git a/OCOVR/openvr_api.cpp b/OCOVR/openvr_api.cpp +index a49444a..3401468 100644 +--- a/OCOVR/openvr_api.cpp ++++ b/OCOVR/openvr_api.cpp +@@ -254,6 +254,17 @@ VR_INTERFACE uint32_t VR_CALLTYPE VR_InitInternal2(EVRInitError* peError, EVRApp + return current_init_token; + } + ++#ifdef _WIN32 ++ // GameNative embeds OpenComposite in each Wine process and has no shared ++ // vrserver for background clients. Do not let helpers allocate a competing ++ // OpenXR session; report the spec-defined benign background-app result. ++ if (eApplicationType == VRApplication_Background) { ++ OOVR_LOG("Ignoring VRApplication_Background: no shared OpenVR server is available"); ++ if (peError) *peError = VRInitError_Init_NoServerForBackgroundApp; ++ return current_init_token; ++ } ++#endif ++ + if (eApplicationType != VRApplication_Scene) + #ifndef _WIN32 + if (eApplicationType != VRApplication_Background) // Proton uses this +diff --git a/OpenOVR/Compositor/compositor.h b/OpenOVR/Compositor/compositor.h +index e83a16a..d650b08 100644 +--- a/OpenOVR/Compositor/compositor.h ++++ b/OpenOVR/Compositor/compositor.h +@@ -2,8 +2,6 @@ + + #include "generated/interfaces/vrtypes.h" + #ifdef WIN32 +-// Windows template libraries +-#include + #include + #endif + +diff --git a/OpenOVR/Compositor/dx12compositor.cpp b/OpenOVR/Compositor/dx12compositor.cpp +index ff2fedd..4462eee 100644 +--- a/OpenOVR/Compositor/dx12compositor.cpp ++++ b/OpenOVR/Compositor/dx12compositor.cpp +@@ -6,8 +6,6 @@ + + #include + +-#include +- + #include + #include + +diff --git a/OpenOVR/Misc/Keyboard/VRKeyboard.cpp b/OpenOVR/Misc/Keyboard/VRKeyboard.cpp +index 6766ed4..c91cba3 100644 +--- a/OpenOVR/Misc/Keyboard/VRKeyboard.cpp ++++ b/OpenOVR/Misc/Keyboard/VRKeyboard.cpp +@@ -298,7 +298,7 @@ void VRKeyboard::Refresh() + { pixels, sizeof(pix_t) * desc.Width, sizeof(pix_t) * desc.Width * desc.Height } + }; + +- CComPtr tex; ++ ComPtr tex; + HRESULT rres = dev->CreateTexture2D(&desc, init, &tex); + // HRESULT hrr = dev->GetDeviceRemovedReason(); + OOVR_FAILED_DX_ABORT(rres); +@@ -309,7 +309,7 @@ void VRKeyboard::Refresh() + int index; + OOVR_FAILED_OVR_ABORT(ovr_GetTextureSwapChainCurrentIndex(*ovr::session, chain, &index)); + +- CComPtr dst; ++ ComPtr dst; + OOVR_FAILED_OVR_ABORT(ovr_GetTextureSwapChainBufferDX(*ovr::session, chain, index, IID_PPV_ARGS(&dst))); + + ctx->CopyResource(dst, tex); diff --git a/tools/prepare-native-openvr-dev.ps1 b/tools/prepare-native-openvr-dev.ps1 new file mode 100644 index 0000000000..ee4af40233 --- /dev/null +++ b/tools/prepare-native-openvr-dev.ps1 @@ -0,0 +1,71 @@ +param( + [string]$Destination, + [string]$Branch = "gamenative-native-openvr" +) + +$ErrorActionPreference = "Stop" +$repository = Split-Path -Parent $PSScriptRoot +$workspace = Split-Path -Parent $repository +$source = if ($Destination) { [IO.Path]::GetFullPath($Destination) } else { Join-Path $workspace "OpenComposite-gamenative" } +$upstream = "https://gitlab.com/znixian/OpenOVR.git" +$commit = "a27e7e6a64bdcd1eff6b7fba1ea2ea34bcf1273d" +$compatibilityPatch = Join-Path $PSScriptRoot "patches\opencomposite-gamenative-wine.patch" +$nativeBackendPatch = Join-Path $PSScriptRoot "patches\opencomposite-gamenative-native-backend.patch" + +function Invoke-Checked { + param([scriptblock]$Command, [string]$Name) + & $Command + if ($LASTEXITCODE -ne 0) { throw "$Name failed with exit code $LASTEXITCODE" } +} + +if (!(Test-Path (Join-Path $source ".git"))) { + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $source) | Out-Null + Invoke-Checked { git clone --recurse-submodules $upstream $source } "OpenComposite clone" +} + +$status = (& git -c "safe.directory=$source" -C $source status --porcelain) +if ($LASTEXITCODE -ne 0) { throw "Unable to inspect OpenComposite checkout" } +if ($status) { throw "OpenComposite checkout has local changes; refusing to switch or patch it: $source" } + +$remoteNames = @(& git -c "safe.directory=$source" -C $source remote) +if ($remoteNames -contains "origin" -and !($remoteNames -contains "upstream")) { + Invoke-Checked { git -c "safe.directory=$source" -C $source remote rename origin upstream } "OpenComposite remote rename" +} +if (!(@(& git -c "safe.directory=$source" -C $source remote) -contains "upstream")) { + Invoke-Checked { git -c "safe.directory=$source" -C $source remote add upstream $upstream } "OpenComposite upstream remote" +} +Invoke-Checked { git -c "safe.directory=$source" -C $source remote set-url --push upstream DISABLED } "OpenComposite upstream push protection" + +& git -c "safe.directory=$source" -C $source cat-file -e "$commit^{commit}" 2>$null +if ($LASTEXITCODE -ne 0) { + Invoke-Checked { git -c "safe.directory=$source" -C $source fetch upstream $commit } "OpenComposite pinned revision fetch" +} + +& git -c "safe.directory=$source" -C $source show-ref --verify --quiet "refs/heads/$Branch" +if ($LASTEXITCODE -eq 0) { + Invoke-Checked { git -c "safe.directory=$source" -C $source switch $Branch } "OpenComposite branch switch" +} else { + Invoke-Checked { git -c "safe.directory=$source" -C $source switch --create $Branch $commit } "OpenComposite branch creation" +} +Invoke-Checked { git -c "safe.directory=$source" -C $source merge-base --is-ancestor $commit HEAD } "OpenComposite pinned-base validation" +Invoke-Checked { git -c "safe.directory=$source" -C $source submodule update --init --recursive } "OpenComposite submodules" + +& git -c "safe.directory=$source" -C $source apply --reverse --check $compatibilityPatch 2>$null +if ($LASTEXITCODE -ne 0) { + Invoke-Checked { git -c "safe.directory=$source" -C $source apply --check $compatibilityPatch } "OpenComposite compatibility patch check" + Invoke-Checked { git -c "safe.directory=$source" -C $source apply $compatibilityPatch } "OpenComposite compatibility patch" + Write-Host "Applied the GameNative Wine compatibility patch. Review and commit it in the OpenComposite checkout." +} + +& git -c "safe.directory=$source" -C $source apply --reverse --check $nativeBackendPatch 2>$null +if ($LASTEXITCODE -ne 0) { + Invoke-Checked { git -c "safe.directory=$source" -C $source apply --check $nativeBackendPatch } "OpenComposite native backend patch check" + Invoke-Checked { git -c "safe.directory=$source" -C $source apply $nativeBackendPatch } "OpenComposite native backend patch" + Write-Host "Applied the GameNative direct OpenVR backend patch. Review and commit it in the OpenComposite checkout." +} + +Write-Host "OpenComposite native-OpenVR checkout ready" +Write-Host " source: $source" +Write-Host " branch: $Branch" +Write-Host " pinned upstream: $commit" +Write-Host " remote: upstream (fetch-only by convention; add your fork as origin before pushing)" diff --git a/tools/stage-opencomposite.ps1 b/tools/stage-opencomposite.ps1 index 29a7f3cc2a..1b444c57ba 100644 --- a/tools/stage-opencomposite.ps1 +++ b/tools/stage-opencomposite.ps1 @@ -1,15 +1,130 @@ +param( + [switch]$PrintToolchainFingerprint, + [string]$SourceDirectory, + [string]$BuildDirectory, + [string]$Destination +) + $ErrorActionPreference = "Stop" $repository = Split-Path -Parent $PSScriptRoot $payload = Join-Path $repository "app\build\generated\xrPayload\modernXr" -$destination = Join-Path $payload "opencomposite_x64.dll" -$uri = "https://opencomposite.znix.xyz/builds/download_build?artefact_id=5r2yCwgFn_ozJ44c&build_id=49918237&commit=43e551a4506880ab1a71b8b9fec2fd7fbb27372f" -$expected = "2f56d45323f252a2ea7c3047c806f9aca3cab36c3f9f71d1dad05a5008b7731a" -New-Item -ItemType Directory -Force -Path $payload | Out-Null -$temporary = "$destination.download" -Invoke-WebRequest -Uri $uri -OutFile $temporary -$actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $temporary).Hash.ToLowerInvariant() -if ($actual -ne $expected) { throw "OpenComposite checksum mismatch: $actual" } -$bytes = [System.IO.File]::ReadAllBytes($temporary) +$destination = if ($Destination) { [IO.Path]::GetFullPath($Destination) } else { Join-Path $payload "opencomposite_x64.dll" } +$commit = "a27e7e6a64bdcd1eff6b7fba1ea2ea34bcf1273d" +$managedSource = [string]::IsNullOrWhiteSpace($SourceDirectory) +$source = if ($managedSource) { Join-Path $repository "app\build\opencomposite-source" } else { [IO.Path]::GetFullPath($SourceDirectory) } +$build = if ($BuildDirectory) { [IO.Path]::GetFullPath($BuildDirectory) } else { Join-Path $repository "app\build\opencomposite-build" } +$compatibilityPatch = Join-Path $PSScriptRoot "patches\opencomposite-gamenative-wine.patch" +$nativeBackendPatch = Join-Path $PSScriptRoot "patches\opencomposite-gamenative-native-backend.patch" +$vulkanDefinition = Join-Path $PSScriptRoot "opencomposite-vulkan-x64.def" +$ndk = if ($env:ANDROID_NDK_HOME) { $env:ANDROID_NDK_HOME } else { Join-Path $env:LOCALAPPDATA "Android\Sdk\ndk\29.0.14206865" } +$ndkIncludes = Join-Path $ndk "toolchains\llvm\prebuilt\windows-x86_64\sysroot\usr\include" +$vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" +$visualStudioVersionRange = "[17.0,18.0)" + +function Invoke-Checked { + param([scriptblock]$Command, [string]$Name) + & $Command + if ($LASTEXITCODE -ne 0) { throw "$Name failed with exit code $LASTEXITCODE" } +} + +function Get-DirectoryFingerprint { + param([string]$Path) + $resolvedRoot = (Resolve-Path -LiteralPath $Path).Path.TrimEnd('\') + '\' + $manifest = Get-ChildItem -LiteralPath $resolvedRoot -Recurse -File | + Sort-Object FullName | + ForEach-Object { + $relativePath = $_.FullName.Substring($resolvedRoot.Length).Replace('\', '/') + $fileHash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash + "$relativePath|$($_.Length)|$fileHash" + } + $manifestBytes = [Text.Encoding]::UTF8.GetBytes(($manifest -join "`n")) + $hasher = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($hasher.ComputeHash($manifestBytes))).Replace('-', '') + } finally { + $hasher.Dispose() + } +} + +if (!(Test-Path (Join-Path $ndkIncludes "vulkan\vulkan.h"))) { throw "Android NDK Vulkan headers are required" } +if (!(Test-Path $vswhere)) { throw "Visual Studio Installer is required" } +$visualStudio = (& $vswhere -latest -version $visualStudioVersionRange -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath | Select-Object -First 1) +if (!$visualStudio) { throw "Visual Studio 2022 C++ x64 build tools are required" } +$libTool = Get-ChildItem (Join-Path $visualStudio "VC\Tools\MSVC\*\bin\Hostx64\x64\lib.exe") | Sort-Object FullName -Descending | Select-Object -First 1 +if (!$libTool) { throw "Visual Studio lib.exe is required" } + +if ($PrintToolchainFingerprint) { + $ndkSourceProperties = Join-Path $ndk "source.properties" + if (!(Test-Path -LiteralPath $ndkSourceProperties -PathType Leaf)) { throw "Android NDK source.properties is required" } + $visualStudioVersion = (& $vswhere -latest -version $visualStudioVersionRange -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationVersion | Select-Object -First 1) + if (!$visualStudioVersion) { throw "Visual Studio version could not be resolved" } + $toolDirectory = Split-Path -Parent $libTool.FullName + $compilerTool = Join-Path $toolDirectory "cl.exe" + $linkerTool = Join-Path $toolDirectory "link.exe" + if (!(Test-Path -LiteralPath $compilerTool -PathType Leaf)) { throw "Visual Studio cl.exe is required" } + if (!(Test-Path -LiteralPath $linkerTool -PathType Leaf)) { throw "Visual Studio link.exe is required" } + $fingerprint = [ordered]@{ + ndkPath = (Resolve-Path -LiteralPath $ndk).Path + ndkSourcePropertiesSha256 = (Get-FileHash -LiteralPath $ndkSourceProperties -Algorithm SHA256).Hash + ndkVulkanHeadersSha256 = Get-DirectoryFingerprint (Join-Path $ndkIncludes "vulkan") + ndkVideoHeadersSha256 = Get-DirectoryFingerprint (Join-Path $ndkIncludes "vk_video") + visualStudioPath = (Resolve-Path -LiteralPath $visualStudio).Path + visualStudioVersion = $visualStudioVersion + compilerPath = (Resolve-Path -LiteralPath $compilerTool).Path + compilerSha256 = (Get-FileHash -LiteralPath $compilerTool -Algorithm SHA256).Hash + linkerSha256 = (Get-FileHash -LiteralPath $linkerTool -Algorithm SHA256).Hash + librarianSha256 = (Get-FileHash -LiteralPath $libTool.FullName -Algorithm SHA256).Hash + } + $fingerprint | ConvertTo-Json -Compress + exit 0 +} + +New-Item -ItemType Directory -Force -Path (Split-Path -Parent $destination) | Out-Null +if ($managedSource) { + if (!(Test-Path (Join-Path $source ".git"))) { + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $source) | Out-Null + Invoke-Checked { git clone --no-checkout https://gitlab.com/znixian/OpenOVR.git $source } "OpenComposite clone" + } + & git -c "safe.directory=$source" -C $source cat-file -e "$commit^{commit}" 2>$null + if ($LASTEXITCODE -ne 0) { + Invoke-Checked { git -c "safe.directory=$source" -C $source fetch --depth 1 origin $commit } "OpenComposite source fetch" + } + Invoke-Checked { git -c "safe.directory=$source" -C $source checkout --detach --force $commit } "OpenComposite checkout" + Invoke-Checked { git -c "safe.directory=$source" -C $source submodule update --init --recursive --depth 1 } "OpenComposite submodules" + Invoke-Checked { git -c "safe.directory=$source" -C $source apply --check $compatibilityPatch } "OpenComposite compatibility patch check" + Invoke-Checked { git -c "safe.directory=$source" -C $source apply $compatibilityPatch } "OpenComposite compatibility patch" + Invoke-Checked { git -c "safe.directory=$source" -C $source apply --check $nativeBackendPatch } "OpenComposite native backend patch check" + Invoke-Checked { git -c "safe.directory=$source" -C $source apply $nativeBackendPatch } "OpenComposite native backend patch" +} else { + if (!(Test-Path (Join-Path $source ".git"))) { throw "Developer OpenComposite source is not a Git checkout: $source" } + Invoke-Checked { git -c "safe.directory=$source" -C $source merge-base --is-ancestor $commit HEAD } "OpenComposite pinned-base validation" + Invoke-Checked { git -c "safe.directory=$source" -C $source submodule update --init --recursive } "OpenComposite developer submodules" + Write-Host "Building developer OpenComposite checkout without checkout, reset, or patch: $source" +} + +$vulkan = Join-Path $source "libs\vulkan" +$vulkanInclude = Join-Path $vulkan "Include\vulkan" +$videoInclude = Join-Path $vulkan "Include\vk_video" +New-Item -ItemType Directory -Force -Path $vulkanInclude, $videoInclude, (Join-Path $vulkan "Lib") | Out-Null +Copy-Item -Recurse -Force -Path (Join-Path $ndkIncludes "vulkan\*") -Destination $vulkanInclude +Copy-Item -Recurse -Force -Path (Join-Path $ndkIncludes "vk_video\*") -Destination $videoInclude +Copy-Item -Force -LiteralPath $vulkanDefinition -Destination (Join-Path $vulkan "vulkan-1.def") +Invoke-Checked { & $libTool.FullName /nologo "/def:$(Join-Path $vulkan 'vulkan-1.def')" /machine:x64 "/out:$(Join-Path $vulkan 'Lib\vulkan-1.lib')" } "Vulkan import library" + +if ($managedSource -and (Test-Path -LiteralPath $build)) { + $allowedBuildRoot = [IO.Path]::GetFullPath((Join-Path $repository "app\build")).TrimEnd('\') + '\' + $resolvedBuild = [IO.Path]::GetFullPath($build) + if (!$resolvedBuild.StartsWith($allowedBuildRoot, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to clear OpenComposite build directory outside app/build: $resolvedBuild" + } + Remove-Item -LiteralPath $resolvedBuild -Recurse -Force +} + +Invoke-Checked { cmake -S $source -B $build -G "Visual Studio 17 2022" -A x64 "-DCMAKE_GENERATOR_INSTANCE=$visualStudio" -DERROR_ON_WARNING=OFF "-DOC_VERSION=$commit-gamenative-native-openvr" -DGAMENATIVE_BACKEND=ON "-DGAMENATIVE_SOURCE_DIR=$repository" } "OpenComposite configure" +Invoke-Checked { cmake --build $build --config Release --target OCOVR --parallel } "OpenComposite build" +$built = Join-Path $build "bin\Release\vrclient_x64.dll" +if (!(Test-Path -LiteralPath $built -PathType Leaf)) { throw "OpenComposite build output is missing" } +Copy-Item -Force -LiteralPath $built -Destination $destination +$bytes = [System.IO.File]::ReadAllBytes($destination) $offset = [BitConverter]::ToInt32($bytes, 0x3c) if ([BitConverter]::ToUInt16($bytes, $offset + 4) -ne 0x8664) { throw "OpenComposite payload is not x64" } -Move-Item -Force -LiteralPath $temporary -Destination $destination diff --git a/ubuntufs/build.gradle.kts b/ubuntufs/build.gradle.kts index eea8b5bcc8..74061895ab 100644 --- a/ubuntufs/build.gradle.kts +++ b/ubuntufs/build.gradle.kts @@ -25,6 +25,9 @@ android { create("modernXr") { dimension = "androidApi" } + create("androidXr") { + dimension = "androidApi" + } } buildTypes {