From 8bf7ba7eca152173d550b189be554cef07f8b0ac Mon Sep 17 00:00:00 2001 From: XxJSONDeruloxX Date: Wed, 14 Oct 2026 20:47:24 -0400 Subject: [PATCH] fix: pace and retain Quest XR sessions --- .github/workflows/build.yml | 2 + .../com/droiddeck/launcher/QuestXrActivity.kt | 5 +- .../launcher/session/SessionService.kt | 38 +++++++++++--- .../launcher/session/SessionVisibility.kt | 4 ++ .../launcher/session/SessionVisibilityTest.kt | 28 ++++++++++ docs/development/quest-openxr.md | 36 +++++++++++++ tools/build_quest_xr_tests.ps1 | 2 +- tools/vr/src/driver_droiddeck.cpp | 23 ++++---- tools/vr/src/xr_vsync_timing.h | 36 +++++++++++++ tools/vr/tests/test_xr_vsync.cpp | 52 +++++++++++++++++++ 10 files changed, 202 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/session/SessionVisibility.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/SessionVisibilityTest.kt create mode 100644 tools/vr/src/xr_vsync_timing.h create mode 100644 tools/vr/tests/test_xr_vsync.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f6559510b..ba8a13b32 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -403,6 +403,8 @@ jobs: run: | g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_tracking.cpp -o /tmp/test-xr-tracking /tmp/test-xr-tracking + g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_vsync.cpp -o /tmp/test-xr-vsync + /tmp/test-xr-vsync g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_frames.cpp -o /tmp/test-xr-frames /tmp/test-xr-frames cc -Itools/vr/third_party/wayland -c tools/vr/src/wayland_client_stub.c -o /tmp/test-wayland-stub.o diff --git a/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt b/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt index 195fb1d66..126d73cbc 100644 --- a/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt @@ -32,7 +32,7 @@ class QuestXrActivity : NativeActivity() { WaylandCompositor.nativeSetFpsLimit(0) } CompositorHost.attachXr(surface) - SessionService.setActivityVisible(this, surface != null) + SessionService.setXrActivityVisible(this, surface != null) WaylandCompositor.nativeVsync(System.nanoTime()) check(WaylandCompositor.nativeWaitSurface(3000)) { "XR surface GPU handshake timed out" } if (surface != null) startSteamVr() @@ -49,7 +49,7 @@ class QuestXrActivity : NativeActivity() { WaylandCompositor.nativeVsync(System.nanoTime()) check(WaylandCompositor.nativeWaitSurface(3000)) { "XR frame GPU handshake timed out" } } - SessionService.setActivityVisible(this, active) + SessionService.setXrActivityVisible(this, active) if (active) startSteamVr() } @@ -76,6 +76,7 @@ class QuestXrActivity : NativeActivity() { override fun onDestroy() { closing = true + SessionService.setXrActivityVisible(this, false) super.onDestroy() } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 1435ae445..3157e916f 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -71,6 +71,8 @@ class SessionService : Service() { private var suspendPolicy = SessionPrefs.SUSPEND_MANUAL private var steamDownloadsInBackground = false private var activityVisible = true + private var xrActivityVisible = false + private val hasVisibleSurface get() = sessionHasVisibleSurface(activityVisible, screenOn, xrActivityVisible) private var screenOn = true private var manualPauseRequested = false private var steamSleepToken: String? = null @@ -170,6 +172,17 @@ class SessionService : Service() { updateSuspendPolicy() return START_NOT_STICKY } + ACTION_XR_ACTIVITY_VISIBLE -> { + xrActivityVisible = true + resumeSession(panelVisible = false) + return START_NOT_STICKY + } + ACTION_XR_ACTIVITY_HIDDEN -> { + xrActivityVisible = false + suspendAttemptFailed = false + updateSuspendPolicy() + return START_NOT_STICKY + } ACTION_SUSPEND_POLICY_CHANGED -> { if (!SessionState.running) return START_NOT_STICKY suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode) @@ -205,6 +218,7 @@ class SessionService : Service() { suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode) steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this) activityVisible = true + xrActivityVisible = false screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true manualPauseRequested = false suspendDownloadNow = false @@ -918,8 +932,8 @@ class SessionService : Service() { Log.i(TAG, "queued Steam game $gameId for the running client") } - private fun resumeSession() { - activityVisible = true + private fun resumeSession(panelVisible: Boolean = true) { + if (panelVisible) activityVisible = true screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn manualPauseRequested = false suspendDownloadNow = false @@ -1110,11 +1124,11 @@ class SessionService : Service() { private fun updateSuspendPolicy() { if (!SessionState.running) return - if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && (!activityVisible || !screenOn)) { + if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && !hasVisibleSurface) { manualPauseRequested = true } val controller = suspendController ?: return - val hidden = !activityVisible || !screenOn + val hidden = !hasVisibleSurface val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM && suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow if (downloadsBlockingSuspend) acquireLocks() @@ -1136,6 +1150,7 @@ class SessionService : Service() { SessionEvents.record(if (shouldSuspend) "session.suspend_requested" else "session.resume_requested", mapOf( "policy" to suspendPolicy, "activityVisible" to activityVisible, "screenOn" to screenOn, "steamSleep" to (steamSleepToken != null), + "xrActivityVisible" to xrActivityVisible, "nativeSleep" to (nativeSleepToken != null), )) if (shouldSuspend) { @@ -1193,7 +1208,7 @@ class SessionService : Service() { } private fun resumeSteamSleepOnReturn() { - if (suspendPolicy in setOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE) && activityVisible && screenOn) { + if (suspendPolicy in setOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE) && hasVisibleSurface) { if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake() completeNativeSleep() completeSteamSleep() @@ -1304,6 +1319,7 @@ class SessionService : Service() { if (!SessionState.running) return SessionState.running = false } + xrActivityVisible = false updateDownloadMonitoring() val stoppedGen = sessionGen val stoppedSession = SessionState.sessionId @@ -1356,8 +1372,8 @@ class SessionService : Service() { } override fun onTaskRemoved(rootIntent: Intent?) { - if (pipTask) { - Log.i(TAG, "PiP task dismissed - retaining the session under its background policy") + if (pipTask || xrActivityVisible) { + Log.i(TAG, "panel task dismissed - retaining the session under its active surface policy") activityVisible = false updateSuspendPolicy() return @@ -1558,6 +1574,8 @@ class SessionService : Service() { private const val ACTION_SUSPEND_POLICY_CHANGED = "com.droiddeck.launcher.SUSPEND_POLICY_CHANGED" private const val ACTION_ACTIVITY_VISIBLE = "com.droiddeck.launcher.ACTIVITY_VISIBLE" private const val ACTION_ACTIVITY_HIDDEN = "com.droiddeck.launcher.ACTIVITY_HIDDEN" + private const val ACTION_XR_ACTIVITY_VISIBLE = "com.droiddeck.launcher.XR_ACTIVITY_VISIBLE" + private const val ACTION_XR_ACTIVITY_HIDDEN = "com.droiddeck.launcher.XR_ACTIVITY_HIDDEN" private const val ACTION_TRACK_AUXILIARY = "com.droiddeck.launcher.TRACK_AUXILIARY" private const val ACTION_STOP_AUXILIARY = "com.droiddeck.launcher.STOP_AUXILIARY" private const val ACTION_AUXILIARY_EXITED = "com.droiddeck.launcher.AUXILIARY_EXITED" @@ -1617,6 +1635,12 @@ class SessionService : Service() { context.startService(Intent(context, SessionService::class.java).setAction(action)) } + fun setXrActivityVisible(context: Context, visible: Boolean) { + if (!SessionState.running) return + val action = if (visible) ACTION_XR_ACTIVITY_VISIBLE else ACTION_XR_ACTIVITY_HIDDEN + context.startService(Intent(context, SessionService::class.java).setAction(action)) + } + fun resume(context: Context) { if (!SessionState.running) return context.startService(Intent(context, SessionService::class.java).setAction(ACTION_RESUME)) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionVisibility.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionVisibility.kt new file mode 100644 index 000000000..33b456ecc --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionVisibility.kt @@ -0,0 +1,4 @@ +package com.droiddeck.launcher.session + +internal fun sessionHasVisibleSurface(panelVisible: Boolean, screenOn: Boolean, xrVisible: Boolean): Boolean = + xrVisible || (panelVisible && screenOn) diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionVisibilityTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionVisibilityTest.kt new file mode 100644 index 000000000..b35ea287e --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionVisibilityTest.kt @@ -0,0 +1,28 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionVisibilityTest { + @Test fun xrVisibilitySurvivesPanelHandoffAndPanelScreenState() { + for (panelVisible in listOf(false, true)) { + for (screenOn in listOf(false, true)) { + assertTrue(sessionHasVisibleSurface(panelVisible, screenOn, true)) + } + } + } + + @Test fun normalPanelStillRequiresAnInteractiveScreen() { + assertTrue(sessionHasVisibleSurface(true, true, false)) + assertFalse(sessionHasVisibleSurface(false, true, false)) + assertFalse(sessionHasVisibleSurface(true, false, false)) + assertFalse(sessionHasVisibleSurface(false, false, false)) + } + + @Test fun losingXrVisibilityReturnsToNormalSuspendRules() { + assertTrue(sessionHasVisibleSurface(false, false, true)) + assertFalse(sessionHasVisibleSurface(false, false, false)) + assertTrue(sessionHasVisibleSurface(true, true, false)) + } +} diff --git a/docs/development/quest-openxr.md b/docs/development/quest-openxr.md index 782ae5b09..88c142328 100644 --- a/docs/development/quest-openxr.md +++ b/docs/development/quest-openxr.md @@ -235,6 +235,42 @@ and the USB stay-awake setting introduced during testing is restored to off. The user requested pausing the goal until they can help with Steam sign-in, on-device Debugging tools, tracking and controllers. The goal is not complete. +### Resumed device work + +Steam sign-in and the on-device Debugging tools prerequisite are now satisfied. +The user's left Touch controller tracks in space and gives a short trigger +pulse in the native scene. The right controller also fails in Quest Home, so +two-controller validation remains blocked by a controller/Quest-side issue. +The native scene reports tracked head flags 15 and a roughly 8.333 ms display +period (120 Hz). This does not establish guest SteamVR image correctness. + +The selected imported Android/Linux drivers failed frame import. Selecting the +bundled Quest-patched Android driver and the Linux runtime default, disabling +Cardboard for the ordinary 2D panel, and restarting the app process restored +Steam's first frame and READY. These were combined configuration changes, not +an isolated single-driver comparison. Original preferences are backed up +locally. No uninstall or data reset was used. + +Valve's pinned ARM64 SteamVR package installed successfully. The guest driver +receives real per-eye texture handles and imports them on the Quest GPU, but +the correctness-first capture path rejects the unavailable producer fences. +The first owned-image test submitted zero projection images; it remains a +failure. The ordinary Wayland diagnostic can present SteamVR frames but does +not establish producer synchronization or image/pose association. + +The Vsync scheduler now rejects repeated predicted targets and PostPresent +uses the same atomically paired runtime tick/period instead of a fixed 60 Hz +interval. Host and actual Android CPU regression tests pass for 72/90/120 Hz, +throttling, prediction reuse, concurrent tick/period publication and restart. +The updated ARM64 driver links and was copied into the live runtime with its +SHA-256 verified. This does not yet establish sustained guest rendering. + +The 2D panel's onStop could also suspend the guest after the XR surface became +visible. XR visibility now has a separate service lease; panel transitions +cannot overwrite it, and normal suspend rules return when XR visibility is +lost. Focused visibility/background-policy tests pass. A signed full-app build +and live validation of this lifecycle fix are pending. + ## Remaining stages - Android/Linux eye-image bridge and frame ownership/synchronization. diff --git a/tools/build_quest_xr_tests.ps1 b/tools/build_quest_xr_tests.ps1 index d1090ae33..51b81e0ce 100644 --- a/tools/build_quest_xr_tests.ps1 +++ b/tools/build_quest_xr_tests.ps1 @@ -9,7 +9,7 @@ if (-not (Test-Path -LiteralPath $compiler -PathType Leaf)) { throw 'Android ARM Push-Location $repo try { New-Item -ItemType Directory -Path build\vr\android-tests -Force | Out-Null - foreach ($name in @('test_xr_tracking', 'test_xr_frames', 'send_xr_frame')) { + foreach ($name in @('test_xr_tracking', 'test_xr_frames', 'test_xr_vsync', 'send_xr_frame')) { & $compiler -std=c++17 -Wall -Wextra -Werror -static-libstdc++ -pthread "tools/vr/tests/$name.cpp" -o "build/vr/android-tests/$name" if ($LASTEXITCODE -ne 0) { throw "Android test compilation failed: $name" } } diff --git a/tools/vr/src/driver_droiddeck.cpp b/tools/vr/src/driver_droiddeck.cpp index e23320ae8..b9e58ef5d 100644 --- a/tools/vr/src/driver_droiddeck.cpp +++ b/tools/vr/src/driver_droiddeck.cpp @@ -19,6 +19,7 @@ #include "presenter.h" #include "xr_tracking_file.h" #include "xr_frame_math.h" +#include "xr_vsync_timing.h" #define DD_EXPORT extern "C" __attribute__((visibility("default"))) @@ -137,11 +138,12 @@ private: class Vsync { public: - static constexpr std::chrono::nanoseconds kInterval{16666667}; + static constexpr auto kInterval = XrVsyncTiming::kDefaultPeriod; static constexpr std::chrono::microseconds kLead{2000}; static void Start() { if (running_.exchange(true)) return; + timing_.Reset(); thread_ = std::thread([] { XrTrackingFile xrFile; auto next = std::chrono::steady_clock::now() + kInterval; @@ -150,19 +152,15 @@ public: DroidDeckXrTracking xr; if (xrFile.Read(&xr) && XrTrackingFile::Fresh(xr)) { interval = std::chrono::nanoseconds(xr.period_ns); - auto tick = std::chrono::steady_clock::time_point(std::chrono::nanoseconds(xr.predicted_ns)); auto now = std::chrono::steady_clock::now(); - if (xr.predicted_ns > 0 && tick > now && tick < now + interval * 3) next = tick; + next = timing_.Target(now, next, xr.predicted_ns, interval); } auto announce = next - kLead; std::this_thread::sleep_until(announce); auto now = std::chrono::steady_clock::now(); double offset = std::chrono::duration(next - now).count(); + timing_.Record(next, interval); VRServerDriverHost()->VsyncEvent(offset); - { - std::lock_guard lock(mutex_); - last_ = next; - } next += interval; now = std::chrono::steady_clock::now(); while (next <= now) next += interval; @@ -173,16 +171,14 @@ public: running_ = false; if (thread_.joinable()) thread_.join(); } - static std::chrono::steady_clock::time_point LastTick() { - std::lock_guard lock(mutex_); - return last_; + static std::chrono::steady_clock::time_point Deadline(uint32_t framesToThrottle) { + return timing_.Deadline(framesToThrottle); } private: static inline std::atomic running_{false}; static inline std::thread thread_; - static inline std::mutex mutex_; - static inline std::chrono::steady_clock::time_point last_{}; + static inline XrVsyncTiming timing_; }; class DirectMode : public IVRDriverDirectModeComponent { @@ -296,8 +292,7 @@ public: } void PostPresent(const Throttling_t *t) override { - auto last = Vsync::LastTick(); - auto until = last + Vsync::kInterval * (1 + (t ? t->nFramesToThrottle : 0)); + auto until = Vsync::Deadline(t ? t->nFramesToThrottle : 0); if (until > std::chrono::steady_clock::now()) std::this_thread::sleep_until(until); } diff --git a/tools/vr/src/xr_vsync_timing.h b/tools/vr/src/xr_vsync_timing.h new file mode 100644 index 000000000..da1b272d7 --- /dev/null +++ b/tools/vr/src/xr_vsync_timing.h @@ -0,0 +1,36 @@ +#pragma once +#include +#include +#include + +class XrVsyncTiming { +public: + using Clock = std::chrono::steady_clock; + static constexpr std::chrono::nanoseconds kDefaultPeriod{16666667}; + + void Reset() { Record({}, kDefaultPeriod); } + + Clock::time_point Target(Clock::time_point now, Clock::time_point scheduled, + int64_t predictedNs, std::chrono::nanoseconds period) const { + std::lock_guard lock(mutex_); + auto predicted = Clock::time_point(std::chrono::nanoseconds(predictedNs)); + return predictedNs > 0 && predicted > now && predicted > last_ && predicted < now + period * 3 + ? predicted : scheduled; + } + + void Record(Clock::time_point target, std::chrono::nanoseconds period) { + std::lock_guard lock(mutex_); + last_ = target; + period_ = period; + } + + Clock::time_point Deadline(uint32_t framesToThrottle) const { + std::lock_guard lock(mutex_); + return last_ + period_ * (1 + static_cast(framesToThrottle)); + } + +private: + mutable std::mutex mutex_; + Clock::time_point last_{}; + std::chrono::nanoseconds period_{kDefaultPeriod}; +}; diff --git a/tools/vr/tests/test_xr_vsync.cpp b/tools/vr/tests/test_xr_vsync.cpp new file mode 100644 index 000000000..03976f82c --- /dev/null +++ b/tools/vr/tests/test_xr_vsync.cpp @@ -0,0 +1,52 @@ +#include "../src/xr_vsync_timing.h" +#include +#include +#include +#include +#include +#include + +int main() { + using namespace std::chrono; + using Clock = XrVsyncTiming::Clock; + XrVsyncTiming timing; + assert(timing.Deadline(0) == Clock::time_point(XrVsyncTiming::kDefaultPeriod)); + auto target = Clock::time_point(seconds(1)); + for (int64_t periodNs : {13888889LL, 11111111LL, 8333333LL}) { + auto period = nanoseconds(periodNs); + timing.Record(target, period); + assert(timing.Deadline(0) == target + period); + assert(timing.Deadline(2) == target + period * 3); + assert(timing.Deadline(std::numeric_limits::max()) == target + period * 4294967296LL); + auto now = target - milliseconds(2); + auto scheduled = target + period; + assert(timing.Target(now, scheduled, 1000000000LL, period) == scheduled); + assert(timing.Target(now, scheduled, 0, period) == scheduled); + assert(timing.Target(now, scheduled, 900000000LL, period) == scheduled); + assert(timing.Target(now, scheduled, 2000000000LL, period) == scheduled); + auto prediction = target + period * 2; + auto predictedNs = duration_cast(prediction.time_since_epoch()).count(); + assert(timing.Target(now, scheduled, predictedNs, period) == prediction); + } + auto first = Clock::time_point(seconds(1)); + auto second = Clock::time_point(seconds(2)); + auto firstPeriod = nanoseconds(13888889); + auto secondPeriod = nanoseconds(8333333); + timing.Record(first, firstPeriod); + std::atomic finished{false}; + std::thread writer([&] { + for (int count = 0; count < 100000; count++) { + timing.Record(second, secondPeriod); + timing.Record(first, firstPeriod); + } + finished = true; + }); + do { + auto deadline = timing.Deadline(0); + assert(deadline == first + firstPeriod || deadline == second + secondPeriod); + } while (!finished); + writer.join(); + timing.Reset(); + assert(timing.Deadline(0) == Clock::time_point(XrVsyncTiming::kDefaultPeriod)); + std::puts("XR pacing validation passed: refresh rates, throttling, repeated predictions, atomic tick/period, restart"); +}