mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-13 03:51:10 +00:00
fix: pace and retain Quest XR sessions
This commit is contained in:
1 parent
dbd277d5bd
commit
8bf7ba7eca
10 files changed
+202
-24
No files matched your search
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user