From 2ef2d4b53d76cf929cd3e22f5363260044fd5409 Mon Sep 17 00:00:00 2001 From: XxJSONDeruloxX Date: Wed, 14 Oct 2026 19:07:40 -0400 Subject: [PATCH] feat: validate Quest guest frame entrypoint --- .../com/droiddeck/launcher/QuestXrActivity.kt | 26 ++++++++-- docs/development/quest-openxr.md | 43 ++++++++++++++-- questxr/src/main/cpp/quest_xr.cpp | 26 ++++++---- tools/build_quest_xr_tests.ps1 | 19 +++++++ tools/deploy_quest_xr.ps1 | 19 ++++++- .../overlay/usr/local/bin/droiddeck-steamvr | 3 ++ tools/test_quest_frame_channel.py | 18 ++++++- tools/test_quest_xr.py | 42 ++++++++++------ tools/tests/test_quest_xr_validation.py | 49 +++++++++++++++++++ tools/vr/src/driver_droiddeck.cpp | 11 +++-- tools/vr/src/presenter.cpp | 10 ++-- tools/vr/src/xr_tracking_file.h | 11 +++++ tools/vr/tests/test_xr_tracking.cpp | 13 ++++- 13 files changed, 245 insertions(+), 45 deletions(-) create mode 100644 tools/build_quest_xr_tests.ps1 create mode 100644 tools/tests/test_quest_xr_validation.py diff --git a/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt b/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt index e7b8d4e89..195fb1d66 100644 --- a/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt @@ -15,8 +15,8 @@ class QuestXrActivity : NativeActivity() { @Volatile private var closing = false @Volatile private var steamVrStatus = SteamVr.Status.UNAVAILABLE override fun onCreate(savedInstanceState: Bundle?) { - if (intent.getBooleanExtra("guest_surface", false)) { - intent.putExtra("surface_test", true) + if (intent.getBooleanExtra("guest_surface", false) || intent.getBooleanExtra("guest_frames", false)) { + if (intent.getBooleanExtra("guest_surface", false)) intent.putExtra("surface_test", true) SessionService.resume(this) } super.onCreate(savedInstanceState) @@ -35,7 +35,26 @@ class QuestXrActivity : NativeActivity() { SessionService.setActivityVisible(this, surface != null) WaylandCompositor.nativeVsync(System.nanoTime()) check(WaylandCompositor.nativeWaitSurface(3000)) { "XR surface GPU handshake timed out" } - if (surface != null && intent.getBooleanExtra("start_steamvr", false) && !steamVrStarted) { + if (surface != null) startSteamVr() + } + + fun onXrActive(active: Boolean) { + if (active) { + check(CompositorHost.isStarted) { "DroidDeck compositor is not running" } + SessionService.resume(this) + WaylandCompositor.nativeSetCardboard(false, 1, 0.064f, 3f, 4f, 0f, 0f, 90f, 0f, 0f) + WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0) + WaylandCompositor.nativeSetZeroCopy(false) + CompositorHost.attachXr(null) + WaylandCompositor.nativeVsync(System.nanoTime()) + check(WaylandCompositor.nativeWaitSurface(3000)) { "XR frame GPU handshake timed out" } + } + SessionService.setActivityVisible(this, active) + if (active) startSteamVr() + } + + private fun startSteamVr() { + if (intent.getBooleanExtra("start_steamvr", false) && !steamVrStarted) { steamVrStarted = true Thread({ if (!SteamVr.run(this, "start")) Log.e("DroidDeckXR", "SteamVR start failed") @@ -57,7 +76,6 @@ class QuestXrActivity : NativeActivity() { override fun onDestroy() { closing = true - if (steamVrStarted) Thread({ SteamVr.run(this, "stop") }, "quest-steamvr-stop").start() super.onDestroy() } } diff --git a/docs/development/quest-openxr.md b/docs/development/quest-openxr.md index ad7438dde..53398a12b 100644 --- a/docs/development/quest-openxr.md +++ b/docs/development/quest-openxr.md @@ -128,8 +128,6 @@ The actual shared tracking file was read through the debuggable companion: 21 ms beyond the monotonic sample time. This confirms the IPC time-domain conversion on the headset. -## Remaining stages - ## Milestone 5: owned image and original render-pose protocol The opt-in `DROIDDECK_VR_PRESENT=questframes` presenter captures an owned RGBA @@ -154,8 +152,14 @@ With `frame_input=true`, the native frontend uses explicitly acquired GLES XR swapchain images, uploads receiver-owned pixels, and submits the captured per-eye poses/FOV. It omits stale images. `frame_verify=true` reads uploaded pixels back from the GPU and compares every byte. This mode is separate from -the asynchronous Android Surface diagnostic. It is not yet the normal Quest -Start flow and does not provide a zero-copy/performance-ready bridge. +the asynchronous Android Surface diagnostic. The main activity's explicit +`guest_frames=true` entrypoint detaches the desktop Surface, keeps the guest +active and starts the same SteamVR helper when requested. The Linux helper +selects the experimental presenter only when this private receiver exists. +`tools/test_quest_xr.py --guest --steamvr` uses that entrypoint and requires owned +source images/frame IDs to advance, not generic desktop presentations. It is +not yet the normal Quest Start flow and does not provide a zero-copy or +performance-ready bridge. Host tests cover image/pose pairing, rotation and asymmetric projection math, latest-frame replacement, copying before acknowledgement, private socket @@ -167,6 +171,7 @@ native target and companion APK also compile. These are host/build results, not headset GPU evidence. The Quest subsequently disappeared from ADB, so the new path cannot yet be deployed or validated on-device. +`tools/build_quest_xr_tests.ps1` builds the three Android test executables. `tools/test_quest_frame_channel.py --adb --native-tests ` runs the Android protocol test and a known-pattern sender under the companion's own UID. It checks the final source frame ID and GPU checksum. Add @@ -178,6 +183,36 @@ The failed full build at `7277386c` was a Debian archive HTTP package 404. CI no bootstraps CA certificates, uses HTTPS for compiler packages and retries downloads while retaining APT signature/package-hash verification. +The signed companion for this stage is retained locally under ignored +`build/questxr-owned-frames-signed.apk`. Deployment supports `-SignOnly` while +disconnected; before installing it now requires an authorized, explicitly +selected Quest 3, rather than risking an unrelated Android device. + +### Reconnected device evidence + +Full CI run 38649944478 passed at `040d31c1` and its APK was downloaded and +release-signed locally. After USB reconnection, the updated diagnostic companion +installed successfully. The private image test passed on the actual Quest: +144 complete frames received, zero rejections, 137 uploaded, final source and +GPU-verified frame ID 144, and expected GPU checksum 1815165381. Every byte of +the owned image matched GLES readback. The protocol unit executable also passed +on Android. These prove image ownership/transport and real GPU upload, not +Linux producer synchronization or SteamVR gameplay. + +A later repeat passed both tracking and frame protocol unit executables on +Android but lost the sustained session at image 138. The native tracking test +also failed: 9 rendered frames, session stopped to IDLE, head flags 7 and no +active controllers. State reports still show Debugging tools off and no running +guest. Steam sign-in is pending. Test failures are retained, not reclassified +as passes. The user cannot attend these prerequisites yet and requested a pause +after the remaining independent work. + +The driver now marks position/orientation that are merely valid but untracked +as out-of-range and rejects nonfinite or non-unit poses. The importer requests +Vulkan 1.2 for its core image-format-list use. Focused Kotlin tests and the new +validation classifier tests pass. The broad Linux suite had one unrelated +AppImage log-tail failure under WSL; the full CI suite passed at `040d31c1`. + ## Remaining stages - Android/Linux eye-image bridge and frame ownership/synchronization. diff --git a/questxr/src/main/cpp/quest_xr.cpp b/questxr/src/main/cpp/quest_xr.cpp index b5d35959b..f778457b1 100644 --- a/questxr/src/main/cpp/quest_xr.cpp +++ b/questxr/src/main/cpp/quest_xr.cpp @@ -113,7 +113,7 @@ public: void health(const char *error = "") { int steamvrState = -1; - if (guestSurface_ && statusCallback_) { + if ((guestSurface_ || guestFramesInput_) && statusCallback_) { steamvrState = env_->CallIntMethod(app_->activity->clazz, statusCallback_); if (env_->ExceptionCheck()) { env_->ExceptionClear(); steamvrState = -1; } } @@ -161,13 +161,14 @@ private: bool running_ = false, exit_ = false, surfaceSupported_ = false, quitRequested_ = false; bool surfaceTest_ = false; bool guestSurface_ = false, guestAttached_ = false; - bool frameInput_ = false, verifyPixels_ = false; + bool frameInput_ = false, verifyPixels_ = false, guestFramesInput_ = false; XrFrameReceiver receiver_; DroidDeckXrFrameHeader sourceHeader_{}; uint64_t uploadedFrames_ = 0, verifiedFrame_ = 0; uint32_t gpuChecksum_ = 0; GLuint sourceTexture_ = 0, sourceProgram_ = 0; jmethodID surfaceCallback_ = nullptr, frameCallback_ = nullptr; + jmethodID activeCallback_ = nullptr; jmethodID statusCallback_ = nullptr; jobject androidJavaSurface_ = nullptr; DroidDeckXrTracking *tracking_ = nullptr; @@ -223,9 +224,15 @@ private: env_->DeleteLocalRef(extra); extra = env_->NewStringUTF("guest_surface"); guestSurface_ = env_->CallBooleanMethod(intent, getBoolean, extra, JNI_FALSE); - if (guestSurface_) { - surfaceTest_ = true; - surfaceCallback_ = env_->GetMethodID(activityClass, "onXrSurface", "(Landroid/view/Surface;II)V"); + env_->DeleteLocalRef(extra); + extra = env_->NewStringUTF("guest_frames"); + guestFramesInput_ = env_->CallBooleanMethod(intent, getBoolean, extra, JNI_FALSE); + if (guestFramesInput_) frameInput_ = true; + if (guestSurface_ || guestFramesInput_) { + if (guestSurface_) { + surfaceTest_ = true; + surfaceCallback_ = env_->GetMethodID(activityClass, "onXrSurface", "(Landroid/view/Surface;II)V"); + } else activeCallback_ = env_->GetMethodID(activityClass, "onXrActive", "(Z)V"); frameCallback_ = env_->GetMethodID(activityClass, "onXrFrame", "(J)J"); statusCallback_ = env_->GetMethodID(activityClass, "onXrStatus", "()I"); if (env_->ExceptionCheck()) { env_->ExceptionClear(); throw std::runtime_error("XR activity bridge methods unavailable"); } @@ -488,9 +495,10 @@ private: } void attachGuest(bool attach) { - if (!guestSurface_ || attach == guestAttached_) return; - env_->CallVoidMethod(app_->activity->clazz, surfaceCallback_, attach ? androidJavaSurface_ : nullptr, - eyes_[0].width * 2, eyes_[0].height); + if ((!guestSurface_ && !guestFramesInput_) || attach == guestAttached_) return; + if (guestFramesInput_) env_->CallVoidMethod(app_->activity->clazz, activeCallback_, static_cast(attach)); + else env_->CallVoidMethod(app_->activity->clazz, surfaceCallback_, attach ? androidJavaSurface_ : nullptr, + eyes_[0].width * 2, eyes_[0].height); if (env_->ExceptionCheck()) { env_->ExceptionDescribe(); env_->ExceptionClear(); throw std::runtime_error("Guest surface handshake failed"); } @@ -717,7 +725,7 @@ private: XrView views[2]{{XR_TYPE_VIEW}, {XR_TYPE_VIEW}}; check(xrLocateViews(session_, &locate, &viewState, 2, &count, views), "xrLocateViews"); publishTracking(views, frameState.predictedDisplayTime); - if (guestSurface_ && guestAttached_) { + if ((guestSurface_ || guestFramesInput_) && guestAttached_) { timespec now{}; clock_gettime(CLOCK_MONOTONIC, &now); guestFrames_ = env_->CallLongMethod(app_->activity->clazz, frameCallback_, (jlong)now.tv_sec * 1000000000LL + now.tv_nsec); if (env_->ExceptionCheck()) { env_->ExceptionClear(); throw std::runtime_error("Guest frame callback failed"); } diff --git a/tools/build_quest_xr_tests.ps1 b/tools/build_quest_xr_tests.ps1 new file mode 100644 index 000000000..d1090ae33 --- /dev/null +++ b/tools/build_quest_xr_tests.ps1 @@ -0,0 +1,19 @@ +param( + [string]$Sdk = "$env:USERPROFILE\android-sdk", + [string]$NdkVersion = '27.3.13750724' +) +$ErrorActionPreference = 'Stop' +$repo = Split-Path $PSScriptRoot -Parent +$compiler = Join-Path $Sdk "ndk\$NdkVersion\toolchains\llvm\prebuilt\windows-x86_64\bin\aarch64-linux-android26-clang++.cmd" +if (-not (Test-Path -LiteralPath $compiler -PathType Leaf)) { throw 'Android ARM64 compiler is unavailable' } +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')) { + & $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" } + } + Write-Output "Built Android test executables: $(Join-Path $repo 'build\vr\android-tests')" +} finally { + Pop-Location +} diff --git a/tools/deploy_quest_xr.ps1 b/tools/deploy_quest_xr.ps1 index 505a91ce0..57a3c1a7e 100644 --- a/tools/deploy_quest_xr.ps1 +++ b/tools/deploy_quest_xr.ps1 @@ -4,7 +4,9 @@ param( [string]$Sdk = "$env:USERPROFILE\android-sdk", [string]$NdkVersion = '27.3.13750724', [string]$BuildTools = '35.0.0', - [string]$Apk + [string]$Apk, + [string]$Serial, + [switch]$SignOnly ) $ErrorActionPreference = 'Stop' function Invoke-Checked([string]$Program, [string[]]$Arguments) { @@ -29,7 +31,20 @@ try { $certificate = Invoke-Checked "$tools\apksigner.bat" @('verify', '--print-certs', $signed) $expected = (Get-Content keystore\release-signer.sha256 -Raw).Trim() if (-not ($certificate -match "certificate SHA-256 digest: $expected")) { throw 'Unexpected signing certificate' } - Invoke-Checked $Adb @('install', '--no-incremental', '-r', $signed) + if (-not $SignOnly) { + $connected = @(Invoke-Checked $Adb @('devices') | ForEach-Object { + if ($_ -match '^(\S+)\s+device\s*$') { $matches[1] } + }) + if (-not $Serial) { + if ($connected.Count -ne 1) { throw 'Specify -Serial with exactly one connected, authorized Quest target' } + $Serial = $connected[0] + } + if ($Serial -notin $connected) { throw 'The selected headset is not connected and authorized' } + $model = (Invoke-Checked $Adb @('-s', $Serial, 'shell', 'getprop', 'ro.product.model') | Out-String).Trim() + if ($model -notmatch '^Quest[ _]3$') { throw "Refusing Quest 3 deployment to model: $model" } + Invoke-Checked $Adb @('-s', $Serial, 'install', '--no-incremental', '-r', $signed) + } + Write-Output "Verified signed APK: $(Join-Path $repo $signed)" } finally { Pop-Location } diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamvr b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamvr index 722769699..b11e88c87 100644 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamvr +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamvr @@ -75,6 +75,9 @@ JSON export VK_LAYER_PATH="$layer${VK_LAYER_PATH:+:$VK_LAYER_PATH}" export VK_INSTANCE_LAYERS="VK_LAYER_DROIDDECK_vr${VK_INSTANCE_LAYERS:+:$VK_INSTANCE_LAYERS}" export VK_LOADER_LAYERS_DISABLE="${VK_LOADER_LAYERS_DISABLE:-~implicit~}" + if [ -S /run/droiddeck/droiddeck-xr-frames ] && [ -z "${DROIDDECK_VR_PRESENT:-}" ]; then + export DROIDDECK_VR_PRESENT=questframes + fi shims=$state/shims mkdir -p "$shims" diff --git a/tools/test_quest_frame_channel.py b/tools/test_quest_frame_channel.py index a21322e38..e35aef65e 100644 --- a/tools/test_quest_frame_channel.py +++ b/tools/test_quest_frame_channel.py @@ -25,7 +25,7 @@ def main(): return subprocess.run([args.adb, '-s', serial, *command], check=True, capture_output=True, text=True, timeout=30).stdout - for name in ('test_xr_frames', 'send_xr_frame'): + for name in ('test_xr_tracking', 'test_xr_frames', 'send_xr_frame'): binary = args.native_tests / name if not binary.is_file(): parser.error(f'Missing Android executable: {binary}') @@ -33,7 +33,8 @@ def main(): adb('push', str(binary), remote) adb('shell', 'run-as', package, 'cp', remote, f'./{name}') adb('shell', 'run-as', package, 'chmod', '700', f'./{name}') - unit_output = adb('shell', 'run-as', package, './test_xr_frames', './files') + unit_output = adb('shell', 'run-as', package, './test_xr_tracking', './files') + unit_output += adb('shell', 'run-as', package, './test_xr_frames', './files') adb('shell', 'am', 'force-stop', package) adb('shell', 'rm', '-f', health_path) failure = None @@ -61,12 +62,25 @@ def main(): time.sleep(0.2) except (subprocess.CalledProcessError, subprocess.TimeoutExpired, json.JSONDecodeError) as error: failure = f'Frame channel operation failed: {error}' + if isinstance(error, subprocess.CalledProcessError) and error.stderr: + failure += f' {error.stderr.strip()}' finally: + try: + sample = json.loads(adb('shell', 'cat', health_path)) + if not samples or sample != samples[-1]: + samples.append(sample) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, json.JSONDecodeError): + pass try: adb('shell', 'am', 'broadcast', '-a', 'com.oculus.vrpowermanager.prox_far') adb('shell', 'am', 'force-stop', package) except (subprocess.CalledProcessError, subprocess.TimeoutExpired): failure = failure or 'Could not restore headset proximity after testing' + try: + log = adb('logcat', '-d', '-s', 'DroidDeckXR:I', 'AndroidRuntime:E') + (args.output / 'logcat.txt').write_text(log, encoding='utf-8') + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + pass latest = samples[-1] if samples else {} if not failure: if latest.get('source') != 'frames' or latest.get('receivedFrames') != sent.get('sent'): diff --git a/tools/test_quest_xr.py b/tools/test_quest_xr.py index de71d6a8c..54efb97e5 100644 --- a/tools/test_quest_xr.py +++ b/tools/test_quest_xr.py @@ -8,6 +8,29 @@ from pathlib import Path from adb_device import resolve_serial +def validate_samples(samples, require_controllers=False, guest=False, steamvr=False, surface=False): + if len(samples) < 2: + return 'No advancing telemetry; check runtime initialization and headset wake/proximity' + if samples[-1]['rendered'] - samples[0]['rendered'] < 72: + return 'Fewer than 72 additional stereo frames were submitted' + if not any(sample['sessionState'] == 5 and sample['headFlags'] & 15 == 15 for sample in samples): + return 'No focused session with tracked 6DoF position and orientation' + if require_controllers and not any(all(sample['controllersActive']) for sample in samples): + return 'Both controllers did not become active' + if steamvr and samples[-1].get('uploadedFrames', 0) - samples[0].get('uploadedFrames', 0) < 72: + return 'No advancing owned SteamVR images; XR submissions alone are insufficient' + if steamvr and samples[-1].get('sourceFrameId', 0) <= samples[0].get('sourceFrameId', 0): + return 'SteamVR source frame IDs did not advance' + if guest and not steamvr and samples[-1].get('guestFrames', 0) - samples[0].get('guestFrames', 0) < 72: + return 'No advancing guest GPU presentation; submitting an XR layer alone is insufficient' + if steamvr and samples[-1].get('steamvrState') != 4: + return 'SteamVR did not report RUNNING' + expected = 'frames' if steamvr else 'guest' if guest else 'surface' if surface else 'native' + if samples[-1].get('source') != expected: + return 'Wrong projection source' + return None + + def main(): parser = argparse.ArgumentParser(description='Validate immersive DroidDeck XR on a connected Quest') parser.add_argument('--adb', default=os.environ.get('ADB', 'adb')) @@ -24,6 +47,8 @@ def main(): parser.error('--seconds must be between 5 and 600') if args.steamvr and not args.guest: parser.error('--steamvr requires --guest') + if args.steamvr and args.surface: + parser.error('--steamvr uses owned frames and cannot use --surface') serial = resolve_serial(args.adb, args.serial) args.output.mkdir(parents=True, exist_ok=True) @@ -42,7 +67,7 @@ def main(): if args.surface: launch += ['--ez', 'surface_test', 'true'] if args.guest: - launch += ['--ez', 'guest_surface', 'true'] + launch += ['--ez', 'guest_frames' if args.steamvr else 'guest_surface', 'true'] if args.steamvr: launch += ['--ez', 'start_steamvr', 'true'] samples = [] @@ -75,20 +100,7 @@ def main(): except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: log = str(error) (args.output / 'logcat.txt').write_text(log, encoding='utf-8') - if len(samples) < 2: - failure = failure or 'No advancing telemetry; check runtime initialization and headset wake/proximity' - elif samples[-1]['rendered'] - samples[0]['rendered'] < 72: - failure = failure or 'Fewer than 72 additional stereo frames were submitted' - elif not any(sample['sessionState'] == 5 and sample['headFlags'] & 15 == 15 for sample in samples): - failure = failure or 'No focused session with tracked 6DoF position and orientation' - elif args.require_controllers and not any(all(sample['controllersActive']) for sample in samples): - failure = failure or 'Both controllers did not become active' - elif args.guest and samples[-1].get('guestFrames', 0) - samples[0].get('guestFrames', 0) < 72: - failure = failure or 'No advancing guest GPU presentation; submitting an XR layer alone is insufficient' - elif args.steamvr and samples[-1].get('steamvrState') != 4: - failure = failure or 'SteamVR did not report RUNNING' - elif samples[-1].get('source') != ('guest' if args.guest else 'surface' if args.surface else 'native'): - failure = failure or 'Wrong projection source' + failure = failure or validate_samples(samples, args.require_controllers, args.guest, args.steamvr, args.surface) report = {'ok': failure is None, 'error': failure, 'samples': samples} (args.output / 'report.json').write_text(json.dumps(report, indent=2), encoding='utf-8') print(json.dumps({'ok': report['ok'], 'error': failure, 'samples': len(samples), diff --git a/tools/tests/test_quest_xr_validation.py b/tools/tests/test_quest_xr_validation.py new file mode 100644 index 000000000..140d988b2 --- /dev/null +++ b/tools/tests/test_quest_xr_validation.py @@ -0,0 +1,49 @@ +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from test_quest_xr import validate_samples + + +class QuestXrValidationTest(unittest.TestCase): + def samples(self, source='native'): + return [{'rendered': count, 'sessionState': 5, 'headFlags': 15, 'source': source, + 'controllersActive': [False, False], 'guestFrames': count, + 'uploadedFrames': count, 'sourceFrameId': count + 1, 'steamvrState': 4} + for count in (0, 144)] + + def test_native_and_surface_are_separate_diagnostics(self): + self.assertIsNone(validate_samples(self.samples())) + self.assertIsNone(validate_samples(self.samples('surface'), surface=True)) + self.assertEqual('Wrong projection source', validate_samples(self.samples('surface'))) + + def test_position_must_be_tracked_not_merely_valid(self): + samples = self.samples() + for sample in samples: + sample['headFlags'] = 7 + self.assertIn('tracked 6DoF', validate_samples(samples)) + + def test_optional_controllers_cannot_be_claimed_as_tested(self): + self.assertIn('controllers', validate_samples(self.samples(), require_controllers=True)) + + def test_steamvr_requires_owned_images_and_source_frame_ids(self): + samples = self.samples('frames') + self.assertIsNone(validate_samples(samples, guest=True, steamvr=True)) + samples[-1]['uploadedFrames'] = 0 + self.assertIn('owned SteamVR images', validate_samples(samples, guest=True, steamvr=True)) + samples[-1]['uploadedFrames'] = 144 + samples[-1]['sourceFrameId'] = samples[0]['sourceFrameId'] + self.assertIn('frame IDs', validate_samples(samples, guest=True, steamvr=True)) + + def test_running_process_alone_is_not_success(self): + samples = self.samples('frames') + samples[-1]['rendered'] = 0 + self.assertIn('stereo frames', validate_samples(samples, guest=True, steamvr=True)) + samples[-1]['rendered'] = 144 + samples[-1]['steamvrState'] = 3 + self.assertIn('RUNNING', validate_samples(samples, guest=True, steamvr=True)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/vr/src/driver_droiddeck.cpp b/tools/vr/src/driver_droiddeck.cpp index f216ade26..e23320ae8 100644 --- a/tools/vr/src/driver_droiddeck.cpp +++ b/tools/vr/src/driver_droiddeck.cpp @@ -425,10 +425,13 @@ public: if (id_ == k_unTrackedDeviceIndexInvalid) return; DriverPose_t pose{}; pose.qWorldFromDriverRotation.w = pose.qDriverFromHeadRotation.w = 1; - pose.qRotation = {hand.pose.orientation[3], hand.pose.orientation[0], hand.pose.orientation[1], hand.pose.orientation[2]}; - for (int axis = 0; axis < 3; axis++) pose.vecPosition[axis] = hand.pose.position[axis]; + pose.qRotation.w = 1; pose.deviceIsConnected = hand.active; - pose.poseIsValid = hand.active && (hand.pose.flags & 3u) == 3u; + pose.poseIsValid = hand.active && XrTrackingFile::Tracked(hand.pose); + if (pose.poseIsValid) { + pose.qRotation = {hand.pose.orientation[3], hand.pose.orientation[0], hand.pose.orientation[1], hand.pose.orientation[2]}; + for (int axis = 0; axis < 3; axis++) pose.vecPosition[axis] = hand.pose.position[axis]; + } pose.result = pose.poseIsValid ? TrackingResult_Running_OK : TrackingResult_Running_OutOfRange; pose_ = pose; VRServerDriverHost()->TrackedDevicePoseUpdated(id_, pose, sizeof(pose)); @@ -580,7 +583,7 @@ public: bool freshXr = xr_.Read(&xr) && XrTrackingFile::Fresh(xr); if (freshXr || xrMode_) { p.deviceIsConnected = true; - p.poseIsValid = freshXr && (xr.head.flags & 3u) == 3u; + p.poseIsValid = freshXr && XrTrackingFile::Tracked(xr.head); p.result = p.poseIsValid ? TrackingResult_Running_OK : TrackingResult_Running_OutOfRange; if (p.poseIsValid) { p.qRotation = {xr.head.orientation[3], xr.head.orientation[0], xr.head.orientation[1], xr.head.orientation[2]}; diff --git a/tools/vr/src/presenter.cpp b/tools/vr/src/presenter.cpp index 87c259db5..c1dafecd0 100644 --- a/tools/vr/src/presenter.cpp +++ b/tools/vr/src/presenter.cpp @@ -183,7 +183,8 @@ private: started_ = true; thread_ = std::thread([this] { try { Run(); } - catch (const std::exception &error) { Log("presenter: %s", error.what()); Fail(); } + catch (const std::exception &error) { Log("presenter: %s", error.what()); } + Cleanup(); Fail(); }); } } @@ -240,6 +241,9 @@ private: lastLog = now; } } + } + + void Cleanup() { if (dev_) vk_.DeviceWaitIdle(dev_); DestroyCapture(); while (!textures_.empty()) Forget(textures_.back().handle); @@ -253,7 +257,7 @@ private: } if (surface_) vk_.DestroySurfaceKHR(inst_, surface_, nullptr); if (inst_) vk_.DestroyInstance(inst_, nullptr); - Fail(); + surface_ = VK_NULL_HANDLE; inst_ = VK_NULL_HANDLE; } std::chrono::microseconds guard_{4000}; @@ -360,7 +364,7 @@ private: VK_GLOBAL(X) #undef X const char *iexts[] = {"VK_KHR_surface", wayland_ ? "VK_KHR_wayland_surface" : "VK_KHR_xlib_surface"}; - VkApplicationInfo app = {VK_STRUCTURE_TYPE_APPLICATION_INFO, nullptr, "droiddeck-vr-presenter", 1, nullptr, 0, VK_API_VERSION_1_1}; + VkApplicationInfo app = {VK_STRUCTURE_TYPE_APPLICATION_INFO, nullptr, "droiddeck-vr-presenter", 1, nullptr, 0, VK_API_VERSION_1_2}; VkInstanceCreateInfo ici = {VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO, nullptr, 0, &app, 0, nullptr, frameChannel_ ? 0u : 2u, frameChannel_ ? nullptr : iexts}; VkResult r = vk_.CreateInstance(&ici, nullptr, &inst_); diff --git a/tools/vr/src/xr_tracking_file.h b/tools/vr/src/xr_tracking_file.h index c8e442e91..308cfcc59 100644 --- a/tools/vr/src/xr_tracking_file.h +++ b/tools/vr/src/xr_tracking_file.h @@ -1,6 +1,7 @@ #pragma once #include "xr_tracking.h" #include +#include #include #include #include @@ -47,6 +48,16 @@ public: && packet.period_ns <= 50000000LL && packet.eyes[0].width > 0 && packet.eyes[0].width <= 8192 && packet.eyes[0].height > 0 && packet.eyes[0].height <= 8192; } + static bool Tracked(const DroidDeckXrPose &pose) { + if ((pose.flags & 15u) != 15u) return false; + float lengthSquared = 0; + for (float value : pose.orientation) { + if (!std::isfinite(value)) return false; + lengthSquared += value * value; + } + for (float value : pose.position) if (!std::isfinite(value)) return false; + return std::abs(lengthSquared - 1.f) < 0.01f; + } private: DroidDeckXrTracking *mapping_ = nullptr; std::mutex mutex_; diff --git a/tools/vr/tests/test_xr_tracking.cpp b/tools/vr/tests/test_xr_tracking.cpp index be3354584..e9fe5251c 100644 --- a/tools/vr/tests/test_xr_tracking.cpp +++ b/tools/vr/tests/test_xr_tracking.cpp @@ -1,9 +1,11 @@ #include "../src/xr_tracking_file.h" #include #include +#include -int main() { - char filename[] = "/tmp/droiddeck-xr-test-XXXXXX"; +int main(int argc, char **argv) { + char filename[256]; + std::snprintf(filename, sizeof(filename), "%s/droiddeck-xr-test-XXXXXX", argc > 1 ? argv[1] : "/tmp"); int descriptor = mkstemp(filename); assert(descriptor >= 0); assert(ftruncate(descriptor, sizeof(DroidDeckXrTracking)) == 0); @@ -20,6 +22,13 @@ int main() { writer->head.orientation[3] = 1; writer->head.position[1] = 1.6f; writer->head.flags = 15; assert(reader.Read(&packet)); assert(XrTrackingFile::Fresh(packet)); assert(packet.head.position[1] == 1.6f); + assert(XrTrackingFile::Tracked(packet.head)); + auto invalidPose = packet.head; invalidPose.flags = 7; + assert(!XrTrackingFile::Tracked(invalidPose)); + invalidPose = packet.head; invalidPose.orientation[3] = 0; + assert(!XrTrackingFile::Tracked(invalidPose)); + invalidPose = packet.head; invalidPose.position[0] = std::numeric_limits::quiet_NaN(); + assert(!XrTrackingFile::Tracked(invalidPose)); __atomic_store_n(&writer->seq, 1u, __ATOMIC_RELEASE); assert(!reader.Read(&packet)); __atomic_store_n(&writer->seq, 2u, __ATOMIC_RELEASE);