diff --git a/android/Build-Quest.ps1 b/android/Build-Quest.ps1
index 6e86b77..340796e 100644
--- a/android/Build-Quest.ps1
+++ b/android/Build-Quest.ps1
@@ -1,7 +1,10 @@
# Builds the standalone Meta Quest APK on a Windows host.
#
# powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 [-Generated
]
-# [-Headset modern|quest1] [-Configuration debug|release] [-Install]
+# [-Headset modern|quest1|frame] [-Configuration debug|release] [-Install]
+#
+# -Headset frame builds the Steam Frame flavour (steamFrame), which runs under Lepton, SteamOS's
+# Android layer, with SteamVR's OpenXR runtime (docs/quest-port.md, "Steam Frame").
#
# One app offers both games: the APK carries a kit for the base game, and for Retro Rewind too
# when -Generated holds a translation that includes the mod.
@@ -25,7 +28,7 @@ param(
[string]$Generated = '',
[string]$Dependencies = '',
[string]$CMakeDir = '',
- [ValidateSet('modern', 'quest1')] [string]$Headset = 'modern',
+ [ValidateSet('modern', 'quest1', 'frame')] [string]$Headset = 'modern',
[ValidateSet('debug', 'release')] [string]$Configuration = 'debug',
[switch]$Install,
[switch]$StockDawn
@@ -80,8 +83,13 @@ if (-not $Dependencies) {
}
$variant = (Get-Culture).TextInfo.ToTitleCase($Configuration)
-$flavour = if ($Headset -eq 'quest1') { 'Quest1' } else { 'ModernQuest' }
-$expectedCpu = if ($Headset -eq 'quest1') { 'kryo' } else { 'cortex-a77' }
+# The Gradle flavour and the -mcpu target its kit must record (headsetCpus in app/build.gradle.kts).
+$flavourDir, $expectedCpu = switch ($Headset) {
+ 'quest1' { 'quest1', 'kryo' }
+ 'frame' { 'steamFrame', 'cortex-x4+nosve' }
+ default { 'modernQuest', 'cortex-a77' }
+}
+$flavour = $flavourDir.Substring(0, 1).ToUpperInvariant() + $flavourDir.Substring(1)
$task = "app:assemble$flavour$variant"
$gradleArgs = @(
'--project-dir', $root,
@@ -98,7 +106,6 @@ Write-Host "gradlew $($gradleArgs -join ' ')"
& (Join-Path $root 'gradlew.bat') @gradleArgs
if ($LASTEXITCODE -ne 0) { throw "Gradle failed ($LASTEXITCODE)" }
-$flavourDir = if ($Headset -eq 'quest1') { 'quest1' } else { 'modernQuest' }
$apkDir = Join-Path $root "app\build\outputs\apk\$flavourDir\$Configuration"
$apk = Get-ChildItem -Path $apkDir -Filter '*.apk' | Select-Object -First 1
if (-not $apk) { throw "No APK under $apkDir" }
@@ -128,6 +135,8 @@ if ($Install) {
if ($LASTEXITCODE -ne 0) { throw "adb install failed ($LASTEXITCODE)" }
if ($Headset -eq 'quest1') {
Write-Host 'Installed. Use WiiCompiled Settings for setup, then launch WiiCompiled VR directly from the library.'
+ } elseif ($Headset -eq 'frame') {
+ Write-Host 'Installed. Build a game on this PC with android/Build-QuestGame.ps1 -Headset frame -Install, then start WiiCompiled VR from the Steam library.'
} else {
Write-Host 'Installed. Build a game with Build on this Quest in the launcher, or on this PC with android/Build-QuestGame.ps1 -Install.'
}
diff --git a/android/Build-QuestGame.ps1 b/android/Build-QuestGame.ps1
index 3dbc754..c63877b 100644
--- a/android/Build-QuestGame.ps1
+++ b/android/Build-QuestGame.ps1
@@ -6,7 +6,7 @@
# SDK's NDK and ninja:
#
# powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 [-Generated ] [-Kit ]
-# [-Headset modern|quest1] [-Configuration debug|release]
+# [-Headset modern|quest1|frame] [-Configuration debug|release]
# [-Data ] [-Output ] [-Install]
#
# WiiCompiled Setup's --build-quest runs the copy staged in an installation's BuildWorkspace\android,
@@ -33,7 +33,7 @@ param(
[string]$Sysroot = '',
[string]$Ninja = '',
[string]$BuiltBy = 'android/Build-QuestGame.ps1',
- [ValidateSet('modern', 'quest1')] [string]$Headset = 'modern',
+ [ValidateSet('modern', 'quest1', 'frame')] [string]$Headset = 'modern',
[ValidateSet('debug', 'release')] [string]$Configuration = 'debug',
[ValidateSet('base', 'retro_rewind')] [string]$Product = 'base',
[string]$Mod = '',
@@ -47,7 +47,7 @@ $root = $PSScriptRoot
$workspace = (Resolve-Path (Join-Path $root '..')).Path
if (-not $Generated) { $Generated = Join-Path $workspace '.scratch\vr-build-workspace\BuildWorkspace\generated' }
if (-not $Manifest) { $Manifest = Join-Path $workspace 'projects\mkwii\recomp.yml' }
-$variant = if ($Headset -eq 'quest1') { 'quest1' } else { 'modernQuest' }
+$variant = switch ($Headset) { 'quest1' { 'quest1' } 'frame' { 'steamFrame' } default { 'modernQuest' } }
$variant += (Get-Culture).TextInfo.ToTitleCase($Configuration)
if (-not $BuildDir) { $BuildDir = Join-Path $root "app\build\questGame\$variant\$Product" }
if (-not $Kit) {
diff --git a/android/Run-Quest.ps1 b/android/Run-Quest.ps1
index f2c3c40..5a193bd 100644
--- a/android/Run-Quest.ps1
+++ b/android/Run-Quest.ps1
@@ -3,7 +3,7 @@
# diagnostics for one session.
#
# powershell -ExecutionPolicy Bypass -File android/Run-Quest.ps1 [-Apk ] [-Data ]
-# [-Headset modern|quest1] [-Configuration debug|release]
+# [-Headset modern|quest1|frame] [-Configuration debug|release]
# [-Seconds 60] [-NoLaunch] [-SkipInstall]
#
# -Data names the extracted disc partition (the directory holding sys/, files/,
@@ -25,7 +25,7 @@
param(
[string]$Apk = '',
[string]$Data = '',
- [ValidateSet('modern', 'quest1')] [string]$Headset = 'modern',
+ [ValidateSet('modern', 'quest1', 'frame')] [string]$Headset = 'modern',
[ValidateSet('debug', 'release')] [string]$Configuration = 'debug',
[int]$Seconds = 60,
[switch]$NoLaunch,
@@ -52,7 +52,7 @@ if (-not $devices) { throw 'No device in "device" state; check the Quest is conn
if (-not $SkipInstall) {
if (-not $Apk) {
- $flavour = if ($Headset -eq 'quest1') { 'quest1' } else { 'modernQuest' }
+ $flavour = switch ($Headset) { 'quest1' { 'quest1' } 'frame' { 'steamFrame' } default { 'modernQuest' } }
$apkDir = Join-Path $root "app\build\outputs\apk\$flavour\$Configuration"
$Apk = Get-ChildItem -Path $apkDir -Filter '*.apk' -ErrorAction SilentlyContinue |
Select-Object -First 1 | ForEach-Object FullName
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index b22638a..e71493b 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -53,6 +53,19 @@ val hasRetroRewindShards: Boolean = run {
shards.isFile && shards.readText().contains("set(MKW_HAVE_RETRO_REWIND_SHARDS ON)")
}
+// Each headset flavour's AArch64 -mcpu target. The game kit records it, and the kit export, the APK
+// audit (Build-Quest.ps1), the PC build (QuestGameKit.psm1), the on-headset build and the package
+// import all refuse a game built for another one.
+val headsetCpus = mapOf(
+ // Snapdragon XR2 and newer. Keep this as the default build target.
+ "modernQuest" to "cortex-a77",
+ // Snapdragon 835. cortex-a77 binaries terminate with SIGILL on Quest 1.
+ "quest1" to "kryo",
+ // Snapdragon 8 Gen 3 (Cortex-X4, A720 and A520, all ARMv9.2). Its firmware does not expose SVE,
+ // which clang would otherwise auto-vectorise with for a cortex-x4.
+ "steamFrame" to "cortex-x4+nosve",
+)
+
// android/nod-jni: nod, the disc image library the PC installer runs as nodtool,
// cross-compiled with cargo for the launcher's "Select disc image". Needs a Rust
// toolchain with the aarch64-linux-android target (see docs/quest-port.md).
@@ -152,11 +165,11 @@ android {
dimension = "headset"
manifestPlaceholders["mkwQuestSupportedDevices"] = "quest2|quest3|quest3s|questpro"
buildConfigField("boolean", "QUEST1_DIRECT_LAUNCH", "false")
- buildConfigField("String", "ANDROID_CPU", "\"cortex-a77\"")
+ buildConfigField("boolean", "STEAM_FRAME", "false")
+ buildConfigField("String", "ANDROID_CPU", "\"${headsetCpus.getValue("modernQuest")}\"")
externalNativeBuild {
cmake {
- // Snapdragon XR2 and newer. Keep this as the default build target.
- arguments += "-DMKW_ANDROID_CPU=cortex-a77"
+ arguments += "-DMKW_ANDROID_CPU=${headsetCpus.getValue("modernQuest")}"
}
}
}
@@ -164,11 +177,30 @@ android {
dimension = "headset"
manifestPlaceholders["mkwQuestSupportedDevices"] = "quest|quest2"
buildConfigField("boolean", "QUEST1_DIRECT_LAUNCH", "true")
- buildConfigField("String", "ANDROID_CPU", "\"kryo\"")
+ buildConfigField("boolean", "STEAM_FRAME", "false")
+ buildConfigField("String", "ANDROID_CPU", "\"${headsetCpus.getValue("quest1")}\"")
externalNativeBuild {
cmake {
- // Snapdragon 835. cortex-a77 binaries terminate with SIGILL on Quest 1.
- arguments += "-DMKW_ANDROID_CPU=kryo"
+ arguments += "-DMKW_ANDROID_CPU=${headsetCpus.getValue("quest1")}"
+ }
+ }
+ }
+ // Valve's Steam Frame, through Lepton (SteamOS's Android layer) and SteamVR's Android
+ // OpenXR runtime. Its manifest (src/steamFrame) drops the Horizon OS entries, and
+ // MKW_ANDROID_HEADSET gives the runtime the Frame's defaults (runtime_config.h).
+ create("steamFrame") {
+ dimension = "headset"
+ // Horizon OS only; src/steamFrame/AndroidManifest.xml removes the entry.
+ manifestPlaceholders["mkwQuestSupportedDevices"] = ""
+ buildConfigField("boolean", "QUEST1_DIRECT_LAUNCH", "false")
+ buildConfigField("boolean", "STEAM_FRAME", "true")
+ buildConfigField("String", "ANDROID_CPU", "\"${headsetCpus.getValue("steamFrame")}\"")
+ externalNativeBuild {
+ cmake {
+ arguments += listOf(
+ "-DMKW_ANDROID_CPU=${headsetCpus.getValue("steamFrame")}",
+ "-DMKW_ANDROID_HEADSET=steam_frame",
+ )
}
}
}
@@ -324,7 +356,7 @@ androidComponents {
dependsOn("merge${capitalized}NativeLibs")
appDir.set(layout.projectDirectory)
script.set(rootProject.layout.projectDirectory.file("Export-QuestGameKit.ps1"))
- androidCpu.set(if (variant.name.startsWith("quest1", ignoreCase = true)) "kryo" else "cortex-a77")
+ androidCpu.set(headsetCpus.getValue(checkNotNull(variant.flavorName) { "${variant.name} has no headset flavour" }))
outputDir.set(layout.buildDirectory.dir("generated/assets/questGameKit/${variant.name}"))
llvmStrip.set(sdkComponents.ndkDirectory.map {
it.file("toolchains/llvm/prebuilt/$host/bin/llvm-strip" + if (host.startsWith("windows")) ".exe" else "")
diff --git a/android/app/src/steamFrame/AndroidManifest.xml b/android/app/src/steamFrame/AndroidManifest.xml
new file mode 100644
index 0000000..4979970
--- /dev/null
+++ b/android/app/src/steamFrame/AndroidManifest.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/steamFrame/java/org/wiicompiled/quest/FrameEntryActivity.kt b/android/app/src/steamFrame/java/org/wiicompiled/quest/FrameEntryActivity.kt
new file mode 100644
index 0000000..6099588
--- /dev/null
+++ b/android/app/src/steamFrame/java/org/wiicompiled/quest/FrameEntryActivity.kt
@@ -0,0 +1,52 @@
+package org.wiicompiled.quest
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import org.wiicompiled.quest.launcher.LauncherActivity
+import org.wiicompiled.quest.launcher.ModLibrary
+
+/**
+ * The Steam Frame's library entry. Lepton starts the one real activity that is both MAIN and
+ * LAUNCHER, in VR when it carries a VR category, and ignores activity-aliases, so neither of the
+ * Quest flavours' entries works there. This one shows nothing: the setup panel (LauncherActivity)
+ * always opens, and when the selected game can start as it is, the game opens on top of it, so the
+ * headset goes straight into VR and quitting the game comes back to the panel.
+ */
+class FrameEntryActivity : Activity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val launcher = Intent(this, LauncherActivity::class.java)
+ if (readyToPlay()) {
+ Log.i(TAG, "Starting the game over the setup panel")
+ startActivities(arrayOf(launcher, Intent(this, QuestActivity::class.java)))
+ } else {
+ Log.i(TAG, "The game is not ready to start; opening the setup panel")
+ startActivity(launcher)
+ }
+ finish()
+ }
+
+ /**
+ * What Home's Play needs (LauncherActivity), short of the steps only the panel performs: the
+ * game files and the selected game built for this app, Retro Rewind's pack, and no enabled mods
+ * to copy into the pack's Patches folder first.
+ */
+ private fun readyToPlay(): Boolean = runCatching {
+ val profile = GameProfile.selected(this)
+ GameStorage.discStatus(this) == GameStorage.DiscStatus.Ready &&
+ GameLibrary.status(this, profile) == GameLibrary.Status.Ready &&
+ GameStorage.modContentReady(this, profile) &&
+ (!profile.modPack || ModLibrary.load(GameStorage.modsDirectory(this)).none { it.enabled })
+ }.getOrElse {
+ Log.w(TAG, "Could not check the installed game", it)
+ false
+ }
+
+ private companion object {
+ // The launcher's tag, so the usual `adb logcat -s WiiCompiledLauncher` shows the routing.
+ const val TAG = "WiiCompiledLauncher"
+ }
+}
diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt
index 3a2cb1e..5820ffb 100644
--- a/runtime/CMakeLists.txt
+++ b/runtime/CMakeLists.txt
@@ -37,6 +37,21 @@ option(MKW_BUILD_PRODUCTS "Build translated WiiCompiled product targets" ON)
# aurora-main and its third-party tree stay unaffected.
set(MKW_PROJECT_COMPILE_DEFINITIONS NOMINMAX)
+# The Android headset family the build is for (the Gradle flavour passes it). The
+# Steam Frame build runs under SteamVR's Android runtime rather than Horizon OS and
+# takes its own defaults (runtime_config.h) through MKW_HEADSET_STEAM_FRAME. The
+# definition reaches every target below, so the game kit's recorded compile flags
+# carry it too.
+if(MKW_PLATFORM_ANDROID)
+ set(MKW_ANDROID_HEADSET "quest" CACHE STRING "Android headset family: quest (Meta Quest, any flavour) or steam_frame")
+ set_property(CACHE MKW_ANDROID_HEADSET PROPERTY STRINGS quest steam_frame)
+ if(MKW_ANDROID_HEADSET STREQUAL "steam_frame")
+ list(APPEND MKW_PROJECT_COMPILE_DEFINITIONS MKW_HEADSET_STEAM_FRAME=1)
+ elseif(NOT MKW_ANDROID_HEADSET STREQUAL "quest")
+ message(FATAL_ERROR "MKW_ANDROID_HEADSET must be quest or steam_frame, not '${MKW_ANDROID_HEADSET}'")
+ endif()
+endif()
+
set(CMAKE_CXX_STANDARD 17)
# Declared before the vendored libraries so the third-party block below can
diff --git a/runtime/cmake/PublicProducts.cmake b/runtime/cmake/PublicProducts.cmake
index 9c78816..4cc7ba2 100644
--- a/runtime/cmake/PublicProducts.cmake
+++ b/runtime/cmake/PublicProducts.cmake
@@ -448,6 +448,7 @@ if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64)$")
elseif(MKW_PLATFORM_ANDROID)
# Cross-compiled, so "native" would describe the build host. Cortex-A77 is
# the Snapdragon XR2 Gen 1 (Quest 2) core; Quest 3 / Pro are supersets.
+ # Each Gradle headset flavour passes its own (android/app/build.gradle.kts).
set(MKW_ANDROID_CPU "cortex-a77" CACHE STRING "AArch64 -mcpu target for the Android products")
set(MKW_BASELINE_ARCH_FLAG -mcpu=${MKW_ANDROID_CPU})
elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "^(aarch64|arm64|ARM64)$")
diff --git a/runtime/include/vr/openxr_input.h b/runtime/include/vr/openxr_input.h
index e1f3b4e..759b40c 100644
--- a/runtime/include/vr/openxr_input.h
+++ b/runtime/include/vr/openxr_input.h
@@ -193,6 +193,11 @@ private:
XrAction m_button_primary = XR_NULL_HANDLE; // A / X
XrAction m_button_secondary = XR_NULL_HANDLE; // B / Y
XrAction m_menu = XR_NULL_HANDLE;
+ // The Steam Frame's left D-pad (valve/frame_controller_valve); Touch has none.
+ XrAction m_dpad_up = XR_NULL_HANDLE;
+ XrAction m_dpad_down = XR_NULL_HANDLE;
+ XrAction m_dpad_left = XR_NULL_HANDLE;
+ XrAction m_dpad_right = XR_NULL_HANDLE;
XrAction m_aim_pose = XR_NULL_HANDLE;
XrAction m_grip_pose = XR_NULL_HANDLE;
XrAction m_haptic = XR_NULL_HANDLE;
diff --git a/runtime/include/vr/openxr_wii_remote.h b/runtime/include/vr/openxr_wii_remote.h
index 666b2f6..bacbbc0 100644
--- a/runtime/include/vr/openxr_wii_remote.h
+++ b/runtime/include/vr/openxr_wii_remote.h
@@ -341,6 +341,12 @@ struct HandInputs {
float squeeze = 0.0f;
float stick_x = 0.0f;
float stick_y = 0.0f; // +up
+ // A D-pad, which only the Steam Frame's left controller has
+ // (valve/frame_controller_valve).
+ bool dpad_up = false;
+ bool dpad_down = false;
+ bool dpad_left = false;
+ bool dpad_right = false;
};
// Adapted from DolphinXR's default "OpenXR Wii Remote" profile
@@ -353,6 +359,8 @@ struct HandInputs {
// HOME has no button; left Y opens the settings panel (openxr_settings_panel.h).
// The grips press nothing: they take hold of the wheel (openxr_driving.h), and C
// is the game's look-behind, which a hand on the wheel would otherwise hold down.
+// A controller D-pad (the Steam Frame's left one) is the remote's D-pad; the
+// Frame's left View button is the left menu and its left shoulder the left Y.
inline uint32_t RemoteButtons(const HandInputs& left, const HandInputs& right) noexcept {
uint32_t hold = 0;
const auto press = [&hold](bool held, uint32_t bit) {
@@ -367,6 +375,10 @@ inline uint32_t RemoteButtons(const HandInputs& left, const HandInputs& right) n
press(right.stick_y < -kPressThreshold, kButtonTwo);
press(left.primary || left.menu, kButtonPlus);
press(left.trigger > kPressThreshold, kButtonZ);
+ press(left.dpad_up || right.dpad_up, kButtonUp);
+ press(left.dpad_down || right.dpad_down, kButtonDown);
+ press(left.dpad_left || right.dpad_left, kButtonLeft);
+ press(left.dpad_right || right.dpad_right, kButtonRight);
return hold;
}
diff --git a/runtime/src/vr/openxr_input.cpp b/runtime/src/vr/openxr_input.cpp
index bc4b3e7..001baf2 100644
--- a/runtime/src/vr/openxr_input.cpp
+++ b/runtime/src/vr/openxr_input.cpp
@@ -146,7 +146,9 @@ private:
(1u << SDL_GAMEPAD_BUTTON_START) | (1u << SDL_GAMEPAD_BUTTON_LEFT_STICK) |
(1u << SDL_GAMEPAD_BUTTON_RIGHT_STICK) |
(1u << SDL_GAMEPAD_BUTTON_LEFT_SHOULDER) |
- (1u << SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER);
+ (1u << SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER) |
+ (1u << SDL_GAMEPAD_BUTTON_DPAD_UP) | (1u << SDL_GAMEPAD_BUTTON_DPAD_DOWN) |
+ (1u << SDL_GAMEPAD_BUTTON_DPAD_LEFT) | (1u << SDL_GAMEPAD_BUTTON_DPAD_RIGHT);
desc.axis_mask = (1u << SDL_GAMEPAD_AXIS_LEFTX) | (1u << SDL_GAMEPAD_AXIS_LEFTY) |
(1u << SDL_GAMEPAD_AXIS_RIGHTX) | (1u << SDL_GAMEPAD_AXIS_RIGHTY) |
(1u << SDL_GAMEPAD_AXIS_LEFT_TRIGGER) | (1u << SDL_GAMEPAD_AXIS_RIGHT_TRIGGER);
@@ -394,7 +396,7 @@ bool OpenXRInput::CreateActions() {
const char* localized;
XrActionType type;
};
- const std::array specs{{
+ const std::array specs{{
{&m_thumbstick, "thumbstick", "Thumbstick", XR_ACTION_TYPE_VECTOR2F_INPUT},
{&m_thumbstick_click, "thumbstick_click", "Thumbstick Click", XR_ACTION_TYPE_BOOLEAN_INPUT},
{&m_trigger, "trigger", "Trigger", XR_ACTION_TYPE_FLOAT_INPUT},
@@ -402,6 +404,10 @@ bool OpenXRInput::CreateActions() {
{&m_button_primary, "button_primary", "A / X", XR_ACTION_TYPE_BOOLEAN_INPUT},
{&m_button_secondary, "button_secondary", "B / Y", XR_ACTION_TYPE_BOOLEAN_INPUT},
{&m_menu, "menu", "Menu", XR_ACTION_TYPE_BOOLEAN_INPUT},
+ {&m_dpad_up, "dpad_up", "D-pad Up", XR_ACTION_TYPE_BOOLEAN_INPUT},
+ {&m_dpad_down, "dpad_down", "D-pad Down", XR_ACTION_TYPE_BOOLEAN_INPUT},
+ {&m_dpad_left, "dpad_left", "D-pad Left", XR_ACTION_TYPE_BOOLEAN_INPUT},
+ {&m_dpad_right, "dpad_right", "D-pad Right", XR_ACTION_TYPE_BOOLEAN_INPUT},
{&m_aim_pose, "aim_pose", "Pointer", XR_ACTION_TYPE_POSE_INPUT},
{&m_grip_pose, "grip_pose", "Motion", XR_ACTION_TYPE_POSE_INPUT},
{&m_haptic, "haptic", "Haptic", XR_ACTION_TYPE_VIBRATION_OUTPUT},
@@ -493,6 +499,40 @@ bool OpenXRInput::SuggestBindings() {
{&m_haptic, "/user/hand/right/output/haptic"},
};
suggest("/interaction_profiles/khr/simple_controller", simple, false);
+
+ // The Steam Frame's controllers (XR_VALVE_frame_controller_interaction). Without the profile
+ // SteamVR presents them as Touch controllers, which loses the left D-pad. Its left hand has a
+ // D-pad where Touch has X and Y, a View button for the menu and a shoulder button, which takes
+ // left Y's place as the settings panel's button. Right X, Y, menu and shoulder stay free.
+ const auto& extensions = m_runtime->EnabledExtensions();
+ if (std::find(extensions.begin(), extensions.end(), "XR_VALVE_frame_controller_interaction") !=
+ extensions.end()) {
+ const std::vector frame{
+ {&m_thumbstick, "/user/hand/left/input/thumbstick"},
+ {&m_thumbstick, "/user/hand/right/input/thumbstick"},
+ {&m_thumbstick_click, "/user/hand/left/input/thumbstick/click"},
+ {&m_thumbstick_click, "/user/hand/right/input/thumbstick/click"},
+ {&m_trigger, "/user/hand/left/input/trigger/value"},
+ {&m_trigger, "/user/hand/right/input/trigger/value"},
+ {&m_squeeze, "/user/hand/left/input/squeeze/value"},
+ {&m_squeeze, "/user/hand/right/input/squeeze/value"},
+ {&m_button_primary, "/user/hand/right/input/a/click"},
+ {&m_button_secondary, "/user/hand/right/input/b/click"},
+ {&m_button_secondary, "/user/hand/left/input/shoulder/click"},
+ {&m_menu, "/user/hand/left/input/view/click"},
+ {&m_dpad_up, "/user/hand/left/input/dpad_up/click"},
+ {&m_dpad_down, "/user/hand/left/input/dpad_down/click"},
+ {&m_dpad_left, "/user/hand/left/input/dpad_left/click"},
+ {&m_dpad_right, "/user/hand/left/input/dpad_right/click"},
+ {&m_aim_pose, "/user/hand/left/input/aim/pose"},
+ {&m_aim_pose, "/user/hand/right/input/aim/pose"},
+ {&m_grip_pose, "/user/hand/left/input/grip/pose"},
+ {&m_grip_pose, "/user/hand/right/input/grip/pose"},
+ {&m_haptic, "/user/hand/left/output/haptic"},
+ {&m_haptic, "/user/hand/right/output/haptic"},
+ };
+ suggest("/interaction_profiles/valve/frame_controller_valve", frame, false);
+ }
return true;
}
@@ -868,6 +908,7 @@ void OpenXRInput::Destroy() {
}
m_thumbstick = m_thumbstick_click = m_trigger = m_squeeze = XR_NULL_HANDLE;
m_button_primary = m_button_secondary = m_menu = m_haptic = XR_NULL_HANDLE;
+ m_dpad_up = m_dpad_down = m_dpad_left = m_dpad_right = XR_NULL_HANDLE;
m_aim_pose = m_grip_pose = XR_NULL_HANDLE;
m_hand_paths[0] = m_hand_paths[1] = XR_NULL_PATH;
m_convert_now_to_xr_time = nullptr;
@@ -983,6 +1024,10 @@ void OpenXRInput::Sync(XrTime predicted_display_time, const OpenXRPointerScreen&
inputs.primary = boolean(m_button_primary, hand, &primary_active);
inputs.secondary = boolean(m_button_secondary, hand, &secondary_active);
inputs.menu = boolean(m_menu, hand);
+ inputs.dpad_up = boolean(m_dpad_up, hand);
+ inputs.dpad_down = boolean(m_dpad_down, hand);
+ inputs.dpad_left = boolean(m_dpad_left, hand);
+ inputs.dpad_right = boolean(m_dpad_right, hand);
inputs.thumbstick_click = boolean(m_thumbstick_click, hand);
inputs.trigger = scalar(m_trigger, hand);
inputs.squeeze = scalar(m_squeeze, hand, &squeeze_active);
@@ -1165,6 +1210,10 @@ void OpenXRInput::Sync(XrTime predicted_display_time, const OpenXRPointerScreen&
pad.buttons[SDL_GAMEPAD_BUTTON_RIGHT_STICK] = right.thumbstick_click;
pad.buttons[SDL_GAMEPAD_BUTTON_LEFT_SHOULDER] = left.squeeze > 0.5f;
pad.buttons[SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER] = right.squeeze > 0.5f;
+ pad.buttons[SDL_GAMEPAD_BUTTON_DPAD_UP] = left.dpad_up || right.dpad_up;
+ pad.buttons[SDL_GAMEPAD_BUTTON_DPAD_DOWN] = left.dpad_down || right.dpad_down;
+ pad.buttons[SDL_GAMEPAD_BUTTON_DPAD_LEFT] = left.dpad_left || right.dpad_left;
+ pad.buttons[SDL_GAMEPAD_BUTTON_DPAD_RIGHT] = left.dpad_right || right.dpad_right;
Relay().Publish(pad);
}
diff --git a/runtime/src/vr/openxr_integration.cpp b/runtime/src/vr/openxr_integration.cpp
index 86b30e3..e91a65e 100644
--- a/runtime/src/vr/openxr_integration.cpp
+++ b/runtime/src/vr/openxr_integration.cpp
@@ -407,7 +407,8 @@ public:
#if defined(_WIN32)
config.required_extensions = {kRequiredAuroraBackend == BACKEND_VULKAN ? "XR_KHR_vulkan_enable2" : "XR_KHR_D3D12_enable"};
config.optional_extensions = {"XR_KHR_win32_convert_performance_counter_time",
- "XR_FB_display_refresh_rate", "XR_EXT_performance_settings"};
+ "XR_FB_display_refresh_rate", "XR_EXT_performance_settings",
+ "XR_VALVE_frame_controller_interaction"};
AddHandMeshExtensions(config);
#else
// Either Vulkan binding extension is acceptable; the backend picks
@@ -415,11 +416,13 @@ public:
config.required_extensions = {"XR_KHR_android_create_instance"};
// XR_FB_passthrough: the room around the virtual screen (OpenXRPassthrough), asked for
// whatever [vr] passthrough says, since the setting is live.
+ // XR_VALVE_frame_controller_interaction: the Steam Frame's controllers, D-pad included
+ // (OpenXRInput::SuggestBindings), under SteamVR here or streamed from a PC.
config.optional_extensions = {"XR_KHR_vulkan_enable2", "XR_KHR_vulkan_enable",
"XR_KHR_convert_timespec_time",
"XR_KHR_android_thread_settings",
"XR_FB_display_refresh_rate", "XR_EXT_performance_settings",
- "XR_FB_passthrough"};
+ "XR_FB_passthrough", "XR_VALVE_frame_controller_interaction"};
AddHandMeshExtensions(config);
config.instance_create_next = OpenXRAndroidInstanceCreateNext();
#endif
diff --git a/runtime/tests/vr_wii_remote_tests.cpp b/runtime/tests/vr_wii_remote_tests.cpp
index 7ffe66d..1feed26 100644
--- a/runtime/tests/vr_wii_remote_tests.cpp
+++ b/runtime/tests/vr_wii_remote_tests.cpp
@@ -282,6 +282,19 @@ void TestButtons() {
left.menu = true;
Check((RemoteButtons(left, right) & kButtonHome) == 0, "left menu is no longer HOME");
+ // The Steam Frame's left D-pad is the remote's, one bit per direction.
+ left = {};
+ left.dpad_up = true;
+ Check(RemoteButtons(left, right) == kButtonUp, "D-pad up is the remote's up");
+ left = {};
+ left.dpad_down = true;
+ left.dpad_left = true;
+ Check(RemoteButtons(left, right) == (kButtonDown | kButtonLeft), "D-pad down and left together");
+ left = {};
+ right.dpad_right = true;
+ Check(RemoteButtons(left, right) == kButtonRight, "a D-pad on either hand counts");
+ right = {};
+
left.stick_x = 1.0f;
left.stick_y = 1.0f;
const std::array stick = NunchukStick(left);