fix: pace and retain Quest XR sessions

This commit is contained in:
XxJSONDeruloxX committed 2026-10-14 20:47:24 -04:00
1 parent dbd277d5bd
commit 8bf7ba7eca
10 files changed
+202 -24

No files matched your search

+2
View File
@@ -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
@@ -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()
}
}
@@ -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))
@@ -0,0 +1,4 @@
package com.droiddeck.launcher.session
internal fun sessionHasVisibleSurface(panelVisible: Boolean, screenOn: Boolean, xrVisible: Boolean): Boolean =
xrVisible || (panelVisible && screenOn)
@@ -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))
}
}
+36
View File
@@ -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.
+1 -1
View File
@@ -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" }
}
+9 -14
View File
@@ -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<double>(next - now).count();
timing_.Record(next, interval);
VRServerDriverHost()->VsyncEvent(offset);
{
std::lock_guard<std::mutex> 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<std::mutex> lock(mutex_);
return last_;
static std::chrono::steady_clock::time_point Deadline(uint32_t framesToThrottle) {
return timing_.Deadline(framesToThrottle);
}
private:
static inline std::atomic<bool> 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);
}
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <chrono>
#include <cstdint>
#include <mutex>
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<std::mutex> 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<std::mutex> lock(mutex_);
last_ = target;
period_ = period;
}
Clock::time_point Deadline(uint32_t framesToThrottle) const {
std::lock_guard<std::mutex> lock(mutex_);
return last_ + period_ * (1 + static_cast<int64_t>(framesToThrottle));
}
private:
mutable std::mutex mutex_;
Clock::time_point last_{};
std::chrono::nanoseconds period_{kDefaultPeriod};
};
+52
View File
@@ -0,0 +1,52 @@
#include "../src/xr_vsync_timing.h"
#include <atomic>
#include <cassert>
#include <cstdio>
#include <initializer_list>
#include <limits>
#include <thread>
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<uint32_t>::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<nanoseconds>(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<bool> 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");
}