Compare commits

...
Author SHA1 Message Date
XxJSONDeruloxX e9a199285e perf: share Quest frames through GPU buffers 2026-10-15 20:25:21 -04:00
XxJSONDeruloxX d8de6895c1 fix: validate SteamVR preference backups 2026-10-15 20:14:22 -04:00
XxJSONDeruloxX fd7cb249b1 perf: request sustained OpenXR performance 2026-10-15 19:27:30 -04:00
XxJSONDeruloxX 21742403a4 fix: preserve OpenVR shared image layouts 2026-10-15 19:16:11 -04:00
XxJSONDeruloxX 46335b7871 fix: route VR scene focus through Gamescope 2026-10-15 19:03:59 -04:00
XxJSONDeruloxX 5e9ebbc7df fix: harden Quest VR compatibility 2026-10-15 18:55:23 -04:00
XxJSONDeruloxX 92f2696a0c fix: use SteamVR compositor on Quest 2026-10-15 18:21:39 -04:00
XxJSONDeruloxX 59f752c70d fix: provide Quest seated tracking origin 2026-10-15 17:36:02 -04:00
XxJSONDeruloxX 828374f236 feat: accept sRGB compositor frames 2026-10-15 16:55:42 -04:00
XxJSONDeruloxX 0a6e08d923 docs: record Quest VR color validation 2026-10-15 16:29:51 -04:00
XxJSONDeruloxX 2f5537acef perf: compress Quest XR captures over adb 2026-10-15 16:27:39 -04:00
XxJSONDeruloxX 15ef4b064b fix: preserve linear color in Quest VR frames 2026-10-15 16:25:53 -04:00
XxJSONDeruloxX 52d351908f feat: capture submitted Quest XR eye frames 2026-10-15 16:08:10 -04:00
XxJSONDeruloxX 19a8beea52 fix: close stale Quest XR sessions before reentry 2026-10-15 15:31:59 -04:00
XxJSONDeruloxX 65c6ca3552 fix: map Quest menu button to SteamVR dashboard 2026-10-15 15:05:48 -04:00
XxJSONDeruloxX 220442d634 feat: launch flat games through native SteamVR overlays 2026-10-15 14:05:16 -04:00
XxJSONDeruloxX d344b7dcba fix: harden Quest SteamVR frame transport 2026-10-15 13:53:00 -04:00
XxJSONDeruloxX fd6111fc69 feat: separate SteamVR and Cardboard tabs 2026-10-15 12:16:39 -04:00
XxJSONDeruloxX 389bef5ad3 feat: add Quest SteamVR to the standard app 2026-10-15 12:09:25 -04:00
73 changed files with 5886 additions and 289 deletions

No files matched your search

+20 -2
View File
@@ -193,7 +193,7 @@ jobs:
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu >/dev/null
sudo apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libvulkan1 mesa-vulkan-drivers >/dev/null
D=app/src/main/assets/linuxfs
mkdir -p "$D"
aarch64-linux-gnu-g++ -shared -fPIC -O2 -Wall -Wno-attributes -Wno-nonnull-compare \
@@ -396,6 +396,24 @@ jobs:
set -euo pipefail
bash tools/linuxfs/check-preloads.sh app/src/main/assets/linuxfs
- name: Check Quest tracking IPC
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 tools/vr/tests/test_xr_touch.cpp -o /tmp/test-xr-touch
/tmp/test-xr-touch
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
cc -Itools/vr/third_party/wayland -c app/src/main/cpp/waylandcomp/generated/xdg-shell-protocol.c -o /tmp/test-xdg-shell.o
g++ -std=c++17 -Wall -Wextra -Werror -Wno-unused-parameter -Wno-missing-field-initializers -pthread -Ibuild/vr/include \
-Itools/vr/third_party/wayland -Iapp/src/main/cpp/waylandcomp/generated \
tools/vr/tests/test_presenter_shutdown.cpp tools/vr/src/presenter.cpp /tmp/test-wayland-stub.o /tmp/test-xdg-shell.o \
-ldl -o /tmp/test-presenter-shutdown
timeout 10 /tmp/test-presenter-shutdown
- name: Rust toolchain for the store engines (aarch64-linux-android)
id: rust
run: |
@@ -429,7 +447,7 @@ jobs:
path: |
app/.cxx
app/build/intermediates/cxx
key: cxx-${{ env.NDK_VERSION }}-${{ hashFiles('app/src/main/cpp/**') }}
key: cxx-${{ env.NDK_VERSION }}-${{ hashFiles('app/src/main/cpp/**', 'tools/vr/src/xr_*.h') }}
restore-keys: |
cxx-${{ env.NDK_VERSION }}-
+2
View File
@@ -150,6 +150,7 @@ android {
}
buildFeatures {
prefab true
compose true
buildConfig true
}
@@ -225,6 +226,7 @@ if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
tasks.named('preBuild').configure { dependsOn(buildRustStores) }
dependencies {
implementation 'org.khronos.openxr:openxr_loader_for_android:1.1.49'
implementation 'com.github.luben:zstd-jni:1.5.2-3@aar'
implementation 'org.apache.commons:commons-compress:1.27.1'
implementation 'org.tukaani:xz:1.10'
+13
View File
@@ -22,6 +22,7 @@
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="true" />
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
<uses-feature android:name="android.hardware.vr.headtracking" android:version="1" android:required="false" />
<application
android:name=".App"
@@ -37,6 +38,18 @@
android:isGame="true">
<meta-data android:name="android.game_mode_config" android:resource="@xml/game_mode_config" />
<activity android:name=".QuestXrActivity" android:exported="true" android:launchMode="singleTask"
android:permission="android.permission.DUMP"
android:resizeableActivity="false" android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|keyboard|navigation|uiMode"
android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen">
<meta-data android:name="android.app.lib_name" android:value="droiddeckxr" />
<intent-filter>
<action android:name="com.droiddeck.launcher.XR" />
<category android:name="com.oculus.intent.category.VR" />
<category android:name="org.khronos.openxr.intent.category.IMMERSIVE_HMD" />
</intent-filter>
</activity>
<activity
android:name=".MainActivity"
+1
View File
@@ -30,6 +30,7 @@ string(SUBSTRING "${WINFG_CHAIN_HASH_FULL}" 0 12 WINFG_CHAIN_HASH)
message(STATUS "win-fg chain: ${WINFG_UPSTREAM} (src ${WINFG_CHAIN_HASH})")
add_subdirectory(waylandcomp)
add_subdirectory(questxr)
add_library(sdnative SHARED sdnative/sdnative.c)
target_link_libraries(sdnative log)
+13
View File
@@ -0,0 +1,13 @@
cmake_minimum_required(VERSION 3.22.1)
project(droiddeckxr C CXX)
find_package(OpenXR REQUIRED CONFIG)
add_library(droiddeckxr_glue STATIC ${ANDROID_NDK}/sources/android/native_app_glue/android_native_app_glue.c)
target_include_directories(droiddeckxr_glue PUBLIC ${ANDROID_NDK}/sources/android/native_app_glue)
add_library(droiddeckxr SHARED quest_xr.cpp)
target_compile_features(droiddeckxr PRIVATE cxx_std_17)
target_compile_definitions(droiddeckxr PRIVATE XR_USE_PLATFORM_ANDROID XR_USE_GRAPHICS_API_OPENGL_ES XR_USE_TIMESPEC)
target_compile_options(droiddeckxr PRIVATE -Wall -Wextra -Wno-missing-field-initializers -Werror=return-type)
target_link_options(droiddeckxr PRIVATE -Wl,-z,max-page-size=16384 -uANativeActivity_onCreate)
target_link_libraries(droiddeckxr PRIVATE droiddeckxr_glue OpenXR::openxr_loader EGL GLESv3 android log)
target_include_directories(droiddeckxr PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../../../../../tools/vr/src)
+201
View File
@@ -0,0 +1,201 @@
#pragma once
#include <EGL/eglext.h>
#include <GLES3/gl31.h>
#include <GLES2/gl2ext.h>
#include <android/hardware_buffer.h>
#include "xr_gpu_channel.h"
class QuestGpuFrames {
public:
bool Start(const std::string &filename, EGLDisplay display) {
const char *extensions = reinterpret_cast<const char *>(glGetString(GL_EXTENSIONS));
const char *eglExtensions = eglQueryString(display, EGL_EXTENSIONS);
if (!extensions || !eglExtensions || !strstr(extensions, "GL_EXT_external_buffer")
|| !strstr(eglExtensions, "EGL_ANDROID_get_native_client_buffer")) return false;
storage_ = reinterpret_cast<PFNGLBUFFERSTORAGEEXTERNALEXTPROC>(eglGetProcAddress("glBufferStorageExternalEXT"));
client_ = reinterpret_cast<PFNEGLGETNATIVECLIENTBUFFERANDROIDPROC>(eglGetProcAddress("eglGetNativeClientBufferANDROID"));
if (strstr(extensions, "GL_OES_texture_view")) view_ = reinterpret_cast<PFNGLTEXTUREVIEWOESPROC>(eglGetProcAddress("glTextureViewOES"));
else if (strstr(extensions, "GL_EXT_texture_view")) view_ = reinterpret_cast<PFNGLTEXTUREVIEWOESPROC>(eglGetProcAddress("glTextureViewEXT"));
if (!storage_ || !client_ || !view_ || filename.size() >= sizeof(sockaddr_un::sun_path)) return false;
const char *source = "#version 310 es\nlayout(local_size_x=8,local_size_y=8)in;"
"layout(std430,binding=0)readonly buffer Source{uint pixels[];};"
"layout(rgba8,binding=0)writeonly uniform highp image2D target;uniform bool bgra;"
"void main(){ivec2 p=ivec2(gl_GlobalInvocationID.xy),size=imageSize(target);"
"if(any(greaterThanEqual(p,size)))return;vec4 value=unpackUnorm4x8(pixels[p.y*size.x+p.x]);"
"imageStore(target,p,bgra?value.bgra:value);}";
GLuint shader = glCreateShader(GL_COMPUTE_SHADER); glShaderSource(shader, 1, &source, nullptr); glCompileShader(shader);
program_ = glCreateProgram(); glAttachShader(program_, shader); glLinkProgram(program_); glDeleteShader(shader);
GLint linked{}; glGetProgramiv(program_, GL_LINK_STATUS, &linked);
if (!linked) { Stop(); return false; }
lock_ = open((filename + ".lock").c_str(), O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, 0600);
struct stat status{};
if (lock_ < 0 || fstat(lock_, &status) || !S_ISREG(status.st_mode) || status.st_uid != getuid()
|| flock(lock_, LOCK_EX | LOCK_NB)) { Stop(); return false; }
if (lstat(filename.c_str(), &status) == 0) {
if (!S_ISSOCK(status.st_mode) || status.st_uid != getuid() || unlink(filename.c_str())) { Stop(); return false; }
} else if (errno != ENOENT) { Stop(); return false; }
listener_ = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
sockaddr_un address{}; address.sun_family = AF_UNIX; strcpy(address.sun_path, filename.c_str());
if (listener_ < 0 || bind(listener_, reinterpret_cast<sockaddr *>(&address), sizeof(address))) { Stop(); return false; }
filename_ = filename;
if (chmod(filename.c_str(), 0600) || listen(listener_, 2)) { Stop(); return false; }
return true;
}
bool Take(DroidDeckXrFrameHeader &header, GLuint &texture) {
if (listener_ < 0) return false;
if (peer_ < 0) {
peer_ = accept4(listener_, nullptr, nullptr, SOCK_CLOEXEC | SOCK_NONBLOCK);
if (peer_ < 0) return false;
if (!xr_frame_peer(peer_)) { Disconnect(); return false; }
configured_ = false; deadline_ = xr_frame_now() + 2000000000LL;
}
pollfd ready{peer_, POLLIN, 0};
if (poll(&ready, 1, 0) <= 0) {
if (!configured_ && xr_frame_now() > deadline_) Disconnect();
return false;
}
if (!(ready.revents & POLLIN)) { Disconnect(); return false; }
if (!configured_) {
XrGpuRequest request;
if (!xr_gpu_receive(peer_, &request, sizeof(request)) || request.magic != XR_GPU_MAGIC
|| request.version != XR_GPU_VERSION || !xr_gpu_dimensions(request.width, request.height)
|| !Allocate(request.width, request.height)) { Disconnect(); return false; }
XrGpuPoolInfo info; info.width = width_; info.height = height_;
for (unsigned index = 0; index < XR_GPU_SLOTS; index++) info.sizes[index] = sizes_[index];
configured_ = xr_gpu_send(peer_, &info, sizeof(info), fds_.data(), fds_.size());
for (int &fd : fds_) { if (fd >= 0) close(fd); fd = -1; }
if (!configured_) Disconnect();
return false;
}
XrGpuFrame frame;
if (!xr_gpu_receive(peer_, &frame, sizeof(frame))) { Disconnect(); return false; }
auto state = xr_gpu_frame_state(frame, width_, height_, lastFrame_, xr_frame_now());
if (state == XrGpuFrameState::Invalid) {
rejected_++; Disconnect(); return false;
}
lastFrame_ = frame.header.frame_id;
if (state == XrGpuFrameState::Stale) {
// No GPU read was submitted, so the producer can safely reclaim a dropped frame.
dropped_++;
if (!xr_gpu_send(peer_, &frame.header.frame_id, sizeof(frame.header.frame_id))) Disconnect();
return false;
}
if (!Output()) { Disconnect(); return false; }
glMemoryBarrier(GL_ALL_BARRIER_BITS);
glUseProgram(program_); glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, buffers_[frame.slot]);
glUniform1i(glGetUniformLocation(program_, "bgra"), !!(frame.flags & XR_GPU_BGRA));
glBindImageTexture(0, raw_, 0, GL_FALSE, 0, GL_WRITE_ONLY, GL_RGBA8);
glDispatchCompute((width_ + 7) / 8, (height_ + 7) / 8, 1);
glMemoryBarrier(GL_TEXTURE_FETCH_BARRIER_BIT | GL_SHADER_IMAGE_ACCESS_BARRIER_BIT);
// The producer may overwrite this slot only after the native GPU has finished reading it.
glFinish();
if (glGetError() != GL_NO_ERROR) { rejected_++; Disconnect(); return false; }
if (!xr_gpu_send(peer_, &frame.header.frame_id, sizeof(frame.header.frame_id))) Disconnect();
received_++;
header = frame.header;
texture = header.color_space == DROIDDECK_XR_COLOR_SRGB_RGBA8 ? srgb_ : raw_;
return true;
}
uint64_t Received() const { return received_; }
uint64_t Rejected() const { return rejected_; }
uint64_t Dropped() const { return dropped_; }
void Stop() {
Disconnect(); DropPool();
glDeleteTextures(1, &srgb_); glDeleteTextures(1, &raw_); srgb_ = raw_ = 0;
if (program_) glDeleteProgram(program_); program_ = 0;
if (listener_ >= 0) close(listener_); listener_ = -1;
if (!filename_.empty()) unlink(filename_.c_str()); filename_.clear();
if (lock_ >= 0) close(lock_); lock_ = -1;
}
private:
int listener_ = -1, peer_ = -1, lock_ = -1;
std::string filename_;
bool configured_ = false;
int64_t deadline_{};
uint32_t width_{}, height_{}, outputWidth_{}, outputHeight_{};
uint64_t lastFrame_{}, received_{}, rejected_{}, dropped_{};
std::array<AHardwareBuffer *, XR_GPU_SLOTS> hardware_{};
std::array<GLuint, XR_GPU_SLOTS> buffers_{};
std::array<int, XR_GPU_SLOTS> fds_{{-1, -1, -1}};
std::array<uint64_t, XR_GPU_SLOTS> sizes_{};
GLuint raw_{}, srgb_{}, program_{};
PFNGLBUFFERSTORAGEEXTERNALEXTPROC storage_{};
PFNEGLGETNATIVECLIENTBUFFERANDROIDPROC client_{};
PFNGLTEXTUREVIEWOESPROC view_{};
void Disconnect() { if (peer_ >= 0) close(peer_); peer_ = -1; configured_ = false; lastFrame_ = 0; }
void DropPool() {
if (buffers_[0]) glFinish();
glDeleteBuffers(buffers_.size(), buffers_.data()); buffers_.fill(0);
for (auto *&buffer : hardware_) { if (buffer) AHardwareBuffer_release(buffer); buffer = nullptr; }
for (int &fd : fds_) { if (fd >= 0) close(fd); fd = -1; }
}
static int AllocationFd(AHardwareBuffer *buffer, uint64_t bytes, uint64_t &allocation) {
int pair[2];
if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, pair)) return -1;
if (AHardwareBuffer_sendHandleToUnixSocket(buffer, pair[0])) { close(pair[0]); close(pair[1]); return -1; }
close(pair[0]);
int selected = -1; unsigned candidates = 0;
for (;;) {
unsigned char data[1024]; iovec bytesReceived{data, sizeof(data)};
alignas(cmsghdr) unsigned char control[CMSG_SPACE(32 * sizeof(int))]{};
msghdr message{}; message.msg_iov = &bytesReceived; message.msg_iovlen = 1;
message.msg_control = control; message.msg_controllen = sizeof(control);
ssize_t result = recvmsg(pair[1], &message, MSG_CMSG_CLOEXEC);
if (result < 0 && errno == EINTR) continue;
if (result <= 0) break;
for (auto *item = CMSG_FIRSTHDR(&message); item; item = CMSG_NXTHDR(&message, item)) {
if (item->cmsg_level != SOL_SOCKET || item->cmsg_type != SCM_RIGHTS) continue;
auto *fds = reinterpret_cast<int *>(CMSG_DATA(item));
for (unsigned index = 0; index < (item->cmsg_len - CMSG_LEN(0)) / sizeof(int); index++) {
struct stat status{};
if (!fstat(fds[index], &status) && status.st_size >= static_cast<int64_t>(bytes)
&& status.st_size <= static_cast<int64_t>(bytes + 65536)) {
candidates++; if (selected >= 0) close(selected);
selected = fds[index]; allocation = status.st_size;
} else close(fds[index]);
}
}
if (message.msg_flags & (MSG_TRUNC | MSG_CTRUNC)) { candidates = 0; break; }
}
close(pair[1]);
if (candidates != 1) { if (selected >= 0) close(selected); return -1; }
return selected;
}
bool Allocate(uint32_t width, uint32_t height) {
DropPool(); width_ = width; height_ = height;
const uint64_t bytes = uint64_t(width) * height * 4;
GLint64 limit{}; glGetInteger64v(GL_MAX_SHADER_STORAGE_BLOCK_SIZE, &limit);
if (bytes > static_cast<uint64_t>(limit)) return false;
AHardwareBuffer_Desc description{}; description.width = bytes; description.height = description.layers = 1;
description.format = AHARDWAREBUFFER_FORMAT_BLOB;
description.usage = AHARDWAREBUFFER_USAGE_GPU_DATA_BUFFER;
glGenBuffers(buffers_.size(), buffers_.data());
for (unsigned index = 0; index < XR_GPU_SLOTS; index++) {
if (AHardwareBuffer_allocate(&description, &hardware_[index])) { DropPool(); return false; }
fds_[index] = AllocationFd(hardware_[index], bytes, sizes_[index]);
if (fds_[index] < 0) { DropPool(); return false; }
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffers_[index]);
storage_(GL_SHADER_STORAGE_BUFFER, 0, bytes, client_(hardware_[index]), 0);
if (glGetError() != GL_NO_ERROR) { DropPool(); return false; }
}
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
return true;
}
bool Output() {
if (raw_ && outputWidth_ == width_ && outputHeight_ == height_) return true;
glDeleteTextures(1, &srgb_); glDeleteTextures(1, &raw_); srgb_ = raw_ = 0;
glGenTextures(1, &raw_); glBindTexture(GL_TEXTURE_2D, raw_);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, width_, height_);
glGenTextures(1, &srgb_); view_(srgb_, GL_TEXTURE_2D, raw_, GL_SRGB8_ALPHA8, 0, 1, 0, 1);
for (GLuint texture : {raw_, srgb_}) {
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
}
outputWidth_ = width_; outputHeight_ = height_;
return glGetError() == GL_NO_ERROR;
}
};
+777
View File
@@ -0,0 +1,777 @@
#include <jni.h>
#include <EGL/egl.h>
#include <GLES3/gl3.h>
#include <android/log.h>
#include <android_native_app_glue.h>
#include <time.h>
#include <openxr/openxr.h>
#include <openxr/openxr_platform.h>
#include <array>
#include <vector>
#include <utility>
#include <string>
#include <stdexcept>
#include <cstdio>
#include <cstring>
#include <cmath>
#include <chrono>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include <cerrno>
#include <cstddef>
#include <ctime>
#include "xr_tracking.h"
#include "xr_frame_channel.h"
#include "xr_frame_math.h"
#include "gpu_frames.h"
static void check(XrResult result, const char *operation) {
if (XR_FAILED(result)) throw std::runtime_error(std::string(operation) + ": " + std::to_string(result));
}
struct Eye {
XrSwapchain swapchain = XR_NULL_HANDLE;
int width = 0, height = 0;
std::vector<XrSwapchainImageOpenGLESKHR> images;
};
class QuestXR {
public:
explicit QuestXR(android_app *app) : app_(app) {}
~QuestXR() { cleanup(); }
void run() {
initialize();
while (!app_->destroyRequested && !exit_) {
android_poll_source *source = nullptr;
int events = 0;
int pollTimeout = running_ ? 0 : 100;
for (int eventCount = 0; eventCount < 32; eventCount++) {
if (ALooper_pollOnce(pollTimeout, nullptr, &events, reinterpret_cast<void **>(&source)) < 0) break;
if (source) source->process(app_, source);
if (app_->destroyRequested) break;
pollTimeout = 0;
}
receiveFrame();
pollEvents();
if (running_ && !app_->destroyRequested) frame();
}
if (exit_ && !app_->destroyRequested) ANativeActivity_finish(app_->activity);
}
void health(const char *error = "") {
int steamvrState = -1;
if (statusCallback_) {
steamvrState = env_->CallIntMethod(app_->activity->clazz, statusCallback_);
if (env_->ExceptionCheck()) { env_->ExceptionClear(); steamvrState = -1; }
}
std::string path = std::string(app_->activity->externalDataPath) + "/xr-health.json";
std::string temp = path + ".tmp";
FILE *file = std::fopen(temp.c_str(), "w");
if (!file) return;
std::fprintf(file,
"{\"frames\":%llu,\"rendered\":%llu,\"sessionState\":%d,\"headFlags\":%llu,"
"\"headPosition\":[%.6f,%.6f,%.6f],\"controllersActive\":[%s,%s],"
"\"periodNs\":%lld,\"surfaceSwapchain\":%s,\"source\":\"%s\",\"guestFrames\":%lld,\"steamvrState\":%d,"
"\"receivedFrames\":%llu,\"rejectedFrames\":%llu,\"uploadedFrames\":%llu,\"sourceFrameId\":%llu,"
"\"gpuReceivedFrames\":%llu,\"gpuDroppedFrames\":%llu,\"gpuVerifiedFrameId\":%llu,\"gpuChecksum\":%u,\"error\":\"%s\"}\n",
(unsigned long long)frames_, (unsigned long long)rendered_, state_,
(unsigned long long)head_.locationFlags,
head_.pose.position.x, head_.pose.position.y, head_.pose.position.z,
handActive_[0] ? "true" : "false", handActive_[1] ? "true" : "false",
(long long)period_, "false", "frames",
0LL, steamvrState, (unsigned long long)(receiver_.Received() + gpuFrames_.Received()),
(unsigned long long)(receiver_.Rejected() + gpuFrames_.Rejected()),
(unsigned long long)uploadedFrames_, (unsigned long long)sourceHeader_.frame_id,
(unsigned long long)gpuFrames_.Received(), (unsigned long long)gpuFrames_.Dropped(),
(unsigned long long)verifiedFrame_, gpuChecksum_, error);
std::fclose(file);
std::rename(temp.c_str(), path.c_str());
}
private:
android_app *app_;
JNIEnv *env_ = nullptr;
XrInstance instance_ = XR_NULL_HANDLE;
XrSession session_ = XR_NULL_HANDLE;
XrSystemId system_ = XR_NULL_SYSTEM_ID;
XrSpace world_ = XR_NULL_HANDLE, view_ = XR_NULL_HANDLE;
XrActionSet actions_ = XR_NULL_HANDLE;
XrAction grip_ = XR_NULL_HANDLE, trigger_ = XR_NULL_HANDLE, vibration_ = XR_NULL_HANDLE;
XrAction squeeze_ = XR_NULL_HANDLE, stick_ = XR_NULL_HANDLE, stickClick_ = XR_NULL_HANDLE;
XrAction primary_ = XR_NULL_HANDLE, secondary_ = XR_NULL_HANDLE, menu_ = XR_NULL_HANDLE;
XrAction triggerTouch_ = XR_NULL_HANDLE, stickTouch_ = XR_NULL_HANDLE, primaryTouch_ = XR_NULL_HANDLE;
XrAction secondaryTouch_ = XR_NULL_HANDLE, thumbrestTouch_ = XR_NULL_HANDLE;
DroidDeckXrHand inputs_[2]{};
PFN_xrConvertTimeToTimespecTimeKHR convertTime_ = nullptr;
bool timespecSupported_ = false;
PFN_xrPerfSettingsSetPerformanceLevelEXT setPerformance_ = nullptr;
bool performanceSupported_ = false;
XrPath hands_[2]{};
XrSpace handSpaces_[2]{};
XrSpaceLocation head_{XR_TYPE_SPACE_LOCATION};
XrSpaceLocation handLocations_[2]{{XR_TYPE_SPACE_LOCATION}, {XR_TYPE_SPACE_LOCATION}};
bool handActive_[2]{};
bool running_ = false, exit_ = false, quitRequested_ = false;
bool guestAttached_ = false, verifyPixels_ = false;
XrFrameReceiver receiver_;
QuestGpuFrames gpuFrames_;
DroidDeckXrFrameHeader sourceHeader_{};
uint64_t uploadedFrames_ = 0, verifiedFrame_ = 0;
uint32_t gpuChecksum_ = 0;
GLuint sourceTexture_ = 0, sourceProgram_ = 0;
GLuint cpuTexture_ = 0;
jmethodID frameCallback_ = nullptr;
jmethodID activeCallback_ = nullptr;
jmethodID statusCallback_ = nullptr;
DroidDeckXrTracking *tracking_ = nullptr;
DroidDeckXrHaptics *haptics_ = nullptr;
uint64_t lastPulse_[2]{};
XrSessionState state_ = XR_SESSION_STATE_UNKNOWN;
uint64_t frames_ = 0, rendered_ = 0;
XrDuration period_ = 0;
EGLDisplay display_ = EGL_NO_DISPLAY;
EGLContext context_ = EGL_NO_CONTEXT;
EGLSurface surface_ = EGL_NO_SURFACE;
std::array<Eye, 2> eyes_;
GLuint framebuffer_ = 0, vertexArray_ = 0;
GLint sourceEncoding_ = 0, eyeEncoding_ = 0;
std::string captureRequest() {
if (frames_ % 9) return {};
std::string file = std::string(app_->activity->externalDataPath) + "/xr-capture.request";
FILE *request = std::fopen(file.c_str(), "r");
if (!request) return {};
char token[34]{};
size_t size = std::fread(token, 1, sizeof(token) - 1, request);
std::fclose(request);
unlink(file.c_str());
if (size != 32 || std::strspn(token, "0123456789abcdef") != 32) return {};
unlink((std::string(app_->activity->externalDataPath) + "/xr-capture.json").c_str());
return token;
}
bool captureTexture(GLuint texture, int width, int height, const char *name) {
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer_);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
GLint encoding = 0;
glGetFramebufferAttachmentParameteriv(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
GL_FRAMEBUFFER_ATTACHMENT_COLOR_ENCODING, &encoding);
if (!std::strcmp(name, "source")) sourceEncoding_ = encoding;
else eyeEncoding_ = encoding;
std::vector<unsigned char> pixels((size_t)width * height * 4);
glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
bool ok = glGetError() == GL_NO_ERROR;
glBindFramebuffer(GL_FRAMEBUFFER, 0);
if (!ok) return false;
std::string file = std::string(app_->activity->externalDataPath) +
"/xr-capture-" + name + ".ppm";
std::string temporary = file + ".tmp";
FILE *output = std::fopen(temporary.c_str(), "wb");
if (!output) return false;
ok = std::fprintf(output, "P6\n%d %d\n255\n", width, height) > 0;
std::vector<unsigned char> row((size_t)width * 3);
for (int y = height - 1; y >= 0 && ok; y--) {
for (int x = 0; x < width; x++)
std::memcpy(row.data() + x * 3, pixels.data() + ((size_t)y * width + x) * 4, 3);
ok = std::fwrite(row.data(), 1, row.size(), output) == row.size();
}
if (std::fclose(output) != 0) ok = false;
if (!ok) { unlink(temporary.c_str()); return false; }
return std::rename(temporary.c_str(), file.c_str()) == 0;
}
void captureResult(const std::string &token, bool ok, bool valid,
const XrCompositionLayerProjectionView (&views)[2], XrTime displayTime) {
std::string file = std::string(app_->activity->externalDataPath) + "/xr-capture.json";
std::string temporary = file + ".tmp";
FILE *output = std::fopen(temporary.c_str(), "w");
if (!output) return;
std::fprintf(output, "{\"id\":\"%s\",\"ok\":%s,\"validLayer\":%s,\"frame\":%llu,"
"\"sourceFrameId\":%llu,\"displayTime\":%lld,\"sessionState\":%d,\"eyes\":[",
token.c_str(), ok ? "true" : "false", valid ? "true" : "false",
(unsigned long long)frames_, (unsigned long long)sourceHeader_.frame_id, (long long)displayTime, state_);
for (int eye = 0; eye < 2; eye++) {
const auto &pose = views[eye].pose;
const auto &fov = views[eye].fov;
std::fprintf(output, "%s{\"width\":%d,\"height\":%d,\"position\":[%g,%g,%g],"
"\"orientation\":[%g,%g,%g,%g],\"fov\":[%g,%g,%g,%g]}",
eye ? "," : "", eyes_[eye].width, eyes_[eye].height,
pose.position.x, pose.position.y, pose.position.z,
pose.orientation.x, pose.orientation.y, pose.orientation.z, pose.orientation.w,
fov.angleLeft, fov.angleRight, fov.angleUp, fov.angleDown);
}
const char *extensions = reinterpret_cast<const char *>(glGetString(GL_EXTENSIONS));
const bool srgbControl = extensions && std::strstr(extensions, "GL_EXT_sRGB_write_control");
std::fprintf(output, "],\"sourceImage\":true,\"sourceColorSpace\":\"%s\",\"sourceEncoding\":%d,\"eyeEncoding\":%d,"
"\"srgbWriteControl\":%s,\"srgbWriteEnabled\":%s}\n",
sourceHeader_.color_space == DROIDDECK_XR_COLOR_SRGB_RGBA8 ? "srgb" : "linear", sourceEncoding_, eyeEncoding_,
srgbControl ? "true" : "false", srgbControl && glIsEnabled(0x8DB9) ? "true" : "false");
if (std::fclose(output) == 0) std::rename(temporary.c_str(), file.c_str());
}
XrPath path(const char *text) {
XrPath result{};
check(xrStringToPath(instance_, text, &result), "xrStringToPath");
return result;
}
void initialize() {
app_->userData = this;
app_->onInputEvent = [](android_app *app, AInputEvent *event) -> int32_t {
if (AInputEvent_getType(event) != AINPUT_EVENT_TYPE_KEY || AKeyEvent_getKeyCode(event) != AKEYCODE_BACK
|| AKeyEvent_getAction(event) != AKEY_EVENT_ACTION_UP) return 0;
auto *runtime = static_cast<QuestXR *>(app->userData);
if (runtime->running_) {
XrResult result = xrRequestExitSession(runtime->session_);
if (XR_SUCCEEDED(result)) runtime->quitRequested_ = true;
else __android_log_print(ANDROID_LOG_ERROR, "DroidDeckXR", "exit request failed: %d", result);
} else ANativeActivity_finish(app->activity);
return 1;
};
app_->activity->vm->AttachCurrentThread(&env_, nullptr);
jclass activityClass = env_->GetObjectClass(app_->activity->clazz);
jmethodID getIntent = env_->GetMethodID(activityClass, "getIntent", "()Landroid/content/Intent;");
jobject intent = env_->CallObjectMethod(app_->activity->clazz, getIntent);
jclass intentClass = env_->GetObjectClass(intent);
jmethodID getBoolean = env_->GetMethodID(intentClass, "getBooleanExtra", "(Ljava/lang/String;Z)Z");
jstring extra = env_->NewStringUTF("frame_verify");
verifyPixels_ = env_->CallBooleanMethod(intent, getBoolean, extra, JNI_FALSE);
env_->DeleteLocalRef(extra);
activeCallback_ = env_->GetMethodID(activityClass, "onXrActive", "(Z)V");
frameCallback_ = env_->GetMethodID(activityClass, "onXrFrame", "(J)V");
statusCallback_ = env_->GetMethodID(activityClass, "onXrStatus", "()I");
if (env_->ExceptionCheck()) { env_->ExceptionClear(); throw std::runtime_error("XR activity bridge methods unavailable"); }
env_->DeleteLocalRef(intentClass); env_->DeleteLocalRef(intent); env_->DeleteLocalRef(activityClass);
PFN_xrInitializeLoaderKHR initializeLoader = nullptr;
check(xrGetInstanceProcAddr(XR_NULL_HANDLE, "xrInitializeLoaderKHR",
reinterpret_cast<PFN_xrVoidFunction *>(&initializeLoader)), "loader init entrypoint");
XrLoaderInitInfoAndroidKHR loader{XR_TYPE_LOADER_INIT_INFO_ANDROID_KHR};
loader.applicationVM = app_->activity->vm;
loader.applicationContext = app_->activity->clazz;
check(initializeLoader(reinterpret_cast<XrLoaderInitInfoBaseHeaderKHR *>(&loader)), "xrInitializeLoaderKHR");
uint32_t count = 0;
check(xrEnumerateInstanceExtensionProperties(nullptr, 0, &count, nullptr), "extensions count");
std::vector<XrExtensionProperties> extensions(count, {XR_TYPE_EXTENSION_PROPERTIES});
check(xrEnumerateInstanceExtensionProperties(nullptr, count, &count, extensions.data()), "extensions");
for (const auto &extension : extensions) {
if (!std::strcmp(extension.extensionName, XR_KHR_CONVERT_TIMESPEC_TIME_EXTENSION_NAME))
timespecSupported_ = true;
if (!std::strcmp(extension.extensionName, XR_EXT_PERFORMANCE_SETTINGS_EXTENSION_NAME))
performanceSupported_ = true;
}
std::vector<const char *> enabled{XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME, XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME};
if (timespecSupported_) enabled.push_back(XR_KHR_CONVERT_TIMESPEC_TIME_EXTENSION_NAME);
if (performanceSupported_) enabled.push_back(XR_EXT_PERFORMANCE_SETTINGS_EXTENSION_NAME);
XrInstanceCreateInfoAndroidKHR androidInfo{XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR};
androidInfo.applicationVM = app_->activity->vm;
androidInfo.applicationActivity = app_->activity->clazz;
XrInstanceCreateInfo create{XR_TYPE_INSTANCE_CREATE_INFO};
create.next = &androidInfo;
std::strcpy(create.applicationInfo.applicationName, "DroidDeck XR");
create.applicationInfo.applicationVersion = 1;
create.applicationInfo.apiVersion = XR_MAKE_VERSION(1, 0, 0);
create.enabledExtensionCount = enabled.size();
create.enabledExtensionNames = enabled.data();
check(xrCreateInstance(&create, &instance_), "xrCreateInstance");
if (performanceSupported_) {
XrResult result = xrGetInstanceProcAddr(instance_, "xrPerfSettingsSetPerformanceLevelEXT",
reinterpret_cast<PFN_xrVoidFunction *>(&setPerformance_));
if (XR_FAILED(result)) setPerformance_ = nullptr;
}
if (timespecSupported_) check(xrGetInstanceProcAddr(instance_, "xrConvertTimeToTimespecTimeKHR",
reinterpret_cast<PFN_xrVoidFunction *>(&convertTime_)), "time conversion entrypoint");
XrSystemGetInfo systemInfo{XR_TYPE_SYSTEM_GET_INFO};
systemInfo.formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY;
check(xrGetSystem(instance_, &systemInfo, &system_), "xrGetSystem");
PFN_xrGetOpenGLESGraphicsRequirementsKHR requirementsFunction = nullptr;
check(xrGetInstanceProcAddr(instance_, "xrGetOpenGLESGraphicsRequirementsKHR",
reinterpret_cast<PFN_xrVoidFunction *>(&requirementsFunction)), "GLES requirements entrypoint");
XrGraphicsRequirementsOpenGLESKHR requirements{XR_TYPE_GRAPHICS_REQUIREMENTS_OPENGL_ES_KHR};
check(requirementsFunction(instance_, system_, &requirements), "GLES requirements");
if (requirements.minApiVersionSupported > XR_MAKE_VERSION(3, 0, 0)
|| requirements.maxApiVersionSupported < XR_MAKE_VERSION(3, 0, 0))
throw std::runtime_error("Runtime does not support GLES 3.0");
display_ = eglGetDisplay(EGL_DEFAULT_DISPLAY);
if (!eglInitialize(display_, nullptr, nullptr)) throw std::runtime_error("eglInitialize failed");
const EGLint attributes[] = {EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT, EGL_SURFACE_TYPE, EGL_PBUFFER_BIT | EGL_WINDOW_BIT,
EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, EGL_DEPTH_SIZE, 24, EGL_NONE};
EGLConfig config{};
EGLint found = 0;
if (!eglChooseConfig(display_, attributes, &config, 1, &found) || found != 1)
throw std::runtime_error("EGL config unavailable");
const EGLint contextAttributes[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE};
context_ = eglCreateContext(display_, config, EGL_NO_CONTEXT, contextAttributes);
const EGLint surfaceAttributes[] = {EGL_WIDTH, 16, EGL_HEIGHT, 16, EGL_NONE};
surface_ = eglCreatePbufferSurface(display_, config, surfaceAttributes);
if (!eglMakeCurrent(display_, surface_, surface_, context_)) throw std::runtime_error("eglMakeCurrent failed");
XrGraphicsBindingOpenGLESAndroidKHR binding{XR_TYPE_GRAPHICS_BINDING_OPENGL_ES_ANDROID_KHR};
binding.display = display_; binding.config = config; binding.context = context_;
XrSessionCreateInfo sessionInfo{XR_TYPE_SESSION_CREATE_INFO};
sessionInfo.next = &binding; sessionInfo.systemId = system_;
check(xrCreateSession(instance_, &sessionInfo, &session_), "xrCreateSession");
XrReferenceSpaceCreateInfo reference{XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
reference.poseInReferenceSpace.orientation.w = 1;
reference.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_STAGE;
XrResult referenceResult = xrCreateReferenceSpace(session_, &reference, &world_);
if (referenceResult == XR_ERROR_REFERENCE_SPACE_UNSUPPORTED) {
reference.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL;
reference.poseInReferenceSpace.position.y = -1.6f;
check(xrCreateReferenceSpace(session_, &reference, &world_), "local standing space");
} else check(referenceResult, "stage space");
reference.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW;
reference.poseInReferenceSpace.position.y = 0;
check(xrCreateReferenceSpace(session_, &reference, &view_), "view space");
initializeActions();
initializeImages();
initializeGraphics();
initializeTracking();
{
std::string filename = std::string(app_->activity->internalDataPath) + "/.wayland-rt/" DROIDDECK_XR_FRAME_SOCKET;
if (!receiver_.Start(filename)) throw std::runtime_error("XR frame receiver unavailable");
bool gpu = !verifyPixels_ && gpuFrames_.Start(std::string(app_->activity->internalDataPath) + "/.wayland-rt/" DROIDDECK_XR_GPU_SOCKET, display_);
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "GPU frame channel available=%d", gpu);
}
health();
}
void initializeActions() {
hands_[0] = path("/user/hand/left"); hands_[1] = path("/user/hand/right");
XrActionSetCreateInfo setInfo{XR_TYPE_ACTION_SET_CREATE_INFO};
std::strcpy(setInfo.actionSetName, "quest"); std::strcpy(setInfo.localizedActionSetName, "Quest controllers");
check(xrCreateActionSet(instance_, &setInfo, &actions_), "action set");
auto action = [&](const char *name, XrActionType type, XrAction *handle) {
XrActionCreateInfo info{XR_TYPE_ACTION_CREATE_INFO};
std::strcpy(info.actionName, name); std::strcpy(info.localizedActionName, name);
info.actionType = type; info.countSubactionPaths = 2; info.subactionPaths = hands_;
check(xrCreateAction(actions_, &info, handle), "create action");
};
action("grip_pose", XR_ACTION_TYPE_POSE_INPUT, &grip_);
action("trigger", XR_ACTION_TYPE_FLOAT_INPUT, &trigger_);
action("squeeze", XR_ACTION_TYPE_FLOAT_INPUT, &squeeze_);
action("stick", XR_ACTION_TYPE_VECTOR2F_INPUT, &stick_);
action("stick_click", XR_ACTION_TYPE_BOOLEAN_INPUT, &stickClick_);
action("primary", XR_ACTION_TYPE_BOOLEAN_INPUT, &primary_);
action("secondary", XR_ACTION_TYPE_BOOLEAN_INPUT, &secondary_);
action("menu", XR_ACTION_TYPE_BOOLEAN_INPUT, &menu_);
action("trigger_touch", XR_ACTION_TYPE_BOOLEAN_INPUT, &triggerTouch_);
action("stick_touch", XR_ACTION_TYPE_BOOLEAN_INPUT, &stickTouch_);
action("primary_touch", XR_ACTION_TYPE_BOOLEAN_INPUT, &primaryTouch_);
action("secondary_touch", XR_ACTION_TYPE_BOOLEAN_INPUT, &secondaryTouch_);
action("thumbrest_touch", XR_ACTION_TYPE_BOOLEAN_INPUT, &thumbrestTouch_);
action("vibration", XR_ACTION_TYPE_VIBRATION_OUTPUT, &vibration_);
std::vector<XrActionSuggestedBinding> bindings;
for (const char *hand : {"left", "right"}) {
std::string root = std::string("/user/hand/") + hand;
bindings.push_back({grip_, path((root + "/input/grip/pose").c_str())});
bindings.push_back({trigger_, path((root + "/input/trigger/value").c_str())});
bindings.push_back({triggerTouch_, path((root + "/input/trigger/touch").c_str())});
bindings.push_back({squeeze_, path((root + "/input/squeeze/value").c_str())});
bindings.push_back({stick_, path((root + "/input/thumbstick").c_str())});
bindings.push_back({stickClick_, path((root + "/input/thumbstick/click").c_str())});
bindings.push_back({stickTouch_, path((root + "/input/thumbstick/touch").c_str())});
bindings.push_back({thumbrestTouch_, path((root + "/input/thumbrest/touch").c_str())});
bindings.push_back({primary_, path((root + (std::strcmp(hand, "left") == 0 ? "/input/x/click" : "/input/a/click")).c_str())});
bindings.push_back({secondary_, path((root + (std::strcmp(hand, "left") == 0 ? "/input/y/click" : "/input/b/click")).c_str())});
bindings.push_back({primaryTouch_, path((root + (std::strcmp(hand, "left") == 0 ? "/input/x/touch" : "/input/a/touch")).c_str())});
bindings.push_back({secondaryTouch_, path((root + (std::strcmp(hand, "left") == 0 ? "/input/y/touch" : "/input/b/touch")).c_str())});
bindings.push_back({vibration_, path((root + "/output/haptic").c_str())});
}
bindings.push_back({menu_, path("/user/hand/left/input/menu/click")});
XrInteractionProfileSuggestedBinding suggestion{XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING};
suggestion.interactionProfile = path("/interaction_profiles/oculus/touch_controller");
suggestion.countSuggestedBindings = bindings.size(); suggestion.suggestedBindings = bindings.data();
check(xrSuggestInteractionProfileBindings(instance_, &suggestion), "Touch bindings");
XrSessionActionSetsAttachInfo attach{XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO};
attach.countActionSets = 1; attach.actionSets = &actions_;
check(xrAttachSessionActionSets(session_, &attach), "attach actions");
for (int hand = 0; hand < 2; hand++) {
XrActionSpaceCreateInfo space{XR_TYPE_ACTION_SPACE_CREATE_INFO};
space.action = grip_; space.subactionPath = hands_[hand]; space.poseInActionSpace.orientation.w = 1;
check(xrCreateActionSpace(session_, &space, &handSpaces_[hand]), "controller space");
}
}
void initializeImages() {
uint32_t count = 0;
check(xrEnumerateViewConfigurationViews(instance_, system_, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO,
0, &count, nullptr), "view count");
if (count != 2) throw std::runtime_error("Expected two stereo views");
std::vector<XrViewConfigurationView> views(count, {XR_TYPE_VIEW_CONFIGURATION_VIEW});
check(xrEnumerateViewConfigurationViews(instance_, system_, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO,
count, &count, views.data()), "view configuration");
check(xrEnumerateSwapchainFormats(session_, 0, &count, nullptr), "format count");
std::vector<int64_t> formats(count);
check(xrEnumerateSwapchainFormats(session_, count, &count, formats.data()), "formats");
int64_t format = 0;
for (int64_t candidate : formats) if (candidate == GL_SRGB8_ALPHA8) format = candidate;
if (!format) throw std::runtime_error("sRGB swapchain unavailable");
for (int eye = 0; eye < 2; eye++) {
Eye &target = eyes_[eye];
target.width = views[eye].recommendedImageRectWidth;
target.height = views[eye].recommendedImageRectHeight;
XrSwapchainCreateInfo create{XR_TYPE_SWAPCHAIN_CREATE_INFO};
create.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT;
create.format = format; create.sampleCount = 1;
create.width = target.width; create.height = target.height;
create.faceCount = 1; create.arraySize = 1; create.mipCount = 1;
check(xrCreateSwapchain(session_, &create, &target.swapchain), "swapchain");
check(xrEnumerateSwapchainImages(target.swapchain, 0, &count, nullptr), "image count");
target.images.resize(count, {XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR});
check(xrEnumerateSwapchainImages(target.swapchain, count, &count,
reinterpret_cast<XrSwapchainImageBaseHeader *>(target.images.data())), "images");
}
}
void initializeTracking() {
std::string directory = std::string(app_->activity->internalDataPath) + "/.wayland-rt";
if (mkdir(directory.c_str(), 0700) != 0 && errno != EEXIST)
throw std::runtime_error("XR tracking directory unavailable");
auto mapFile = [&](const char *name, size_t size) {
std::string filename = directory + "/" + name;
int descriptor = open(filename.c_str(), O_CREAT | O_RDWR | O_CLOEXEC, 0600);
if (descriptor < 0) throw std::runtime_error("XR IPC file unavailable");
if (ftruncate(descriptor, size) != 0) {
close(descriptor); throw std::runtime_error("XR IPC file size failed");
}
void *mapping = mmap(nullptr, size, PROT_READ | PROT_WRITE, MAP_SHARED, descriptor, 0);
close(descriptor);
if (mapping == MAP_FAILED) throw std::runtime_error("XR IPC map failed");
return mapping;
};
tracking_ = static_cast<DroidDeckXrTracking *>(mapFile(DROIDDECK_XR_FILE, sizeof(DroidDeckXrTracking)));
haptics_ = static_cast<DroidDeckXrHaptics *>(mapFile(DROIDDECK_XR_HAPTIC_FILE, sizeof(DroidDeckXrHaptics)));
haptics_->magic = DROIDDECK_XR_HAPTIC_MAGIC; haptics_->version = DROIDDECK_XR_VERSION;
for (int hand = 0; hand < 2; hand++) lastPulse_[hand] = haptics_->hands[hand].id;
}
static DroidDeckXrPose posePacket(const XrPosef &pose, XrSpaceLocationFlags flags) {
return {{pose.orientation.x, pose.orientation.y, pose.orientation.z, pose.orientation.w},
{pose.position.x, pose.position.y, pose.position.z}, (uint32_t)flags};
}
void publishTracking(const XrView views[2], XrTime predicted) {
if (!tracking_) return;
timespec now{}; clock_gettime(CLOCK_MONOTONIC, &now);
DroidDeckXrTracking packet{};
packet.magic = DROIDDECK_XR_MAGIC; packet.version = DROIDDECK_XR_VERSION;
packet.active = state_ == XR_SESSION_STATE_VISIBLE || state_ == XR_SESSION_STATE_FOCUSED;
packet.sampled_ns = (int64_t)now.tv_sec * 1000000000LL + now.tv_nsec;
if (convertTime_) {
timespec displayTime{};
check(convertTime_(instance_, predicted, &displayTime), "display time conversion");
packet.predicted_ns = (int64_t)displayTime.tv_sec * 1000000000LL + displayTime.tv_nsec;
}
packet.period_ns = period_;
packet.head = posePacket(head_.pose, head_.locationFlags);
for (int eye = 0; eye < 2; eye++) {
packet.eyes[eye].pose = posePacket(views[eye].pose, head_.locationFlags);
packet.eyes[eye].fov[0] = views[eye].fov.angleLeft;
packet.eyes[eye].fov[1] = views[eye].fov.angleRight;
packet.eyes[eye].fov[2] = views[eye].fov.angleUp;
packet.eyes[eye].fov[3] = views[eye].fov.angleDown;
packet.eyes[eye].width = eyes_[eye].width; packet.eyes[eye].height = eyes_[eye].height;
packet.hands[eye] = inputs_[eye];
packet.hands[eye].pose = posePacket(handLocations_[eye].pose, handLocations_[eye].locationFlags);
packet.hands[eye].active = handActive_[eye];
}
uint32_t sequence = __atomic_load_n(&tracking_->seq, __ATOMIC_RELAXED);
__atomic_store_n(&tracking_->seq, sequence | 1u, __ATOMIC_SEQ_CST);
__atomic_thread_fence(__ATOMIC_SEQ_CST);
std::memcpy(tracking_, &packet, offsetof(DroidDeckXrTracking, seq));
std::memcpy(reinterpret_cast<char *>(tracking_) + offsetof(DroidDeckXrTracking, active),
reinterpret_cast<const char *>(&packet) + offsetof(DroidDeckXrTracking, active),
sizeof(packet) - offsetof(DroidDeckXrTracking, active));
__atomic_store_n(&tracking_->seq, (sequence | 1u) + 1u, __ATOMIC_RELEASE);
}
void attachGuest(bool attach) {
if (attach == guestAttached_) return;
env_->CallVoidMethod(app_->activity->clazz, activeCallback_, static_cast<jboolean>(attach));
if (env_->ExceptionCheck()) {
env_->ExceptionDescribe(); env_->ExceptionClear(); throw std::runtime_error("Guest XR handshake failed");
}
guestAttached_ = attach;
}
void pollHaptics() {
if (!haptics_) return;
DroidDeckXrHaptics packet{};
uint32_t sequence = __atomic_load_n(&haptics_->seq, __ATOMIC_ACQUIRE);
if (sequence & 1u) return;
std::memcpy(&packet, haptics_, sizeof(packet));
__atomic_thread_fence(__ATOMIC_ACQUIRE);
if (__atomic_load_n(&haptics_->seq, __ATOMIC_RELAXED) != sequence
|| packet.magic != DROIDDECK_XR_HAPTIC_MAGIC || packet.version != DROIDDECK_XR_VERSION) return;
for (int hand = 0; hand < 2; hand++) {
const auto &pulse = packet.hands[hand];
if (pulse.id == lastPulse_[hand]) continue;
lastPulse_[hand] = pulse.id;
if (state_ != XR_SESSION_STATE_FOCUSED || !handActive_[hand]
|| pulse.duration_ns < 1000000 || pulse.duration_ns > 1000000000
|| !std::isfinite(pulse.amplitude) || pulse.amplitude < 0 || pulse.amplitude > 1
|| !std::isfinite(pulse.frequency) || pulse.frequency < 0) continue;
XrHapticActionInfo info{XR_TYPE_HAPTIC_ACTION_INFO}; info.action = vibration_; info.subactionPath = hands_[hand];
XrHapticVibration vibration{XR_TYPE_HAPTIC_VIBRATION};
vibration.duration = pulse.duration_ns; vibration.amplitude = pulse.amplitude; vibration.frequency = pulse.frequency;
check(xrApplyHapticFeedback(session_, &info, reinterpret_cast<XrHapticBaseHeader *>(&vibration)), "guest haptics");
}
}
GLuint shader(GLenum type, const char *text) {
GLuint handle = glCreateShader(type);
glShaderSource(handle, 1, &text, nullptr); glCompileShader(handle);
GLint success = 0; glGetShaderiv(handle, GL_COMPILE_STATUS, &success);
if (!success) { glDeleteShader(handle); throw std::runtime_error("Shader compile failed"); }
return handle;
}
void initializeGraphics() {
glGenFramebuffers(1, &framebuffer_);
glGenVertexArrays(1, &vertexArray_);
GLuint vertex = shader(GL_VERTEX_SHADER,
"#version 300 es\nout vec2 uv; uniform float eye; void main(){vec2 coordinate=vec2(float((gl_VertexID<<1)&2),float(gl_VertexID&2));uv=vec2(coordinate.x*0.5+eye*0.5,1.0-coordinate.y);gl_Position=vec4(coordinate*2.0-1.0,0.0,1.0);}");
GLuint fragment = shader(GL_FRAGMENT_SHADER,
"#version 300 es\nprecision highp float; in vec2 uv; uniform sampler2D source; uniform float eye; out vec4 outputColor; void main(){float edge=0.5/float(textureSize(source,0).x);vec2 coordinate=vec2(clamp(uv.x,eye*0.5+edge,(eye+1.0)*0.5-edge),uv.y);outputColor=texture(source,coordinate);}");
sourceProgram_ = glCreateProgram(); glAttachShader(sourceProgram_, vertex); glAttachShader(sourceProgram_, fragment);
glLinkProgram(sourceProgram_); glDeleteShader(vertex); glDeleteShader(fragment);
GLint linked = 0; glGetProgramiv(sourceProgram_, GL_LINK_STATUS, &linked);
if (!linked) throw std::runtime_error("Frame shader link failed");
glGenTextures(1, &sourceTexture_); glBindTexture(GL_TEXTURE_2D, sourceTexture_);
cpuTexture_ = sourceTexture_;
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
}
void receiveFrame() {
if (gpuFrames_.Take(sourceHeader_, sourceTexture_)) {
uploadedFrames_++;
if (uploadedFrames_ % 72 == 1) health();
return;
}
DroidDeckXrPixels frame;
if (!receiver_.Take(&frame)) return;
GLint maximum = 0; glGetIntegerv(GL_MAX_TEXTURE_SIZE, &maximum);
if (frame.header.width > static_cast<uint32_t>(maximum) || frame.header.height > static_cast<uint32_t>(maximum))
throw std::runtime_error("XR source exceeds GPU texture limit");
sourceTexture_ = cpuTexture_;
glBindTexture(GL_TEXTURE_2D, sourceTexture_);
GLenum encoding = frame.header.color_space == DROIDDECK_XR_COLOR_SRGB_RGBA8 ? GL_SRGB8_ALPHA8 : GL_RGBA8;
glTexImage2D(GL_TEXTURE_2D, 0, encoding, frame.header.width, frame.header.height, 0,
GL_RGBA, GL_UNSIGNED_BYTE, frame.pixels.data());
if (verifyPixels_) {
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer_);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, 0);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, sourceTexture_, 0);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
throw std::runtime_error("Frame readback framebuffer incomplete");
std::vector<unsigned char> readback(frame.header.bytes);
glReadPixels(0, 0, frame.header.width, frame.header.height, GL_RGBA, GL_UNSIGNED_BYTE, readback.data());
if (readback != frame.pixels) throw std::runtime_error("Frame GPU readback does not match owned pixels");
gpuChecksum_ = 2166136261u;
for (unsigned char value : readback) gpuChecksum_ = (gpuChecksum_ ^ value) * 16777619u;
verifiedFrame_ = frame.header.frame_id;
glBindFramebuffer(GL_FRAMEBUFFER, 0);
}
if (glGetError() != GL_NO_ERROR) throw std::runtime_error("Frame texture upload failed");
sourceHeader_ = frame.header;
receiver_.Recycle(std::move(frame.pixels));
uploadedFrames_++;
if (verifyPixels_ || uploadedFrames_ % 72 == 1) health();
}
void render(Eye &eye, uint32_t image) {
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer_);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, eye.images[image].image, 0);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
throw std::runtime_error("Incomplete eye framebuffer");
{
glViewport(0, 0, eye.width, eye.height); glDisable(GL_DEPTH_TEST);
glUseProgram(sourceProgram_); glBindVertexArray(vertexArray_);
glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, sourceTexture_);
glUniform1i(glGetUniformLocation(sourceProgram_, "source"), 0);
glUniform1f(glGetUniformLocation(sourceProgram_, "eye"), &eye == &eyes_[1] ? 1.f : 0.f);
glDrawArrays(GL_TRIANGLES, 0, 3);
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
if (glGetError() != GL_NO_ERROR) throw std::runtime_error("OpenGL render error");
glFlush();
}
void pollEvents() {
XrEventDataBuffer event{XR_TYPE_EVENT_DATA_BUFFER};
XrResult result;
while ((result = xrPollEvent(instance_, &event)) == XR_SUCCESS) {
if (event.type == XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) {
state_ = reinterpret_cast<XrEventDataSessionStateChanged *>(&event)->state;
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "session state=%d", state_);
attachGuest(state_ == XR_SESSION_STATE_VISIBLE || state_ == XR_SESSION_STATE_FOCUSED);
if (state_ == XR_SESSION_STATE_READY) {
XrSessionBeginInfo begin{XR_TYPE_SESSION_BEGIN_INFO};
begin.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
check(xrBeginSession(session_, &begin), "xrBeginSession"); running_ = true;
if (setPerformance_) {
for (auto domain : {XR_PERF_SETTINGS_DOMAIN_CPU_EXT, XR_PERF_SETTINGS_DOMAIN_GPU_EXT}) {
XrResult request = setPerformance_(session_, domain, XR_PERF_SETTINGS_LEVEL_SUSTAINED_HIGH_EXT);
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "sustained performance domain=%d result=%d", domain, request);
}
}
} else if (state_ == XR_SESSION_STATE_STOPPING) {
check(xrEndSession(session_), "xrEndSession"); running_ = false;
if (quitRequested_) exit_ = true;
} else if (state_ == XR_SESSION_STATE_EXITING || state_ == XR_SESSION_STATE_LOSS_PENDING) exit_ = true;
health();
} else if (event.type == XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING) exit_ = true;
else if (event.type == XR_TYPE_EVENT_DATA_PERF_SETTINGS_EXT) {
const auto &performance = *reinterpret_cast<XrEventDataPerfSettingsEXT *>(&event);
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "performance domain=%d subdomain=%d level=%d->%d",
performance.domain, performance.subDomain, performance.fromLevel, performance.toLevel);
}
event = {XR_TYPE_EVENT_DATA_BUFFER};
}
check(result, "xrPollEvent");
}
void frame() {
XrFrameWaitInfo wait{XR_TYPE_FRAME_WAIT_INFO};
XrFrameState frameState{XR_TYPE_FRAME_STATE};
check(xrWaitFrame(session_, &wait, &frameState), "xrWaitFrame");
period_ = frameState.predictedDisplayPeriod;
XrFrameBeginInfo begin{XR_TYPE_FRAME_BEGIN_INFO}; check(xrBeginFrame(session_, &begin), "xrBeginFrame");
check(xrLocateSpace(view_, world_, frameState.predictedDisplayTime, &head_), "head pose");
if (state_ == XR_SESSION_STATE_FOCUSED) {
XrActiveActionSet active{actions_, XR_NULL_PATH};
XrActionsSyncInfo sync{XR_TYPE_ACTIONS_SYNC_INFO}; sync.countActiveActionSets = 1; sync.activeActionSets = &active;
check(xrSyncActions(session_, &sync), "xrSyncActions");
for (int hand = 0; hand < 2; hand++) {
XrActionStateGetInfo get{XR_TYPE_ACTION_STATE_GET_INFO}; get.action = grip_; get.subactionPath = hands_[hand];
XrActionStatePose pose{XR_TYPE_ACTION_STATE_POSE}; check(xrGetActionStatePose(session_, &get, &pose), "grip state");
handActive_[hand] = pose.isActive;
check(xrLocateSpace(handSpaces_[hand], world_, frameState.predictedDisplayTime, &handLocations_[hand]), "hand pose");
get.action = trigger_;
XrActionStateFloat trigger{XR_TYPE_ACTION_STATE_FLOAT}; check(xrGetActionStateFloat(session_, &get, &trigger), "trigger state");
inputs_[hand].trigger = trigger.isActive ? trigger.currentState : 0;
get.action = squeeze_;
XrActionStateFloat squeeze{XR_TYPE_ACTION_STATE_FLOAT}; check(xrGetActionStateFloat(session_, &get, &squeeze), "squeeze state");
inputs_[hand].squeeze = squeeze.isActive ? squeeze.currentState : 0;
get.action = stick_;
XrActionStateVector2f stick{XR_TYPE_ACTION_STATE_VECTOR2F}; check(xrGetActionStateVector2f(session_, &get, &stick), "stick state");
inputs_[hand].stick[0] = stick.isActive ? stick.currentState.x : 0;
inputs_[hand].stick[1] = stick.isActive ? stick.currentState.y : 0;
inputs_[hand].buttons = 0;
const std::pair<XrAction, uint32_t> buttons[] = {
{stickClick_, DROIDDECK_XR_STICK_CLICK}, {primary_, DROIDDECK_XR_PRIMARY_CLICK},
{secondary_, DROIDDECK_XR_SECONDARY_CLICK}, {menu_, DROIDDECK_XR_MENU_CLICK},
{triggerTouch_, DROIDDECK_XR_TRIGGER_TOUCH}, {stickTouch_, DROIDDECK_XR_STICK_TOUCH},
{primaryTouch_, DROIDDECK_XR_PRIMARY_TOUCH}, {secondaryTouch_, DROIDDECK_XR_SECONDARY_TOUCH},
{thumbrestTouch_, DROIDDECK_XR_THUMBREST_TOUCH}};
for (const auto &button : buttons) {
get.action = button.first;
XrActionStateBoolean value{XR_TYPE_ACTION_STATE_BOOLEAN}; check(xrGetActionStateBoolean(session_, &get, &value), "button state");
if (value.isActive && value.currentState) inputs_[hand].buttons |= button.second;
}
}
} else handActive_[0] = handActive_[1] = false;
pollHaptics();
XrViewLocateInfo locate{XR_TYPE_VIEW_LOCATE_INFO}; locate.viewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
locate.displayTime = frameState.predictedDisplayTime; locate.space = world_;
XrViewState viewState{XR_TYPE_VIEW_STATE}; uint32_t count = 0;
XrView views[2]{{XR_TYPE_VIEW}, {XR_TYPE_VIEW}};
check(xrLocateViews(session_, &locate, &viewState, 2, &count, views), "xrLocateViews");
publishTracking(views, frameState.predictedDisplayTime);
if (guestAttached_) {
timespec now{}; clock_gettime(CLOCK_MONOTONIC, &now);
env_->CallVoidMethod(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"); }
}
XrCompositionLayerProjectionView projection[2]{{XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}, {XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}};
bool valid = frameState.shouldRender && count == 2 &&
(viewState.viewStateFlags & (XR_VIEW_STATE_ORIENTATION_VALID_BIT | XR_VIEW_STATE_POSITION_VALID_BIT))
== (XR_VIEW_STATE_ORIENTATION_VALID_BIT | XR_VIEW_STATE_POSITION_VALID_BIT);
{
valid = valid && sourceHeader_.frame_id && xr_frame_now() - sourceHeader_.captured_ns < 500000000LL;
if (valid) for (int eye = 0; eye < 2; eye++) {
auto source = xr_frame_view(sourceHeader_, eye);
views[eye].pose.orientation = {source.orientation[0], source.orientation[1], source.orientation[2], source.orientation[3]};
views[eye].pose.position = {source.position[0], source.position[1], source.position[2]};
views[eye].fov = {source.fov[0], source.fov[1], source.fov[2], source.fov[3]};
}
}
const std::string capture = captureRequest();
bool captured = valid;
if (!capture.empty() && valid)
captured = captureTexture(sourceTexture_, sourceHeader_.width, sourceHeader_.height, "source");
if (valid) for (int eye = 0; eye < 2; eye++) {
uint32_t image = 0;
XrSwapchainImageAcquireInfo acquire{XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO};
check(xrAcquireSwapchainImage(eyes_[eye].swapchain, &acquire, &image), "acquire image");
XrSwapchainImageWaitInfo imageWait{XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO}; imageWait.timeout = XR_INFINITE_DURATION;
check(xrWaitSwapchainImage(eyes_[eye].swapchain, &imageWait), "wait image");
render(eyes_[eye], image);
if (!capture.empty()) captured = captureTexture(eyes_[eye].images[image].image, eyes_[eye].width, eyes_[eye].height, eye ? "right" : "left") && captured;
XrSwapchainImageReleaseInfo release{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO};
check(xrReleaseSwapchainImage(eyes_[eye].swapchain, &release), "release image");
projection[eye].pose = views[eye].pose; projection[eye].fov = views[eye].fov;
projection[eye].subImage.swapchain = eyes_[eye].swapchain;
projection[eye].subImage.imageRect.extent = {eyes_[eye].width, eyes_[eye].height};
}
XrCompositionLayerProjection layer{XR_TYPE_COMPOSITION_LAYER_PROJECTION};
layer.space = world_; layer.viewCount = 2; layer.views = projection;
const XrCompositionLayerBaseHeader *layers[] = {reinterpret_cast<XrCompositionLayerBaseHeader *>(&layer)};
XrFrameEndInfo end{XR_TYPE_FRAME_END_INFO}; end.displayTime = frameState.predictedDisplayTime;
end.environmentBlendMode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE; end.layerCount = valid ? 1 : 0; end.layers = layers;
check(xrEndFrame(session_, &end), "xrEndFrame");
if (!capture.empty()) captureResult(capture, captured, valid, projection, frameState.predictedDisplayTime);
frames_++; if (valid) rendered_++;
if (frames_ % 72 == 0) {
health();
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "frames=%llu rendered=%llu head=%llu hands=%d,%d period=%lld",
(unsigned long long)frames_, (unsigned long long)rendered_, (unsigned long long)head_.locationFlags,
handActive_[0], handActive_[1], (long long)period_);
}
}
void cleanup() {
receiver_.Stop();
gpuFrames_.Stop();
try { attachGuest(false); } catch (const std::exception &error) {
__android_log_print(ANDROID_LOG_ERROR, "DroidDeckXR", "cleanup: %s", error.what());
}
if (tracking_) {
__atomic_store_n(&tracking_->active, 0u, __ATOMIC_RELEASE);
munmap(tracking_, sizeof(DroidDeckXrTracking));
}
if (haptics_) munmap(haptics_, sizeof(DroidDeckXrHaptics));
if (display_ != EGL_NO_DISPLAY && context_ != EGL_NO_CONTEXT) {
glDeleteFramebuffers(1, &framebuffer_); glDeleteVertexArrays(1, &vertexArray_);
glDeleteTextures(1, &cpuTexture_); glDeleteProgram(sourceProgram_);
}
for (auto &eye : eyes_) if (eye.swapchain) xrDestroySwapchain(eye.swapchain);
for (auto space : handSpaces_) if (space) xrDestroySpace(space);
if (view_) xrDestroySpace(view_); if (world_) xrDestroySpace(world_);
if (session_) xrDestroySession(session_);
if (actions_) xrDestroyActionSet(actions_);
if (instance_) xrDestroyInstance(instance_);
if (display_ != EGL_NO_DISPLAY) {
eglMakeCurrent(display_, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
if (surface_ != EGL_NO_SURFACE) eglDestroySurface(display_, surface_);
if (context_ != EGL_NO_CONTEXT) eglDestroyContext(display_, context_);
eglTerminate(display_);
}
if (env_) app_->activity->vm->DetachCurrentThread();
}
};
void android_main(android_app *app) {
QuestXR runtime(app);
try { runtime.run(); }
catch (const std::exception &error) {
__android_log_print(ANDROID_LOG_ERROR, "DroidDeckXR", "%s", error.what());
runtime.health(error.what());
ANativeActivity_finish(app->activity);
}
}
+32 -1
View File
@@ -138,6 +138,9 @@ static pthread_mutex_t g_req_lock = PTHREAD_MUTEX_INITIALIZER;
static ANativeWindow *g_req_window;
static int g_req_pending;
static int g_req_detached;
static uint64_t g_req_generation, g_req_applied;
static pthread_cond_t g_req_condition = PTHREAD_COND_INITIALIZER;
static volatile int g_mode = VKP_MODE_STRETCH, g_align = VKP_ALIGN_CENTER;
@@ -218,26 +221,53 @@ void vk_present_set_window(ANativeWindow *window) {
if (g_req_pending && g_req_window && g_req_window != window) superseded = g_req_window;
g_req_window = window;
g_req_pending = 1;
g_req_generation++;
if (!window) g_req_detached = 1;
pthread_mutex_unlock(&g_req_lock);
if (superseded) ANativeWindow_release(superseded);
}
static void window_request_applied(uint64_t generation) {
pthread_mutex_lock(&g_req_lock);
g_req_applied = generation;
pthread_cond_broadcast(&g_req_condition);
pthread_mutex_unlock(&g_req_lock);
}
int vkp_wait_window_request(int timeout_ms) {
if (timeout_ms <= 0 || timeout_ms > 10000) return 0;
struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
deadline.tv_sec += timeout_ms / 1000;
deadline.tv_nsec += (timeout_ms % 1000) * 1000000L;
if (deadline.tv_nsec >= 1000000000L) { deadline.tv_sec++; deadline.tv_nsec -= 1000000000L; }
pthread_mutex_lock(&g_req_lock);
const uint64_t generation = g_req_generation;
int error = 0;
while (g_req_applied < generation && error == 0)
error = pthread_cond_timedwait(&g_req_condition, &g_req_lock, &deadline);
int applied = g_req_applied >= generation;
pthread_mutex_unlock(&g_req_lock);
return applied;
}
int vkp_apply_window_request(void) {
ANativeWindow *w;
pthread_mutex_lock(&g_req_lock);
if (!g_req_pending) { pthread_mutex_unlock(&g_req_lock); return 0; }
w = g_req_window;
const int detached = g_req_detached;
const uint64_t generation = g_req_generation;
g_req_window = NULL;
g_req_pending = 0;
g_req_detached = 0;
pthread_mutex_unlock(&g_req_lock);
if (w == g_window) {
if (w) ANativeWindow_release(w);
if (!w || !detached) return 0;
if (!w || !detached) { window_request_applied(generation); return 0; }
sc_layer_window_gone();
destroy_swapchain();
window_request_applied(generation);
droiddeck_log("gpu", "screen surface rebound: rebuilding the swapchain at its new size");
return 1;
}
@@ -248,6 +278,7 @@ int vkp_apply_window_request(void) {
g_window = w;
g_swap_retry_at_ns = 0;
g_swap_fail_logged = 0;
window_request_applied(generation);
droiddeck_log("gpu", w ? "screen surface attached" : "screen surface gone: presenting paused");
return 1;
}
@@ -51,6 +51,7 @@ const char *vkp_gpu_name(void);
int vkp_ready(void);
int vkp_has_window(void);
int vkp_wait_window_request(int timeout_ms);
int vkp_device_lost(void);
int vkp_render(int scene_w, int scene_h, const struct vkp_draw *draws, int n);
@@ -592,6 +592,12 @@ static char *dup_bytes(JNIEnv *env, jbyteArray arr, int *len) {
return buf;
}
JNIEXPORT jboolean JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeWaitSurface(
JNIEnv *env, jclass clazz, jint timeout_ms) {
return vkp_wait_window_request(timeout_ms) ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetClipboardText(JNIEnv *env, jclass clazz, jbyteArray utf8) {
int len;
@@ -0,0 +1,118 @@
package com.droiddeck.launcher
import android.app.NativeActivity
import android.os.Bundle
import android.os.SystemClock
import android.util.Log
import android.widget.Toast
import com.droiddeck.launcher.gpu.SteamVr
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.wayland.CompositorHost
import com.droiddeck.launcher.wayland.WaylandCompositor
class QuestXrActivity : NativeActivity() {
private var steamVrStarted = false
@Volatile private var closing = false
@Volatile private var steamVrStatus = SteamVr.Status.UNAVAILABLE
private var sessionId: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
sessionId = SessionState.sessionId
SessionService.setXrActivityVisible(this, true)
super.onCreate(savedInstanceState)
}
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()
WaylandCompositor.nativeVsync(System.nanoTime())
check(WaylandCompositor.nativeWaitSurface(3000)) { "XR frame GPU handshake timed out" }
}
SessionService.setXrActivityVisible(this, active)
if (active) startSteamVr()
}
private fun startSteamVr() {
if (!steamVrStarted) {
steamVrStarted = true
Thread({
try {
val started = synchronized(steamVrLock) {
if (closing || !sessionAlive()) false else {
steamVrOwner = this
SteamVr.run(this, "start")
}
}
if (!started) {
if (!closing) leaveXr("SteamVR start failed", sessionAlive())
return@Thread
}
val startupDeadline = SystemClock.elapsedRealtime() + 30_000
var lastRunning: Long? = null
while (!closing) {
if (!sessionAlive()) {
leaveXr("Steam session ended", false)
break
}
steamVrStatus = SteamVr.status(this)
val now = SystemClock.elapsedRealtime()
if (steamVrStatus == SteamVr.Status.RUNNING) lastRunning = now
val deadline = lastRunning?.plus(5_000) ?: startupDeadline
if (steamVrStatus != SteamVr.Status.RUNNING && now >= deadline) {
leaveXr("SteamVR stopped or did not finish starting", true)
break
}
Thread.sleep(1000)
}
} finally {
synchronized(steamVrLock) {
// A retiring activity must not stop a replacement's SteamVR session.
if (steamVrOwner === this) {
if (sessionAlive()) SteamVr.run(this, "stop")
steamVrOwner = null
}
}
}
}, "quest-steamvr-start").start()
}
}
private fun sessionAlive() = SessionState.running && !SessionState.stopRequested &&
sessionId != null && SessionState.sessionId == sessionId
private fun leaveXr(reason: String, failed: Boolean) {
Log.w("DroidDeckXR", reason)
closing = true
runOnUiThread {
if (failed) Toast.makeText(this, R.string.cardboard_steamvr_failed, Toast.LENGTH_LONG).show()
finish()
}
}
fun onXrFrame(frameTimeNanos: Long) {
WaylandCompositor.nativeVsync(frameTimeNanos)
}
fun onXrStatus(): Int = steamVrStatus.ordinal
override fun onDestroy() {
closing = true
SessionService.setXrActivityVisible(this, false)
super.onDestroy()
}
override fun onStop() {
SessionService.setXrActivityVisible(this, false)
super.onStop()
}
companion object {
private val steamVrLock = Any()
private var steamVrOwner: QuestXrActivity? = null
}
}
@@ -521,8 +521,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
releaseDrawerDirection()
}
}
LaunchedEffect(cardboard.enabled, steamVrSwitchedPicture) {
while (cardboard.enabled || steamVrSwitchedPicture) {
LaunchedEffect(cardboard.enabled, steamVrSwitchedPicture, drawerOpen, drawerPage) {
while (cardboard.enabled || steamVrSwitchedPicture || (drawerOpen && drawerPage == SessionDrawerPage.STEAMVR)) {
val now = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
SteamVr.status(this@SessionActivity)
}
@@ -559,6 +559,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onCardboard = { c -> SessionPrefs.setCardboard(this@SessionActivity, c); applyCardboard(c) },
onCardboardRecenter = { headTracker?.recenter() },
steamVr = steamVrStatus,
immersiveSteamVr = com.droiddeck.launcher.session.QuestExperimental.isSupported(),
onSteamVr = { action -> steamVrAction(action) },
textureAnisotropy = if (drawerGameProfileId == null) textureAnisotropy else drawerTextureAnisotropy,
textureLodBias = if (drawerGameProfileId == null) textureLodBias else drawerTextureLodBias,
@@ -2383,6 +2384,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun steamVrAction(action: String) {
if (action == "start" && com.droiddeck.launcher.session.QuestExperimental.isSupported()) {
startActivity(android.content.Intent(this, QuestXrActivity::class.java))
return
}
if (action == "start") {
switchPictureForSteamVr()
steamVrSeenRunning = false
@@ -71,6 +71,8 @@ object SessionFiles {
"usr/local/bin/droiddeck-steam-games" to "usr/local/bin/droiddeck-steam-games",
"usr/local/bin/droiddeck-pad-defaults" to "usr/local/bin/droiddeck-pad-defaults",
"usr/local/bin/droiddeck-steamvr" to "usr/local/bin/droiddeck-steamvr",
"usr/local/bin/droiddeck-steamvr-settings" to "usr/local/bin/droiddeck-steamvr-settings",
"usr/local/bin/droiddeck-steamvr-game" to "usr/local/bin/droiddeck-steamvr-game",
"usr/local/bin/droiddeck-bwrap" to "usr/local/bin/droiddeck-bwrap",
"usr/local/bin/droiddeck-flatpak" to "usr/local/bin/droiddeck-flatpak",
"usr/local/bin/droiddeck-flatpak-setup" to "usr/local/bin/droiddeck-flatpak-setup",
@@ -104,6 +106,7 @@ object SessionFiles {
listOf("NOTICE", "GPL-2", "GPL-3", "LGPL-2.1", "7zip-License", "7zip-unRarLicense").map { "usr/local/share/licenses/droiddeck-msitools/$it" }
val vr = listOf(
"droiddeck/bin/linuxarm64/driver_droiddeck.so",
"droiddeck/bin/linuxarm64/xr-display-mode",
"droiddeck/driver.vrdrivermanifest",
"droiddeck/resources/settings/default.vrsettings",
"layer/libVkLayer_droiddeck_vr.so",
@@ -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
@@ -919,8 +933,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
@@ -1111,11 +1125,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()
@@ -1137,6 +1151,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) {
@@ -1194,7 +1209,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()
@@ -1305,6 +1320,7 @@ class SessionService : Service() {
if (!SessionState.running) return
SessionState.running = false
}
xrActivityVisible = false
updateDownloadMonitoring()
val stoppedGen = sessionGen
val stoppedSession = SessionState.sessionId
@@ -1357,8 +1373,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
@@ -1559,6 +1575,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"
@@ -1618,6 +1636,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)
@@ -5,6 +5,8 @@ internal enum class SessionDrawerPage(val firstControl: String) {
SECOND_SCREEN("second-screen:none"),
DISPLAY("hud"),
EFFECTS("look"),
STEAMVR("steamvr"),
CARDBOARD("cardboard"),
GAMES("anisotropy"),
SESSION("suspend"),
}
@@ -42,6 +42,8 @@ import androidx.compose.material.icons.outlined.PictureInPictureAlt
import androidx.compose.material.icons.automirrored.outlined.ExitToApp
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SportsEsports
import androidx.compose.material.icons.outlined.ViewInAr
import androidx.compose.material.icons.outlined.Vrpano
import androidx.compose.material3.Icon
import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonDefaults
@@ -151,6 +153,8 @@ private val SessionDrawerPage.title: Int get() = when (this) {
SessionDrawerPage.CONTROLLER -> R.string.drawer_page_controller
SessionDrawerPage.SECOND_SCREEN -> R.string.drawer_second_screen
SessionDrawerPage.DISPLAY -> R.string.drawer_page_display
SessionDrawerPage.STEAMVR -> R.string.steamvr_title
SessionDrawerPage.CARDBOARD -> R.string.cardboard_title
SessionDrawerPage.EFFECTS -> R.string.drawer_page_effects
SessionDrawerPage.GAMES -> R.string.game_settings_title
SessionDrawerPage.SESSION -> R.string.mode_session
@@ -160,6 +164,8 @@ private val SessionDrawerPage.icon get() = when (this) {
SessionDrawerPage.CONTROLLER -> Icons.Outlined.SportsEsports
SessionDrawerPage.SECOND_SCREEN -> Icons.Outlined.ScreenShare
SessionDrawerPage.DISPLAY -> Icons.Outlined.DesktopWindows
SessionDrawerPage.STEAMVR -> Icons.Outlined.ViewInAr
SessionDrawerPage.CARDBOARD -> Icons.Outlined.Vrpano
SessionDrawerPage.EFFECTS -> Icons.Outlined.AutoFixHigh
SessionDrawerPage.GAMES -> Icons.Outlined.Layers
SessionDrawerPage.SESSION -> Icons.Outlined.Settings
@@ -209,6 +215,7 @@ class DrawerActions(
val cardboard: Cardboard = Cardboard.OFF,
val cardboardSensor: Boolean = false,
val steamVr: SteamVr.Status? = null,
val immersiveSteamVr: Boolean = false,
val textureAnisotropy: Int? = 0,
val textureLodBias: String? = TextureFiltering.LOD_BIAS_OFF,
val secondScreenMode: SecondScreenMode,
@@ -452,12 +459,13 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
stringResource(R.string.display_sharpness), null, a.upscaleSharpness, 0..100, step = 5,
format = { "$it%" }, modifier = focus.track(page, "upscale-sharpness"), onChange = a.onUpscaleSharpness)
}
CardboardGroup(host, a) { key -> focus.track(page, key) }
}
SessionDrawerPage.EFFECTS -> {
ScreenEffectsGroup(host, a, advancedEffectsExpanded,
onAdvancedToggle = { advancedEffectsExpanded = !advancedEffectsExpanded }) { key -> focus.track(page, key) }
}
SessionDrawerPage.STEAMVR -> SteamVrGroup(a) { key -> focus.track(page, key) }
SessionDrawerPage.CARDBOARD -> CardboardGroup(host, a) { key -> focus.track(page, key) }
SessionDrawerPage.CONTROLLER -> {
SettingsGroup(stringResource(R.string.drawer_page_controller)) {
a.padType?.let { type ->
@@ -915,17 +923,34 @@ private fun CardboardGroup(host: MenuHost, a: DrawerActions, track: (String) ->
}
if (c.headTracking && a.cardboardSensor)
ActionRow(stringResource(R.string.cardboard_recenter), null, stringResource(R.string.cardboard_recenter_button), a.onCardboardRecenter)
}
}
@Composable
private fun SteamVrGroup(a: DrawerActions, track: (String) -> Modifier) {
SettingsGroup(stringResource(R.string.steamvr_title)) {
val vrLabel = stringResource(R.string.cardboard_steamvr)
val enterLabel = stringResource(if (a.immersiveSteamVr) R.string.steamvr_enter else R.string.cardboard_steamvr_start)
val enterHint = stringResource(if (a.immersiveSteamVr) R.string.steamvr_quest_ready_hint else R.string.cardboard_steamvr_ready_hint)
@Composable fun button(label: String, hint: String?, text: String, action: String) {
SettingsRow(label, hint) {
SecondaryButton(text, modifier = track("steamvr"), onClick = { a.onSteamVr(action) })
}
}
when (a.steamVr) {
SteamVr.Status.NOT_INSTALLED -> ActionRow(vrLabel, stringResource(R.string.cardboard_steamvr_install_hint),
stringResource(R.string.cardboard_steamvr_install), { a.onSteamVr("install") })
SteamVr.Status.NOT_INSTALLED -> button(vrLabel, stringResource(R.string.cardboard_steamvr_install_hint),
stringResource(R.string.cardboard_steamvr_install), "install")
SteamVr.Status.INSTALLING -> SettingsRow(vrLabel, stringResource(R.string.cardboard_steamvr_installing)) {}
SteamVr.Status.READY -> ActionRow(vrLabel, stringResource(R.string.cardboard_steamvr_ready_hint),
stringResource(R.string.cardboard_steamvr_start), { a.onSteamVr("start") })
SteamVr.Status.RUNNING -> ActionRow(vrLabel, stringResource(R.string.cardboard_steamvr_running_hint),
stringResource(R.string.cardboard_steamvr_stop), { a.onSteamVr("stop") })
SteamVr.Status.READY -> button(vrLabel, enterHint, enterLabel, "start")
SteamVr.Status.RUNNING -> {
if (a.immersiveSteamVr) button(vrLabel, enterHint, enterLabel, "start")
SettingsRow(vrLabel, stringResource(R.string.cardboard_steamvr_running_hint)) {
SecondaryButton(stringResource(R.string.cardboard_steamvr_stop),
modifier = track(if (a.immersiveSteamVr) "steamvr-stop" else "steamvr"), onClick = { a.onSteamVr("stop") })
}
}
SteamVr.Status.UNAVAILABLE -> SettingsRow(vrLabel, stringResource(R.string.cardboard_steamvr_unavailable)) {}
null -> {}
null -> SettingsRow(vrLabel, stringResource(R.string.steamvr_checking)) {}
}
}
}
@@ -7,6 +7,7 @@ import com.droiddeck.launcher.session.SessionState
object CompositorHost {
@Volatile
private var started = false
@Volatile
private var vsyncRunning = false
private val vsync = object : Choreographer.FrameCallback {
@@ -71,6 +72,14 @@ object CompositorHost {
private var attached: Surface? = null
@Synchronized
fun attachXr() {
check(started) { "DroidDeck compositor is not running" }
pauseVsync()
attached = null
WaylandCompositor.nativeSetSurface(null)
}
@Synchronized
fun detach(surface: Surface?) {
if (!started) return
@@ -148,6 +148,7 @@ public final class WaylandCompositor {
String nativeLibDir);
public static native void nativeSetSurface(Surface surface);
public static native boolean nativeWaitSurface(int timeoutMs);
public static native void nativeSendPointer(int action, int x, int y);
+4
View File
@@ -595,6 +595,10 @@
<string name="cardboard_no_sensor">This device has no rotation sensor.</string>
<string name="cardboard_recenter">Recenter view</string>
<string name="cardboard_recenter_button">Recenter</string>
<string name="steamvr_title">SteamVR</string>
<string name="steamvr_enter">Enter SteamVR</string>
<string name="steamvr_quest_ready_hint">Enter immersive VR with headset tracking and Touch controllers.</string>
<string name="steamvr_checking">Checking SteamVR…</string>
<string name="cardboard_steamvr">SteamVR (experimental)</string>
<string name="cardboard_steamvr_install">Install</string>
<string name="cardboard_steamvr_install_hint">Downloads Valve\'s ARM64 SteamVR (571 MB).</string>
@@ -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))
}
}
@@ -20,7 +20,9 @@ class SessionDrawerPageTest {
assertEquals(sessionDrawerPages(false), connected.filter { it != SessionDrawerPage.SECOND_SCREEN })
for (pages in listOf(connected, sessionDrawerPages(false))) {
assertEquals(SessionDrawerPage.EFFECTS, pages.step(SessionDrawerPage.DISPLAY, 1))
assertEquals(SessionDrawerPage.GAMES, pages.step(SessionDrawerPage.EFFECTS, 1))
assertEquals(SessionDrawerPage.STEAMVR, pages.step(SessionDrawerPage.EFFECTS, 1))
assertEquals(SessionDrawerPage.CARDBOARD, pages.step(SessionDrawerPage.STEAMVR, 1))
assertEquals(SessionDrawerPage.GAMES, pages.step(SessionDrawerPage.CARDBOARD, 1))
assertEquals(SessionDrawerPage.SESSION, pages.step(SessionDrawerPage.GAMES, 1))
}
}
+14 -1
View File
@@ -117,12 +117,25 @@ tools/droiddeckctl prefs set session some_key true --type bool
tools/droiddeckctl logs latest ./session-artifacts
tools/droiddeckctl screenshot ./screen.png --display 4
tools/droiddeckctl xr-frame ./xr-capture
tools/droiddeckctl record ./clip.mp4 --seconds 8 --display 0
tools/droiddeckctl pacing --seconds 30
tools/droiddeckctl displays
tools/droiddeckctl wake
```
`xr-frame` captures both app eye textures while SteamVR is open, saving `left.png`,
`right.png` and `capture.json` with the matching source frame, projection poses,
FOV and dimensions. It works over wired or wireless authorized ADB.
Newer builds also include `source.png`, the incoming stereo texture before the
Android eye draw, and sRGB attachment/write state in the metadata for color debugging.
Source PNGs use the incoming frame's color space and eye PNGs use sRGB. Transfers use Android's
gzip utility to avoid sending full uncompressed images over wireless ADB.
A unique request ID prevents stale images from passing as a fresh capture; no valid XR
layer produces an explicit error. Captures exclude Meta system overlays and
the headset compositor's final reprojection. GPU readback runs only on request
and can briefly affect frame timing. Remote files are overwritten each time.
A sleeping device suspends the session and hides the app's windows from `ui dump`; `wake` first.
`pacing` reads SurfaceFlinger's timestats for the game's layer (the session's SurfaceView when the
@@ -176,7 +189,7 @@ Automator show them:
| `play-steam` | Play on the Steam and Games pages |
| `setting-<key>` | every toggle and choice row, by its key (`setting-logs`, `setting-agent-commands`, ...) |
| `tab-<n>` | a page's tab strip (Setup's Overview, Controller, Session, Launcher...), by position |
| `drawer-tab-controller`, `-second_screen`, `-display`, `-effects`, `-games`, `-session` | the session drawer's pages |
| `drawer-tab-controller`, `-second_screen`, `-display`, `-effects`, `-steamvr`, `-cardboard`, `-games`, `-session` | the session drawer's pages |
| `drawer-stop`, `drawer-stop-confirm`, `drawer-stop-cancel` | stopping a session from the drawer |
## Scenarios
+133 -2
View File
@@ -1,7 +1,7 @@
# Experimental Quest 2/3/3S support
Quest 3/3S runs DroidDeck as a 2D panel. This does not add immersive VR or
OpenXR support. Quest 2 has a separate experimental Adreno 6xx driver path.
Quest runs DroidDeck as a 2D panel, with an experimental immersive SteamVR path
in the standard app. Quest 2 has a separate experimental Adreno 6xx driver path.
Quest 2 selects `turnip26.3.0-quest2`, a pinned Banners-Turnip A6xx/A7xx build
with the same GPUOBJ adapter. It does not select the Quest 3 A7xx binary.
@@ -64,3 +64,134 @@ On a headset, check the Steam login panel, sign-in, game installation and
then an owned game's rendering/input. Capture the device/OS, session logs,
`files/quest-startup.txt` and any `files/quest-last-crash.txt` failure.
Host allocation and preload-chain tests do not establish headset operation.
## Experimental immersive SteamVR
In a running Steam session, open the session menu's SteamVR tab, install SteamVR
once, then choose Enter SteamVR. Cardboard has its own tab and can remain off. On supported Quest devices this opens the standard app's
OpenXR activity. The guest driver receives headset poses, Touch controller input
and display timing, and returns owned stereo images tagged with the latest eye poses.
SteamVR haptics return through the tracking bridge. No root or separate XR APK
is required. Other devices retain their existing SteamVR start behavior.
The left Touch menu button is SteamVR's system button and toggles its dashboard.
The right Meta button remains reserved by the headset OS.
The driver supplies SteamVR's version-5 chaperone configuration through
`Prop_DriverProvidedChaperoneJson_String`, following Valve's driver API. Standing
tracking keeps the OpenXR stage coordinates; seated tracking starts at the head's
position and yaw. No synthetic play area or collision walls are advertised.
Without a seated transform, SteamVR reported valid raw and standing poses but
invalid seated poses, causing Thumper to remain behind its tracking-loss fade.
On Quest, supplying both transforms made all three origins valid; seated height
started within a millimetre of zero while standing and raw height stayed unchanged.
Quest uses SteamVR's normal Linux compositor through its display component.
The Vulkan layer captures only the HMD display swapchain, retains its present
waits, and chains a GPU copy before presentation. Three bounded capture buffers
allow rendering and transfer to overlap. Where native GLES supports
`GL_EXT_external_buffer` and texture views, Android allocates three
`AHardwareBuffer` BLOBs and passes their DMA-BUF allocations to Linux through
`SCM_RIGHTS`. Vulkan imports them as buffers and transfers foreign queue-family
ownership before and after copying the display image. A native compute shader
reads each shared buffer into a private RGBA8 texture; an sRGB texture view
preserves hardware transfer decoding. Native GPU completion precedes the
acknowledgement, so Linux cannot overwrite an active reader. This removes the CPU
pixel copy and upload, but still includes GPU copies and explicit completion waits.
If capability negotiation or buffer import fails before frames are submitted,
the existing CPU transport remains available. It brackets the DMA-BUF read with
`DMA_BUF_IOCTL_SYNC`, copies to owned pixels, then acknowledges and uploads them.
Both channels authenticate the peer UID and bound buffer sizes. Missing
acknowledgements after submission quarantine the transfer and fail presentation
instead of falling back while a reader might still own a buffer.
The helper temporarily enables SteamVR's Linux Vulkan asynchronous queue and disables motion
smoothing, backing up those preferences and restoring them on stop or the next start
after an interrupted session. It excludes the pinned Steam Frame package's optional CVP backend,
whose library constructor exits on Quest's unsupported SoC. The original library
is retained with a `.droiddeck-disabled` suffix. SteamVR binaries are not patched.
Cardboard retains its existing direct-mode path; `questframes` remains an explicit
diagnostic override for the earlier driver presenter.
The panel compositor detaches before OpenXR starts displaying guest frames.
Session suspension accounts for the immersive activity while the panel is hidden.
Closing the immersive activity stops SteamVR. The panel remains available for
returning to the normal Steam session.
The immersive activity belongs to the Steam session that opened it. Ending that
session closes XR; a failed startup or stopped SteamVR also returns to the panel
with an error instead of leaving a black scene. Enter SteamVR can then start a
fresh connection.
SteamVR scene-application and dashboard events also guide Gamescope's game focus.
While the dashboard is hidden, the guest agent resolves the live scene process's
Steam app ID and promotes its focusable window. Opening the dashboard or ending
the scene restores the previous order if no other client has replaced it. This
keeps keyboard/gamepad input with the VR scene without game-specific IDs.
With Steam already running, `python3 tools/test_quest_xr.py --serial SERIAL`
launches the immersive activity and verifies advancing owned images, tracked
6DoF, focus and SteamVR status. Add `--require-controllers` while holding both
controllers. Results include telemetry and logcat in `build/questxr-validation`.
Host tests and APK installation alone do not establish headset operation.
On Quest 3, a 45-second hardware run maintained focused tracked 6DoF, both
controllers active, and advancing stereo images. A headset tester confirmed
controller models and trigger animation. That run delivered about 8.5 new stereo
images per second despite a 90 Hz XR session.
That earlier measurement used the driver-direct presenter. With SteamVR's normal
compositor, a native Vulkan scene passed its first submission and continued
submitting stereo frames. Thumper and The Thrill of the Fight produced clean
immersive scenes. SUPERHOT's Windows OpenXR build initially showed shared-image
corruption: DXVK supplied a restricted mutable-image format list while SteamVR
created its corresponding opaque shared images without one. Turnip can select
different memory layouts for those descriptions. On the Quest bridge, the layer
normalizes the known opaque-FD stereo-array creation chains to SteamVR's unrestricted list,
without changing application-owned single-layer OpenVR images, DMA-BUF/modifier
images or other targets. A headset capture then
showed clean SUPERHOT scene geometry; interactive gameplay remains unverified.
A subsequent 60-second shared-buffer dashboard run delivered about
43 new stereo images per second. Reusing Android pixel buffers raised the next
60-second run to about 68 received and 65 uploaded images per second, with valid
focused tracking and no rejected frames. These dashboard results do not establish refresh-rate gameplay: game input,
sustained game performance, and exact association with SteamVR's final compositor
pose still require validation.
The native GPU-buffer channel passed 256 full-size buffer reuse cycles, including
head/tail pixel checks and sRGB texture-view decoding. A subsequent SUPERHOT
scene run delivered about 43 new stereo images/s and 58 XR draws/s over one
minute, with focused tracking and no rejected frames. The earlier CPU-path
SUPERHOT run uploaded about 28 images/s; these are separate runs, not a controlled
benchmark. Capturing the source and eye images can delay frame consumption;
the GPU receiver acknowledges stale frames without reading them and keeps the
last private image until a fresh frame arrives. Missing completion still fails
closed. `frame_verify` explicitly selects the CPU path for byte comparison.
The frame channel supports linear and sRGB RGBA8. SteamVR's final display image
uses sRGB display values; Android decodes those during texture sampling and writes
to an sRGB eye swapchain, encoding the display values once. A Quest color-chart
capture verified that 64, 128, 192 and 224 grey patches retain those values;
the previous sRGB source upload darkened them to approximately 13, 55, 134 and
190. The transport remains 8-bit SDR; this correction does not add HDR or higher
precision gradients.
When available, `XR_EXT_performance_settings` requests sustained-high CPU and GPU
performance for the running OpenXR session. These are runtime-managed hints;
device power and thermal policies remain authoritative. Unsupported or rejected
requests do not prevent startup. This does not change render resolution.
### Experimental flat games in SteamVR
Start SteamVR first, then add `droiddeck-steamvr-game %command%` to a flat
game's Steam launch options. Remove that prefix to restore normal panel launch.
This runs a nested, unmodified Gamescope OpenVR backend for that game. Gamescope
owns the GPU overlay, placement and input routing; no CPU screen capture or
PipeWire service is used. The existing DroidDeck Vulkan synchronization layer
remains enabled. On Quest, a connected XR receiver selects Gamescope's Vulkan
texture sharing instead of DMA-BUF modifiers. Explicit environment overrides
are preserved; other devices retain the upstream modifier default.
An upstream GPU cube overlay has been observed on Quest 3. Flat-game stability,
controller interaction and performance still require hardware validation. This
uses Valve's public Gamescope backend; the exact Steam Frame OS launch setup
has not been verified.
+103 -1
View File
@@ -5,13 +5,18 @@ from __future__ import annotations
import argparse
import base64
import gzip
import json
import os
import re
import shutil
import subprocess
import struct
import sys
import tempfile
import time
import uuid
import zlib
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -382,8 +387,101 @@ def input_request(args: argparse.Namespace) -> dict:
raise ControlError(f"Unknown input type {kind}", "INVALID_INPUT")
def xr_ppm_to_png(data: bytes, linear: bool = False) -> bytes:
header = re.match(rb"P6\n([0-9]+) ([0-9]+)\n255\n", data)
if not header:
raise ControlError("Invalid XR image header", "XR_CAPTURE_FAILED")
width, height = map(int, header.groups())
pixels = data[header.end():]
if not (0 < width <= 16384 and 0 < height <= 16384) or len(pixels) != width * height * 3:
raise ControlError("Invalid XR image dimensions or incomplete pixels", "XR_CAPTURE_FAILED")
def chunk(kind: bytes, value: bytes) -> bytes:
return struct.pack('>I', len(value)) + kind + value + struct.pack('>I', zlib.crc32(kind + value))
stride = width * 3
rows = b''.join(b'\0' + pixels[start:start + stride] for start in range(0, len(pixels), stride))
transfer = chunk(b'gAMA', struct.pack('>I', 100000)) if linear else chunk(b'sRGB', b'\0')
return (b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0)) + transfer +
chunk(b'IDAT', zlib.compress(rows)) + chunk(b'IEND', b''))
def xr_capture(adb: str, serial: str, destination: Path, timeout: float) -> dict:
if not 0 < timeout <= 120:
raise ControlError("XR capture timeout must be between 0 and 120 seconds", "INVALID_TIMEOUT")
remote = f'/sdcard/Android/data/{PACKAGE}/files/xr-capture'
token = uuid.uuid4().hex
with tempfile.TemporaryDirectory() as temporary:
request = Path(temporary) / 'request'
request.write_text(token, encoding='ascii')
pushed = run_adb(adb, serial, 'push', str(request), remote + '.request.tmp')
if pushed.returncode:
raise ControlError(pushed.stderr.decode(errors='replace'), 'XR_CAPTURE_FAILED')
moved = run_adb(adb, serial, 'shell', 'mv', remote + '.request.tmp', remote + '.request')
if moved.returncode:
raise ControlError(moved.stderr.decode(errors='replace'), 'XR_CAPTURE_FAILED')
deadline = time.monotonic() + timeout
metadata = None
while time.monotonic() < deadline:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
try:
result = run_adb(adb, serial, 'shell', 'cat', remote + '.json', timeout=remaining)
except ControlError as error:
if not isinstance(error.__cause__, subprocess.TimeoutExpired):
raise
break
try:
answer = json.loads(result.stdout)
if answer.get('id') == token:
metadata = answer
break
except (ValueError, AttributeError):
pass
time.sleep(0.2)
if metadata is None:
try:
run_adb(adb, serial, 'shell', 'rm', '-f', remote + '.request', timeout=1)
except ControlError:
pass
raise ControlError('No fresh XR capture. Enter SteamVR using a build with XR capture support.', 'TIMEOUT')
if not metadata.get('ok'):
detail = 'No valid XR projection layer is being submitted' if not metadata.get('validLayer') else 'GPU readback or image write failed'
raise ControlError(detail, 'XR_CAPTURE_FAILED')
images = {}
for eye in (('left', 'right', 'source') if metadata.get('sourceImage') else ('left', 'right')):
pulled = run_adb(adb, serial, 'exec-out', 'gzip', '-c', remote + '-' + eye + '.ppm', timeout=120)
if pulled.returncode:
raise ControlError(pulled.stderr.decode(errors='replace'), 'XR_CAPTURE_FAILED')
try:
pixels = gzip.decompress(pulled.stdout)
except (OSError, EOFError, zlib.error) as error:
raise ControlError('Incomplete compressed XR image', 'XR_CAPTURE_FAILED') from error
images[eye] = xr_ppm_to_png(pixels, linear=eye == 'source' and metadata.get('sourceColorSpace') == 'linear')
# Detect overlapping captures before publishing images with another request's metadata.
check = run_adb(adb, serial, 'shell', 'cat', remote + '.json')
try:
unchanged = json.loads(check.stdout).get('id') == token
except (ValueError, AttributeError):
unchanged = False
if not unchanged:
raise ControlError('XR capture was replaced by another request; retry', 'XR_CAPTURE_FAILED')
try:
destination.mkdir(parents=True, exist_ok=True)
for eye, image in images.items():
(destination / (eye + '.png')).write_bytes(image)
(destination / 'capture.json').write_text(json.dumps(metadata, indent=2), encoding='utf-8')
except OSError as error:
raise ControlError(str(error), 'FILE_IO') from error
return {'ok': True, 'command': 'xr-frame', 'deviceSerial': serial, 'capture': metadata,
'left': str((destination / 'left.png').resolve()), 'right': str((destination / 'right.png').resolve()),
**({'source': str((destination / 'source.png').resolve())} if 'source' in images else {}),
'scope': 'App eye textures before headset compositor reprojection; excludes Meta system overlays'}
def execute(args: argparse.Namespace, adb: str, serial: str) -> dict:
command = args.command
if command == 'xr-frame':
return xr_capture(adb, serial, Path(args.path), args.timeout)
if command == "state":
return add_serial(get_state(adb, serial), serial)
@@ -717,6 +815,10 @@ def build_parser() -> argparse.ArgumentParser:
shot.add_argument("path")
shot.add_argument("--display", type=int, help="logical display id (see displays); default the default display")
xr = commands.add_parser('xr-frame', help='capture both submitted XR eyes and pose metadata')
xr.add_argument('path', help='output directory for left.png, right.png and capture.json')
xr.add_argument('--timeout', type=float, default=15)
rec = commands.add_parser("record", help="record the screen to an mp4")
rec.add_argument("path")
rec.add_argument("--seconds", type=float, default=10)
@@ -738,7 +840,7 @@ EXIT_SESSION = {
}
EXIT_TIMEOUT = {"TIMEOUT", "ARTIFACT_TIMEOUT"}
EXIT_FILE = {"NO_SESSION_LOGS", "LOG_PULL_FAILED", "SCREENSHOT_FAILED", "FILE_IO", "RECORD_FAILED", "PUSH_FAILED",
"PACING_FAILED"}
"PACING_FAILED", "XR_CAPTURE_FAILED"}
EXIT_DENIED = {"AGENT_COMMANDS_DISABLED", "PROTECTED_PREFS", "PROTECTED_CONTROL"}
@@ -177,6 +177,11 @@ class X:
return list(struct.unpack("<%dI" % n, data))
return data
def cardinals(self, window, name, values):
self.s.sendall(struct.pack("<BBHIIIB3xI", 18, 0, 6 + len(values), window,
self.atom(name), self.atom("CARDINAL"), 32, len(values))
+ struct.pack("<%dI" % len(values), *values))
def windows(x):
"""Top-level windows that are mapped or carry an app id: what gamescope picks focus from."""
@@ -249,6 +254,42 @@ def focus_snapshot():
return snap
def vr_scene_app(path=Path('/run/droiddeck/droiddeck-steamvr/scene-focus.json'), proc=Path('/proc')):
"""Only a live SteamVR scene with its dashboard hidden may claim game focus."""
try:
state = json.loads(path.read_text())
server, scene = int(state['serverPid']), int(state['scenePid'])
if server <= 0 or scene <= 0 or state['dashboard'] is not False:
return None
if (proc / str(server) / 'comm').read_text().strip() != 'vrserver':
return None
for entry in (proc / str(scene) / 'environ').read_bytes().split(b'\0'):
if entry.startswith(b'SteamAppId='):
app = int(entry.split(b'=', 1)[1])
return app if 0 < app < 2**32 else None
except (OSError, ValueError, TypeError, KeyError):
pass
return None
class VrSceneFocus:
def __init__(self):
self.original = None
self.written = None
def order(self, current, focusable, app):
if app is None or app not in focusable:
restore = self.original if self.written == current else None
self.original = self.written = None
return restore
if current and current[0] == app:
return None
if self.original is None:
self.original = list(current)
self.written = [app] + [item for item in current if item != app]
return self.written
class FocusWatcher(threading.Thread):
"""Rewrites focus.json when gamescope's focus properties change, and every few seconds when
the windows do (a window can change without any root property moving)."""
@@ -259,15 +300,28 @@ class FocusWatcher(threading.Thread):
self.changed = threading.Event()
self.last = None
self.seq = 0
self.vr_focus = VrSceneFocus()
def run(self):
threading.Thread(target=self.listen, daemon=True, name="focus-events").start()
last_app, last_snapshot = None, 0
while True:
self.changed.wait(timeout=5)
notified = self.changed.wait(timeout=0.5)
self.changed.clear()
app = vr_scene_app()
if not notified and app == last_app and time.monotonic() - last_snapshot < 5:
continue
last_app, last_snapshot = app, time.monotonic()
time.sleep(0.2)
try:
snap = focus_snapshot()
order = self.vr_focus.order(snap.get('baselayerAppIds', []), snap.get('focusableApps', []), app)
if order is not None:
x = X(os.environ['DISPLAY'])
try:
x.cardinals(x.root, 'GAMESCOPECTRL_BASELAYER_APPID', order)
finally:
x.close()
except (OSError, AgentError) as e:
log("focus snapshot failed: %s" % e)
time.sleep(2)
@@ -7,13 +7,21 @@ driver=$vrdir/droiddeck
layer=$vrdir/layer
logs=$HOME/.local/share/Steam/logs
state=${XDG_RUNTIME_DIR:-/tmp}/droiddeck-steamvr
umask 077
mkdir -p "$state"
chmod 700 "$state"
PKG_URL=https://steamdeck-packages.steamos.cloud/archlinux-deckard-hotfixes/release/0.3.x/deckard-steamvr-rel-r25358740%2B28b72a4f-1-aarch64.pkg.tar.zst
PKG_SHA256=90a600775eb3e8856dc475f83166a93a290a0b1f3b4e82a69c8e2a8f06b9ddc9
running() { pgrep -x vrserver >/dev/null 2>&1; }
installed() { [ -x "$steamvr/bin/linuxarm64/vrstartup" ]; }
quest_settings() {
backup=$HOME/.local/share/droiddeck/steamvr-quest-settings.json
[ "$1" != restore ] || [ -f "$backup" ] || return 0
python3 "${DROIDDECK_VR_SETTINGS_HELPER:-/usr/local/bin/droiddeck-steamvr-settings}" "$1" \
"$HOME/.local/share/Steam/config/steamvr.vrsettings" "$backup"
}
stop() {
rm -f "$state/active"
@@ -21,7 +29,16 @@ stop() {
for p in $(pgrep -x "$n"); do kill "$p" 2>/dev/null; done
done
for p in $(pgrep -f "$steamvr/bin/linuxarm64/vrcompositor-launcher"); do kill "$p" 2>/dev/null; done
return 0
attempts=0
while pgrep -x 'vrserver|vrcompositor' >/dev/null 2>&1; do
attempts=$((attempts + 1))
if [ "$attempts" = 10 ]; then
for p in $(pgrep -x 'vrserver|vrcompositor'); do kill -KILL "$p" 2>/dev/null; done
fi
[ "$attempts" -lt 20 ] || { echo 'SteamVR did not stop; preferences remain backed up' >&2; return 1; }
sleep 0.1
done
quest_settings restore
}
case "${1:-status}" in
@@ -52,12 +69,12 @@ install)
echo "SteamVR installed in $steamvr"
;;
stop)
stop
stop || exit 1
;;
start)
installed || { echo "SteamVR is not installed (droiddeck-steamvr install)"; exit 1; }
[ -f "$driver/bin/linuxarm64/driver_droiddeck.so" ] || { echo "the Cardboard driver is missing from $driver"; exit 1; }
stop
stop || exit 1
sleep 1
mkdir -p "$HOME/.config/openvr" "$HOME/.config/openxr/1" "$logs" "$HOME/.local/share/Steam/config"
cat > "$HOME/.config/openvr/openvrpaths.vrpath" <<JSON
@@ -75,6 +92,29 @@ 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=questwsi
fi
if [ "${DROIDDECK_VR_PRESENT:-}" = questwsi ]; then
quest_settings apply || exit 1
startup_handed_off=0
trap '[ "$startup_handed_off" = 1 ] || quest_settings restore' EXIT
# The pinned Frame package's CVP constructor exits on Quest's unsupported SoC.
cvp=$steamvr/drivers/cv/bin/linuxarm64/libArcturusPerception.so
if [ -f "$cvp" ]; then
disabled=$cvp.droiddeck-disabled
if [ -e "$disabled" ]; then disabled=$(mktemp "$disabled.XXXXXX") || exit 1; fi
mv "$cvp" "$disabled" || exit 1
echo "Disabled Frame-only CVP backend for Quest; SteamVR uses its normal compositor"
fi
fi
if [ "${DROIDDECK_VR_PRESENT:-}" = questframes ]; then
export DROIDDECK_VR_PRODUCER_LEASE=${DROIDDECK_VR_PRODUCER_LEASE:-1}
fi
if [ -z "${DROIDDECK_VR_DISPLAY_MODE:-}" ] && [ -x "$driver/bin/linuxarm64/xr-display-mode" ]; then
mode=$("$driver/bin/linuxarm64/xr-display-mode") || mode=
if [ -n "$mode" ]; then export DROIDDECK_VR_DISPLAY_MODE="$mode"; fi
fi
shims=$state/shims
mkdir -p "$shims"
@@ -86,7 +126,10 @@ JSON
: > "$state/active"
cd "$steamvr/bin/linuxarm64" || exit 1
setsid ./vrstartup > "$state/vrstartup.log" 2>&1 < /dev/null &
startup_log=$logs/droiddeck-vrstartup.log
ln -sf "$startup_log" "$state/vrstartup.log"
setsid ./vrstartup > "$startup_log" 2>&1 < /dev/null &
startup_handed_off=1
echo "SteamVR starting; logs in $logs and $state/vrstartup.log"
;;
*)
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
set -eu
[ "$#" -gt 0 ] || { echo 'usage: droiddeck-steamvr-game COMMAND [ARG...]' >&2; exit 2; }
pgrep -x vrserver >/dev/null 2>&1 || {
echo 'Start SteamVR before launching a game with droiddeck-steamvr-game.' >&2
exit 1
}
vrdir=${DROIDDECK_VR_DIR:-/usr/local/lib/droiddeck-vr}
export VK_LAYER_PATH="$vrdir/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~}"
# Quest's guest GPU images use Vulkan sharing rather than DMA-BUF modifiers.
if [ -S "/run/droiddeck/droiddeck-xr-frames" ]; then
export gamescope_vr_use_modifiers=${gamescope_vr_use_modifiers:-false}
fi
export gamescope_vr_nudge_to_visible=${gamescope_vr_nudge_to_visible:-true}
exec gamescope --backend openvr \
--vr-overlay-key "gamescope.droiddeck.game.$$" \
--vr-overlay-default-name 'DroidDeck game' \
--vr-overlay-show-immediately --vr-overlay-physical-width 2.4 -- "$@"
@@ -0,0 +1,80 @@
#!/usr/bin/python3
"""Preserve SteamVR preferences across Quest's required compositor overrides."""
import json
import os
from pathlib import Path
import sys
OVERRIDES = {'enableLinuxVulkanAsync': True, 'motionSmoothing': False}
def read(path):
try:
with path.open() as source:
settings = json.load(source)
except FileNotFoundError:
return {}
if not isinstance(settings, dict) or not isinstance(settings.get('steamvr', {}), dict):
raise ValueError('Invalid SteamVR settings object')
return settings
def write(path, value):
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
temporary = path.with_name(path.name + '.tmp')
with os.fdopen(os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600), 'w') as destination:
json.dump(value, destination, indent=3)
destination.write('\n')
os.replace(temporary, path)
def saved_preferences(path):
try:
backup = read(path)
if not isinstance(backup.get('section'), bool) or not isinstance(backup.get('values'), dict):
raise ValueError('invalid backup object')
for key in OVERRIDES:
entry = backup['values'].get(key)
if not isinstance(entry, list) or len(entry) != 2 or not isinstance(entry[0], bool):
raise ValueError('invalid saved preference: ' + key)
return backup
except ValueError as error:
raise ValueError(f'Invalid preference backup {path}; repair the saved section/values and retry restore. '
'The backup and current preferences have been preserved.') from error
def update(operation, settings_path, backup_path):
settings = read(settings_path)
if operation == 'apply':
if backup_path.exists():
saved_preferences(backup_path)
else:
section = settings.get('steamvr', {})
write(backup_path, {'section': 'steamvr' in settings,
'values': {key: [key in section, section.get(key)] for key in OVERRIDES}})
settings.setdefault('steamvr', {}).update(OVERRIDES)
write(settings_path, settings)
elif operation == 'restore':
if not backup_path.exists():
return
backup = saved_preferences(backup_path)
section = settings.setdefault('steamvr', {})
for key in OVERRIDES:
present, value = backup['values'][key]
if present:
section[key] = value
else:
section.pop(key, None)
if not section and not backup['section']:
settings.pop('steamvr')
write(settings_path, settings)
backup_path.unlink()
else:
raise ValueError('Expected apply or restore')
if __name__ == '__main__':
try:
update(sys.argv[1], Path(sys.argv[2]), Path(sys.argv[3]))
except (OSError, ValueError, KeyError, IndexError, TypeError) as error:
sys.exit('Could not update SteamVR settings: ' + str(error))
+1 -1
View File
@@ -8,7 +8,7 @@ from zipfile import ZipFile
SYSTEM = set('libc.so libm.so libdl.so liblog.so libandroid.so libz.so libvulkan.so libstdc++.so '
'libGLESv2.so libEGL.so libnativewindow.so libjnigraphics.so libaaudio.so '
'libGLESv2.so libGLESv3.so libEGL.so libnativewindow.so libjnigraphics.so libaaudio.so '
'libOpenSLES.so libmediandk.so libcamera2ndk.so libsync.so libneuralnetworks.so'.split())
EXPORTS = ['Java_com_droiddeck_launcher_stores_StoresNative_nativeVersion',
'Java_com_droiddeck_launcher_stores_gog_GogNative_nativeStart',
+10 -1
View File
@@ -1,10 +1,19 @@
"""Reject an APK with missing or stale session helpers, including cached local bundles."""
from pathlib import Path
import re
import sys
from zipfile import ZipFile
OVERLAY = Path(__file__).resolve().parents[1] / 'linuxfs/overlay'
SESSION_FILES = OVERLAY.parents[2] / 'app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt'
def check_extraction(overlay, source):
deployed = set(re.findall(r'"(usr/local/bin/droiddeck-[^"]+)"\s+to\s+"\1"', source))
return ['not extracted at session start: ' + path.relative_to(overlay).as_posix()
for path in sorted((overlay / 'usr/local/bin').glob('droiddeck-*'))
if path.relative_to(overlay).as_posix() not in deployed]
def source_assets(overlay=OVERLAY):
@@ -23,7 +32,7 @@ def source_assets(overlay=OVERLAY):
def check(apk, overlay=OVERLAY):
errors = []
errors = check_extraction(overlay, SESSION_FILES.read_text()) if overlay == OVERLAY else []
with ZipFile(apk) as package:
for source, asset in source_assets(overlay).items():
try:
+134
View File
@@ -0,0 +1,134 @@
import argparse
import json
import os
import subprocess
import time
from pathlib import Path
from adb_device import resolve_serial
def validate_samples(samples, require_controllers=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 samples[-1]['sessionState'] != 5 or samples[-1]['headFlags'] & 15 != 15:
return 'Session did not remain focused with tracked 6DoF through the final sample'
if require_controllers and not any(all(sample['controllersActive']) for sample in samples):
return 'Both controllers did not become active'
if samples[-1].get('uploadedFrames', 0) - samples[0].get('uploadedFrames', 0) < 72:
return 'No advancing owned SteamVR images; XR submissions alone are insufficient'
if samples[-1].get('sourceFrameId', 0) <= samples[0].get('sourceFrameId', 0):
return 'SteamVR source frame IDs did not advance'
if samples[-1].get('steamvrState') != 4:
return 'SteamVR did not report RUNNING'
expected = 'frames'
if samples[-1].get('source') != expected:
return 'Wrong projection source'
return None
def summarize_samples(samples):
result = {'sampleCount': len(samples), 'focusedTrackedFraction': None, 'observedSeconds': None, 'ratesHz': {}}
if not samples:
return result
result['focusedTrackedFraction'] = sum(sample.get('sessionState') == 5 and sample.get('headFlags', 0) & 15 == 15
for sample in samples) / len(samples)
if len(samples) < 2 or '_hostMonotonicSeconds' not in samples[0] or '_hostMonotonicSeconds' not in samples[-1]:
return result
duration = samples[-1]['_hostMonotonicSeconds'] - samples[0]['_hostMonotonicSeconds']
if duration <= 0:
return result
result['observedSeconds'] = duration
for counter in ('rendered', 'guestFrames', 'receivedFrames', 'rejectedFrames', 'uploadedFrames'):
if counter in samples[0] and counter in samples[-1]:
delta = samples[-1][counter] - samples[0][counter]
result['ratesHz'][counter] = delta / duration if delta >= 0 else None
period = samples[-1].get('periodNs', 0)
result['runtimeRefreshHz'] = 1000000000 / period if period > 0 else None
return result
def collect_device_metrics(adb, package):
result = {}
for name, command in [('battery', ('shell', 'dumpsys', 'battery')),
('thermal', ('shell', 'dumpsys', 'thermalservice')),
('memory', ('shell', 'dumpsys', 'meminfo', package))]:
try:
result[name] = {'available': True, 'text': adb(*command)}
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
result[name] = {'available': False, 'error': type(error).__name__}
return result
def main():
parser = argparse.ArgumentParser(description='Validate immersive DroidDeck XR on a connected Quest')
parser.add_argument('--adb', default=os.environ.get('ADB', 'adb'))
parser.add_argument('--serial')
parser.add_argument('--seconds', type=int, default=20)
parser.add_argument('--output', type=Path, default=Path('build/questxr-validation'))
parser.add_argument('--require-controllers', action='store_true')
parser.add_argument('--keep-awake', action='store_true')
parser.add_argument('--metrics', action='store_true', help='Record public battery, thermal and package memory dumps before/after testing')
args = parser.parse_args()
if args.seconds < 5 or args.seconds > 600:
parser.error('--seconds must be between 5 and 600')
serial = resolve_serial(args.adb, args.serial)
args.output.mkdir(parents=True, exist_ok=True)
def adb(*command):
return subprocess.run([args.adb, '-s', serial, *command], check=True,
capture_output=True, text=True, timeout=15).stdout
package = 'com.droiddeck.launcher'
health_path = f'/sdcard/Android/data/{package}/files/xr-health.json'
adb('shell', 'rm', '-f', health_path)
adb('shell', 'input', 'keyevent', 'KEYCODE_WAKEUP')
launch = ['shell', 'am', 'start', '-n', f'{package}/.QuestXrActivity']
samples = []
failure = None
metrics = {'before': collect_device_metrics(adb, package)} if args.metrics else None
try:
adb('shell', 'am', 'broadcast', '-a', 'com.oculus.vrpowermanager.prox_close')
adb(*launch)
deadline = time.monotonic() + args.seconds
while time.monotonic() < deadline:
time.sleep(1)
adb('shell', 'am', 'broadcast', '-a', 'com.oculus.vrpowermanager.prox_close')
try:
sample = json.loads(adb('shell', 'cat', health_path))
sample['_hostMonotonicSeconds'] = time.monotonic()
samples.append(sample)
if sample.get('error'):
failure = sample['error']
break
except (subprocess.CalledProcessError, json.JSONDecodeError):
pass
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
failure = f'ADB operation failed: {type(error).__name__}'
finally:
if not args.keep_awake:
try:
adb('shell', 'am', 'broadcast', '-a', 'com.oculus.vrpowermanager.prox_far')
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
failure = failure or 'Could not restore headset proximity after testing'
try:
log = adb('logcat', '-d', '-s', 'DroidDeckXR:I', 'OpenXR-Loader:W', 'AndroidRuntime:E')
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
log = str(error)
(args.output / 'logcat.txt').write_text(log, encoding='utf-8')
if metrics is not None:
metrics['after'] = collect_device_metrics(adb, package)
failure = failure or validate_samples(samples, args.require_controllers)
report = {'ok': failure is None, 'error': failure, 'samples': samples, 'summary': summarize_samples(samples), 'deviceMetrics': metrics}
(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),
'last': samples[-1] if samples else None}, indent=2))
return 0 if report['ok'] else 1
if __name__ == '__main__':
raise SystemExit(main())
+30
View File
@@ -335,5 +335,35 @@ class AgentTest(unittest.TestCase):
self.assertIn(child.pid, agent.game_pids(424242))
class VrFocusTest(unittest.TestCase):
def test_scene_dashboard_and_external_focus_changes(self):
focus = agent.VrSceneFocus()
self.assertEqual(focus.order([9, 7], [7, 42], 42), [42, 9, 7])
self.assertIsNone(focus.order([42, 9, 7], [7, 42], 42))
self.assertEqual(focus.order([42, 9, 7], [7, 42], None), [9, 7])
self.assertIsNone(focus.order([9, 7], [7], 42))
self.assertEqual(focus.order([9, 7], [7, 42], 42), [42, 9, 7])
# Do not overwrite a different focus order installed after our last write.
self.assertIsNone(focus.order([123, 7], [7, 42], None))
def test_scene_requires_live_server_and_game_environment(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
state = root / 'scene.json'
for pid in (100, 200):
(root / str(pid)).mkdir()
(root / '100/comm').write_text('vrserver\n')
(root / '200/environ').write_bytes(b'SteamAppId=42\0Other=value\0')
state.write_text(json.dumps(dict(serverPid=100, scenePid=200, dashboard=False)))
self.assertEqual(agent.vr_scene_app(state, root), 42)
state.write_text(json.dumps(dict(serverPid=100, scenePid=200, dashboard=True)))
self.assertIsNone(agent.vr_scene_app(state, root))
state.write_text(json.dumps(dict(serverPid=100, scenePid=200, dashboard=False)))
(root / '100/comm').write_text('other\n')
self.assertIsNone(agent.vr_scene_app(state, root))
(root / '100/comm').unlink()
self.assertIsNone(agent.vr_scene_app(state, root))
if __name__ == "__main__":
unittest.main()
+119
View File
@@ -0,0 +1,119 @@
import importlib.machinery
import importlib.util
import json
import gzip
from pathlib import Path
import struct
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
import zlib
TOOLS = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(TOOLS))
loader = importlib.machinery.SourceFileLoader('ctl_xr', str(TOOLS / 'droiddeckctl'))
spec = importlib.util.spec_from_loader(loader.name, loader)
ctl = importlib.util.module_from_spec(spec)
loader.exec_module(ctl)
class XrCaptureTest(unittest.TestCase):
def test_png_keeps_rgb_rows_and_dimensions(self):
pixels = bytes([255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255])
png = ctl.xr_ppm_to_png(b'P6\n2 2\n255\n' + pixels)
self.assertEqual(png[:8], b'\x89PNG\r\n\x1a\n')
self.assertEqual(struct.unpack('>II', png[16:24]), (2, 2))
position = png.index(b'IDAT')
size = struct.unpack('>I', png[position - 4:position])[0]
self.assertEqual(zlib.decompress(png[position + 4:position + 4 + size]),
b'\0' + pixels[:6] + b'\0' + pixels[6:])
def test_incomplete_or_invalid_images_are_rejected(self):
for data in (b'P6\n2 2\n255\nabc', b'P6\n0 1\n255\n', b'P6\n999999 1\n255\n', b'junk'):
with self.subTest(data=data), self.assertRaises(ctl.ControlError):
ctl.xr_ppm_to_png(data)
def test_linear_source_and_display_eye_have_distinct_png_transfer_tags(self):
image = b'P6\n1 1\n255\n\x37\x37\x37'
self.assertIn(b'gAMA' + struct.pack('>I', 100000), ctl.xr_ppm_to_png(image, linear=True))
self.assertIn(b'sRGB\0', ctl.xr_ppm_to_png(image))
def capture(self, root, failed=False, replaced=False, no_response=False, source=False, truncated=False):
calls = []
token = None
reads = 0
def adb(_adb, _serial, *args, **kwargs):
nonlocal token, reads
calls.append(args)
stdout = b''
if args[0] == 'push':
token = Path(args[1]).read_text()
if args[:2] == ('shell', 'cat'):
reads += 1
stdout = json.dumps({'id': ('old' if reads == 1 or no_response or (replaced and reads > 2) else token),
'ok': not failed, 'validLayer': not failed, 'sourceImage': source}).encode()
if args[0] == 'exec-out':
stdout = gzip.compress(b'P6\n1 1\n255\n\x12\x34\x56')
if truncated:
stdout = stdout[:-5]
return subprocess.CompletedProcess(args, 0, stdout, b'')
with patch.object(ctl, 'run_adb', side_effect=adb), patch.object(ctl.time, 'sleep'):
result = ctl.xr_capture('adb', 'quest', root, 0.01 if no_response else 2)
return result, calls
def test_stale_response_is_ignored_and_matching_pair_is_published(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory) / 'capture'
result, calls = self.capture(root)
self.assertTrue(result['ok'])
self.assertEqual((root / 'left.png').read_bytes(), (root / 'right.png').read_bytes())
self.assertEqual(len([c for c in calls if c[:2] == ('shell', 'cat')]), 3)
self.assertTrue((root / 'capture.json').is_file())
def test_invalid_layer_replaced_capture_and_timeout_publish_no_images(self):
for flags in ({'failed': True}, {'replaced': True}, {'no_response': True}, {'truncated': True}):
with self.subTest(flags=flags), tempfile.TemporaryDirectory() as directory:
root = Path(directory) / 'capture'
with self.assertRaises(ctl.ControlError):
self.capture(root, **flags)
self.assertFalse(root.exists())
def test_source_capture_is_pulled_when_supported_by_the_headset(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory) / 'capture'
result, calls = self.capture(root, source=True)
self.assertEqual(result['source'], str((root / 'source.png').resolve()))
self.assertEqual(len([c for c in calls if c[0] == 'exec-out']), 3)
def test_output_creation_and_write_failures_are_file_errors(self):
for operation in ('mkdir', 'write_bytes'):
with self.subTest(operation=operation), tempfile.TemporaryDirectory() as directory:
with patch.object(Path, operation, side_effect=PermissionError('output denied')):
with self.assertRaises(ctl.ControlError) as caught:
self.capture(Path(directory) / 'capture')
self.assertEqual(caught.exception.code, 'FILE_IO')
self.assertEqual(ctl.exit_code(caught.exception), 6)
def test_stalled_metadata_read_uses_capture_deadline_and_reports_timeout(self):
calls = []
def adb(_adb, _serial, *args, **kwargs):
calls.append((args, kwargs))
if args[:2] == ('shell', 'cat'):
self.assertGreater(kwargs['timeout'], 0)
self.assertLessEqual(kwargs['timeout'], 0.1)
try:
raise subprocess.TimeoutExpired('adb', kwargs['timeout'])
except subprocess.TimeoutExpired as cause:
raise ctl.ControlError('metadata stalled', 'ADB_FAILED') from cause
return subprocess.CompletedProcess(args, 0, b'', b'')
with tempfile.TemporaryDirectory() as directory, patch.object(ctl, 'run_adb', side_effect=adb):
with self.assertRaises(ctl.ControlError) as caught:
ctl.xr_capture('adb', 'quest', Path(directory) / 'capture', 0.1)
self.assertEqual(caught.exception.code, 'TIMEOUT')
self.assertTrue(any(args[:3] == ('shell', 'rm', '-f') for args, _ in calls))
if __name__ == '__main__':
unittest.main()
+85
View File
@@ -0,0 +1,85 @@
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from test_quest_xr import collect_device_metrics, summarize_samples, validate_samples
import subprocess
class QuestXrValidationTest(unittest.TestCase):
def samples(self, source='frames'):
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_requires_owned_frame_projection(self):
self.assertIsNone(validate_samples(self.samples()))
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_one_early_tracked_sample_cannot_hide_final_tracking_loss(self):
samples = self.samples()
samples[-1]['headFlags'] = 7
self.assertIn('final sample', validate_samples(samples))
samples[-1]['headFlags'] = 15
samples[-1]['sessionState'] = 4
self.assertIn('final sample', validate_samples(samples))
def test_measured_rates_are_separate_from_runtime_refresh(self):
samples = self.samples('frames')
samples[0]['_hostMonotonicSeconds'] = 10
samples[-1]['_hostMonotonicSeconds'] = 12
samples[-1]['periodNs'] = 8333333
summary = summarize_samples(samples)
self.assertEqual(summary['observedSeconds'], 2)
self.assertEqual(summary['ratesHz']['uploadedFrames'], 72)
self.assertAlmostEqual(summary['runtimeRefreshHz'], 120, places=3)
self.assertEqual(summary['focusedTrackedFraction'], 1)
samples[-1]['uploadedFrames'] = -1
self.assertIsNone(summarize_samples(samples)['ratesHz']['uploadedFrames'])
self.assertIsNone(summarize_samples(self.samples())['observedSeconds'])
self.assertIsNone(summarize_samples([])['focusedTrackedFraction'])
def test_metrics_failures_stay_unavailable_without_privileged_fallback(self):
commands = []
def adb(*command):
commands.append(command)
if command[-1] == 'battery':
return 'temperature: 310'
raise subprocess.CalledProcessError(1, command, stderr='Permission denied')
metrics = collect_device_metrics(adb, 'com.droiddeck.launcher')
self.assertEqual(len(commands), 3)
self.assertTrue(metrics['battery']['available'])
self.assertFalse(metrics['thermal']['available'])
self.assertFalse(metrics['memory']['available'])
def test_steamvr_requires_owned_images_and_source_frame_ids(self):
samples = self.samples('frames')
self.assertIsNone(validate_samples(samples))
samples[-1]['uploadedFrames'] = 0
self.assertIn('owned SteamVR images', validate_samples(samples))
samples[-1]['uploadedFrames'] = 144
samples[-1]['sourceFrameId'] = samples[0]['sourceFrameId']
self.assertIn('frame IDs', validate_samples(samples))
def test_running_process_alone_is_not_success(self):
samples = self.samples('frames')
samples[-1]['rendered'] = 0
self.assertIn('stereo frames', validate_samples(samples))
samples[-1]['rendered'] = 144
samples[-1]['steamvrState'] = 3
self.assertIn('RUNNING', validate_samples(samples))
if __name__ == '__main__':
unittest.main()
+6
View File
@@ -37,3 +37,9 @@ class SessionAssetsTest(unittest.TestCase):
with ZipFile(self.apk, 'w') as package:
package.writestr(self.name, self.helper.read_bytes())
self.assertEqual([], assets.check(self.apk, self.overlay))
def test_bundled_helper_must_also_be_extracted(self):
self.assertEqual(['not extracted at session start: usr/local/bin/droiddeck-login1'],
assets.check_extraction(self.overlay, ''))
mapping = '"usr/local/bin/droiddeck-login1" to "usr/local/bin/droiddeck-login1"'
self.assertEqual([], assets.check_extraction(self.overlay, mapping))
+65
View File
@@ -0,0 +1,65 @@
import json
import os
from pathlib import Path
import socket
import subprocess
import tempfile
import unittest
SOURCE = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin/droiddeck-steamvr-game'
class SteamVrGameTest(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.helper = self.root / 'helper'
self.assertIn('/run/droiddeck/droiddeck-xr-frames', SOURCE.read_text())
self.helper.write_text(SOURCE.read_text().replace('/run/droiddeck/droiddeck-xr-frames',
str(self.root / 'droiddeck-xr-frames')), encoding='utf-8')
self.command('pgrep', '#!/bin/sh\nexit 0\n')
self.command('gamescope', '#!/usr/bin/env python3\nimport json, os, sys\n'
'print(json.dumps(dict(argv=sys.argv[1:], modifiers=os.getenv("gamescope_vr_use_modifiers"), '
'layers=os.getenv("VK_INSTANCE_LAYERS"))))\n')
self.env = {key: value for key, value in os.environ.items()
if not key.startswith(('gamescope_vr_', 'VK_'))}
self.env.update(PATH=str(self.root) + os.pathsep + os.environ['PATH'],
XDG_RUNTIME_DIR=str(self.root))
def command(self, name, text):
path = self.root / name
path.write_text(text, encoding='utf-8')
path.chmod(0o755)
def run_game(self, *args):
return subprocess.run(['sh', str(self.helper), *args], env=self.env,
capture_output=True, text=True, timeout=5)
def test_native_backend_preserves_command_arguments_and_nonquest_defaults(self):
result = self.run_game('game with spaces', 'a b', '$literal')
self.assertEqual(result.returncode, 0, result.stderr)
data = json.loads(result.stdout)
self.assertEqual(data['argv'][:2], ['--backend', 'openvr'])
self.assertEqual(data['argv'][-4:], ['--', 'game with spaces', 'a b', '$literal'])
self.assertIsNone(data['modifiers'])
self.assertEqual(data['layers'], 'VK_LAYER_DROIDDECK_vr')
def test_quest_selects_vulkan_sharing_and_preserves_explicit_override(self):
with socket.socket(socket.AF_UNIX) as receiver:
receiver.bind(str(self.root / 'droiddeck-xr-frames'))
self.assertEqual(json.loads(self.run_game('game').stdout)['modifiers'], 'false')
self.env['gamescope_vr_use_modifiers'] = 'true'
self.assertEqual(json.loads(self.run_game('game').stdout)['modifiers'], 'true')
def test_missing_runtime_and_missing_command_fail_clearly(self):
self.assertEqual(self.run_game().returncode, 2)
self.command('pgrep', '#!/bin/sh\nexit 1\n')
result = self.run_game('game')
self.assertEqual(result.returncode, 1)
self.assertIn('Start SteamVR', result.stderr)
if __name__ == '__main__':
unittest.main()
+203
View File
@@ -0,0 +1,203 @@
import os
import json
import shutil
import socket
import subprocess
import tempfile
import time
import unittest
from pathlib import Path
SOURCE = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin/droiddeck-steamvr'
class SteamVrHelperTest(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.home = self.root / 'home'
self.runtime = self.root / 'runtime'
self.commands = self.root / 'commands'
self.driver = self.root / 'driver/droiddeck/bin/linuxarm64'
self.steamvr = self.root / 'steamvr'
for directory in (self.commands, self.driver, self.steamvr / 'bin/linuxarm64'):
directory.mkdir(parents=True)
self.helper = self.root / 'droiddeck-steamvr'
self.assertIn('/run/droiddeck/droiddeck-xr-frames', SOURCE.read_text())
self.helper.write_text(SOURCE.read_text().replace('/run/droiddeck/droiddeck-xr-frames',
str(self.runtime / 'droiddeck-xr-frames')), encoding='utf-8')
self.executable(self.commands / 'pgrep', 'exit 1')
self.executable(self.commands / 'sleep', 'exit 0')
self.executable(self.commands / 'xset', 'exit 0')
self.executable(self.commands / 'setsid', 'exec "$@"')
self.executable(self.driver / 'xr-display-mode', 'echo 3360x1760@120')
self.executable(self.steamvr / 'bin/linuxarm64/vrstartup',
'echo "display=${DROIDDECK_VR_DISPLAY_MODE:-unset}"')
(self.driver / 'driver_droiddeck.so').touch()
self.environment = dict(os.environ)
self.environment.pop('DROIDDECK_VR_DISPLAY_MODE', None)
self.environment.pop('DROIDDECK_VR_PRESENT', None)
self.environment.pop('DROIDDECK_VR_PRODUCER_LEASE', None)
self.environment.update(HOME=str(self.home), XDG_RUNTIME_DIR=str(self.runtime),
STEAMVR=str(self.steamvr), DROIDDECK_VR_DIR=str(self.root / 'driver'),
DROIDDECK_VR_SETTINGS_HELPER=str(SOURCE.with_name('droiddeck-steamvr-settings')),
PATH=str(self.commands) + os.pathsep + os.environ['PATH'])
def executable(self, destination, body):
destination.write_text('#!/bin/sh\n' + body + '\n', encoding='utf-8')
destination.chmod(0o755)
def start(self):
result = subprocess.run(['sh', str(self.helper), 'start'], env=self.environment,
capture_output=True, text=True, timeout=5)
self.assertEqual(result.returncode, 0, result.stderr)
log = self.home / '.local/share/Steam/logs/droiddeck-vrstartup.log'
deadline = time.monotonic() + 2
while time.monotonic() < deadline:
if log.is_file() and 'display=' in log.read_text():
return log
time.sleep(0.01)
self.fail('Startup log did not arrive')
def test_fresh_runtime_mode_is_selected_and_log_survives_runtime_cleanup(self):
log = self.start()
self.assertEqual((self.runtime / 'droiddeck-steamvr').stat().st_mode & 0o777, 0o700)
self.assertEqual(log.read_text().strip(), 'display=3360x1760@120')
self.assertEqual((self.runtime / 'droiddeck-steamvr/vrstartup.log').resolve(), log)
shutil.rmtree(self.runtime)
self.assertTrue(log.is_file())
def test_explicit_display_override_is_preserved(self):
self.environment['DROIDDECK_VR_DISPLAY_MODE'] = '1920x1080@90'
self.assertEqual(self.start().read_text().strip(), 'display=1920x1080@90')
def test_stale_or_missing_tracking_keeps_the_default(self):
self.executable(self.driver / 'xr-display-mode', 'exit 1')
self.assertEqual(self.start().read_text().strip(), 'display=unset')
def test_older_runtime_without_the_reader_still_starts(self):
(self.driver / 'xr-display-mode').unlink()
self.assertEqual(self.start().read_text().strip(), 'display=unset')
def lease_log(self):
self.executable(self.steamvr / 'bin/linuxarm64/vrstartup',
'printf "display=%s present=%s lease=%s\\n" "${DROIDDECK_VR_DISPLAY_MODE:-unset}" '
'"${DROIDDECK_VR_PRESENT:-unset}" "${DROIDDECK_VR_PRODUCER_LEASE:-unset}"')
return self.start().read_text().strip()
def test_quest_receiver_uses_normal_compositor_and_excludes_unsupported_frame_backend(self):
self.runtime.mkdir()
cvp = self.steamvr / 'drivers/cv/bin/linuxarm64/libArcturusPerception.so'
cvp.parent.mkdir(parents=True)
cvp.write_bytes(b'frame-only backend')
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as receiver:
receiver.bind(str(self.runtime / 'droiddeck-xr-frames'))
self.assertIn('present=questwsi lease=unset', self.lease_log())
settings = json.loads((self.home / '.local/share/Steam/config/steamvr.vrsettings').read_text())
self.assertEqual(settings['steamvr'], {'enableLinuxVulkanAsync': True, 'motionSmoothing': False})
self.assertFalse(cvp.exists())
self.assertEqual(Path(str(cvp) + '.droiddeck-disabled').read_bytes(), b'frame-only backend')
def test_quest_settings_preserve_other_preferences_and_start_is_idempotent(self):
self.environment['DROIDDECK_VR_PRESENT'] = 'questwsi'
path = self.home / '.local/share/Steam/config/steamvr.vrsettings'
path.parent.mkdir(parents=True)
path.write_text(json.dumps({'steamvr': {'somePreference': 42}, 'dashboard': {'somePreference': True}}))
self.start()
self.start()
settings = json.loads(path.read_text())
self.assertEqual(settings['steamvr']['somePreference'], 42)
self.assertEqual(settings['dashboard'], {'somePreference': True})
def test_quest_refuses_to_overwrite_malformed_settings(self):
self.environment['DROIDDECK_VR_PRESENT'] = 'questwsi'
path = self.home / '.local/share/Steam/config/steamvr.vrsettings'
path.parent.mkdir(parents=True)
path.write_text('invalid json')
result = subprocess.run(['sh', str(self.helper), 'start'], env=self.environment,
capture_output=True, text=True, timeout=5)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(path.read_text(), 'invalid json')
def test_quest_stop_restores_only_overridden_preferences(self):
self.environment['DROIDDECK_VR_PRESENT'] = 'questwsi'
path = self.home / '.local/share/Steam/config/steamvr.vrsettings'
path.parent.mkdir(parents=True)
path.write_text(json.dumps({'steamvr': {'motionSmoothing': True, 'somePreference': 42}}))
self.start()
settings = json.loads(path.read_text())
settings['dashboard'] = {'changedDuringSession': True}
path.write_text(json.dumps(settings))
subprocess.run(['sh', str(self.helper), 'stop'], env=self.environment, check=True)
self.assertEqual(json.loads(path.read_text()), {'steamvr': {'motionSmoothing': True, 'somePreference': 42},
'dashboard': {'changedDuringSession': True}})
def test_invalid_backup_preserves_preferences_until_repaired(self):
settings = self.root / 'steamvr.vrsettings'
backup = self.root / 'quest-backup.json'
helper = SOURCE.with_name('droiddeck-steamvr-settings')
original = {'steamvr': {'motionSmoothing': True}, 'dashboard': {'unchanged': 42}}
settings.write_text(json.dumps(original))
subprocess.run([str(helper), 'apply', str(settings), str(backup)], check=True)
valid_backup = backup.read_bytes()
applied = settings.read_bytes()
invalid = ['truncated {', '{}', '{"section": true, "values": []}',
'{"section": false, "values": {"motionSmoothing": [false]}}']
for content in invalid:
for operation in ('apply', 'restore'):
with self.subTest(content=content, operation=operation):
backup.write_text(content)
result = subprocess.run([str(helper), operation, str(settings), str(backup)],
capture_output=True, text=True)
self.assertNotEqual(result.returncode, 0)
self.assertIn('repair the saved section/values and retry restore', result.stderr)
self.assertEqual(backup.read_text(), content)
self.assertEqual(settings.read_bytes(), applied)
backup.write_bytes(valid_backup)
subprocess.run([str(helper), 'restore', str(settings), str(backup)], check=True)
self.assertEqual(json.loads(settings.read_text()), original)
self.assertFalse(backup.exists())
def test_quest_update_preserves_previous_cvp_backup(self):
self.environment['DROIDDECK_VR_PRESENT'] = 'questwsi'
cvp = self.steamvr / 'drivers/cv/bin/linuxarm64/libArcturusPerception.so'
cvp.parent.mkdir(parents=True)
cvp.write_bytes(b'updated library')
backup = Path(str(cvp) + '.droiddeck-disabled')
backup.write_bytes(b'previous library')
self.start()
self.assertFalse(cvp.exists())
self.assertEqual(backup.read_bytes(), b'previous library')
self.assertEqual([p.read_bytes() for p in cvp.parent.glob('*.droiddeck-disabled.*')], [b'updated library'])
def test_start_failure_after_overrides_restores_preferences(self):
self.environment['DROIDDECK_VR_PRESENT'] = 'questwsi'
path = self.home / '.local/share/Steam/config/steamvr.vrsettings'
path.parent.mkdir(parents=True)
original = {'steamvr': {'enableLinuxVulkanAsync': False, 'motionSmoothing': True}}
path.write_text(json.dumps(original))
cvp = self.steamvr / 'drivers/cv/bin/linuxarm64/libArcturusPerception.so'
cvp.parent.mkdir(parents=True)
cvp.touch()
self.executable(self.commands / 'mv', 'exit 1')
result = subprocess.run(['sh', str(self.helper), 'start'], env=self.environment,
capture_output=True, text=True, timeout=5)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(json.loads(path.read_text()), original)
def test_explicit_quest_presenter_enables_the_lease(self):
self.environment['DROIDDECK_VR_PRESENT'] = 'questframes'
self.assertIn('present=questframes lease=1', self.lease_log())
def test_explicit_diagnostic_override_is_preserved(self):
self.environment.update(DROIDDECK_VR_PRESENT='questframes', DROIDDECK_VR_PRODUCER_LEASE='0')
self.assertIn('present=questframes lease=0', self.lease_log())
def test_flat_presenter_does_not_enable_the_producer_lease(self):
self.assertIn('present=unset lease=unset', self.lease_log())
if __name__ == '__main__':
unittest.main()
+25
View File
@@ -0,0 +1,25 @@
import json
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
@unittest.skipUnless(shutil.which("g++"), "needs a Linux C++ compiler")
class XrChaperoneTest(unittest.TestCase):
def test_seated_origin_and_stage_preservation(self):
repository = Path(__file__).resolve().parents[2]
with tempfile.TemporaryDirectory() as directory:
executable = Path(directory) / "chaperone-test"
subprocess.run(["g++", "-std=c++17", "-Wall", "-Wextra", "-Werror", "-O2",
str(repository / "tools/vr/tests/test_xr_chaperone.cpp"), "-o", str(executable)], check=True)
result = json.loads(subprocess.check_output([str(executable)], text=True))
self.assertEqual((result["jsonid"], result["version"]), ("chaperone_info", 5))
self.assertEqual(result["time"], "2026-10-15T21:00:00Z")
universe, = result["universes"]
self.assertEqual(int(universe["universeID"]), 2)
self.assertEqual(universe["standing"], {"translation": [0, 0, 0], "yaw": 0})
self.assertEqual(universe["collision_bounds"], [])
self.assertEqual(universe["play_area"], [0, 0])
self.assertAlmostEqual(universe["seated"]["translation"][1], -1.6, places=6)
+45
View File
@@ -0,0 +1,45 @@
import ctypes.util
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
@unittest.skipUnless(shutil.which("gcc"), "needs gcc on a Linux host")
class VrProducerLeaseTest(unittest.TestCase):
def compile_and_run(self, source, headers=None, gpu=False):
repository = Path(__file__).resolve().parents[2]
with tempfile.TemporaryDirectory() as directory:
executable = Path(directory) / "producer-test"
command = ["gcc", "-std=c11", "-O2", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", "-pthread"]
if headers:
command += ["-I" + str(headers)]
command += [str(repository / "tools/vr/tests" / source), "-ldl"]
if gpu:
command += ["-l:libvulkan.so.1"]
subprocess.run(command + ["-o", str(executable)], check=True)
environment = dict(os.environ)
if gpu:
manifests = sorted(Path("/usr/share/vulkan/icd.d").glob("lvp_icd*.json"))
environment["VK_DRIVER_FILES"] = str(manifests[0])
environment["VK_ICD_FILENAMES"] = str(manifests[0])
result = subprocess.run([str(executable)], env=environment, capture_output=True, text=True, timeout=20)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
return result.stdout
def test_protocol_and_retained_ownership(self):
self.assertIn("disconnect quarantine passed", self.compile_and_run("test_producer_lease.c"))
def test_real_gpu_completion_and_frozen_overwrite(self):
if not ctypes.util.find_library("vulkan") or not list(Path("/usr/share/vulkan/icd.d").glob("lvp_icd*.json")):
self.skipTest("needs Vulkan loader and Lavapipe")
repository = Path(__file__).resolve().parents[2]
candidates = [os.environ.get("VK_INCLUDE", ""), "/usr/include", repository / "build/vr/include"]
candidates += [path for root in (os.environ.get("ANDROID_HOME"), os.environ.get("ANDROID_SDK_ROOT")) if root
for path in Path(root).glob("ndk/*/toolchains/llvm/prebuilt/*/sysroot/usr/include")]
headers = next((Path(path) for path in candidates if path and (Path(path) / "vulkan/vk_layer.h").is_file()), None)
if headers is None:
self.skipTest("needs Vulkan headers")
self.assertIn("1024 buffer values passed", self.compile_and_run("test_producer_gpu.c", headers=headers, gpu=True))
+26
View File
@@ -0,0 +1,26 @@
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
@unittest.skipUnless(shutil.which("gcc"), "needs gcc on a Linux host")
class VrSemaphoreLayerTest(unittest.TestCase):
def test_waits_are_never_fabricated(self):
repository = Path(__file__).resolve().parents[2]
candidates = [os.environ.get("VK_INCLUDE", ""), "/usr/include", repository / "build/vr/include"]
candidates += [path for root in (os.environ.get("ANDROID_HOME"), os.environ.get("ANDROID_SDK_ROOT")) if root
for path in Path(root).glob("ndk/*/toolchains/llvm/prebuilt/*/sysroot/usr/include")]
headers = next((Path(path) for path in candidates if path and (Path(path) / "vulkan/vk_layer.h").is_file()), None)
if headers is None:
self.skipTest("needs Vulkan headers")
with tempfile.TemporaryDirectory() as directory:
executable = Path(directory) / "semaphore-layer-test"
subprocess.run(["gcc", "-std=c11", "-O2", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter",
"-pthread", "-I" + str(headers), str(repository / "tools/vr/tests/test_semaphore_layer.c"),
"-ldl", "-o", str(executable)], check=True)
result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=20)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("fd ownership passed", result.stdout)
+23
View File
@@ -0,0 +1,23 @@
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
@unittest.skipUnless(shutil.which('g++'), 'needs a Linux C++ compiler')
class VrWsiTest(unittest.TestCase):
def test_compositor_handoff_and_frame_transport(self):
repository = Path(__file__).resolve().parents[2]
headers = next((p for p in (Path('/usr/include'), repository / 'build/vr/include')
if (p / 'vulkan/vulkan.h').is_file()), None)
if headers is None:
self.skipTest('needs Vulkan headers')
with tempfile.TemporaryDirectory() as directory:
for name in ('test_wsi_capture', 'test_xr_shared', 'test_xr_frames', 'test_xr_gpu'):
with self.subTest(name=name):
executable = Path(directory) / name
subprocess.run(['g++', '-std=c++17', '-O2', '-pthread', '-Wall', '-Wextra', '-Werror',
'-Wno-missing-field-initializers', '-I' + str(headers),
str(repository / 'tools/vr/tests' / (name + '.cpp')), '-o', str(executable)], check=True)
subprocess.run([str(executable)], check=True, timeout=20)
+7 -2
View File
@@ -29,6 +29,8 @@ compile() {
local cmd=(sh -c '
set -e
mkdir -p build/vr/stub
aarch64-linux-gnu-g++ -std=c++17 -O2 -Wall -Wextra -Werror -static-libstdc++ -static-libgcc -pthread \
-o build/vr/out/droiddeck/bin/linuxarm64/xr-display-mode tools/vr/src/xr_display_mode.cpp
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wl,-soname,libwayland-client.so.0 -Itools/vr/third_party/wayland \
-o build/vr/stub/libwayland-client.so tools/vr/src/wayland_client_stub.c
aarch64-linux-gnu-gcc -c -fPIC -O2 -Itools/vr/third_party/wayland -o build/vr/xdg-shell-protocol.o \
@@ -38,8 +40,11 @@ compile() {
-Itools/vr/third_party/wayland -Iapp/src/main/cpp/waylandcomp/generated \
-o build/vr/out/droiddeck/bin/linuxarm64/driver_droiddeck.so tools/vr/src/driver_droiddeck.cpp tools/vr/src/presenter.cpp \
build/vr/xdg-shell-protocol.o -Lbuild/vr/stub -lwayland-client -ldl
aarch64-linux-gnu-gcc -std=c11 -shared -fPIC -O2 -Wall -Wextra -Wno-unused-parameter -fvisibility=hidden -pthread \
-Wl,-z,defs -Ibuild/vr/include -o build/vr/out/layer/libVkLayer_droiddeck_vr.so tools/vr/src/vk_layer_droiddeck_vr.c -ldl
aarch64-linux-gnu-gcc -std=c11 -c -fPIC -O2 -Wall -Wextra -Wno-unused-parameter -fvisibility=hidden -pthread \
-DDROIDDECK_WSI_CAPTURE -Ibuild/vr/include -o build/vr/vk_layer.o tools/vr/src/vk_layer_droiddeck_vr.c
aarch64-linux-gnu-g++ -std=c++17 -shared -fPIC -O2 -Wall -Wextra -Wno-missing-field-initializers -fvisibility=hidden -pthread \
-static-libstdc++ -static-libgcc -Wl,-z,defs -Wl,-z,nodelete -Ibuild/vr/include \
-o build/vr/out/layer/libVkLayer_droiddeck_vr.so build/vr/vk_layer.o tools/vr/src/wsi_capture.cpp -ldl
aarch64-linux-gnu-strip --strip-unneeded build/vr/out/droiddeck/bin/linuxarm64/driver_droiddeck.so \
build/vr/out/layer/libVkLayer_droiddeck_vr.so')
if [ "$native" = 1 ]; then
+256 -30
View File
@@ -7,6 +7,7 @@
#include <chrono>
#include <cmath>
#include <cstdio>
#include <ctime>
#include <cstring>
#include <fcntl.h>
#include <sys/mman.h>
@@ -17,6 +18,11 @@
#include "../../../app/src/main/cpp/waylandcomp/src/headpose_shm.h"
#include "presenter.h"
#include "xr_tracking_file.h"
#include "xr_frame_math.h"
#include "xr_vsync_timing.h"
#include "xr_touch_input.h"
#include "xr_chaperone.h"
#define DD_EXPORT extern "C" __attribute__((visibility("default")))
@@ -87,6 +93,27 @@ HmdQuaternion_t QuatFromMatrix(const float m[9]) {
return q;
}
HmdMatrix34_t XrPoseMatrix(const DroidDeckXrPose &pose) {
const float qx = pose.orientation[0], qy = pose.orientation[1], qz = pose.orientation[2], qw = pose.orientation[3];
HmdMatrix34_t matrix{};
matrix.m[0][0] = 1 - 2 * (qy * qy + qz * qz); matrix.m[0][1] = 2 * (qx * qy - qw * qz); matrix.m[0][2] = 2 * (qx * qz + qw * qy);
matrix.m[1][0] = 2 * (qx * qy + qw * qz); matrix.m[1][1] = 1 - 2 * (qx * qx + qz * qz); matrix.m[1][2] = 2 * (qy * qz - qw * qx);
matrix.m[2][0] = 2 * (qx * qz - qw * qy); matrix.m[2][1] = 2 * (qy * qz + qw * qx); matrix.m[2][2] = 1 - 2 * (qx * qx + qy * qy);
for (int axis = 0; axis < 3; axis++) matrix.m[axis][3] = pose.position[axis];
return matrix;
}
HmdMatrix34_t XrEyeToHead(const DroidDeckXrPose &head, const DroidDeckXrPose &eye) {
HmdMatrix34_t headMatrix = XrPoseMatrix(head), eyeMatrix = XrPoseMatrix(eye), result{};
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 3; column++)
for (int inner = 0; inner < 3; inner++) result.m[row][column] += headMatrix.m[inner][row] * eyeMatrix.m[inner][column];
for (int inner = 0; inner < 3; inner++)
result.m[row][3] += headMatrix.m[inner][row] * (eyeMatrix.m[inner][3] - headMatrix.m[inner][3]);
}
return result;
}
class VirtualDisplay : public IVRVirtualDisplay {
public:
void Present(const PresentInfo_t *info, uint32_t size) override {
@@ -114,26 +141,32 @@ 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;
while (running_) {
auto interval = kInterval;
DroidDeckXrTracking xr;
if (xrFile.Read(&xr) && XrTrackingFile::Fresh(xr)) {
interval = std::chrono::nanoseconds(xr.period_ns);
auto now = std::chrono::steady_clock::now();
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>(announce - now).count();
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 += kInterval;
next += interval;
now = std::chrono::steady_clock::now();
while (next <= now) next += kInterval;
while (next <= now) next += interval;
}
});
}
@@ -141,20 +174,22 @@ 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 {
public:
void ConfigureXr(const DroidDeckXrTracking &tracking) {
xrWidth_ = tracking.eyes[0].width * 2; xrHeight_ = tracking.eyes[0].height;
for (int eye = 0; eye < 2; eye++) eyeToHead_[eye] = XrEyeToHead(tracking.head, tracking.eyes[eye].pose);
}
struct Set {
SharedTextureHandle_t handles[3] = {};
int fds[3] = {-1, -1, -1};
@@ -187,7 +222,7 @@ public:
out->unTextureFlags = 0;
Log(" -> handles 0x%llx 0x%llx 0x%llx, fds %d %d %d", (unsigned long long)set.handles[0],
(unsigned long long)set.handles[1], (unsigned long long)set.handles[2], set.fds[0], set.fds[1], set.fds[2]);
droiddeck::Presenter::AddTextures(set.handles, set.fds, d->nFormat, d->nWidth, d->nHeight);
droiddeck::Presenter::AddTextures(set.handles, set.fds, d->nFormat, d->nWidth, d->nHeight, pid);
std::lock_guard<std::mutex> lock(mutex_);
sets_.push_back(set);
}
@@ -223,10 +258,19 @@ public:
void SubmitLayer(const SubmitLayerPerEye_t (&eyes)[2]) override {
layers_++;
if (layerInFrame_++ == 0) {
frame_.render = {};
if (xrWidth_) {
frame_.render.magic = DROIDDECK_XR_FRAME_MAGIC; frame_.render.version = DROIDDECK_XR_FRAME_VERSION;
frame_.render.width = xrWidth_; frame_.render.height = xrHeight_;
frame_.render.bytes = xrWidth_ * xrHeight_ * 4;
frame_.render.color_space = DROIDDECK_XR_COLOR_LINEAR_RGBA8;
}
for (int e = 0; e < 2; e++) {
frame_.texture[e] = eyes[e].hTexture;
frame_.bounds[e][0] = eyes[e].bounds.uMin; frame_.bounds[e][1] = eyes[e].bounds.vMin;
frame_.bounds[e][2] = eyes[e].bounds.uMax; frame_.bounds[e][3] = eyes[e].bounds.vMax;
std::memcpy(frame_.render.projection[e], eyes[e].mProjection.m, sizeof(eyes[e].mProjection.m));
xr_frame_eye_pose(frame_.render.eye_pose[e], eyes[e].mHmdPose.m, eyeToHead_[e].m);
}
}
if (frames_ < 3)
@@ -237,6 +281,8 @@ public:
void Present(SharedTextureHandle_t sync) override {
frames_++;
frame_.render.frame_id = frames_; frame_.render.captured_ns = XrTrackingFile::Now();
frame_.layers = layerInFrame_;
if (layerInFrame_) droiddeck::Presenter::Submit(frame_);
if (layerInFrame_ != lastLayerCount_) {
static int said;
@@ -250,8 +296,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);
}
@@ -277,6 +322,8 @@ private:
uint64_t frames_ = 0, layers_ = 0;
int layerInFrame_ = 0, lastLayerCount_ = -1;
droiddeck::EyeFrame frame_{};
uint32_t xrWidth_ = 0, xrHeight_ = 0;
HmdMatrix34_t eyeToHead_[2]{};
};
struct PadState {
@@ -342,12 +389,17 @@ public:
EVRInitError Activate(uint32_t id) override {
id_ = id;
PropertyContainerHandle_t c = VRProperties()->TrackedDeviceToPropertyContainer(id_);
VRProperties()->SetStringProperty(c, Prop_ModelNumber_String, "DroidDeck Pad Hand");
VRProperties()->SetStringProperty(c, Prop_ManufacturerName_String, "DroidDeck");
XrTrackingFile tracking;
DroidDeckXrTracking packet{};
xrMode_ = tracking.Read(&packet) && XrTrackingFile::Fresh(packet);
VRProperties()->SetStringProperty(c, Prop_ModelNumber_String, xrMode_ ? "DroidDeck Quest Touch" : "DroidDeck Pad Hand");
VRProperties()->SetStringProperty(c, Prop_ManufacturerName_String, xrMode_ ? "Meta" : "DroidDeck");
VRProperties()->SetStringProperty(c, Prop_TrackingSystemName_String, "droiddeck");
VRProperties()->SetStringProperty(c, Prop_RenderModelName_String, "generic_controller");
VRProperties()->SetStringProperty(c, Prop_ControllerType_String, "vive_controller");
VRProperties()->SetStringProperty(c, Prop_InputProfilePath_String, "{htc}/input/vive_controller_profile.json");
VRProperties()->SetStringProperty(c, Prop_RenderModelName_String, xrMode_ ?
(right_ ? "oculus_quest_plus_controller_right" : "oculus_quest_plus_controller_left") : "generic_controller");
VRProperties()->SetStringProperty(c, Prop_ControllerType_String, xrMode_ ? "oculus_touch" : "vive_controller");
VRProperties()->SetStringProperty(c, Prop_InputProfilePath_String, xrMode_ ?
"{oculus}/input/touch_profile.json" : "{htc}/input/vive_controller_profile.json");
VRProperties()->SetInt32Property(c, Prop_ControllerRoleHint_Int32,
right_ ? TrackedControllerRole_RightHand : TrackedControllerRole_LeftHand);
VRProperties()->SetInt32Property(c, Prop_DeviceClass_Int32, TrackedDeviceClass_Controller);
@@ -357,12 +409,21 @@ public:
in->CreateBooleanComponent(c, "/input/grip/click", &grip_);
in->CreateBooleanComponent(c, "/input/trigger/click", &triggerClick_);
in->CreateScalarComponent(c, "/input/trigger/value", &trigger_, VRScalarType_Absolute, VRScalarUnits_NormalizedOneSided);
in->CreateScalarComponent(c, "/input/trackpad/x", &padX_, VRScalarType_Absolute, VRScalarUnits_NormalizedTwoSided);
in->CreateScalarComponent(c, "/input/trackpad/y", &padY_, VRScalarType_Absolute, VRScalarUnits_NormalizedTwoSided);
in->CreateBooleanComponent(c, "/input/trackpad/click", &padClick_);
in->CreateBooleanComponent(c, "/input/trackpad/touch", &padTouch_);
in->CreateScalarComponent(c, xrMode_ ? "/input/joystick/x" : "/input/trackpad/x", &padX_, VRScalarType_Absolute, VRScalarUnits_NormalizedTwoSided);
in->CreateScalarComponent(c, xrMode_ ? "/input/joystick/y" : "/input/trackpad/y", &padY_, VRScalarType_Absolute, VRScalarUnits_NormalizedTwoSided);
in->CreateBooleanComponent(c, xrMode_ ? "/input/joystick/click" : "/input/trackpad/click", &padClick_);
in->CreateBooleanComponent(c, xrMode_ ? "/input/joystick/touch" : "/input/trackpad/touch", &padTouch_);
if (xrMode_) {
in->CreateScalarComponent(c, "/input/grip/value", &gripValue_, VRScalarType_Absolute, VRScalarUnits_NormalizedOneSided);
in->CreateBooleanComponent(c, "/input/trigger/touch", &triggerTouch_);
in->CreateBooleanComponent(c, right_ ? "/input/a/click" : "/input/x/click", &primaryClick_);
in->CreateBooleanComponent(c, right_ ? "/input/b/click" : "/input/y/click", &secondaryClick_);
in->CreateBooleanComponent(c, right_ ? "/input/a/touch" : "/input/x/touch", &primaryTouch_);
in->CreateBooleanComponent(c, right_ ? "/input/b/touch" : "/input/y/touch", &secondaryTouch_);
in->CreateBooleanComponent(c, "/input/thumbrest/touch", &thumbrestTouch_);
}
in->CreateHapticComponent(c, "/output/haptic", &haptic_);
Log("%s hand activated (game pad)", right_ ? "right" : "left");
Log("%s hand activated (%s)", right_ ? "right" : "left", xrMode_ ? "Quest Touch" : "game pad");
return VRInitError_None;
}
void Deactivate() override { id_ = k_unTrackedDeviceIndexInvalid; }
@@ -371,6 +432,44 @@ public:
void DebugRequest(const char *, char *resp, uint32_t size) override { if (size) resp[0] = 0; }
DriverPose_t GetPose() override { return pose_; }
VRInputComponentHandle_t HapticHandle() const { return haptic_; }
void UpdateXr(const DroidDeckXrHand &hand, int64_t predicted) {
if (id_ == k_unTrackedDeviceIndexInvalid) return;
DriverPose_t pose{};
pose.qWorldFromDriverRotation.w = pose.qDriverFromHeadRotation.w = 1;
pose.qRotation.w = 1;
pose.deviceIsConnected = hand.active;
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.poseTimeOffset = XrTrackingFile::PoseTimeOffset(predicted, XrTrackingFile::Now());
}
pose.result = pose.poseIsValid ? TrackingResult_Running_OK : TrackingResult_Running_OutOfRange;
pose_ = pose;
VRServerDriverHost()->TrackedDevicePoseUpdated(id_, pose, sizeof(pose));
XrTouchInput input = xr_touch_input(hand, right_);
auto driverInput = VRDriverInput();
driverInput->UpdateScalarComponent(trigger_, input.trigger, 0);
driverInput->UpdateBooleanComponent(triggerClick_, input.triggerClick, 0);
driverInput->UpdateBooleanComponent(grip_, input.gripClick, 0);
driverInput->UpdateScalarComponent(padX_, input.stickX, 0);
driverInput->UpdateScalarComponent(padY_, input.stickY, 0);
driverInput->UpdateBooleanComponent(padClick_, input.stickClick, 0);
driverInput->UpdateBooleanComponent(padTouch_, input.stickTouch, 0);
driverInput->UpdateBooleanComponent(menu_, false, 0);
driverInput->UpdateBooleanComponent(system_, input.systemClick, 0);
if (xrMode_) {
driverInput->UpdateScalarComponent(gripValue_, input.grip, 0);
driverInput->UpdateBooleanComponent(triggerTouch_, input.triggerTouch, 0);
driverInput->UpdateBooleanComponent(primaryClick_, input.primaryClick, 0);
driverInput->UpdateBooleanComponent(secondaryClick_, input.secondaryClick, 0);
driverInput->UpdateBooleanComponent(primaryTouch_, input.primaryTouch, 0);
driverInput->UpdateBooleanComponent(secondaryTouch_, input.secondaryTouch, 0);
driverInput->UpdateBooleanComponent(thumbrestTouch_, input.thumbrestTouch, 0);
}
}
void Update(const HmdQuaternion_t &head, const PadState *pad) {
if (id_ == k_unTrackedDeviceIndexInvalid) return;
@@ -409,10 +508,13 @@ public:
private:
bool right_;
bool xrMode_ = false;
uint32_t id_ = k_unTrackedDeviceIndexInvalid;
DriverPose_t pose_ = {};
VRInputComponentHandle_t system_ = 0, menu_ = 0, grip_ = 0, triggerClick_ = 0, trigger_ = 0, padX_ = 0, padY_ = 0,
padClick_ = 0, padTouch_ = 0, haptic_ = 0;
VRInputComponentHandle_t gripValue_ = 0, triggerTouch_ = 0, primaryClick_ = 0, secondaryClick_ = 0,
primaryTouch_ = 0, secondaryTouch_ = 0, thumbrestTouch_ = 0;
};
class CardboardHmd : public ITrackedDeviceServerDriver, public IVRDisplayComponent {
@@ -426,6 +528,12 @@ public:
if (height_ <= 0) height_ = 1080;
float hz = VRSettings()->GetFloat(kSettingsSection, "displayFrequency");
if (hz <= 0.0f) hz = 60.0f;
DroidDeckXrTracking xr;
xrMode_ = xr_.Read(&xr) && XrTrackingFile::Fresh(xr);
if (xrMode_) {
width_ = xr.eyes[0].width * 2; height_ = xr.eyes[0].height;
hz = 1000000000.f / xr.period_ns;
}
tanHalf_ = 1.0f;
eyeAspect_ = (width_ * 0.5f) / height_;
droiddeck_headpose hp;
@@ -433,17 +541,31 @@ public:
tanHalf_ = hp.tan_half_fov;
if (hp.eye_aspect > 0.0f) eyeAspect_ = hp.eye_aspect;
}
VRProperties()->SetStringProperty(c, Prop_ModelNumber_String, "DroidDeck Cardboard");
VRProperties()->SetStringProperty(c, Prop_ModelNumber_String, xrMode_ ? "DroidDeck Quest OpenXR" : "DroidDeck Cardboard");
VRProperties()->SetStringProperty(c, Prop_ManufacturerName_String, "DroidDeck");
VRProperties()->SetStringProperty(c, Prop_TrackingSystemName_String, "droiddeck");
VRProperties()->SetFloatProperty(c, Prop_UserIpdMeters_Float, 0.064f);
if (xrMode_) {
VRServerDriverHost()->SetDisplayEyeToHead(id_, XrEyeToHead(xr.head, xr.eyes[0].pose), XrEyeToHead(xr.head, xr.eyes[1].pose));
dm_.ConfigureXr(xr);
float distanceSquared = 0;
for (int axis = 0; axis < 3; axis++) {
float difference = xr.eyes[0].pose.position[axis] - xr.eyes[1].pose.position[axis];
distanceSquared += difference * difference;
}
VRProperties()->SetFloatProperty(c, Prop_UserIpdMeters_Float, std::sqrt(distanceSquared));
}
VRProperties()->SetFloatProperty(c, Prop_UserHeadToEyeDepthMeters_Float, 0.0f);
VRProperties()->SetFloatProperty(c, Prop_DisplayFrequency_Float, hz);
VRProperties()->SetFloatProperty(c, Prop_SecondsFromVsyncToPhotons_Float, 0.011f);
VRProperties()->SetUint64Property(c, Prop_CurrentUniverseId_Uint64, 2);
chaperone_.Reset();
if (xrMode_) PublishChaperone(xr);
VRProperties()->SetBoolProperty(c, Prop_IsOnDesktop_Bool, false);
VRProperties()->SetBoolProperty(c, Prop_DisplayDebugMode_Bool, false);
directMode_ = VRSettings()->GetBool(kSettingsSection, "directMode");
const char *presentation = std::getenv("DROIDDECK_VR_PRESENT");
if (xrMode_ && presentation && !std::strcmp(presentation, "questwsi")) directMode_ = false;
VRProperties()->SetBoolProperty(c, Prop_HasDriverDirectModeComponent_Bool, directMode_);
if (directMode_) {
VRProperties()->SetBoolProperty(c, Prop_DriverDirectModeSendsVsyncEvents_Bool, true);
@@ -488,6 +610,19 @@ public:
p.qWorldFromDriverRotation.w = 1;
p.qDriverFromHeadRotation.w = 1;
p.qRotation.w = 1;
DroidDeckXrTracking xr;
bool freshXr = xr_.Read(&xr) && XrTrackingFile::Fresh(xr);
if (freshXr || xrMode_) {
p.deviceIsConnected = true;
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]};
for (int axis = 0; axis < 3; axis++) p.vecPosition[axis] = xr.head.position[axis];
p.poseTimeOffset = XrTrackingFile::PoseTimeOffset(xr.predicted_ns, XrTrackingFile::Now());
}
return p;
}
p.vecPosition[1] = 1.6;
droiddeck_headpose hp;
bool ok = pose_.Read(&hp);
@@ -509,6 +644,7 @@ public:
bool IsDisplayOnDesktop() override { return !directMode_; }
bool IsDisplayRealDisplay() override { return false; }
void GetRecommendedRenderTargetSize(uint32_t *w, uint32_t *h) override {
if (xrMode_) { *w = width_ / 2; *h = height_; return; }
*w = (uint32_t)(width_ / 2 * 1.4f);
*h = (uint32_t)(height_ * 1.4f);
}
@@ -516,7 +652,14 @@ public:
*y = 0; *w = (uint32_t)width_ / 2; *h = (uint32_t)height_;
*x = eye == Eye_Left ? 0 : (uint32_t)width_ / 2;
}
void GetProjectionRaw(EVREye, float *left, float *right, float *top, float *bottom) override {
void GetProjectionRaw(EVREye eye, float *left, float *right, float *top, float *bottom) override {
DroidDeckXrTracking xr;
if (xrMode_ && xr_.Read(&xr) && XrTrackingFile::Fresh(xr)) {
const float *fov = xr.eyes[eye == Eye_Right ? 1 : 0].fov;
*left = std::tan(fov[0]); *right = std::tan(fov[1]);
*top = -std::tan(fov[2]); *bottom = -std::tan(fov[3]);
return;
}
*left = -tanHalf_ * eyeAspect_; *right = tanHalf_ * eyeAspect_;
*top = -tanHalf_; *bottom = tanHalf_;
}
@@ -532,10 +675,34 @@ public:
}
private:
void PublishChaperone(const DroidDeckXrTracking &tracking) {
chaperone_.Update(tracking, [this](const DroidDeckXrPose &head) {
char timestamp[32];
std::time_t now = std::time(nullptr); std::tm utc{}; gmtime_r(&now, &utc);
std::strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%SZ", &utc);
const auto json = xr_chaperone_json(head, timestamp);
const auto container = VRProperties()->TrackedDeviceToPropertyContainer(id_);
return VRProperties()->SetStringProperty(container, Prop_DriverProvidedChaperoneJson_String, json.c_str()) == TrackedProp_Success;
});
}
void PoseLoop() {
uint32_t lastSeq = 0;
auto lastSent = std::chrono::steady_clock::now();
while (running_) {
DroidDeckXrTracking xr;
if (xrMode_) {
bool fresh = xr_.Read(&xr) && XrTrackingFile::Fresh(xr);
if (fresh) PublishChaperone(xr);
DriverPose_t head = GetPose();
VRServerDriverHost()->TrackedDevicePoseUpdated(id_, head, sizeof(head));
for (int hand = 0; hand < 2; hand++) if (hands_[hand]) {
DroidDeckXrHand state = fresh ? xr.hands[hand] : DroidDeckXrHand{};
hands_[hand]->UpdateXr(state, fresh ? xr.predicted_ns : 0);
}
VRDriverInput()->UpdateBooleanComponent(proximity_, fresh, 0);
std::this_thread::sleep_for(std::chrono::milliseconds(2));
continue;
}
droiddeck_headpose hp;
bool fresh = pose_.Read(&hp) && hp.seq != lastSeq;
auto now = std::chrono::steady_clock::now();
@@ -570,6 +737,9 @@ public:
private:
PoseFile pose_;
XrTrackingFile xr_;
XrChaperoneOrigin chaperone_;
bool xrMode_ = false;
PadRing pad_;
PadState lastPad_;
VRInputComponentHandle_t proximity_ = 0;
@@ -586,6 +756,7 @@ class Provider : public IServerTrackedDeviceProvider {
public:
EVRInitError Init(IVRDriverContext *ctx) override {
VR_INIT_SERVER_DRIVER_CONTEXT(ctx);
focusEnabled_ = access("/run/droiddeck/droiddeck-xr-frames", F_OK) == 0;
Log("DroidDeck Cardboard driver loaded");
if (!VRServerDriverHost()->TrackedDeviceAdded("droiddeck-cardboard-hmd", TrackedDeviceClass_HMD, &hmd_))
Log("TrackedDeviceAdded failed");
@@ -596,17 +767,72 @@ public:
}
return VRInitError_None;
}
void Cleanup() override { VR_CLEANUP_SERVER_DRIVER_CONTEXT(); }
void Cleanup() override {
if (focusEnabled_) unlink(kFocusPath);
if (haptics_) { munmap(haptics_, sizeof(*haptics_)); haptics_ = nullptr; }
VR_CLEANUP_SERVER_DRIVER_CONTEXT();
}
const char *const *GetInterfaceVersions() override { return k_InterfaceVersions; }
void RunFrame() override {
VREvent_t e;
while (VRServerDriverHost()->PollNextEvent(&e, sizeof(e))) {}
while (VRServerDriverHost()->PollNextEvent(&e, sizeof(e))) {
if (focusEnabled_) {
bool changed = false;
if (e.eventType == VREvent_SceneApplicationChanged) { scenePid_ = e.data.process.pid; changed = true; }
if (e.eventType == VREvent_DashboardActivated) { dashboard_ = true; changed = true; }
if (e.eventType == VREvent_DashboardDeactivated) { dashboard_ = false; changed = true; }
if (changed) PublishFocus();
}
if (e.eventType == VREvent_Input_HapticVibration) {
const auto &pulse = e.data.hapticVibration;
int hand = pulse.componentHandle == left_.HapticHandle() ? 0 : pulse.componentHandle == right_.HapticHandle() ? 1 : -1;
if (hand >= 0) SendHaptic(hand, pulse);
}
}
}
bool ShouldBlockStandbyMode() override { return false; }
void EnterStandby() override {}
void LeaveStandby() override {}
private:
static constexpr const char *kFocusPath = "/run/droiddeck/droiddeck-steamvr/scene-focus.json";
bool focusEnabled_ = false, dashboard_ = true;
uint32_t scenePid_ = 0;
void PublishFocus() {
const std::string temporary = std::string(kFocusPath) + ".tmp";
int fd = open(temporary.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC | O_NOFOLLOW, 0600);
if (fd < 0) return;
char body[128];
int length = snprintf(body, sizeof(body), "{\"serverPid\":%d,\"scenePid\":%u,\"dashboard\":%s}\n",
getpid(), scenePid_, dashboard_ ? "true" : "false");
bool written = write(fd, body, length) == length;
close(fd);
if (written) rename(temporary.c_str(), kFocusPath);
else unlink(temporary.c_str());
}
DroidDeckXrHaptics *haptics_ = nullptr;
void SendHaptic(int hand, const VREvent_HapticVibration_t &pulse) {
if (!std::isfinite(pulse.fAmplitude) || !std::isfinite(pulse.fFrequency) || !std::isfinite(pulse.fDurationSeconds)) return;
if (!haptics_) {
int descriptor = open("/run/droiddeck/" DROIDDECK_XR_HAPTIC_FILE, O_RDWR | O_CLOEXEC);
if (descriptor < 0) return;
struct stat status{};
if (fstat(descriptor, &status) != 0 || status.st_size != sizeof(*haptics_)) { close(descriptor); return; }
void *mapping = mmap(nullptr, sizeof(*haptics_), PROT_READ | PROT_WRITE, MAP_SHARED, descriptor, 0);
close(descriptor);
if (mapping == MAP_FAILED) return;
haptics_ = static_cast<DroidDeckXrHaptics *>(mapping);
}
uint32_t sequence = __atomic_load_n(&haptics_->seq, __ATOMIC_RELAXED);
__atomic_store_n(&haptics_->seq, sequence | 1u, __ATOMIC_SEQ_CST);
__atomic_thread_fence(__ATOMIC_SEQ_CST);
auto &target = haptics_->hands[hand];
target.id++;
target.duration_ns = (int64_t)(std::clamp(pulse.fDurationSeconds, 0.001f, 1.f) * 1000000000.0);
target.amplitude = std::clamp(pulse.fAmplitude, 0.f, 1.f);
target.frequency = std::max(pulse.fFrequency, 0.f);
__atomic_store_n(&haptics_->seq, (sequence | 1u) + 1u, __ATOMIC_RELEASE);
}
CardboardHmd hmd_;
CardboardController left_{false}, right_{true};
};
+252 -30
View File
@@ -9,6 +9,9 @@ typedef unsigned long VisualID;
#include "xdg-shell-client-protocol.h"
#include "presenter.h"
#include "xr_tracking_file.h"
#include "xr_frame_channel.h"
#include "producer_lease.h"
#include <atomic>
#include <chrono>
@@ -36,7 +39,7 @@ void Log(const char *fmt, ...) {
va_start(ap, fmt);
vsnprintf(buf, sizeof(buf), fmt, ap);
va_end(ap);
if (vr::VRDriverLog()) vr::VRDriverLog()->Log(buf);
if (vr::VRDriverContext() && vr::VRDriverLog()) vr::VRDriverLog()->Log(buf);
fprintf(stderr, "[droiddeck] %s\n", buf);
}
@@ -73,13 +76,15 @@ struct X11 {
X(GetPhysicalDeviceMemoryProperties) X(CreateDevice) X(GetDeviceProcAddr) X(CreateXlibSurfaceKHR) \
X(CreateWaylandSurfaceKHR) \
X(GetPhysicalDeviceSurfaceSupportKHR) X(GetPhysicalDeviceSurfaceCapabilitiesKHR) \
X(GetPhysicalDeviceSurfaceFormatsKHR) X(GetPhysicalDeviceSurfacePresentModesKHR) X(GetPhysicalDeviceProperties)
X(GetPhysicalDeviceSurfaceFormatsKHR) X(GetPhysicalDeviceSurfacePresentModesKHR) X(GetPhysicalDeviceProperties) X(DestroySurfaceKHR)
#define VK_DEVICE(X) \
X(GetDeviceQueue) X(CreateSwapchainKHR) X(DestroySwapchainKHR) X(GetSwapchainImagesKHR) X(AcquireNextImageKHR) \
X(QueuePresentKHR) X(CreateCommandPool) X(AllocateCommandBuffers) X(BeginCommandBuffer) X(EndCommandBuffer) \
X(ResetCommandBuffer) X(CmdPipelineBarrier) X(CmdBlitImage) X(CmdClearColorImage) X(QueueSubmit) X(CreateFence) \
X(WaitForFences) X(ResetFences) X(CreateSemaphore) X(CreateImage) X(DestroyImage) X(GetImageMemoryRequirements) \
X(AllocateMemory) X(FreeMemory) X(BindImageMemory) X(DeviceWaitIdle)
X(AllocateMemory) X(FreeMemory) X(BindImageMemory) X(DeviceWaitIdle) X(DestroyDevice) X(DestroyFence) X(DestroySemaphore) \
X(DestroyCommandPool) X(CreateBuffer) X(DestroyBuffer) X(GetBufferMemoryRequirements) X(BindBufferMemory) \
X(MapMemory) X(UnmapMemory) X(InvalidateMappedMemoryRanges) X(CmdCopyImageToBuffer)
struct Vk {
void *lib = nullptr;
@@ -94,12 +99,31 @@ struct Imported {
VkImage image = VK_NULL_HANDLE;
VkDeviceMemory memory = VK_NULL_HANDLE;
uint32_t width = 0, height = 0;
uint32_t producerPid = 0;
};
struct PendingImport {
uint64_t handle;
int fd;
uint32_t format, width, height;
uint32_t format, width, height, producerPid;
};
class CaptureLease {
public:
explicit CaptureLease(uint32_t pid) : acquired_(droiddeck_producer_acquire(pid, &lease_)) {}
~CaptureLease() {
if (lease_.descriptor < 0) return;
if (gpuComplete_) droiddeck_producer_release(&lease_);
else close(lease_.descriptor);
}
bool Acquired() const { return acquired_; }
void BeginGpu() { gpuComplete_ = false; }
void EndGpu() { gpuComplete_ = true; }
bool Release() { return gpuComplete_ && droiddeck_producer_release(&lease_); }
private:
droiddeck_producer_lease lease_{-1, 0};
bool acquired_;
bool gpuComplete_ = true;
};
VkFormat UnormOf(uint32_t f) {
@@ -117,65 +141,90 @@ public:
return impl;
}
void Add(const uint64_t handles[3], const int fds[3], uint32_t format, uint32_t w, uint32_t h) {
void Add(const uint64_t handles[3], const int fds[3], uint32_t format, uint32_t w, uint32_t h, uint32_t producerPid) {
std::lock_guard<std::mutex> lock(mutex_);
if (failed_ || exit_) return;
for (int i = 0; i < 3; i++) {
if (fds[i] < 0) continue;
int fd = dup(fds[i]);
if (fd >= 0) pending_.push_back({handles[i], fd, format, w, h});
if (fd >= 0) pending_.push_back({handles[i], fd, format, w, h, producerPid});
}
EnsureThread();
cv_.notify_one();
cv_.notify_all();
}
void Remove(uint64_t handle) {
std::lock_guard<std::mutex> lock(mutex_);
removals_.push_back(handle);
cv_.notify_one();
cv_.notify_all();
}
void Submit(const EyeFrame &f) {
std::lock_guard<std::mutex> lock(mutex_);
std::unique_lock<std::mutex> lock(mutex_);
if (failed_ || exit_) return;
bool blocking = FrameChannelRequested();
if (blocking) cv_.wait(lock, [this] { return failed_ || exit_ || (!haveFrame_ && !inFlight_); });
if (failed_ || exit_) return;
EnsureThread();
frame_ = f;
frameAt_ = std::chrono::steady_clock::now();
haveFrame_ = true;
submitted_++;
cv_.notify_one();
uint64_t ticket = ++ticket_;
cv_.notify_all();
if (blocking) cv_.wait(lock, [this, ticket] { return failed_ || completed_ >= ticket; });
}
void Shutdown() {
{
std::lock_guard<std::mutex> lock(mutex_);
exit_ = true;
cv_.notify_one();
cv_.notify_all();
}
if (thread_.joinable()) thread_.join();
}
private:
static bool FrameChannelRequested() {
const char *mode = getenv("DROIDDECK_VR_PRESENT");
return mode && !strcmp(mode, "questframes");
}
void Fail() {
std::lock_guard<std::mutex> lock(mutex_);
failed_ = true;
for (const auto &pending : pending_) close(pending.fd);
pending_.clear(); cv_.notify_all();
}
void EnsureThread() {
if (!started_) {
started_ = true;
thread_ = std::thread([this] { Run(); });
thread_ = std::thread([this] {
try { Run(); }
catch (const std::exception &error) { Log("presenter: %s", error.what()); }
Cleanup(); Fail();
});
}
}
void Run() {
const char *mode = getenv("DROIDDECK_VR_PRESENT");
wayland_ = !(mode && !strcmp(mode, "x11")) && InitWayland();
if (!wayland_ && !InitWindow()) {
frameChannel_ = FrameChannelRequested();
if (!frameChannel_) wayland_ = !(mode && !strcmp(mode, "x11")) && InitWayland();
if (!frameChannel_ && !wayland_ && !InitWindow()) {
Log("presenter: unavailable, frames are not shown");
return;
Fail(); return;
}
if (!InitVulkan()) {
Log("presenter: unavailable, frames are not shown");
return;
Fail(); return;
}
uint64_t shown = 0;
auto lastLog = std::chrono::steady_clock::now();
while (true) {
EyeFrame f;
uint64_t ticket = 0;
std::chrono::steady_clock::time_point at;
std::vector<PendingImport> imports;
std::vector<uint64_t> removals;
@@ -189,13 +238,19 @@ private:
at = frameAt_;
bool draw = haveFrame_;
haveFrame_ = false;
inFlight_ = draw; ticket = ticket_;
if (!draw) f.texture[0] = f.texture[1] = 0;
}
if (wl_) { wl_display_dispatch_pending(wl_); wl_display_flush(wl_); }
for (auto &p : imports) Import(p);
for (auto h : removals) Forget(h);
if (f.texture[0]) std::this_thread::sleep_until(at + guard_);
if (f.texture[0] && Draw(f)) shown++;
if (f.texture[0] && !frameChannel_) std::this_thread::sleep_until(at + guard_);
if (f.texture[0] && (frameChannel_ ? Capture(f) : Draw(f))) shown++;
{
std::lock_guard<std::mutex> lock(mutex_);
inFlight_ = false; completed_ = ticket; cv_.notify_all();
if (failed_) break;
}
auto now = std::chrono::steady_clock::now();
if (now - lastLog > std::chrono::seconds(10)) {
Log("presenter: %llu frames shown, %llu submitted (last 10 s)", (unsigned long long)shown,
@@ -204,7 +259,23 @@ private:
lastLog = now;
}
}
}
void Cleanup() {
if (dev_) vk_.DeviceWaitIdle(dev_);
DestroyCapture();
while (!textures_.empty()) Forget(textures_.back().handle);
if (dev_) {
if (swapchain_) vk_.DestroySwapchainKHR(dev_, swapchain_, nullptr);
if (acquired_) vk_.DestroySemaphore(dev_, acquired_, nullptr);
if (rendered_) vk_.DestroySemaphore(dev_, rendered_, nullptr);
if (fence_) vk_.DestroyFence(dev_, fence_, nullptr);
if (pool_) vk_.DestroyCommandPool(dev_, pool_, nullptr);
vk_.DestroyDevice(dev_, nullptr); dev_ = VK_NULL_HANDLE;
}
if (surface_) vk_.DestroySurfaceKHR(inst_, surface_, nullptr);
if (inst_) vk_.DestroyInstance(inst_, nullptr);
surface_ = VK_NULL_HANDLE; inst_ = VK_NULL_HANDLE;
}
std::chrono::microseconds guard_{4000};
@@ -268,6 +339,11 @@ private:
if (pread(fd, eye, sizeof(eye), 72) == (ssize_t)sizeof(eye) && eye[0] && eye[1]) { width_ = eye[0] * 2; height_ = eye[1]; }
close(fd);
}
XrTrackingFile xrFile;
DroidDeckXrTracking tracking;
if (xrFile.Read(&tracking) && XrTrackingFile::Fresh(tracking)) {
width_ = tracking.eyes[0].width * 2; height_ = tracking.eyes[0].height;
}
if (!width_ && cfgW_ > 0 && cfgH_ > 0) { width_ = (uint32_t)cfgW_; height_ = (uint32_t)cfgH_; }
if (!width_) { width_ = 1920; height_ = 1080; }
Log("presenter: %ux%u fullscreen window on Wayland display %s (configured %s)", width_, height_, name, configured_ ? "yes" : "no");
@@ -306,8 +382,9 @@ 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};
VkInstanceCreateInfo ici = {VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO, nullptr, 0, &app, 0, nullptr, 2, iexts};
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_);
if (r != VK_SUCCESS) { Log("presenter: vkCreateInstance %d", r); return false; }
#define X(n) vk_.n = (PFN_vk##n)vk_.GetInstanceProcAddr(inst_, "vk" #n);
@@ -322,10 +399,10 @@ private:
vk_.GetPhysicalDeviceProperties(pd_, &props);
vk_.GetPhysicalDeviceMemoryProperties(pd_, &mem_);
if (wayland_) {
if (!frameChannel_ && wayland_) {
VkWaylandSurfaceCreateInfoKHR wci = {VK_STRUCTURE_TYPE_WAYLAND_SURFACE_CREATE_INFO_KHR, nullptr, 0, wl_, wlSurf_};
if ((r = vk_.CreateWaylandSurfaceKHR(inst_, &wci, nullptr, &surface_)) != VK_SUCCESS) { Log("presenter: wayland surface %d", r); return false; }
} else {
} else if (!frameChannel_) {
VkXlibSurfaceCreateInfoKHR sci = {VK_STRUCTURE_TYPE_XLIB_SURFACE_CREATE_INFO_KHR, nullptr, 0, dpy_, win_};
if ((r = vk_.CreateXlibSurfaceKHR(inst_, &sci, nullptr, &surface_)) != VK_SUCCESS) { Log("presenter: xlib surface %d", r); return false; }
}
@@ -337,14 +414,15 @@ private:
qfam_ = UINT32_MAX;
for (uint32_t i = 0; i < nq; i++) {
VkBool32 present = VK_FALSE;
vk_.GetPhysicalDeviceSurfaceSupportKHR(pd_, i, surface_, &present);
if (frameChannel_) present = VK_TRUE;
else vk_.GetPhysicalDeviceSurfaceSupportKHR(pd_, i, surface_, &present);
if ((qf[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) && present) { qfam_ = i; break; }
}
if (qfam_ == UINT32_MAX) { Log("presenter: no queue can present"); return false; }
float prio = 1.0f;
VkDeviceQueueCreateInfo qci = {VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO, nullptr, 0, qfam_, 1, &prio};
const char *dexts[] = {"VK_KHR_swapchain", "VK_KHR_external_memory_fd"};
VkDeviceCreateInfo dci = {VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO, nullptr, 0, 1, &qci, 0, nullptr, 2, dexts, nullptr};
const char *dexts[] = {"VK_KHR_external_memory_fd", "VK_KHR_swapchain"};
VkDeviceCreateInfo dci = {VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO, nullptr, 0, 1, &qci, 0, nullptr, frameChannel_ ? 1u : 2u, dexts, nullptr};
if ((r = vk_.CreateDevice(pd_, &dci, nullptr, &dev_)) != VK_SUCCESS) { Log("presenter: vkCreateDevice %d", r); return false; }
#define X(n) vk_.n = (PFN_vk##n)vk_.GetDeviceProcAddr(dev_, "vk" #n);
VK_DEVICE(X)
@@ -361,7 +439,7 @@ private:
VkSemaphoreCreateInfo semci = {VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO, nullptr, 0};
vk_.CreateSemaphore(dev_, &semci, nullptr, &acquired_);
vk_.CreateSemaphore(dev_, &semci, nullptr, &rendered_);
if (!CreateSwapchain()) return false;
if (!frameChannel_ && !CreateSwapchain()) return false;
Log("presenter: Vulkan on %s, queue family %u", props.deviceName, qfam_);
return true;
}
@@ -423,6 +501,7 @@ private:
Imported im{p.handle};
im.width = p.width;
im.height = p.height;
im.producerPid = p.producerPid;
const char *fl = getenv("DROIDDECK_VR_IMPORT_FORMAT_LIST");
const bool formatList = !fl || strcmp(fl, "0") != 0;
VkFormat formats[2] = {(VkFormat)p.format, UnormOf(p.format)};
@@ -439,7 +518,9 @@ private:
vk_.GetImageMemoryRequirements(dev_, im.image, &req);
VkImportMemoryFdInfoKHR imp = {VK_STRUCTURE_TYPE_IMPORT_MEMORY_FD_INFO_KHR, nullptr, VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT, p.fd};
VkMemoryDedicatedAllocateInfo ded = {VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO, &imp, im.image, VK_NULL_HANDLE};
VkMemoryAllocateInfo mai = {VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, &ded, req.size, (uint32_t)MemoryType(req.memoryTypeBits)};
int memoryType = MemoryType(req.memoryTypeBits);
if (memoryType < 0) { vk_.DestroyImage(dev_, im.image, nullptr); close(p.fd); return; }
VkMemoryAllocateInfo mai = {VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, &ded, req.size, (uint32_t)memoryType};
r = vk_.AllocateMemory(dev_, &mai, nullptr, &im.memory);
if (r != VK_SUCCESS) {
Log("presenter: import memory (fd %d, %llu bytes) %d", p.fd, (unsigned long long)req.size, r);
@@ -447,7 +528,10 @@ private:
vk_.DestroyImage(dev_, im.image, nullptr);
return;
}
vk_.BindImageMemory(dev_, im.image, im.memory, 0);
if (vk_.BindImageMemory(dev_, im.image, im.memory, 0) != VK_SUCCESS) {
vk_.DestroyImage(dev_, im.image, nullptr); vk_.FreeMemory(dev_, im.memory, nullptr);
return;
}
textures_.push_back(im);
Log("presenter: imported texture 0x%llx (%ux%u, format %u)", (unsigned long long)p.handle, p.width, p.height, p.format);
}
@@ -476,6 +560,137 @@ private:
return b;
}
void DestroyCapture() {
if (!dev_) return;
if (captureImage_) vk_.DestroyImage(dev_, captureImage_, nullptr);
if (captureMemory_) vk_.FreeMemory(dev_, captureMemory_, nullptr);
if (readbackBuffer_) vk_.DestroyBuffer(dev_, readbackBuffer_, nullptr);
if (readbackMemory_) vk_.FreeMemory(dev_, readbackMemory_, nullptr);
captureImage_ = VK_NULL_HANDLE; captureMemory_ = readbackMemory_ = VK_NULL_HANDLE;
readbackBuffer_ = VK_NULL_HANDLE; captureWidth_ = captureHeight_ = 0;
}
bool PrepareCapture(const DroidDeckXrFrameHeader &header) {
if (captureWidth_ == header.width && captureHeight_ == header.height) return true;
DestroyCapture();
auto fail = [this](VkResult result) { Log("presenter: capture allocation failed %d", result); DestroyCapture(); return false; };
VkImageCreateInfo image{VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO};
image.imageType = VK_IMAGE_TYPE_2D; image.format = VK_FORMAT_R8G8B8A8_UNORM;
image.extent = {header.width, header.height, 1}; image.mipLevels = image.arrayLayers = 1;
image.samples = VK_SAMPLE_COUNT_1_BIT; image.tiling = VK_IMAGE_TILING_OPTIMAL;
image.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT;
VkResult result = vk_.CreateImage(dev_, &image, nullptr, &captureImage_);
if (result != VK_SUCCESS) return fail(result);
VkMemoryRequirements requirements{}; vk_.GetImageMemoryRequirements(dev_, captureImage_, &requirements);
int type = MemoryType(requirements.memoryTypeBits);
if (type < 0) return fail(VK_ERROR_FEATURE_NOT_PRESENT);
VkMemoryAllocateInfo allocation{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO};
allocation.allocationSize = requirements.size; allocation.memoryTypeIndex = type;
result = vk_.AllocateMemory(dev_, &allocation, nullptr, &captureMemory_);
if (result != VK_SUCCESS) return fail(result);
result = vk_.BindImageMemory(dev_, captureImage_, captureMemory_, 0);
if (result != VK_SUCCESS) return fail(result);
VkBufferCreateInfo buffer{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; buffer.size = header.bytes;
buffer.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT;
result = vk_.CreateBuffer(dev_, &buffer, nullptr, &readbackBuffer_);
if (result != VK_SUCCESS) return fail(result);
vk_.GetBufferMemoryRequirements(dev_, readbackBuffer_, &requirements);
type = -1;
for (uint32_t index = 0; index < mem_.memoryTypeCount; index++) {
if ((requirements.memoryTypeBits & (1u << index)) && (mem_.memoryTypes[index].propertyFlags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT)) {
type = index;
if (mem_.memoryTypes[index].propertyFlags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) break;
}
}
if (type < 0) return fail(VK_ERROR_FEATURE_NOT_PRESENT);
allocation.allocationSize = requirements.size; allocation.memoryTypeIndex = type;
result = vk_.AllocateMemory(dev_, &allocation, nullptr, &readbackMemory_);
if (result != VK_SUCCESS) return fail(result);
result = vk_.BindBufferMemory(dev_, readbackBuffer_, readbackMemory_, 0);
if (result != VK_SUCCESS) return fail(result);
captureWidth_ = header.width; captureHeight_ = header.height;
return true;
}
bool Capture(const EyeFrame &frame) {
if (frame.layers != 1 || !xr_frame_valid(frame.render)) {
static unsigned said = 0;
if (said++ < 3) Log("presenter: refusing Quest frame with unsupported layer count or render geometry (%u layers)", frame.layers);
return false;
}
const Imported *eye[2] = {Find(frame.texture[0]), Find(frame.texture[1])};
if (!eye[0] || !eye[1]) return false;
for (int index = 0; index < 2; index++) {
for (float value : frame.bounds[index]) if (!std::isfinite(value) || value < 0 || value > 1) return false;
if (frame.bounds[index][0] == frame.bounds[index][2] || frame.bounds[index][1] == frame.bounds[index][3]) return false;
}
if (!eye[0]->producerPid || eye[0]->producerPid != eye[1]->producerPid) return false;
CaptureLease lease(eye[0]->producerPid);
if (!lease.Acquired()) {
static unsigned said = 0;
if (said++ < 3) Log("presenter: refusing Quest frame without a completed, retained producer lease (pid %u)", eye[0]->producerPid);
return false;
}
VkResult result = vk_.WaitForFences(dev_, 1, &fence_, VK_TRUE, UINT64_MAX);
if (result != VK_SUCCESS) { Fail(); return false; }
if (!PrepareCapture(frame.render)) return false;
if (vk_.ResetCommandBuffer(cmd_, 0) != VK_SUCCESS) { Fail(); return false; }
VkCommandBufferBeginInfo begin{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO}; begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
if (vk_.BeginCommandBuffer(cmd_, &begin) != VK_SUCCESS) { Fail(); return false; }
VkImageMemoryBarrier before[] = {
Barrier(captureImage_, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0, VK_ACCESS_TRANSFER_WRITE_BIT),
Barrier(eye[0]->image, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_GENERAL, 0, VK_ACCESS_TRANSFER_READ_BIT, VK_QUEUE_FAMILY_EXTERNAL, qfam_),
Barrier(eye[1]->image, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_GENERAL, 0, VK_ACCESS_TRANSFER_READ_BIT, VK_QUEUE_FAMILY_EXTERNAL, qfam_)};
vk_.CmdPipelineBarrier(cmd_, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr,
eye[0] == eye[1] ? 2 : 3, before);
int32_t half = frame.render.width / 2;
for (int index = 0; index < 2; index++) {
const float *bounds = frame.bounds[index];
VkImageBlit blit{}; blit.srcSubresource = blit.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
blit.srcOffsets[0] = {static_cast<int32_t>(bounds[0] * eye[index]->width), static_cast<int32_t>(bounds[1] * eye[index]->height), 0};
blit.srcOffsets[1] = {static_cast<int32_t>(bounds[2] * eye[index]->width), static_cast<int32_t>(bounds[3] * eye[index]->height), 1};
blit.dstOffsets[0] = {index * half, 0, 0}; blit.dstOffsets[1] = {(index + 1) * half, static_cast<int32_t>(frame.render.height), 1};
vk_.CmdBlitImage(cmd_, eye[index]->image, VK_IMAGE_LAYOUT_GENERAL, captureImage_, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_LINEAR);
}
VkImageMemoryBarrier after[] = {
Barrier(captureImage_, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT),
Barrier(eye[0]->image, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_GENERAL, VK_ACCESS_TRANSFER_READ_BIT, 0, qfam_, VK_QUEUE_FAMILY_EXTERNAL),
Barrier(eye[1]->image, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_GENERAL, VK_ACCESS_TRANSFER_READ_BIT, 0, qfam_, VK_QUEUE_FAMILY_EXTERNAL)};
vk_.CmdPipelineBarrier(cmd_, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr,
eye[0] == eye[1] ? 2 : 3, after);
VkBufferImageCopy copy{}; copy.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
copy.imageExtent = {frame.render.width, frame.render.height, 1};
vk_.CmdCopyImageToBuffer(cmd_, captureImage_, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, readbackBuffer_, 1, &copy);
VkBufferMemoryBarrier host{VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER};
host.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; host.dstAccessMask = VK_ACCESS_HOST_READ_BIT;
host.srcQueueFamilyIndex = host.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
host.buffer = readbackBuffer_; host.size = VK_WHOLE_SIZE;
vk_.CmdPipelineBarrier(cmd_, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_HOST_BIT, 0, 0, nullptr, 1, &host, 0, nullptr);
if (vk_.EndCommandBuffer(cmd_) != VK_SUCCESS || vk_.ResetFences(dev_, 1, &fence_) != VK_SUCCESS) { Fail(); return false; }
VkSubmitInfo submit{VK_STRUCTURE_TYPE_SUBMIT_INFO}; submit.commandBufferCount = 1; submit.pCommandBuffers = &cmd_;
lease.BeginGpu();
result = vk_.QueueSubmit(queue_, 1, &submit, fence_);
if (result != VK_SUCCESS) {
if (vk_.DeviceWaitIdle(dev_) == VK_SUCCESS) lease.EndGpu();
Fail(); return false;
}
result = vk_.WaitForFences(dev_, 1, &fence_, VK_TRUE, UINT64_MAX);
if (result != VK_SUCCESS) {
if (vk_.DeviceWaitIdle(dev_) == VK_SUCCESS) lease.EndGpu();
Fail(); return false;
}
lease.EndGpu();
if (!lease.Release()) return false;
void *mapping = nullptr;
if (vk_.MapMemory(dev_, readbackMemory_, 0, VK_WHOLE_SIZE, 0, &mapping) != VK_SUCCESS) return false;
VkMappedMemoryRange range{VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE}; range.memory = readbackMemory_; range.size = VK_WHOLE_SIZE;
result = vk_.InvalidateMappedMemoryRanges(dev_, 1, &range);
bool sent = result == VK_SUCCESS && sender_.Send("/run/droiddeck/" DROIDDECK_XR_FRAME_SOCKET,
frame.render, static_cast<unsigned char *>(mapping));
vk_.UnmapMemory(dev_, readbackMemory_);
return sent;
}
bool Draw(const EyeFrame &f) {
const Imported *eye[2] = {Find(f.texture[0]), Find(f.texture[1])};
if (!eye[0] || !eye[1]) return false;
@@ -545,7 +760,14 @@ private:
std::vector<uint64_t> removals_;
EyeFrame frame_{};
std::chrono::steady_clock::time_point frameAt_{};
bool haveFrame_ = false, exit_ = false, started_ = false;
bool haveFrame_ = false, exit_ = false, started_ = false, inFlight_ = false, failed_ = false;
uint64_t ticket_ = 0, completed_ = 0;
bool frameChannel_ = false;
XrFrameSender sender_;
VkImage captureImage_ = VK_NULL_HANDLE;
VkDeviceMemory captureMemory_ = VK_NULL_HANDLE, readbackMemory_ = VK_NULL_HANDLE;
VkBuffer readbackBuffer_ = VK_NULL_HANDLE;
uint32_t captureWidth_ = 0, captureHeight_ = 0;
std::atomic<uint64_t> submitted_{0};
std::thread thread_;
@@ -573,7 +795,7 @@ private:
}
void Presenter::AddTextures(const uint64_t h[3], const int fds[3], uint32_t f, uint32_t w, uint32_t hh) { Impl::Get().Add(h, fds, f, w, hh); }
void Presenter::AddTextures(const uint64_t h[3], const int fds[3], uint32_t f, uint32_t w, uint32_t hh, uint32_t pid) { Impl::Get().Add(h, fds, f, w, hh, pid); }
void Presenter::RemoveTexture(uint64_t h) { Impl::Get().Remove(h); }
void Presenter::Submit(const EyeFrame &f) { Impl::Get().Submit(f); }
void Presenter::Shutdown() { Impl::Get().Shutdown(); }
+5 -1
View File
@@ -1,16 +1,20 @@
#pragma once
#include <cstdint>
#include "xr_frame.h"
namespace droiddeck {
struct EyeFrame {
uint64_t texture[2];
float bounds[2][4];
DroidDeckXrFrameHeader render;
uint32_t layers = 0;
};
class Presenter {
public:
static void AddTextures(const uint64_t handles[3], const int fds[3], uint32_t vkFormat, uint32_t width, uint32_t height);
static void AddTextures(const uint64_t handles[3], const int fds[3], uint32_t vkFormat, uint32_t width, uint32_t height,
uint32_t producerPid);
static void RemoveTexture(uint64_t handle);
static void Submit(const EyeFrame &frame);
static void Shutdown();
+207
View File
@@ -0,0 +1,207 @@
#pragma once
#include <errno.h>
#include <fcntl.h>
#include <poll.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/un.h>
#include <time.h>
#include <unistd.h>
#define DROIDDECK_PRODUCER_MAGIC 0x44504c31u
#define DROIDDECK_PRODUCER_ACQUIRE 1u
#define DROIDDECK_PRODUCER_READY 2u
#define DROIDDECK_PRODUCER_RELEASE 3u
#define DROIDDECK_PRODUCER_RELEASED 4u
struct droiddeck_producer_message {
uint32_t magic;
uint32_t command;
uint64_t ticket;
};
static inline int64_t droiddeck_producer_now(void) {
struct timespec current;
clock_gettime(CLOCK_MONOTONIC, &current);
return (int64_t)current.tv_sec * 1000000000LL + current.tv_nsec;
}
static inline int droiddeck_producer_path(char *path, size_t capacity, uint32_t pid) {
const char *runtime = getenv("XDG_RUNTIME_DIR");
int length = snprintf(path, capacity, "%s/droiddeck-steamvr/producer-%u.sock",
runtime && *runtime ? runtime : "/run/droiddeck", pid);
return pid && length > 0 && (size_t)length < capacity;
}
static inline int droiddeck_producer_private_parent(const char *path) {
char parent[sizeof(((struct sockaddr_un *)0)->sun_path)];
if (strlen(path) >= sizeof(parent)) return 0;
strcpy(parent, path);
char *separator = strrchr(parent, '/');
if (!separator) return 0;
*separator = 0;
struct stat metadata;
return lstat(parent, &metadata) == 0 && S_ISDIR(metadata.st_mode) &&
metadata.st_uid == getuid() && !(metadata.st_mode & 0077);
}
static inline int droiddeck_producer_peer(int descriptor, uint32_t expected_pid) {
struct ucred peer;
socklen_t length = sizeof(peer);
return getsockopt(descriptor, SOL_SOCKET, SO_PEERCRED, &peer, &length) == 0 &&
peer.uid == getuid() && (!expected_pid || (uint32_t)peer.pid == expected_pid);
}
static inline int droiddeck_producer_io(int descriptor, struct droiddeck_producer_message *message,
int writing, int64_t deadline) {
for (;;) {
int64_t remaining = deadline - droiddeck_producer_now();
if (remaining <= 0) return 0;
struct pollfd ready = {descriptor, (short)(writing ? POLLOUT : POLLIN), 0};
int result = poll(&ready, 1, (int)((remaining + 999999) / 1000000));
if (result < 0 && errno == EINTR) continue;
if (result <= 0 || !(ready.revents & ready.events)) return 0;
ssize_t transferred = writing ? send(descriptor, message, sizeof(*message), MSG_NOSIGNAL | MSG_DONTWAIT)
: recv(descriptor, message, sizeof(*message), MSG_TRUNC | MSG_DONTWAIT);
if (transferred < 0 && (errno == EAGAIN || errno == EINTR)) continue;
return transferred == (ssize_t)sizeof(*message) && message->magic == DROIDDECK_PRODUCER_MAGIC;
}
}
struct droiddeck_producer_lease {
int descriptor;
uint64_t ticket;
};
static inline int droiddeck_producer_acquire(uint32_t pid, struct droiddeck_producer_lease *lease) {
lease->descriptor = -1;
lease->ticket = 0;
struct sockaddr_un address;
memset(&address, 0, sizeof(address));
address.sun_family = AF_UNIX;
if (!droiddeck_producer_path(address.sun_path, sizeof(address.sun_path), pid) ||
!droiddeck_producer_private_parent(address.sun_path)) return 0;
int descriptor = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
if (descriptor < 0) return 0;
int64_t deadline = droiddeck_producer_now() + 250000000LL;
struct droiddeck_producer_message message = {DROIDDECK_PRODUCER_MAGIC, DROIDDECK_PRODUCER_ACQUIRE, 0};
if (connect(descriptor, (struct sockaddr *)&address, sizeof(address)) != 0 ||
!droiddeck_producer_peer(descriptor, pid) ||
!droiddeck_producer_io(descriptor, &message, 1, deadline) ||
!droiddeck_producer_io(descriptor, &message, 0, deadline) ||
message.command != DROIDDECK_PRODUCER_READY || !message.ticket) {
close(descriptor);
return 0;
}
lease->descriptor = descriptor;
lease->ticket = message.ticket;
return 1;
}
static inline int droiddeck_producer_release(struct droiddeck_producer_lease *lease) {
if (lease->descriptor < 0) return 0;
int64_t deadline = droiddeck_producer_now() + 250000000LL;
struct droiddeck_producer_message message = {DROIDDECK_PRODUCER_MAGIC, DROIDDECK_PRODUCER_RELEASE, lease->ticket};
int released = droiddeck_producer_io(lease->descriptor, &message, 1, deadline) &&
droiddeck_producer_io(lease->descriptor, &message, 0, deadline) &&
message.command == DROIDDECK_PRODUCER_RELEASED && message.ticket == lease->ticket;
close(lease->descriptor);
lease->descriptor = -1;
return released;
}
#ifdef DROIDDECK_PRODUCER_SERVER
#include <pthread.h>
struct droiddeck_producer_server {
int listener;
pthread_t thread;
int (*acquire)(int64_t deadline);
void (*release)(void);
void (*quarantine)(void);
char path[sizeof(((struct sockaddr_un *)0)->sun_path)];
};
static void *droiddeck_producer_serve(void *argument) {
struct droiddeck_producer_server *server = argument;
uint64_t ticket = 0;
for (;;) {
int connection = accept4(server->listener, NULL, NULL, SOCK_CLOEXEC | SOCK_NONBLOCK);
if (connection < 0) {
if (errno == EINTR) continue;
break;
}
struct droiddeck_producer_message message;
int64_t deadline = droiddeck_producer_now() + 150000000LL;
if (!droiddeck_producer_peer(connection, 0) ||
!droiddeck_producer_io(connection, &message, 0, deadline) ||
message.command != DROIDDECK_PRODUCER_ACQUIRE || message.ticket || !server->acquire(deadline)) {
close(connection);
continue;
}
uint64_t granted = ++ticket;
message = (struct droiddeck_producer_message){DROIDDECK_PRODUCER_MAGIC, DROIDDECK_PRODUCER_READY, granted};
if (!droiddeck_producer_io(connection, &message, 1, droiddeck_producer_now() + 100000000LL)) {
server->release();
close(connection);
continue;
}
int valid = 1;
while (valid) {
struct pollfd request = {connection, POLLIN, 0};
int ready = poll(&request, 1, -1);
if (ready < 0 && errno == EINTR) continue;
if (ready <= 0 || !(request.revents & POLLIN)) { valid = 0; break; }
valid = droiddeck_producer_io(connection, &message, 0, droiddeck_producer_now() + 100000000LL) &&
message.command == DROIDDECK_PRODUCER_RELEASE && message.ticket == granted;
break;
}
if (!valid) {
server->quarantine();
close(connection);
break;
}
server->release();
message = (struct droiddeck_producer_message){DROIDDECK_PRODUCER_MAGIC, DROIDDECK_PRODUCER_RELEASED, granted};
droiddeck_producer_io(connection, &message, 1, droiddeck_producer_now() + 100000000LL);
close(connection);
}
return NULL;
}
static int droiddeck_producer_start(struct droiddeck_producer_server *server) {
struct sockaddr_un address;
memset(&address, 0, sizeof(address));
address.sun_family = AF_UNIX;
if (!droiddeck_producer_path(address.sun_path, sizeof(address.sun_path), getpid()) ||
!droiddeck_producer_private_parent(address.sun_path)) return 0;
struct stat existing;
if (lstat(address.sun_path, &existing) == 0) {
if (!S_ISSOCK(existing.st_mode) || existing.st_uid != getuid()) return 0;
int probe = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
if (probe < 0) return 0;
int connected = connect(probe, (struct sockaddr *)&address, sizeof(address));
int error = errno;
close(probe);
if (connected == 0 || error != ECONNREFUSED || unlink(address.sun_path) != 0) return 0;
} else if (errno != ENOENT) return 0;
server->listener = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0);
if (server->listener < 0) return 0;
if (bind(server->listener, (struct sockaddr *)&address, sizeof(address)) != 0) {
close(server->listener);
return 0;
}
if (chmod(address.sun_path, 0600) == 0 && listen(server->listener, 4) == 0 &&
pthread_create(&server->thread, NULL, droiddeck_producer_serve, server) == 0) {
strcpy(server->path, address.sun_path);
return 1;
}
close(server->listener);
unlink(address.sun_path);
return 0;
}
#endif
+326 -194
View File
@@ -11,6 +11,11 @@
#include <sys/stat.h>
#include <unistd.h>
#include <errno.h>
#define DROIDDECK_PRODUCER_SERVER
#include "producer_lease.h"
#ifdef DROIDDECK_WSI_CAPTURE
#include "wsi_capture.h"
#endif
#define LAYER_NAME "VK_LAYER_DROIDDECK_vr"
#define EXPORT __attribute__((visibility("default")))
@@ -30,13 +35,20 @@ static void say(const char *fmt, const char *arg) {
static void crash_handler(int sig, siginfo_t *si, void *uc_) {
ucontext_t *uc = uc_;
#if defined(__aarch64__)
void *program_counter = (void *)uc->uc_mcontext.pc;
#elif defined(__x86_64__)
void *program_counter = (void *)uc->uc_mcontext.gregs[REG_RIP];
#else
void *program_counter = NULL;
#endif
char buf[512];
int n = snprintf(buf, sizeof(buf), "\n[crashtrace] %s: signal %d addr %p pc %p\n", program_invocation_short_name, sig,
si->si_addr, (void *)uc->uc_mcontext.pc);
si->si_addr, program_counter);
if (write(2, buf, n)) {}
void *frames[48];
int count = backtrace(frames, 48);
if (count > 1) frames[1] = (void *)uc->uc_mcontext.pc;
if (count > 1) frames[1] = program_counter;
for (int i = 1; i < count; i++) {
Dl_info info;
if (dladdr(frames[i], &info) && info.dli_fname)
@@ -80,6 +92,7 @@ struct inst {
PFN_vkDestroyInstance destroy;
int stripped;
int active;
int producer;
};
struct dev {
VkDevice handle;
@@ -87,6 +100,8 @@ struct dev {
PFN_vkDestroyDevice destroy;
int active;
int display;
int producer;
int quest_images;
};
static int steamvr_running(void) {
@@ -187,8 +202,12 @@ static VkResult VKAPI_CALL CreateInstance(const VkInstanceCreateInfo *ci, const
pthread_mutex_lock(&g_lock);
for (int i = 0; i < MAX_OBJS; i++) {
if (!g_inst[i].handle) {
const char *lease_mode = getenv("DROIDDECK_VR_PRODUCER_LEASE");
int producer = lease_mode && !strcmp(lease_mode, "1") && !strcmp(program_invocation_short_name, "vrcompositor") &&
(!ci->pApplicationInfo || !ci->pApplicationInfo->pApplicationName ||
strcmp(ci->pApplicationInfo->pApplicationName, "droiddeck-vr-presenter"));
g_inst[i] = (struct inst){*out, next_gipa,
(PFN_vkDestroyInstance)next_gipa(*out, "vkDestroyInstance"), stripped, active};
(PFN_vkDestroyInstance)next_gipa(*out, "vkDestroyInstance"), stripped, active, producer};
break;
}
}
@@ -223,33 +242,64 @@ static VkResult VKAPI_CALL CreateDevice(VkPhysicalDevice pd, const VkDeviceCreat
const int display = in && in->stripped;
VkDeviceCreateInfo mine = *ci;
const char **names = calloc(ci->enabledExtensionCount + 1, sizeof(*names));
const char **names = calloc(ci->enabledExtensionCount + 4, sizeof(*names));
if (!names) return VK_ERROR_OUT_OF_HOST_MEMORY;
uint32_t n = 0;
for (uint32_t i = 0; i < ci->enabledExtensionCount; i++) {
if (display && !strcmp(ci->ppEnabledExtensionNames[i], "VK_EXT_display_control")) { say("taking %s off the device", ci->ppEnabledExtensionNames[i]); continue; }
names[n++] = ci->ppEnabledExtensionNames[i];
}
#ifdef DROIDDECK_WSI_CAPTURE
const char *present_mode = getenv("DROIDDECK_VR_PRESENT");
if (present_mode && !strcmp(present_mode, "questwsi") && !strcmp(program_invocation_short_name, "vrcompositor")) {
PFN_vkEnumerateDeviceExtensionProperties enumerate = (PFN_vkEnumerateDeviceExtensionProperties)
next_gipa(in ? in->handle : VK_NULL_HANDLE, "vkEnumerateDeviceExtensionProperties");
uint32_t count = 0;
if (enumerate && enumerate(pd, NULL, &count, NULL) == VK_SUCCESS && count) {
VkExtensionProperties *supported = calloc(count, sizeof(*supported));
if (supported && enumerate(pd, NULL, &count, supported) == VK_SUCCESS) {
const char *required[] = {"VK_KHR_external_memory_fd", "VK_EXT_external_memory_dma_buf", "VK_EXT_queue_family_foreign"};
for (unsigned j = 0; j < 3; j++) {
int enabled = 0, available = 0;
for (uint32_t k = 0; k < n; k++) if (!strcmp(names[k], required[j])) enabled = 1;
for (uint32_t k = 0; k < count; k++) if (!strcmp(supported[k].extensionName, required[j])) available = 1;
if (!enabled && available) names[n++] = required[j];
}
}
free(supported);
}
}
#endif
mine.enabledExtensionCount = n;
mine.ppEnabledExtensionNames = names;
VkResult r = next_create(pd, &mine, alloc, out);
free(names);
if (r != VK_SUCCESS) return r;
if (r != VK_SUCCESS) { free(names); return r; }
pthread_mutex_lock(&g_lock);
for (int i = 0; i < MAX_OBJS; i++) {
if (!g_dev[i].handle) {
g_dev[i] = (struct dev){*out, next_gdpa, (PFN_vkDestroyDevice)next_gdpa(*out, "vkDestroyDevice"), active, display};
g_dev[i] = (struct dev){*out, next_gdpa, (PFN_vkDestroyDevice)next_gdpa(*out, "vkDestroyDevice"), active, display,
in && in->producer, active && access("/run/droiddeck/droiddeck-xr-frames", F_OK) == 0};
break;
}
}
pthread_mutex_unlock(&g_lock);
#ifdef DROIDDECK_WSI_CAPTURE
if (in) droiddeck_wsi_device(*out, pd, in->handle, in->gipa, next_gdpa, &mine);
#endif
free(names);
return VK_SUCCESS;
}
static void forget_device_semaphores(VkDevice dev);
static void producer_destroy_device(struct dev *device, const VkAllocationCallbacks *allocator);
static void VKAPI_CALL DestroyDevice(VkDevice device, const VkAllocationCallbacks *alloc) {
struct dev *d = dev_for(device);
if (!d) return;
#ifdef DROIDDECK_WSI_CAPTURE
droiddeck_wsi_destroy(device);
#endif
if (d->producer) producer_destroy_device(d, alloc);
forget_device_semaphores(device);
PFN_vkDestroyDevice f = d->destroy;
pthread_mutex_lock(&g_lock);
@@ -263,6 +313,7 @@ static void VKAPI_CALL DestroyDevice(VkDevice device, const VkAllocationCallback
#define FAKE_DISPLAY ((VkDisplayKHR)(uintptr_t)0xdd0001)
#define FAKE_MODE ((VkDisplayModeKHR)(uintptr_t)0xdd0002)
static VkSurfaceKHR g_hmd_surface;
static uint32_t g_mode_w = 1920, g_mode_h = 1080, g_mode_mhz = 60000;
static void mode_init(void) {
@@ -365,6 +416,7 @@ static VkResult VKAPI_CALL CreateDisplayPlaneSurfaceKHR(VkInstance i, const VkDi
if (!headless) { say("%s: no headless surface to stand in", "vkCreateDisplayPlaneSurfaceKHR"); return VK_ERROR_INITIALIZATION_FAILED; }
VkHeadlessSurfaceCreateInfoEXT hci = {VK_STRUCTURE_TYPE_HEADLESS_SURFACE_CREATE_INFO_EXT, NULL, 0};
VkResult r = headless(i, &hci, a, s);
if (r == VK_SUCCESS) g_hmd_surface = *s;
say("the display's surface is a headless one (%s)", r == VK_SUCCESS ? "ok" : "failed");
return r;
}
@@ -423,12 +475,24 @@ static VkResult VKAPI_CALL CreateSwapchainKHR(VkDevice d, const VkSwapchainCreat
VkSwapchainKHR *out) {
PFN_vkCreateSwapchainKHR next = NEXT(d, CreateSwapchainKHR);
VkSwapchainCreateInfoKHR sized = *ci;
#ifdef DROIDDECK_WSI_CAPTURE
const char *capture_mode = getenv("DROIDDECK_VR_PRESENT");
const int capture = capture_mode && !strcmp(capture_mode, "questwsi")
&& !strcmp(program_invocation_short_name, "vrcompositor") && ci->surface == g_hmd_surface;
if (capture)
sized.imageUsage |= VK_IMAGE_USAGE_TRANSFER_SRC_BIT;
#endif
mode_init();
if (sized.imageExtent.width == UINT32_MAX || sized.imageExtent.height == UINT32_MAX)
sized.imageExtent = (VkExtent2D){g_mode_w, g_mode_h};
ci = &sized;
VkResult r = next(d, ci, a, out);
if (r == VK_SUCCESS) return r;
if (r == VK_SUCCESS) {
#ifdef DROIDDECK_WSI_CAPTURE
if (capture) droiddeck_wsi_swapchain(d, *out, ci);
#endif
return r;
}
char line[256];
int n = snprintf(line, sizeof(line), "format %d space %d mode %d images %u %ux%u usage 0x%x chain:", ci->imageFormat,
ci->imageColorSpace, ci->presentMode, ci->minImageCount, ci->imageExtent.width, ci->imageExtent.height,
@@ -444,16 +508,30 @@ static VkResult VKAPI_CALL CreateSwapchainKHR(VkDevice d, const VkSwapchainCreat
if (r != VK_SUCCESS) {
mine.imageFormat = VK_FORMAT_B8G8R8A8_UNORM;
mine.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT;
#ifdef DROIDDECK_WSI_CAPTURE
if (capture) mine.imageUsage |= VK_IMAGE_USAGE_TRANSFER_SRC_BIT;
#endif
mine.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR;
mine.preTransform = VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR;
r = next(d, &mine, a, out);
}
say("headless swapchain: %s", r == VK_SUCCESS ? "ok" : "still refused");
#ifdef DROIDDECK_WSI_CAPTURE
if (r == VK_SUCCESS && capture) droiddeck_wsi_swapchain(d, *out, &mine);
#endif
return r;
}
static void VKAPI_CALL DestroySwapchainKHR(VkDevice device, VkSwapchainKHR swapchain, const VkAllocationCallbacks *allocator) {
#ifdef DROIDDECK_WSI_CAPTURE
droiddeck_wsi_forget(device, swapchain);
#endif
NEXT(device, DestroySwapchainKHR)(device, swapchain, allocator);
}
static PFN_vkVoidFunction display_device_hook(const char *name) {
HOOK_AS("vkCreateSwapchainKHR", CreateSwapchainKHR)
HOOK_AS("vkDestroySwapchainKHR", DestroySwapchainKHR)
HOOK_AS("vkDisplayPowerControlEXT", DisplayPowerControlEXT)
HOOK_AS("vkRegisterDeviceEventEXT", RegisterDeviceEventEXT)
HOOK_AS("vkRegisterDisplayEventEXT", RegisterDisplayEventEXT)
@@ -470,6 +548,112 @@ static PFN_vkVoidFunction display_device_hook(const char *name) {
#define SHARED_NAME "droiddeck-shared-sem"
#define POLL_US 300
static pthread_mutex_t g_submission_lock = PTHREAD_MUTEX_INITIALIZER;
static unsigned g_producer_idle_calls;
struct producer_queue {
VkQueue queue;
VkDevice device;
VkFence fence;
};
static struct producer_queue g_producer_queues[MAX_OBJS];
static int g_producer_overflow;
static int g_producer_started;
static int64_t g_producer_retry_at;
static struct droiddeck_producer_server g_producer_server;
__attribute__((destructor)) static void producer_process_exit(void) {
if (g_producer_started && g_producer_server.path[0]) unlink(g_producer_server.path);
}
static int producer_acquire(int64_t deadline) {
while (pthread_mutex_trylock(&g_submission_lock) != 0) {
if (droiddeck_producer_now() >= deadline) return 0;
usleep(100);
}
int queues = 0;
if (g_producer_overflow || g_producer_idle_calls) goto fail;
for (unsigned index = 0; index < MAX_OBJS; index++) {
struct producer_queue *entry = &g_producer_queues[index];
if (!entry->queue) continue;
queues++;
struct dev *device = dev_for(entry->device);
if (!device) goto fail;
PFN_vkGetFenceStatus status = (PFN_vkGetFenceStatus)device->gdpa(device->handle, "vkGetFenceStatus");
PFN_vkDestroyFence destroy = (PFN_vkDestroyFence)device->gdpa(device->handle, "vkDestroyFence");
if (entry->fence) {
if (status(device->handle, entry->fence) != VK_SUCCESS) goto fail;
destroy(device->handle, entry->fence, NULL);
entry->fence = VK_NULL_HANDLE;
}
VkFenceCreateInfo create = {VK_STRUCTURE_TYPE_FENCE_CREATE_INFO, NULL, 0};
PFN_vkCreateFence create_fence = (PFN_vkCreateFence)device->gdpa(device->handle, "vkCreateFence");
if (create_fence(device->handle, &create, NULL, &entry->fence) != VK_SUCCESS) goto fail;
PFN_vkQueueSubmit submit = (PFN_vkQueueSubmit)device->gdpa(device->handle, "vkQueueSubmit");
if (submit(entry->queue, 0, NULL, entry->fence) != VK_SUCCESS) {
destroy(device->handle, entry->fence, NULL);
entry->fence = VK_NULL_HANDLE;
goto fail;
}
}
if (!queues) goto fail;
for (unsigned index = 0; index < MAX_OBJS; index++) {
struct producer_queue *entry = &g_producer_queues[index];
if (!entry->queue) continue;
int64_t remaining = deadline - droiddeck_producer_now();
if (remaining <= 0) goto fail;
struct dev *device = dev_for(entry->device);
PFN_vkWaitForFences wait = (PFN_vkWaitForFences)device->gdpa(device->handle, "vkWaitForFences");
if (wait(device->handle, 1, &entry->fence, VK_TRUE, (uint64_t)remaining) != VK_SUCCESS) goto fail;
}
return 1;
fail:
pthread_mutex_unlock(&g_submission_lock);
return 0;
}
static void producer_release(void) {
pthread_mutex_unlock(&g_submission_lock);
}
static void producer_quarantine(void) {
say("%s", "producer lease lost before capture completion: submissions quarantined; restart SteamVR");
}
static void producer_record(struct dev *device, VkQueue queue) {
if (!g_producer_started && droiddeck_producer_now() >= g_producer_retry_at) {
g_producer_retry_at = droiddeck_producer_now() + 1000000000LL;
g_producer_server.acquire = producer_acquire;
g_producer_server.release = producer_release;
g_producer_server.quarantine = producer_quarantine;
if (droiddeck_producer_start(&g_producer_server)) {
pthread_detach(g_producer_server.thread);
g_producer_started = 1;
}
}
for (unsigned index = 0; index < MAX_OBJS; index++) {
struct producer_queue *entry = &g_producer_queues[index];
if (entry->queue == queue) return;
if (!entry->queue) {
*entry = (struct producer_queue){queue, device->handle, VK_NULL_HANDLE};
return;
}
}
g_producer_overflow = 1;
}
static void producer_destroy_device(struct dev *device, const VkAllocationCallbacks *allocator) {
(void)allocator;
pthread_mutex_lock(&g_submission_lock);
PFN_vkDestroyFence destroy = (PFN_vkDestroyFence)device->gdpa(device->handle, "vkDestroyFence");
for (unsigned index = 0; index < MAX_OBJS; index++) {
struct producer_queue *entry = &g_producer_queues[index];
if (entry->device != device->handle) continue;
if (entry->fence) destroy(device->handle, entry->fence, NULL);
memset(entry, 0, sizeof(*entry));
}
pthread_mutex_unlock(&g_submission_lock);
}
struct bridged {
VkSemaphore sem;
VkDevice dev;
@@ -477,7 +661,6 @@ struct bridged {
int fd;
int timeline;
unsigned long ino;
uint64_t submitted;
};
static pthread_mutex_t g_br_lock = PTHREAD_MUTEX_INITIALIZER;
static struct bridged g_br[MAX_SEMS];
@@ -597,7 +780,10 @@ static VkResult VKAPI_CALL CreateSemaphore(VkDevice d, const VkSemaphoreCreateIn
if (timeline) timeline_note(*out, 1);
return r;
}
if (!export_info) return r;
if (!export_info || !timeline ||
((const VkExportSemaphoreCreateInfo *)export_info)->handleTypes != VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT) return r;
for (const VkBaseInStructure *item = ci->pNext; item; item = item->pNext)
if (item != export_info && item != (const void *)type_info) return r;
VkSemaphoreTypeCreateInfo type = {0};
VkSemaphoreCreateInfo mine = *ci;
mine.pNext = NULL;
@@ -612,13 +798,26 @@ static VkResult VKAPI_CALL CreateSemaphore(VkDevice d, const VkSemaphoreCreateIn
int fd = memfd_create(SHARED_NAME, MFD_CLOEXEC);
_Atomic uint64_t *counter = NULL;
if (fd >= 0 && ftruncate(fd, 4096) == 0) counter = map_counter(fd);
if (!counter) { if (fd >= 0) close(fd); return r; }
if (!counter) {
if (fd >= 0) close(fd);
NEXT(d, DestroySemaphore)(d, *out, a);
timeline_note(*out, 0);
*out = VK_NULL_HANDLE;
return VK_ERROR_OUT_OF_HOST_MEMORY;
}
atomic_store(counter, timeline ? type_info->initialValue : 0);
pthread_mutex_lock(&g_br_lock);
struct bridged *slot = bridged_find(VK_NULL_HANDLE);
if (slot) *slot = (struct bridged){*out, d, counter, fd, timeline, fd_ino(fd), 0};
if (slot) *slot = (struct bridged){*out, d, counter, fd, timeline, fd_ino(fd)};
pthread_mutex_unlock(&g_br_lock);
if (!slot) { munmap((void *)counter, 4096); close(fd); return r; }
if (!slot) {
munmap((void *)counter, 4096);
close(fd);
NEXT(d, DestroySemaphore)(d, *out, a);
timeline_note(*out, 0);
*out = VK_NULL_HANDLE;
return VK_ERROR_TOO_MANY_OBJECTS;
}
if (timeline) poller_start();
return r;
}
@@ -646,6 +845,11 @@ static VkResult VKAPI_CALL GetSemaphoreFdKHR(VkDevice d, const VkSemaphoreGetFdI
pthread_mutex_lock(&g_br_lock);
struct bridged *b = bridged_find(info->semaphore);
int mine = b && b->fd >= 0 ? dup(b->fd) : -2;
if (b && info->handleType != VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT) {
if (mine >= 0) close(mine);
pthread_mutex_unlock(&g_br_lock);
return VK_ERROR_INVALID_EXTERNAL_HANDLE;
}
pthread_mutex_unlock(&g_br_lock);
if (mine == -2) return NEXT(d, GetSemaphoreFdKHR)(d, info, fd);
*fd = mine;
@@ -654,113 +858,29 @@ static VkResult VKAPI_CALL GetSemaphoreFdKHR(VkDevice d, const VkSemaphoreGetFdI
static VkResult VKAPI_CALL ImportSemaphoreFdKHR(VkDevice d, const VkImportSemaphoreFdInfoKHR *info) {
if (info->fd < 0 || !is_shared_counter(info->fd)) return NEXT(d, ImportSemaphoreFdKHR)(d, info);
_Atomic uint64_t *counter = map_counter(info->fd);
const unsigned long ino = fd_ino(info->fd);
close(info->fd);
const int timeline = timeline_known(info->semaphore);
struct stat metadata;
if (!timeline || info->flags || info->handleType != VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT ||
fstat(info->fd, &metadata) != 0 || metadata.st_size != 4096) return VK_ERROR_INVALID_EXTERNAL_HANDLE;
_Atomic uint64_t *counter = map_counter(info->fd);
if (!counter) return VK_ERROR_OUT_OF_HOST_MEMORY;
const unsigned long ino = fd_ino(info->fd);
pthread_mutex_lock(&g_br_lock);
struct bridged *b = bridged_find(info->semaphore);
if (b) bridged_drop(b);
b = bridged_find(VK_NULL_HANDLE);
if (b && counter) *b = (struct bridged){info->semaphore, d, counter, -1, timeline, ino, 0};
else if (counter) munmap((void *)counter, 4096);
if (b) *b = (struct bridged){info->semaphore, d, counter, info->fd, timeline, ino};
pthread_mutex_unlock(&g_br_lock);
if (!b) {
munmap((void *)counter, 4096);
return VK_ERROR_TOO_MANY_OBJECTS;
}
if (timeline) poller_start();
static int said;
if (said++ < 4) say(timeline ? "imported a shared timeline semaphore (%s): bridged" : "imported a shared binary semaphore (%s): its waits are dropped",
program_invocation_short_name);
if (said++ < 4) say("imported a shared timeline semaphore (%s): waits retained", program_invocation_short_name);
return VK_SUCCESS;
}
static int drop_wait(VkSemaphore s) {
pthread_mutex_lock(&g_br_lock);
struct bridged *b = bridged_find(s);
int r = b && !b->timeline && b->fd < 0;
pthread_mutex_unlock(&g_br_lock);
return r;
}
static uint64_t now_us(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (uint64_t)ts.tv_sec * 1000000u + (uint64_t)ts.tv_nsec / 1000u;
}
#define SUBMIT_GRACE_US 3000
static int submit_drop_wait(VkSemaphore s, uint64_t value) {
pthread_mutex_lock(&g_br_lock);
struct bridged *b = bridged_find(s);
if (!b) { pthread_mutex_unlock(&g_br_lock); return 0; }
if (!b->timeline) { int r = b->fd < 0; pthread_mutex_unlock(&g_br_lock); return r; }
const int imported = b->fd < 0;
const uint64_t queued = b->submitted;
VkDevice dev = b->dev;
_Atomic uint64_t *counter = b->counter;
pthread_mutex_unlock(&g_br_lock);
if (!imported || !value || value <= queued) return 0;
struct dev *d = dev_for(dev);
if (!d) return 0;
PFN_vkGetSemaphoreCounterValue get = (PFN_vkGetSemaphoreCounterValue)d->gdpa(dev, "vkGetSemaphoreCounterValue");
if (!get) get = (PFN_vkGetSemaphoreCounterValue)d->gdpa(dev, "vkGetSemaphoreCounterValueKHR");
PFN_vkSignalSemaphore sig = (PFN_vkSignalSemaphore)d->gdpa(dev, "vkSignalSemaphore");
if (!sig) sig = (PFN_vkSignalSemaphore)d->gdpa(dev, "vkSignalSemaphoreKHR");
if (!get) return 0;
const uint64_t until = now_us() + SUBMIT_GRACE_US;
for (;;) {
uint64_t local = 0;
if (get(dev, s, &local) == VK_SUCCESS && local >= value) return 0;
uint64_t shared = atomic_load(counter);
if (shared >= value && sig) {
VkSemaphoreSignalInfo si = {VK_STRUCTURE_TYPE_SEMAPHORE_SIGNAL_INFO, NULL, s, shared};
if (sig(dev, &si) == VK_SUCCESS) return 0;
}
if (now_us() >= until) break;
usleep(100);
}
static _Atomic int dropped;
if (atomic_fetch_add(&dropped, 1) == 0)
say("%s: dropping a submit's wait on a shared value nobody has signalled yet (Turnip would block the submit)",
program_invocation_short_name);
return 1;
}
static void note_signal(VkSemaphore s, uint64_t value) {
pthread_mutex_lock(&g_br_lock);
struct bridged *b = bridged_find(s);
if (b && b->timeline && value > b->submitted) b->submitted = value;
pthread_mutex_unlock(&g_br_lock);
}
static void note_submit_signals(uint32_t count, const VkSubmitInfo *submits) {
for (uint32_t i = 0; i < count; i++) {
const VkTimelineSemaphoreSubmitInfo *t = NULL;
for (const VkBaseInStructure *p = submits[i].pNext; p; p = p->pNext)
if (p->sType == VK_STRUCTURE_TYPE_TIMELINE_SEMAPHORE_SUBMIT_INFO) t = (const void *)p;
for (uint32_t j = 0; t && j < submits[i].signalSemaphoreCount && j < t->signalSemaphoreValueCount; j++)
note_signal(submits[i].pSignalSemaphores[j], t->pSignalSemaphoreValues[j]);
}
}
static void note_submit2_signals(uint32_t count, const VkSubmitInfo2 *submits) {
for (uint32_t i = 0; i < count; i++)
for (uint32_t j = 0; j < submits[i].signalSemaphoreInfoCount; j++)
note_signal(submits[i].pSignalSemaphoreInfos[j].semaphore, submits[i].pSignalSemaphoreInfos[j].value);
}
static uint32_t keep_waits(const VkSemaphore *in, const VkPipelineStageFlags *stages, const uint64_t *values, uint32_t n,
VkSemaphore *out, VkPipelineStageFlags *out_stages, uint64_t *out_values) {
uint32_t k = 0;
for (uint32_t i = 0; i < n; i++) {
if (submit_drop_wait(in[i], values ? values[i] : 0)) continue;
out[k] = in[i];
if (stages) out_stages[k] = stages[i];
if (values) out_values[k] = values[i];
k++;
}
return k;
}
static void trace_sem(const char *what, VkSemaphore sem, uint64_t value) {
if (!sem_trace_on() || value > 3) return;
pthread_mutex_lock(&g_br_lock);
@@ -786,55 +906,11 @@ static VkResult VKAPI_CALL QueueSubmit(VkQueue q, uint32_t count, const VkSubmit
for (uint32_t j = 0; j < submits[i].signalSemaphoreCount; j++)
trace_sem("signals", submits[i].pSignalSemaphores[j], t && j < t->signalSemaphoreValueCount ? t->pSignalSemaphoreValues[j] : 0);
}
int any = 0;
for (uint32_t i = 0; i < count && !any; i++) {
const VkTimelineSemaphoreSubmitInfo *t = NULL;
for (const VkBaseInStructure *p = submits[i].pNext; p; p = p->pNext)
if (p->sType == VK_STRUCTURE_TYPE_TIMELINE_SEMAPHORE_SUBMIT_INFO) t = (const void *)p;
for (uint32_t j = 0; j < submits[i].waitSemaphoreCount && !any; j++)
any = submit_drop_wait(submits[i].pWaitSemaphores[j], t && j < t->waitSemaphoreValueCount ? t->pWaitSemaphoreValues[j] : 0);
}
if (!any) {
VkResult r = next(q, count, submits, fence);
note_submit_signals(count, submits);
return r;
}
VkSubmitInfo *copy = calloc(count, sizeof(*copy));
VkTimelineSemaphoreSubmitInfo *tl = calloc(count, sizeof(*tl));
void **bufs = calloc(count * 3, sizeof(void *));
for (uint32_t i = 0; i < count; i++) {
copy[i] = submits[i];
uint32_t n = submits[i].waitSemaphoreCount;
if (!n) continue;
VkSemaphore *sems = calloc(n, sizeof(*sems));
VkPipelineStageFlags *stages = calloc(n, sizeof(*stages));
uint64_t *vals = calloc(n, sizeof(*vals));
bufs[i * 3] = sems; bufs[i * 3 + 1] = stages; bufs[i * 3 + 2] = vals;
const VkTimelineSemaphoreSubmitInfo *t = NULL;
for (const VkBaseInStructure *p = submits[i].pNext; p; p = p->pNext)
if (p->sType == VK_STRUCTURE_TYPE_TIMELINE_SEMAPHORE_SUBMIT_INFO) t = (const void *)p;
uint32_t k = keep_waits(submits[i].pWaitSemaphores, submits[i].pWaitDstStageMask,
t && t->waitSemaphoreValueCount ? t->pWaitSemaphoreValues : NULL, n, sems, stages, vals);
copy[i].waitSemaphoreCount = k;
copy[i].pWaitSemaphores = sems;
copy[i].pWaitDstStageMask = stages;
if (t) {
tl[i] = *t;
tl[i].waitSemaphoreValueCount = t->waitSemaphoreValueCount ? k : 0;
tl[i].pWaitSemaphoreValues = vals;
const void *rest = NULL;
const VkBaseInStructure *prev = NULL;
for (const VkBaseInStructure *p = submits[i].pNext; p; prev = p, p = p->pNext) if (p == (const void *)t) { rest = p->pNext; break; }
(void)prev;
tl[i].pNext = rest;
copy[i].pNext = &tl[i];
}
}
VkResult r = next(q, count, copy, fence);
note_submit_signals(count, submits);
for (uint32_t i = 0; i < count * 3; i++) free(bufs[i]);
free(bufs); free(tl); free(copy);
return r;
struct dev *device = dev_for(q);
if (device->producer) { pthread_mutex_lock(&g_submission_lock); producer_record(device, q); }
VkResult result = next(q, count, submits, fence);
if (device->producer) pthread_mutex_unlock(&g_submission_lock);
return result;
}
static VkResult VKAPI_CALL QueueSubmit2(VkQueue q, uint32_t count, const VkSubmitInfo2 *submits, VkFence fence) {
@@ -847,47 +923,73 @@ static VkResult VKAPI_CALL QueueSubmit2(VkQueue q, uint32_t count, const VkSubmi
for (uint32_t j = 0; j < submits[i].signalSemaphoreInfoCount; j++)
trace_sem("signals", submits[i].pSignalSemaphoreInfos[j].semaphore, submits[i].pSignalSemaphoreInfos[j].value);
}
int any = 0;
for (uint32_t i = 0; i < count && !any; i++)
for (uint32_t j = 0; j < submits[i].waitSemaphoreInfoCount && !any; j++)
any = submit_drop_wait(submits[i].pWaitSemaphoreInfos[j].semaphore, submits[i].pWaitSemaphoreInfos[j].value);
if (!any) {
VkResult r = next(q, count, submits, fence);
note_submit2_signals(count, submits);
return r;
struct dev *device = dev_for(q);
if (device->producer) { pthread_mutex_lock(&g_submission_lock); producer_record(device, q); }
VkResult result = next(q, count, submits, fence);
if (device->producer) pthread_mutex_unlock(&g_submission_lock);
return result;
}
static VkResult VKAPI_CALL QueuePresentKHR(VkQueue queue, const VkPresentInfoKHR *info) {
struct dev *device = dev_for(queue);
PFN_vkQueuePresentKHR next = (PFN_vkQueuePresentKHR)device->gdpa(device->handle, "vkQueuePresentKHR");
if (device->producer) { pthread_mutex_lock(&g_submission_lock); producer_record(device, queue); }
#ifdef DROIDDECK_WSI_CAPTURE
VkResult result = droiddeck_wsi_present(device->handle, queue, info, next);
#else
VkResult result = next(queue, info);
#endif
if (device->producer) pthread_mutex_unlock(&g_submission_lock);
return result;
}
static VkResult VKAPI_CALL QueueBindSparse(VkQueue queue, uint32_t count, const VkBindSparseInfo *binds, VkFence fence) {
struct dev *device = dev_for(queue);
PFN_vkQueueBindSparse next = (PFN_vkQueueBindSparse)device->gdpa(device->handle, "vkQueueBindSparse");
if (device->producer) { pthread_mutex_lock(&g_submission_lock); producer_record(device, queue); }
VkResult result = next(queue, count, binds, fence);
if (device->producer) pthread_mutex_unlock(&g_submission_lock);
return result;
}
static VkResult VKAPI_CALL QueueWaitIdle(VkQueue queue) {
struct dev *device = dev_for(queue);
PFN_vkQueueWaitIdle next = (PFN_vkQueueWaitIdle)device->gdpa(device->handle, "vkQueueWaitIdle");
if (device->producer) {
pthread_mutex_lock(&g_submission_lock);
g_producer_idle_calls++;
pthread_mutex_unlock(&g_submission_lock);
}
VkSubmitInfo2 *copy = calloc(count, sizeof(*copy));
VkSemaphoreSubmitInfo **waits = calloc(count, sizeof(*waits));
for (uint32_t i = 0; i < count; i++) {
copy[i] = submits[i];
uint32_t n = submits[i].waitSemaphoreInfoCount, k = 0;
waits[i] = calloc(n + 1, sizeof(VkSemaphoreSubmitInfo));
for (uint32_t j = 0; j < n; j++)
if (!submit_drop_wait(submits[i].pWaitSemaphoreInfos[j].semaphore, submits[i].pWaitSemaphoreInfos[j].value))
waits[i][k++] = submits[i].pWaitSemaphoreInfos[j];
copy[i].waitSemaphoreInfoCount = k;
copy[i].pWaitSemaphoreInfos = waits[i];
VkResult result = next(queue);
if (device->producer) {
pthread_mutex_lock(&g_submission_lock);
g_producer_idle_calls--;
pthread_mutex_unlock(&g_submission_lock);
}
VkResult r = next(q, count, copy, fence);
note_submit2_signals(count, submits);
for (uint32_t i = 0; i < count; i++) free(waits[i]);
free(waits); free(copy);
return r;
return result;
}
static VkResult VKAPI_CALL DeviceWaitIdle(VkDevice handle) {
struct dev *device = dev_for(handle);
PFN_vkDeviceWaitIdle next = (PFN_vkDeviceWaitIdle)device->gdpa(handle, "vkDeviceWaitIdle");
if (device->producer) {
pthread_mutex_lock(&g_submission_lock);
g_producer_idle_calls++;
pthread_mutex_unlock(&g_submission_lock);
}
VkResult result = next(handle);
if (device->producer) {
pthread_mutex_lock(&g_submission_lock);
g_producer_idle_calls--;
pthread_mutex_unlock(&g_submission_lock);
}
return result;
}
static VkResult VKAPI_CALL WaitSemaphores(VkDevice d, const VkSemaphoreWaitInfo *info, uint64_t timeout) {
PFN_vkWaitSemaphores next = (PFN_vkWaitSemaphores)dev_for(d)->gdpa(d, "vkWaitSemaphores");
if (!next) next = (PFN_vkWaitSemaphores)dev_for(d)->gdpa(d, "vkWaitSemaphoresKHR");
VkSemaphore sems[64]; uint64_t vals[64]; uint32_t k = 0;
for (uint32_t i = 0; i < info->semaphoreCount && k < 64; i++)
if (!drop_wait(info->pSemaphores[i])) { sems[k] = info->pSemaphores[i]; vals[k] = info->pValues[i]; k++; }
if (k == info->semaphoreCount) {
return next(d, info, timeout);
}
if (!k) return VK_SUCCESS;
VkSemaphoreWaitInfo mine = *info;
mine.semaphoreCount = k; mine.pSemaphores = sems; mine.pValues = vals;
return next(d, &mine, timeout);
return next(d, info, timeout);
}
static VkResult VKAPI_CALL GetSemaphoreCounterValue(VkDevice d, VkSemaphore s, uint64_t *value) {
@@ -897,6 +999,30 @@ static VkResult VKAPI_CALL GetSemaphoreCounterValue(VkDevice d, VkSemaphore s, u
}
static VkResult VKAPI_CALL CreateImage(VkDevice d, const VkImageCreateInfo *ci, const VkAllocationCallbacks *a, VkImage *out) {
VkImageCreateInfo compatible = *ci;
VkExternalMemoryImageCreateInfo shared;
const VkBaseInStructure *first = ci->pNext;
// SteamVR's OpenXR stereo arrays omit a format list, while DXVK adds one on import.
// Leave single-layer OpenVR images alone: the application owns their export layout.
if (dev_for(d)->quest_images && ci->arrayLayers > 1 && (ci->flags & VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT) && ci->tiling == VK_IMAGE_TILING_OPTIMAL
&& first && first->sType == VK_STRUCTURE_TYPE_IMAGE_FORMAT_LIST_CREATE_INFO) {
const VkBaseInStructure *second = first->pNext;
if (second && second->sType == VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_IMAGE_CREATE_INFO
&& ((const VkExternalMemoryImageCreateInfo *)second)->handleTypes == VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT) {
compatible.pNext = second; ci = &compatible;
say("%s", "matching opaque shared image layout to SteamVR's unrestricted format list");
}
}
if (dev_for(d)->quest_images && ci->arrayLayers > 1 && (ci->flags & VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT) && ci->tiling == VK_IMAGE_TILING_OPTIMAL
&& first && first->sType == VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_IMAGE_CREATE_INFO) {
const VkExternalMemoryImageCreateInfo *external = (const void *)first;
const VkBaseInStructure *second = external->pNext;
if (external->handleTypes == VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT && second
&& second->sType == VK_STRUCTURE_TYPE_IMAGE_FORMAT_LIST_CREATE_INFO) {
shared = *external; shared.pNext = second->pNext; compatible.pNext = &shared; ci = &compatible;
say("%s", "matching opaque shared image layout to SteamVR's unrestricted format list");
}
}
VkResult r = NEXT(d, CreateImage)(d, ci, a, out);
if (!sem_trace_on()) return r;
const VkExternalMemoryImageCreateInfo *ext = NULL;
@@ -907,11 +1033,13 @@ static VkResult VKAPI_CALL CreateImage(VkDevice d, const VkImageCreateInfo *ci,
}
if (!ext) return r;
char line[256];
int n = snprintf(line, sizeof(line), "%s image %ux%u format %d flags 0x%x usage 0x%x tiling %d handles 0x%x list",
int n = snprintf(line, sizeof(line), "%s image %ux%u format %d flags 0x%x usage 0x%x tiling %d layers %u mips %u samples %u handles 0x%x list",
program_invocation_short_name, ci->extent.width, ci->extent.height, ci->format, ci->flags, ci->usage,
ci->tiling, ext->handleTypes);
ci->tiling, ci->arrayLayers, ci->mipLevels, ci->samples, ext->handleTypes);
for (uint32_t i = 0; list && i < list->viewFormatCount && n < (int)sizeof(line) - 8; i++)
n += snprintf(line + n, sizeof(line) - n, " %d", list->pViewFormats[i]);
for (const VkBaseInStructure *p = ci->pNext; p && n < (int)sizeof(line) - 16; p = p->pNext)
n += snprintf(line + n, sizeof(line) - n, " chain:%d", p->sType);
say("%s", line);
return r;
}
@@ -925,6 +1053,10 @@ static PFN_vkVoidFunction semaphore_hook(const char *name) {
HOOK_AS("vkQueueSubmit", QueueSubmit)
HOOK_AS("vkQueueSubmit2", QueueSubmit2)
HOOK_AS("vkQueueSubmit2KHR", QueueSubmit2)
HOOK_AS("vkQueuePresentKHR", QueuePresentKHR)
HOOK_AS("vkQueueBindSparse", QueueBindSparse)
HOOK_AS("vkQueueWaitIdle", QueueWaitIdle)
HOOK_AS("vkDeviceWaitIdle", DeviceWaitIdle)
HOOK_AS("vkWaitSemaphores", WaitSemaphores)
HOOK_AS("vkWaitSemaphoresKHR", WaitSemaphores)
HOOK_AS("vkGetSemaphoreCounterValue", GetSemaphoreCounterValue)
+404
View File
@@ -0,0 +1,404 @@
#define VK_NO_PROTOTYPES
#include "wsi_capture.h"
#include "xr_tracking_file.h"
#include "xr_frame_channel.h"
#include "xr_gpu_channel.h"
#include <algorithm>
#include <cstdio>
#include <memory>
#include <unordered_map>
#include <condition_variable>
#include <array>
namespace {
#define FUNCTIONS(X) \
X(GetSwapchainImagesKHR) X(CreateCommandPool) X(DestroyCommandPool) X(AllocateCommandBuffers) \
X(ResetCommandBuffer) X(BeginCommandBuffer) X(EndCommandBuffer) X(CmdPipelineBarrier) \
X(CmdCopyImageToBuffer) X(CreateBuffer) X(DestroyBuffer) X(GetBufferMemoryRequirements) \
X(AllocateMemory) X(FreeMemory) X(BindBufferMemory) X(MapMemory) X(UnmapMemory) \
X(InvalidateMappedMemoryRanges) X(CreateFence) X(DestroyFence) X(ResetFences) X(WaitForFences) X(QueueSubmit) \
X(CreateSemaphore) X(DestroySemaphore) X(GetMemoryFdKHR) X(CreateImage) X(DestroyImage) \
X(GetImageMemoryRequirements) X(BindImageMemory) X(CmdBlitImage)
struct Device {
VkDevice handle{};
VkPhysicalDeviceMemoryProperties memory{};
bool foreign{};
PFN_vkGetMemoryFdPropertiesKHR GetMemoryFdPropertiesKHR{};
std::unordered_map<VkQueue, uint32_t> queues;
#define MEMBER(name) PFN_vk##name name{};
FUNCTIONS(MEMBER)
#undef MEMBER
};
struct CaptureSlot {
Device *device{};
VkCommandPool pool{};
VkCommandBuffer command{};
VkBuffer buffer{};
VkDeviceMemory memory{};
VkImage rgbaImage{};
VkDeviceMemory rgbaMemory{};
int sharedFd = -1;
bool rgbaInitialized{};
void *mapping{};
VkFence fence{};
bool busy{}, queued{};
DroidDeckXrFrameHeader header{};
~CaptureSlot() { Clear(); }
void Clear() {
if (!device) return;
if (busy && fence) device->WaitForFences(device->handle, 1, &fence, VK_TRUE, UINT64_MAX);
if (sharedFd >= 0) close(sharedFd);
if (fence) device->DestroyFence(device->handle, fence, nullptr);
if (pool) device->DestroyCommandPool(device->handle, pool, nullptr);
if (buffer) device->DestroyBuffer(device->handle, buffer, nullptr);
if (rgbaImage) device->DestroyImage(device->handle, rgbaImage, nullptr);
if (rgbaMemory) device->FreeMemory(device->handle, rgbaMemory, nullptr);
if (mapping) device->UnmapMemory(device->handle, memory);
if (memory) device->FreeMemory(device->handle, memory, nullptr);
device = nullptr; pool = VK_NULL_HANDLE; command = VK_NULL_HANDLE; buffer = VK_NULL_HANDLE;
memory = VK_NULL_HANDLE; rgbaImage = VK_NULL_HANDLE; rgbaMemory = VK_NULL_HANDLE;
sharedFd = -1; mapping = nullptr; fence = VK_NULL_HANDLE; busy = queued = rgbaInitialized = false;
}
bool Initialize(Device &d, VkExtent2D extent, VkFormat format, uint32_t family, int imported = -1, uint64_t size = 0) {
device = &d;
VkCommandPoolCreateInfo pc{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO};
pc.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; pc.queueFamilyIndex = family;
if (d.CreateCommandPool(d.handle, &pc, nullptr, &pool)) return false;
VkCommandBufferAllocateInfo ca{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO};
ca.commandPool = pool; ca.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; ca.commandBufferCount = 1;
if (d.AllocateCommandBuffers(d.handle, &ca, &command)) return false;
VkBufferCreateInfo bc{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
VkExternalMemoryBufferCreateInfo external{VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_BUFFER_CREATE_INFO};
external.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT; bc.pNext = &external;
bc.size = uint64_t(extent.width) * extent.height * 4; bc.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT;
if (d.CreateBuffer(d.handle, &bc, nullptr, &buffer)) return false;
VkMemoryRequirements requirements{}; d.GetBufferMemoryRequirements(d.handle, buffer, &requirements);
VkImportMemoryFdInfoKHR import{VK_STRUCTURE_TYPE_IMPORT_MEMORY_FD_INFO_KHR};
VkMemoryFdPropertiesKHR fdProperties{VK_STRUCTURE_TYPE_MEMORY_FD_PROPERTIES_KHR};
if (imported >= 0) {
if (!d.GetMemoryFdPropertiesKHR || size < requirements.size
|| d.GetMemoryFdPropertiesKHR(d.handle, VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT, imported, &fdProperties)) return false;
requirements.memoryTypeBits &= fdProperties.memoryTypeBits;
}
uint32_t type = d.memory.memoryTypeCount;
for (uint32_t i = 0; i < d.memory.memoryTypeCount; i++) {
auto flags = d.memory.memoryTypes[i].propertyFlags;
if (!(requirements.memoryTypeBits & (1u << i)) || (imported < 0 && !(flags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT))) continue;
type = i;
if (flags & VK_MEMORY_PROPERTY_HOST_CACHED_BIT) break;
}
if (type == d.memory.memoryTypeCount) return false;
VkMemoryAllocateInfo alloc{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO};
VkExportMemoryAllocateInfo exported{VK_STRUCTURE_TYPE_EXPORT_MEMORY_ALLOCATE_INFO};
exported.handleTypes = external.handleTypes; alloc.pNext = &exported;
alloc.allocationSize = requirements.size; alloc.memoryTypeIndex = type;
if (imported >= 0) {
import.handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT; import.fd = dup(imported);
if (import.fd < 0) return false;
alloc.pNext = &import; alloc.allocationSize = size;
}
VkResult allocated = d.AllocateMemory(d.handle, &alloc, nullptr, &memory);
if (allocated && import.fd >= 0 && imported >= 0) close(import.fd);
if (allocated || d.BindBufferMemory(d.handle, buffer, memory, 0)) return false;
if (imported < 0) {
if (d.MapMemory(d.handle, memory, 0, VK_WHOLE_SIZE, 0, &mapping)) return false;
VkMemoryGetFdInfoKHR getFd{VK_STRUCTURE_TYPE_MEMORY_GET_FD_INFO_KHR};
getFd.memory = memory; getFd.handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT;
if (d.GetMemoryFdKHR(d.handle, &getFd, &sharedFd)) return false;
}
if (imported < 0) {
VkImageCreateInfo image{VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO};
image.imageType = VK_IMAGE_TYPE_2D; image.extent = {extent.width, extent.height, 1};
image.mipLevels = image.arrayLayers = 1; image.samples = VK_SAMPLE_COUNT_1_BIT;
image.format = (format == VK_FORMAT_R8G8B8A8_SRGB || format == VK_FORMAT_B8G8R8A8_SRGB)
? VK_FORMAT_R8G8B8A8_SRGB : VK_FORMAT_R8G8B8A8_UNORM;
image.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT;
if (d.CreateImage(d.handle, &image, nullptr, &rgbaImage)) return false;
d.GetImageMemoryRequirements(d.handle, rgbaImage, &requirements);
type = 0;
while (type < d.memory.memoryTypeCount && !(requirements.memoryTypeBits & (1u << type))) type++;
if (type == d.memory.memoryTypeCount) return false;
alloc.pNext = nullptr; alloc.allocationSize = requirements.size; alloc.memoryTypeIndex = type;
if (d.AllocateMemory(d.handle, &alloc, nullptr, &rgbaMemory)
|| d.BindImageMemory(d.handle, rgbaImage, rgbaMemory, 0)) return false;
}
VkFenceCreateInfo fc{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO};
if (d.CreateFence(d.handle, &fc, nullptr, &fence)) return false;
return true;
}
};
struct Swapchain {
explicit Swapchain(const char *trackingPath = "/run/droiddeck/" DROIDDECK_XR_FILE) : tracking(trackingPath) {}
Device *device{};
VkExtent2D extent{};
VkFormat format{};
std::vector<VkImage> images;
std::vector<VkSemaphore> ready;
std::array<CaptureSlot, 3> slots;
uint64_t frame{};
XrTrackingFile tracking;
XrFrameSender sender;
XrGpuSender gpuSender;
bool gpu{};
bool initialized{}, attempted{};
uint32_t family{};
std::mutex sendMutex;
std::condition_variable sendReady;
bool stopping{}, failed{};
std::thread sendThread;
~Swapchain() {
{ std::lock_guard<std::mutex> lock(sendMutex); stopping = true; sendReady.notify_all(); }
if (sendThread.joinable()) sendThread.join();
for (auto semaphore : ready) if (semaphore) device->DestroySemaphore(device->handle, semaphore, nullptr);
}
void Run() noexcept {
try { Loop(); }
catch (...) {
std::lock_guard<std::mutex> lock(sendMutex); failed = true;
std::fprintf(stderr, "[droiddeck-vr-layer] WSI worker failed; restart SteamVR\n");
}
}
void Loop() {
for (;;) {
CaptureSlot *slot;
{
std::unique_lock<std::mutex> lock(sendMutex);
sendReady.wait(lock, [this] {
return stopping || std::any_of(slots.begin(), slots.end(), [](const auto &item) { return item.queued; });
});
slot = nullptr;
for (auto &item : slots) if (item.queued && (!slot || item.header.frame_id < slot->header.frame_id)) slot = &item;
if (!slot) return;
slot->queued = false;
}
auto &d = *device;
const auto header = slot->header;
auto &fence = slot->fence;
auto &memory = slot->memory;
auto sharedFd = slot->sharedFd;
int64_t begin = XrTrackingFile::Now();
VkResult result = d.WaitForFences(d.handle, 1, &fence, VK_TRUE, UINT64_MAX);
int64_t waited = XrTrackingFile::Now();
VkMappedMemoryRange range{VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE}; range.memory = memory; range.size = VK_WHOLE_SIZE;
if (result == VK_SUCCESS && !gpu) result = d.InvalidateMappedMemoryRanges(d.handle, 1, &range);
int64_t invalidated = XrTrackingFile::Now();
if (result == VK_SUCCESS && XrTrackingFile::Now() - header.captured_ns < 200000000LL) {
if (gpu) {
uint32_t flags = (format == VK_FORMAT_B8G8R8A8_UNORM || format == VK_FORMAT_B8G8R8A8_SRGB) ? XR_GPU_BGRA : 0;
if (!gpuSender.Send(header, static_cast<uint32_t>(slot - slots.data()), flags)) result = VK_ERROR_DEVICE_LOST;
} else sender.SendShared("/run/droiddeck/" DROIDDECK_XR_FRAME_SOCKET, header, sharedFd);
}
if (header.frame_id % 120 == 0) std::fprintf(stderr, "[droiddeck-vr-layer] WSI ms wait=%.2f invalidate=%.2f shared=%.2f\n",
(waited-begin)/1e6, (invalidated-waited)/1e6, (XrTrackingFile::Now()-invalidated)/1e6);
std::lock_guard<std::mutex> lock(sendMutex);
if (result != VK_SUCCESS || sender.SharedLeaseLost()) {
std::fprintf(stderr, "[droiddeck-vr-layer] WSI buffer quarantined: result=%d leaseLost=%d; restart SteamVR\n", result, sender.SharedLeaseLost());
failed = true; return;
}
slot->busy = false;
}
}
bool Initialize(VkSwapchainKHR chain, VkQueue queue) {
if (initialized) return true;
if (attempted) return false;
attempted = true;
auto &d = *device;
auto queueFamily = d.queues.find(queue);
if (queueFamily == d.queues.end()) return false;
family = queueFamily->second;
uint32_t count = 0;
if (d.GetSwapchainImagesKHR(d.handle, chain, &count, nullptr) || !count) return false;
images.resize(count);
if (d.GetSwapchainImagesKHR(d.handle, chain, &count, images.data())) return false;
ready.resize(count);
VkSemaphoreCreateInfo semaphoreInfo{VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO};
for (auto &semaphore : ready) if (d.CreateSemaphore(d.handle, &semaphoreInfo, nullptr, &semaphore)) return false;
XrGpuPoolInfo info;
std::array<int, XR_GPU_SLOTS> fds{{-1, -1, -1}};
gpu = d.foreign && gpuSender.Connect("/run/droiddeck/" DROIDDECK_XR_GPU_SOCKET, extent.width, extent.height, info, fds);
if (gpu) {
for (unsigned index = 0; index < slots.size(); index++)
if (!slots[index].Initialize(d, extent, format, family, fds[index], info.sizes[index])) { gpu = false; break; }
for (int fd : fds) if (fd >= 0) close(fd);
}
if (!gpu) {
gpuSender.Close();
for (auto &slot : slots) { slot.Clear(); if (!slot.Initialize(d, extent, format, family)) return false; }
}
sendThread = std::thread([this] { Run(); });
initialized = true;
std::fprintf(stderr, "[droiddeck-vr-layer] capturing compositor swapchain %ux%u format %d channel=%s\n", extent.width, extent.height, format, gpu ? "gpu" : "cpu");
return true;
}
VkResult Fail(VkResult result, CaptureSlot &slot) {
std::lock_guard<std::mutex> lock(sendMutex);
failed = true; slot.busy = false;
return result;
}
bool Header(DroidDeckXrFrameHeader &header) {
DroidDeckXrTracking packet{};
if (!tracking.Read(&packet) || !XrTrackingFile::Fresh(packet)) return false;
header = {};
header.magic = DROIDDECK_XR_FRAME_MAGIC; header.version = DROIDDECK_XR_FRAME_VERSION;
header.width = extent.width; header.height = extent.height;
header.bytes = extent.width * extent.height * 4;
header.color_space = DROIDDECK_XR_COLOR_SRGB_RGBA8;
header.frame_id = ++frame; header.captured_ns = XrTrackingFile::Now();
for (int eye = 0; eye < 2; eye++) {
const auto &pose = packet.eyes[eye].pose;
const float x = pose.orientation[0], y = pose.orientation[1], z = pose.orientation[2], w = pose.orientation[3];
float matrix[3][4] = {{1-2*(y*y+z*z),2*(x*y-w*z),2*(x*z+w*y),pose.position[0]},
{2*(x*y+w*z),1-2*(x*x+z*z),2*(y*z-w*x),pose.position[1]},
{2*(x*z-w*y),2*(y*z+w*x),1-2*(x*x+y*y),pose.position[2]}};
std::memcpy(header.eye_pose[eye], matrix, sizeof(matrix));
const auto *fov = packet.eyes[eye].fov;
float left = std::tan(fov[0]), right = std::tan(fov[1]), up = std::tan(fov[2]), down = std::tan(fov[3]);
auto &p = header.projection[eye];
p[0][0] = 2/(right-left); p[0][2] = (right+left)/(right-left);
p[1][1] = 2/(up-down); p[1][2] = (up+down)/(up-down);
p[2][2] = -1; p[2][3] = -0.2f; p[3][2] = -1;
}
return xr_frame_valid(header);
}
VkResult Capture(VkQueue queue, uint32_t index, const VkPresentInfoKHR &present, VkSemaphore *completion) {
auto &d = *device;
if (index >= images.size()) return VK_ERROR_OUT_OF_DATE_KHR;
CaptureSlot *selected = nullptr;
{
std::lock_guard<std::mutex> lock(sendMutex);
if (failed) return VK_ERROR_DEVICE_LOST;
for (auto &slot : slots) if (!slot.busy) { selected = &slot; slot.busy = true; break; }
}
if (!selected) return VK_SUCCESS;
auto &slot = *selected;
DroidDeckXrFrameHeader header{};
if (!Header(header)) { std::lock_guard<std::mutex> lock(sendMutex); slot.busy = false; return VK_SUCCESS; }
auto &command = slot.command;
auto &buffer = slot.buffer;
auto &fence = slot.fence;
auto &rgbaImage = slot.rgbaImage;
auto &rgbaInitialized = slot.rgbaInitialized;
VkResult result = d.ResetCommandBuffer(command, 0);
if (result) return Fail(result, slot);
VkCommandBufferBeginInfo begin{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
if ((result = d.BeginCommandBuffer(command, &begin))) return Fail(result, slot);
VkImageMemoryBarrier image{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
image.srcAccessMask = VK_ACCESS_MEMORY_WRITE_BIT; image.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
image.oldLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; image.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
image.srcQueueFamilyIndex = image.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
image.image = images[index]; image.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
d.CmdPipelineBarrier(command, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &image);
VkBufferImageCopy copy{}; copy.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
copy.imageExtent = {extent.width, extent.height, 1};
if (!gpu) {
VkImageMemoryBarrier rgba = image;
rgba.image = rgbaImage; rgba.srcAccessMask = rgbaInitialized ? VK_ACCESS_TRANSFER_READ_BIT : 0;
rgba.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
rgba.oldLayout = rgbaInitialized ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_UNDEFINED;
rgba.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
d.CmdPipelineBarrier(command, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &rgba);
VkImageBlit blit{}; blit.srcSubresource = blit.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
blit.srcOffsets[1] = blit.dstOffsets[1] = {static_cast<int32_t>(extent.width), static_cast<int32_t>(extent.height), 1};
d.CmdBlitImage(command, images[index], VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, rgbaImage,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_NEAREST);
rgba.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; rgba.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
rgba.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; rgba.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
d.CmdPipelineBarrier(command, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &rgba);
}
VkBufferMemoryBarrier ownership{VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER};
ownership.srcQueueFamilyIndex = VK_QUEUE_FAMILY_FOREIGN_EXT; ownership.dstQueueFamilyIndex = family;
ownership.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; ownership.buffer = buffer; ownership.size = VK_WHOLE_SIZE;
if (gpu) d.CmdPipelineBarrier(command, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
0, 0, nullptr, 1, &ownership, 0, nullptr);
d.CmdCopyImageToBuffer(command, gpu ? images[index] : rgbaImage, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, buffer, 1, &copy);
image.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT; image.dstAccessMask = 0;
image.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; image.newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;
VkBufferMemoryBarrier host{VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER};
host.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; host.dstAccessMask = VK_ACCESS_HOST_READ_BIT;
host.srcQueueFamilyIndex = host.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; host.buffer = buffer; host.size = VK_WHOLE_SIZE;
if (gpu) {
host.dstAccessMask = 0; host.srcQueueFamilyIndex = family; host.dstQueueFamilyIndex = VK_QUEUE_FAMILY_FOREIGN_EXT;
}
d.CmdPipelineBarrier(command, VK_PIPELINE_STAGE_TRANSFER_BIT, (gpu ? 0 : VK_PIPELINE_STAGE_HOST_BIT) | VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT,
0, 0, nullptr, 1, &host, 1, &image);
if ((result = d.EndCommandBuffer(command)) || (result = d.ResetFences(d.handle, 1, &fence))) return Fail(result, slot);
std::vector<VkPipelineStageFlags> stages(present.waitSemaphoreCount, VK_PIPELINE_STAGE_TRANSFER_BIT);
VkSubmitInfo submit{VK_STRUCTURE_TYPE_SUBMIT_INFO};
submit.waitSemaphoreCount = present.waitSemaphoreCount; submit.pWaitSemaphores = present.pWaitSemaphores;
submit.pWaitDstStageMask = stages.data(); submit.commandBufferCount = 1; submit.pCommandBuffers = &command;
submit.signalSemaphoreCount = 1; submit.pSignalSemaphores = &ready[index];
if ((result = d.QueueSubmit(queue, 1, &submit, fence))) return Fail(result, slot);
rgbaInitialized = true;
{ std::lock_guard<std::mutex> lock(sendMutex); slot.header = header; slot.queued = true; sendReady.notify_all(); }
*completion = ready[index];
return VK_SUCCESS;
}
};
std::mutex mutex;
std::unordered_map<VkDevice, std::unique_ptr<Device>> devices;
std::unordered_map<VkSwapchainKHR, std::unique_ptr<Swapchain>> swapchains;
}
extern "C" void droiddeck_wsi_device(VkDevice device, VkPhysicalDevice physical, VkInstance instance,
PFN_vkGetInstanceProcAddr gipa, PFN_vkGetDeviceProcAddr gdpa, const VkDeviceCreateInfo *create) {
const char *mode = getenv("DROIDDECK_VR_PRESENT");
if (!mode || strcmp(mode, "questwsi") || strcmp(program_invocation_short_name, "vrcompositor")) return;
auto d = std::make_unique<Device>(); d->handle = device;
d->GetMemoryFdPropertiesKHR = reinterpret_cast<PFN_vkGetMemoryFdPropertiesKHR>(gdpa(device, "vkGetMemoryFdPropertiesKHR"));
for (uint32_t index = 0; index < create->enabledExtensionCount; index++)
if (!strcmp(create->ppEnabledExtensionNames[index], "VK_EXT_queue_family_foreign")) d->foreign = true;
#define LOAD(name) d->name = reinterpret_cast<PFN_vk##name>(gdpa(device, "vk" #name)); if (!d->name) return;
FUNCTIONS(LOAD)
#undef LOAD
auto properties = reinterpret_cast<PFN_vkGetPhysicalDeviceMemoryProperties>(gipa(instance, "vkGetPhysicalDeviceMemoryProperties"));
properties(physical, &d->memory);
auto getQueue = reinterpret_cast<PFN_vkGetDeviceQueue>(gdpa(device, "vkGetDeviceQueue"));
for (uint32_t index = 0; index < create->queueCreateInfoCount; index++) {
const auto &info = create->pQueueCreateInfos[index];
if (info.flags) continue;
for (uint32_t number = 0; number < info.queueCount; number++) {
VkQueue queue{}; getQueue(device, info.queueFamilyIndex, number, &queue);
if (queue) d->queues[queue] = info.queueFamilyIndex;
}
}
std::lock_guard<std::mutex> lock(mutex); devices[device] = std::move(d);
}
extern "C" void droiddeck_wsi_destroy(VkDevice device) {
std::lock_guard<std::mutex> lock(mutex);
for (auto it = swapchains.begin(); it != swapchains.end();)
if (it->second->device->handle == device) it = swapchains.erase(it); else ++it;
devices.erase(device);
}
extern "C" void droiddeck_wsi_swapchain(VkDevice device, VkSwapchainKHR chain, const VkSwapchainCreateInfoKHR *create) {
std::lock_guard<std::mutex> lock(mutex);
auto it = devices.find(device); if (it == devices.end()) return;
if (create->imageFormat != VK_FORMAT_R8G8B8A8_UNORM && create->imageFormat != VK_FORMAT_R8G8B8A8_SRGB &&
create->imageFormat != VK_FORMAT_B8G8R8A8_UNORM && create->imageFormat != VK_FORMAT_B8G8R8A8_SRGB) return;
auto chainState = std::make_unique<Swapchain>(); chainState->device = it->second.get();
chainState->extent = create->imageExtent; chainState->format = create->imageFormat;
swapchains[chain] = std::move(chainState);
}
extern "C" void droiddeck_wsi_forget(VkDevice device, VkSwapchainKHR chain) {
(void)device; std::lock_guard<std::mutex> lock(mutex); swapchains.erase(chain);
}
extern "C" VkResult droiddeck_wsi_present(VkDevice device, VkQueue queue, const VkPresentInfoKHR *present, PFN_vkQueuePresentKHR next) try {
(void)device; std::lock_guard<std::mutex> lock(mutex);
if (present->swapchainCount != 1) return next(queue, present);
auto it = swapchains.find(present->pSwapchains[0]);
if (it == swapchains.end()) return next(queue, present);
if (!it->second->Initialize(present->pSwapchains[0], queue)) return VK_ERROR_INITIALIZATION_FAILED;
auto family = it->second->device->queues.find(queue);
if (family == it->second->device->queues.end() || family->second != it->second->family)
return VK_ERROR_INITIALIZATION_FAILED;
VkSemaphore completion{};
VkResult result = it->second->Capture(queue, present->pImageIndices[0], *present, &completion);
if (result) return result;
if (!completion) return next(queue, present);
// Reacquiring this swapchain image also proves its previous present wait semaphore is reusable.
VkPresentInfoKHR copy = *present; copy.waitSemaphoreCount = 1; copy.pWaitSemaphores = &completion;
return next(queue, &copy);
} catch (...) {
std::fprintf(stderr, "[droiddeck-vr-layer] WSI capture failed; restart SteamVR\n");
return VK_ERROR_OUT_OF_HOST_MEMORY;
}
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <vulkan/vulkan.h>
#ifdef __cplusplus
extern "C" {
#endif
void droiddeck_wsi_device(VkDevice device, VkPhysicalDevice physical, VkInstance instance,
PFN_vkGetInstanceProcAddr gipa, PFN_vkGetDeviceProcAddr gdpa,
const VkDeviceCreateInfo *create);
void droiddeck_wsi_destroy(VkDevice device);
void droiddeck_wsi_swapchain(VkDevice device, VkSwapchainKHR swapchain, const VkSwapchainCreateInfoKHR *create);
void droiddeck_wsi_forget(VkDevice device, VkSwapchainKHR swapchain);
VkResult droiddeck_wsi_present(VkDevice device, VkQueue queue, const VkPresentInfoKHR *present,
PFN_vkQueuePresentKHR next);
#ifdef __cplusplus
}
#endif
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include "xr_tracking_file.h"
#include <cmath>
#include <iomanip>
#include <locale>
#include <sstream>
#include <string>
struct XrSeatedOrigin {
double yaw, translation[3];
};
class XrChaperoneOrigin {
public:
void Reset() { published_ = false; }
template <typename Publish> void Update(const DroidDeckXrTracking &tracking, Publish publish) {
if (!published_ && XrTrackingFile::Fresh(tracking) && XrTrackingFile::Tracked(tracking.head))
published_ = publish(tracking.head);
}
private:
bool published_ = false;
};
inline XrSeatedOrigin xr_seated_origin(const DroidDeckXrPose &head) {
const auto *q = head.orientation;
double yaw = std::atan2(2 * (q[0] * q[2] + q[3] * q[1]), 1 - 2 * (q[0] * q[0] + q[1] * q[1]));
double c = std::cos(yaw), s = std::sin(yaw);
return {-yaw, {-c * head.position[0] + s * head.position[2], -head.position[1],
-s * head.position[0] - c * head.position[2]}};
}
inline std::string xr_chaperone_json(const DroidDeckXrPose &head, const char *timestamp) {
auto seated = xr_seated_origin(head);
std::ostringstream json; json.imbue(std::locale::classic()); json << std::setprecision(9);
json << "{\"jsonid\":\"chaperone_info\",\"version\":5,\"time\":\"" << timestamp
<< "\",\"universes\":[{\"universeID\":\"2\",\"standing\":{\"translation\":[0,0,0],\"yaw\":0},"
<< "\"seated\":{\"translation\":[" << seated.translation[0] << ',' << seated.translation[1] << ',' << seated.translation[2]
<< "],\"yaw\":" << seated.yaw << "},\"play_area\":[0,0],\"collision_bounds\":[]}]}";
return json.str();
}
+11
View File
@@ -0,0 +1,11 @@
#include "xr_display_mode.h"
#include <cstdio>
int main(int argc, char **argv) {
XrTrackingFile reader(argc > 1 ? argv[1] : "/run/droiddeck/" DROIDDECK_XR_FILE);
DroidDeckXrTracking tracking{};
if (!reader.Read(&tracking)) return 1;
auto mode = XrDisplayMode(tracking);
if (mode.empty()) return 1;
std::puts(mode.c_str());
}
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#include "xr_tracking_file.h"
inline std::string XrDisplayMode(const DroidDeckXrTracking &tracking) {
if (!XrTrackingFile::Fresh(tracking)) return {};
auto refresh = static_cast<uint32_t>(std::llround(1000000000.0 / tracking.period_ns));
return std::to_string(tracking.eyes[0].width * 2) + "x" + std::to_string(tracking.eyes[0].height)
+ "@" + std::to_string(refresh);
}
+53
View File
@@ -0,0 +1,53 @@
#pragma once
#include <stdint.h>
#include <cmath>
#define DROIDDECK_XR_FRAME_MAGIC 0x46584444u
#define DROIDDECK_XR_FRAME_VERSION 1u
#define DROIDDECK_XR_FRAME_SHARED_VERSION 2u
#define DROIDDECK_XR_FRAME_SOCKET "droiddeck-xr-frames"
#define DROIDDECK_XR_FRAME_MAX_BYTES (64u * 1024u * 1024u)
#define DROIDDECK_XR_COLOR_LINEAR_RGBA8 1u
#define DROIDDECK_XR_COLOR_SRGB_RGBA8 2u
struct DroidDeckXrFrameHeader {
uint32_t magic, version, width, height;
uint64_t frame_id;
int64_t captured_ns;
uint32_t bytes, color_space;
float eye_pose[2][3][4];
float projection[2][4][4];
};
static_assert(sizeof(DroidDeckXrFrameHeader) == 264, "XR frame protocol layout");
inline bool xr_frame_valid(const DroidDeckXrFrameHeader &header) {
uint64_t bytes = (uint64_t)header.width * header.height * 4;
if (header.magic != DROIDDECK_XR_FRAME_MAGIC || (header.version != DROIDDECK_XR_FRAME_VERSION
&& header.version != DROIDDECK_XR_FRAME_SHARED_VERSION)
|| !header.frame_id || header.width < 2 || (header.width & 1u) || !header.height
|| header.width > 8192 || header.height > 4096 || bytes > DROIDDECK_XR_FRAME_MAX_BYTES
|| header.bytes != bytes || (header.color_space != DROIDDECK_XR_COLOR_LINEAR_RGBA8
&& header.color_space != DROIDDECK_XR_COLOR_SRGB_RGBA8) || header.captured_ns <= 0) return false;
for (int eye = 0; eye < 2; eye++) {
for (const auto &row : header.eye_pose[eye]) for (float value : row) if (!std::isfinite(value)) return false;
for (const auto &row : header.projection[eye]) for (float value : row) if (!std::isfinite(value)) return false;
const auto &projection = header.projection[eye];
if (projection[0][0] < 0.0001f || projection[1][1] < 0.0001f
|| std::abs(projection[3][2] + 1.f) > 0.0001f || std::abs(projection[3][3]) > 0.0001f
|| std::abs(projection[3][0]) > 0.0001f || std::abs(projection[3][1]) > 0.0001f
|| std::abs(projection[0][1]) > 0.0001f || std::abs(projection[0][3]) > 0.0001f
|| std::abs(projection[1][0]) > 0.0001f || std::abs(projection[1][3]) > 0.0001f) return false;
for (int row = 0; row < 3; row++) for (int other = 0; other < 3; other++) {
float product = 0;
for (int column = 0; column < 3; column++) product += header.eye_pose[eye][row][column] * header.eye_pose[eye][other][column];
if (std::abs(product - (row == other ? 1.f : 0.f)) > 0.01f) return false;
}
const auto &rotation = header.eye_pose[eye];
float determinant = rotation[0][0] * (rotation[1][1] * rotation[2][2] - rotation[1][2] * rotation[2][1])
- rotation[0][1] * (rotation[1][0] * rotation[2][2] - rotation[1][2] * rotation[2][0])
+ rotation[0][2] * (rotation[1][0] * rotation[2][1] - rotation[1][1] * rotation[2][0]);
if (std::abs(determinant - 1.f) > 0.01f) return false;
}
return true;
}
+289
View File
@@ -0,0 +1,289 @@
#pragma once
#include "xr_frame.h"
#include <atomic>
#include <cerrno>
#include <cstring>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include <poll.h>
#include <fcntl.h>
#include <sys/file.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/mman.h>
#include <sys/ioctl.h>
#include <linux/dma-buf.h>
#include <sys/un.h>
#include <time.h>
#include <unistd.h>
inline int64_t xr_frame_now() {
timespec now{}; clock_gettime(CLOCK_MONOTONIC, &now);
return (int64_t)now.tv_sec * 1000000000LL + now.tv_nsec;
}
inline bool xr_frame_io(int descriptor, void *data, size_t size, bool write, int64_t deadline) {
auto *bytes = static_cast<unsigned char *>(data);
while (size) {
int64_t remaining = deadline - xr_frame_now();
if (remaining <= 0) return false;
pollfd ready{descriptor, (short)(write ? POLLOUT : POLLIN), 0};
int result = poll(&ready, 1, (int)((remaining + 999999) / 1000000));
if (result < 0 && errno == EINTR) continue;
if (result <= 0 || !(ready.revents & ready.events)) return false;
ssize_t done = write ? send(descriptor, bytes, size, MSG_NOSIGNAL | MSG_DONTWAIT)
: recv(descriptor, bytes, size, MSG_DONTWAIT);
if (done < 0 && (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK)) continue;
if (done <= 0) return false;
bytes += done; size -= done;
}
return true;
}
inline bool xr_frame_peer(int descriptor) {
struct { pid_t pid; uid_t uid; gid_t gid; } peer{};
socklen_t size = sizeof(peer);
return getsockopt(descriptor, SOL_SOCKET, SO_PEERCRED, &peer, &size) == 0
&& size == sizeof(peer) && peer.uid == getuid();
}
struct XrFrameDescriptor {
int fd = -1;
~XrFrameDescriptor() { if (fd >= 0) close(fd); }
XrFrameDescriptor() = default;
XrFrameDescriptor(const XrFrameDescriptor &) = delete;
XrFrameDescriptor &operator=(const XrFrameDescriptor &) = delete;
};
inline bool xr_frame_header(int socket, DroidDeckXrFrameHeader &header, XrFrameDescriptor &shared, int64_t deadline) {
for (;;) {
int64_t remaining = deadline - xr_frame_now();
if (remaining <= 0) return false;
pollfd ready{socket, POLLIN, 0};
int result = poll(&ready, 1, (int)((remaining + 999999) / 1000000));
if (result < 0 && errno == EINTR) continue;
if (result <= 0 || !(ready.revents & POLLIN)) return false;
alignas(cmsghdr) unsigned char control[CMSG_SPACE(8 * sizeof(int))]{};
iovec data{&header, sizeof(header)};
msghdr message{}; message.msg_iov = &data; message.msg_iovlen = 1;
message.msg_control = control; message.msg_controllen = sizeof(control);
ssize_t bytes = recvmsg(socket, &message, MSG_DONTWAIT | MSG_CMSG_CLOEXEC);
if (bytes < 0 && (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK)) continue;
if (bytes <= 0) return false;
unsigned count = 0;
for (auto *item = CMSG_FIRSTHDR(&message); item; item = CMSG_NXTHDR(&message, item)) {
if (item->cmsg_level != SOL_SOCKET || item->cmsg_type != SCM_RIGHTS) continue;
size_t descriptors = (item->cmsg_len - CMSG_LEN(0)) / sizeof(int);
auto *fds = reinterpret_cast<int *>(CMSG_DATA(item));
for (size_t index = 0; index < descriptors; index++) {
if (++count == 1) shared.fd = fds[index]; else close(fds[index]);
}
}
if ((message.msg_flags & (MSG_CTRUNC | MSG_TRUNC)) || count > 1) return false;
return xr_frame_io(socket, reinterpret_cast<unsigned char *>(&header) + bytes, sizeof(header) - bytes, false, deadline);
}
}
inline bool xr_frame_shared_pixels(int descriptor, unsigned char *pixels, size_t bytes) {
struct stat status{};
if (fstat(descriptor, &status) != 0 || status.st_size < static_cast<int64_t>(bytes)) return false;
dma_buf_sync sync{DMA_BUF_SYNC_START | DMA_BUF_SYNC_READ};
if (ioctl(descriptor, DMA_BUF_IOCTL_SYNC, &sync) != 0) return false;
void *mapping = mmap(nullptr, bytes, PROT_READ, MAP_SHARED, descriptor, 0);
bool success = mapping != MAP_FAILED;
if (success) { std::memcpy(pixels, mapping, bytes); munmap(mapping, bytes); }
sync.flags = DMA_BUF_SYNC_END | DMA_BUF_SYNC_READ;
return ioctl(descriptor, DMA_BUF_IOCTL_SYNC, &sync) == 0 && success;
}
struct DroidDeckXrPixels {
DroidDeckXrFrameHeader header{};
std::vector<unsigned char> pixels;
};
class XrFrameReceiver {
public:
~XrFrameReceiver() { Stop(); }
bool Start(const std::string &filename) {
if (listener_ >= 0 || filename.empty() || filename.size() >= sizeof(sockaddr_un::sun_path)) return false;
lock_ = open((filename + ".lock").c_str(), O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, 0600);
struct stat status{};
if (lock_ < 0) return false;
if (fstat(lock_, &status) != 0 || !S_ISREG(status.st_mode) || status.st_uid != getuid()
|| flock(lock_, LOCK_EX | LOCK_NB) != 0) { close(lock_); lock_ = -1; return false; }
if (lstat(filename.c_str(), &status) == 0) {
if (!S_ISSOCK(status.st_mode) || status.st_uid != getuid() || unlink(filename.c_str()) != 0) {
close(lock_); lock_ = -1; return false;
}
} else if (errno != ENOENT) { close(lock_); lock_ = -1; return false; }
sockaddr_un address{}; address.sun_family = AF_UNIX;
std::strcpy(address.sun_path, filename.c_str());
listener_ = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
if (listener_ < 0) { Stop(); return false; }
if (bind(listener_, reinterpret_cast<sockaddr *>(&address), sizeof(address)) != 0) { Stop(); return false; }
filename_ = filename;
if (chmod(filename.c_str(), 0600) != 0 || listen(listener_, 2) != 0) { Stop(); return false; }
{ std::lock_guard<std::mutex> lock(mutex_); pending_ = {}; }
received_ = rejected_ = 0;
stopping_ = false;
thread_ = std::thread([this] { Loop(); });
return true;
}
void Stop() {
stopping_ = true;
if (thread_.joinable()) thread_.join();
if (listener_ >= 0) close(listener_);
listener_ = -1;
if (!filename_.empty()) unlink(filename_.c_str());
filename_.clear();
if (lock_ >= 0) close(lock_);
lock_ = -1;
std::lock_guard<std::mutex> lock(mutex_);
pending_ = {}; recycled_.clear();
}
bool Take(DroidDeckXrPixels *out) {
std::lock_guard<std::mutex> lock(mutex_);
if (pending_.pixels.empty()) return false;
*out = std::move(pending_);
pending_ = {};
return true;
}
void Recycle(std::vector<unsigned char> pixels) {
std::lock_guard<std::mutex> lock(mutex_);
if (recycled_.size() < 3) recycled_.push_back(std::move(pixels));
}
uint64_t Rejected() const { return rejected_; }
uint64_t Received() const { return received_; }
private:
int listener_ = -1;
int lock_ = -1;
std::string filename_;
std::atomic<bool> stopping_{false};
std::atomic<uint64_t> rejected_{0}, received_{0};
std::thread thread_;
std::mutex mutex_;
DroidDeckXrPixels pending_;
std::vector<std::vector<unsigned char>> recycled_;
void Loop() {
while (!stopping_) {
pollfd ready{listener_, POLLIN, 0};
if (poll(&ready, 1, 50) <= 0) continue;
int client = accept4(listener_, nullptr, nullptr, SOCK_CLOEXEC | SOCK_NONBLOCK);
if (client < 0) continue;
if (!xr_frame_peer(client)) { rejected_++; close(client); continue; }
uint64_t lastFrame = 0;
while (!stopping_) {
pollfd idle{client, POLLIN, 0};
int available;
do {
available = poll(&idle, 1, 50);
} while (!stopping_ && (available == 0 || (available < 0 && errno == EINTR)));
if (stopping_ || available <= 0 || !(idle.revents & POLLIN)) break;
DroidDeckXrPixels frame;
XrFrameDescriptor shared;
int64_t deadline = xr_frame_now() + 200000000LL;
if (!xr_frame_header(client, frame.header, shared, deadline)) break;
int64_t now = xr_frame_now();
if (!xr_frame_valid(frame.header) || frame.header.captured_ns > now
|| now - frame.header.captured_ns > 500000000LL || frame.header.frame_id <= lastFrame
|| (frame.header.version == DROIDDECK_XR_FRAME_SHARED_VERSION) != (shared.fd >= 0)) { rejected_++; break; }
try {
{
std::lock_guard<std::mutex> lock(mutex_);
if (!recycled_.empty()) {
frame.pixels = std::move(recycled_.back()); recycled_.pop_back();
}
}
frame.pixels.resize(frame.header.bytes);
}
catch (...) { rejected_++; break; }
deadline = xr_frame_now() + 200000000LL;
bool copied = shared.fd >= 0 ? xr_frame_shared_pixels(shared.fd, frame.pixels.data(), frame.pixels.size())
: xr_frame_io(client, frame.pixels.data(), frame.pixels.size(), false, deadline);
if (!copied) { rejected_++; break; }
if (xr_frame_now() - frame.header.captured_ns > 500000000LL) { rejected_++; break; }
uint64_t acknowledgement = frame.header.frame_id;
lastFrame = acknowledgement;
{
std::lock_guard<std::mutex> lock(mutex_);
if (!pending_.pixels.empty() && recycled_.size() < 3) recycled_.push_back(std::move(pending_.pixels));
pending_ = std::move(frame);
}
received_++;
if (!xr_frame_io(client, &acknowledgement, sizeof(acknowledgement), true, deadline)) break;
}
close(client);
}
}
};
class XrFrameSender {
public:
XrFrameSender() = default;
XrFrameSender(const XrFrameSender &) = delete;
XrFrameSender &operator=(const XrFrameSender &) = delete;
~XrFrameSender() { Disconnect(); }
bool Send(const std::string &filename, const DroidDeckXrFrameHeader &header, const unsigned char *pixels) {
return SendFrame(filename, header, pixels, -1);
}
bool SendShared(const std::string &filename, DroidDeckXrFrameHeader header, int shared) {
if (shared < 0 || sharedLeaseLost_) return false;
header.version = DROIDDECK_XR_FRAME_SHARED_VERSION;
return SendFrame(filename, header, nullptr, shared);
}
bool SharedLeaseLost() const { return sharedLeaseLost_; }
private:
int descriptor_ = -1;
bool sharedLeaseLost_ = false;
std::string filename_;
bool SendFrame(const std::string &filename, const DroidDeckXrFrameHeader &header, const unsigned char *pixels, int shared) {
if ((!pixels && shared < 0) || !xr_frame_valid(header) || filename.empty()
|| filename.size() >= sizeof(sockaddr_un::sun_path)
|| (header.version == DROIDDECK_XR_FRAME_SHARED_VERSION) != (shared >= 0)) return false;
if (filename_ != filename) Disconnect();
int64_t deadline = xr_frame_now() + 200000000LL;
if (descriptor_ < 0) {
sockaddr_un address{}; address.sun_family = AF_UNIX;
std::strcpy(address.sun_path, filename.c_str());
descriptor_ = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
if (descriptor_ < 0) return false;
int result = connect(descriptor_, reinterpret_cast<sockaddr *>(&address), sizeof(address));
if (result != 0 && errno == EINPROGRESS) {
pollfd ready{descriptor_, POLLOUT, 0};
int error = 0; socklen_t size = sizeof(error);
result = poll(&ready, 1, 200) > 0 && getsockopt(descriptor_, SOL_SOCKET, SO_ERROR, &error, &size) == 0
&& error == 0 ? 0 : -1;
}
if (result != 0 || !xr_frame_peer(descriptor_)) {
Disconnect(); return false;
}
filename_ = filename;
}
uint64_t acknowledgement = 0;
bool sent = false;
if (shared >= 0) {
iovec data{const_cast<DroidDeckXrFrameHeader *>(&header), sizeof(header)};
alignas(cmsghdr) unsigned char control[CMSG_SPACE(sizeof(int))]{};
msghdr message{}; message.msg_iov = &data; message.msg_iovlen = 1;
message.msg_control = control; message.msg_controllen = sizeof(control);
auto *item = CMSG_FIRSTHDR(&message); item->cmsg_level = SOL_SOCKET; item->cmsg_type = SCM_RIGHTS;
item->cmsg_len = CMSG_LEN(sizeof(int)); std::memcpy(CMSG_DATA(item), &shared, sizeof(shared));
ssize_t bytes;
do { bytes = sendmsg(descriptor_, &message, MSG_NOSIGNAL | MSG_DONTWAIT); } while (bytes < 0 && errno == EINTR);
if (bytes > 0) {
sharedLeaseLost_ = true;
sent = xr_frame_io(descriptor_, reinterpret_cast<unsigned char *>(data.iov_base) + bytes,
sizeof(header) - bytes, true, deadline);
}
} else sent = xr_frame_io(descriptor_, const_cast<DroidDeckXrFrameHeader *>(&header), sizeof(header), true, deadline)
&& xr_frame_io(descriptor_, const_cast<unsigned char *>(pixels), header.bytes, true, deadline);
if (!sent
|| !xr_frame_io(descriptor_, &acknowledgement, sizeof(acknowledgement), false, deadline)
|| acknowledgement != header.frame_id) { Disconnect(); return false; }
sharedLeaseLost_ = false;
return true;
}
void Disconnect() { if (descriptor_ >= 0) close(descriptor_); descriptor_ = -1; filename_.clear(); }
};
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include "xr_frame.h"
struct DroidDeckXrRenderView {
float orientation[4];
float position[3];
float fov[4];
};
inline void xr_frame_eye_pose(float result[3][4], const float head[3][4], const float eyeToHead[3][4]) {
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 3; column++) {
result[row][column] = 0;
for (int inner = 0; inner < 3; inner++) result[row][column] += head[row][inner] * eyeToHead[inner][column];
}
result[row][3] = head[row][3];
for (int inner = 0; inner < 3; inner++) result[row][3] += head[row][inner] * eyeToHead[inner][3];
}
}
inline DroidDeckXrRenderView xr_frame_view(const DroidDeckXrFrameHeader &header, int eye) {
const auto &matrix = header.eye_pose[eye];
DroidDeckXrRenderView view{};
float trace = matrix[0][0] + matrix[1][1] + matrix[2][2];
if (trace > 0) {
float scale = std::sqrt(trace + 1.f) * 2.f;
view.orientation[3] = scale * 0.25f;
view.orientation[0] = (matrix[2][1] - matrix[1][2]) / scale;
view.orientation[1] = (matrix[0][2] - matrix[2][0]) / scale;
view.orientation[2] = (matrix[1][0] - matrix[0][1]) / scale;
} else {
int axis = matrix[1][1] > matrix[0][0] ? 1 : 0;
if (matrix[2][2] > matrix[axis][axis]) axis = 2;
int next = (axis + 1) % 3, last = (axis + 2) % 3;
float scale = std::sqrt(1.f + matrix[axis][axis] - matrix[next][next] - matrix[last][last]) * 2.f;
view.orientation[axis] = scale * 0.25f;
view.orientation[next] = (matrix[axis][next] + matrix[next][axis]) / scale;
view.orientation[last] = (matrix[axis][last] + matrix[last][axis]) / scale;
view.orientation[3] = (matrix[last][next] - matrix[next][last]) / scale;
}
float lengthSquared = 0;
for (float value : view.orientation) lengthSquared += value * value;
for (float &value : view.orientation) value /= std::sqrt(lengthSquared);
for (int axis = 0; axis < 3; axis++) view.position[axis] = matrix[axis][3];
const auto &projection = header.projection[eye];
view.fov[0] = std::atan((projection[0][2] - 1.f) / projection[0][0]);
view.fov[1] = std::atan((projection[0][2] + 1.f) / projection[0][0]);
view.fov[2] = std::atan((projection[1][2] + 1.f) / projection[1][1]);
view.fov[3] = std::atan((projection[1][2] - 1.f) / projection[1][1]);
return view;
}
+118
View File
@@ -0,0 +1,118 @@
#pragma once
#include "xr_frame_channel.h"
#include <array>
#define DROIDDECK_XR_GPU_SOCKET "droiddeck-xr-gpu"
constexpr uint32_t XR_GPU_MAGIC = 0x47584444u;
constexpr uint32_t XR_GPU_VERSION = 1;
constexpr unsigned XR_GPU_SLOTS = 3;
constexpr uint32_t XR_GPU_BGRA = 1;
struct XrGpuRequest { uint32_t magic = XR_GPU_MAGIC, version = XR_GPU_VERSION, width{}, height{}; };
struct XrGpuPoolInfo {
uint32_t magic = XR_GPU_MAGIC, version = XR_GPU_VERSION, width{}, height{};
uint64_t sizes[XR_GPU_SLOTS]{};
};
struct XrGpuFrame { DroidDeckXrFrameHeader header{}; uint32_t slot{}, flags{}; };
static_assert(sizeof(XrGpuRequest) == 16 && sizeof(XrGpuPoolInfo) == 40 && sizeof(XrGpuFrame) == 272,
"XR GPU protocol layout");
inline bool xr_gpu_dimensions(uint32_t width, uint32_t height) {
return width >= 2 && !(width & 1) && height && width <= 8192 && height <= 4096
&& uint64_t(width) * height * 4 <= DROIDDECK_XR_FRAME_MAX_BYTES;
}
enum class XrGpuFrameState { Invalid, Stale, Ready };
inline XrGpuFrameState xr_gpu_frame_state(const XrGpuFrame &frame, uint32_t width, uint32_t height,
uint64_t previous, int64_t now) {
if (frame.slot >= XR_GPU_SLOTS || (frame.flags & ~XR_GPU_BGRA) || !xr_frame_valid(frame.header)
|| frame.header.width != width || frame.header.height != height || frame.header.frame_id <= previous
|| frame.header.captured_ns > now + 100000000LL) return XrGpuFrameState::Invalid;
return now - frame.header.captured_ns > 200000000LL ? XrGpuFrameState::Stale : XrGpuFrameState::Ready;
}
// SEQPACKET keeps descriptor transfer and its metadata in one indivisible message.
inline bool xr_gpu_send(int socket, const void *data, size_t size, const int *fds = nullptr, unsigned count = 0) {
if (count > XR_GPU_SLOTS) return false;
iovec bytes{const_cast<void *>(data), size};
alignas(cmsghdr) unsigned char control[CMSG_SPACE(XR_GPU_SLOTS * sizeof(int))]{};
msghdr message{}; message.msg_iov = &bytes; message.msg_iovlen = 1;
if (count) {
message.msg_control = control; message.msg_controllen = CMSG_SPACE(count * sizeof(int));
auto *item = CMSG_FIRSTHDR(&message); item->cmsg_level = SOL_SOCKET; item->cmsg_type = SCM_RIGHTS;
item->cmsg_len = CMSG_LEN(count * sizeof(int)); std::memcpy(CMSG_DATA(item), fds, count * sizeof(int));
}
ssize_t result;
do { result = sendmsg(socket, &message, MSG_NOSIGNAL | MSG_DONTWAIT); } while (result < 0 && errno == EINTR);
return result == static_cast<ssize_t>(size);
}
inline bool xr_gpu_receive(int socket, void *data, size_t size, int *fds = nullptr, unsigned count = 0) {
if (count > XR_GPU_SLOTS) return false;
iovec bytes{data, size};
alignas(cmsghdr) unsigned char control[CMSG_SPACE(16 * sizeof(int))]{};
msghdr message{}; message.msg_iov = &bytes; message.msg_iovlen = 1;
message.msg_control = control; message.msg_controllen = sizeof(control);
ssize_t result;
do { result = recvmsg(socket, &message, MSG_DONTWAIT | MSG_CMSG_CLOEXEC); } while (result < 0 && errno == EINTR);
unsigned received = 0;
std::array<int, XR_GPU_SLOTS> owned{{-1, -1, -1}};
for (auto *item = CMSG_FIRSTHDR(&message); result >= 0 && item; item = CMSG_NXTHDR(&message, item)) {
if (item->cmsg_level != SOL_SOCKET || item->cmsg_type != SCM_RIGHTS) continue;
auto *handles = reinterpret_cast<int *>(CMSG_DATA(item));
for (unsigned index = 0; index < (item->cmsg_len - CMSG_LEN(0)) / sizeof(int); index++) {
if (received < owned.size()) owned[received] = handles[index]; else close(handles[index]);
received++;
}
}
bool valid = result == static_cast<ssize_t>(size) && !(message.msg_flags & (MSG_TRUNC | MSG_CTRUNC)) && received == count;
for (unsigned index = 0; index < owned.size(); index++) {
if (valid && index < count) fds[index] = owned[index];
else if (owned[index] >= 0) close(owned[index]);
}
return valid;
}
inline bool xr_gpu_wait(int socket, int timeout) {
pollfd ready{socket, POLLIN, 0};
int result;
do { result = poll(&ready, 1, timeout); } while (result < 0 && errno == EINTR);
return result > 0 && (ready.revents & POLLIN);
}
class XrGpuSender {
public:
~XrGpuSender() { Close(); }
bool Connect(const char *filename, uint32_t width, uint32_t height, XrGpuPoolInfo &pool,
std::array<int, XR_GPU_SLOTS> &fds) {
Close();
if (!xr_gpu_dimensions(width, height) || !filename || strlen(filename) >= sizeof(sockaddr_un::sun_path)) return false;
socket_ = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
if (socket_ < 0) return false;
sockaddr_un address{}; address.sun_family = AF_UNIX; std::strcpy(address.sun_path, filename);
if (connect(socket_, reinterpret_cast<sockaddr *>(&address), sizeof(address)) || !xr_frame_peer(socket_)) { Close(); return false; }
XrGpuRequest request; request.width = width; request.height = height;
if (!xr_gpu_send(socket_, &request, sizeof(request)) || !xr_gpu_wait(socket_, 2000)
|| !xr_gpu_receive(socket_, &pool, sizeof(pool), fds.data(), fds.size())) { Close(); return false; }
bool valid = pool.magic == XR_GPU_MAGIC && pool.version == XR_GPU_VERSION && pool.width == width && pool.height == height;
const uint64_t bytes = uint64_t(width) * height * 4;
for (unsigned index = 0; index < fds.size(); index++) {
struct stat status{};
if (fstat(fds[index], &status) || pool.sizes[index] < bytes || pool.sizes[index] > bytes + 65536
|| status.st_size != static_cast<int64_t>(pool.sizes[index])) valid = false;
}
if (!valid) { for (int &fd : fds) { close(fd); fd = -1; } Close(); }
return valid;
}
bool Send(const DroidDeckXrFrameHeader &header, uint32_t slot, uint32_t flags = 0) {
if (socket_ < 0 || slot >= XR_GPU_SLOTS || (flags & ~XR_GPU_BGRA) || !xr_frame_valid(header)) return false;
XrGpuFrame frame; frame.header = header; frame.slot = slot; frame.flags = flags;
uint64_t ack{};
if (!xr_gpu_send(socket_, &frame, sizeof(frame)) || !xr_gpu_wait(socket_, 5000)
|| !xr_gpu_receive(socket_, &ack, sizeof(ack)) || ack != header.frame_id) { Close(); return false; }
return true;
}
void Close() { if (socket_ >= 0) close(socket_); socket_ = -1; }
private:
int socket_ = -1;
};
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include "xr_tracking.h"
#include <algorithm>
#include <cmath>
struct XrTouchInput {
float trigger = 0, grip = 0, stickX = 0, stickY = 0;
bool triggerClick = false, gripClick = false, stickClick = false;
bool primaryClick = false, secondaryClick = false, systemClick = false;
bool triggerTouch = false, stickTouch = false, primaryTouch = false, secondaryTouch = false, thumbrestTouch = false;
};
inline XrTouchInput xr_touch_input(const DroidDeckXrHand &hand, bool right) {
XrTouchInput input;
if (!hand.active) return input;
auto scalar = [](float value, float minimum) { return std::isfinite(value) ? std::clamp(value, minimum, 1.f) : 0.f; };
input.trigger = scalar(hand.trigger, 0);
input.grip = scalar(hand.squeeze, 0);
input.stickX = scalar(hand.stick[0], -1);
input.stickY = scalar(hand.stick[1], -1);
input.triggerClick = input.trigger > 0.6f;
input.gripClick = input.grip > 0.6f;
input.stickClick = hand.buttons & DROIDDECK_XR_STICK_CLICK;
input.primaryClick = hand.buttons & DROIDDECK_XR_PRIMARY_CLICK;
input.secondaryClick = hand.buttons & DROIDDECK_XR_SECONDARY_CLICK;
input.systemClick = !right && (hand.buttons & DROIDDECK_XR_MENU_CLICK);
input.triggerTouch = hand.buttons & DROIDDECK_XR_TRIGGER_TOUCH;
input.stickTouch = hand.buttons & DROIDDECK_XR_STICK_TOUCH;
input.primaryTouch = hand.buttons & DROIDDECK_XR_PRIMARY_TOUCH;
input.secondaryTouch = hand.buttons & DROIDDECK_XR_SECONDARY_TOUCH;
input.thumbrestTouch = hand.buttons & DROIDDECK_XR_THUMBREST_TOUCH;
return input;
}
+59
View File
@@ -0,0 +1,59 @@
#pragma once
#include <stdint.h>
#define DROIDDECK_XR_MAGIC 0x52584444u
#define DROIDDECK_XR_VERSION 1u
#define DROIDDECK_XR_FILE "droiddeck-xr-tracking"
#define DROIDDECK_XR_STICK_CLICK (1u << 0)
#define DROIDDECK_XR_PRIMARY_CLICK (1u << 1)
#define DROIDDECK_XR_SECONDARY_CLICK (1u << 2)
#define DROIDDECK_XR_MENU_CLICK (1u << 3)
#define DROIDDECK_XR_TRIGGER_TOUCH (1u << 4)
#define DROIDDECK_XR_STICK_TOUCH (1u << 5)
#define DROIDDECK_XR_PRIMARY_TOUCH (1u << 6)
#define DROIDDECK_XR_SECONDARY_TOUCH (1u << 7)
#define DROIDDECK_XR_THUMBREST_TOUCH (1u << 8)
struct DroidDeckXrPose {
float orientation[4];
float position[3];
uint32_t flags;
};
struct DroidDeckXrEye {
DroidDeckXrPose pose;
float fov[4];
uint32_t width, height;
};
struct DroidDeckXrHand {
DroidDeckXrPose pose;
float trigger, squeeze, stick[2];
uint32_t buttons, active;
};
struct DroidDeckXrTracking {
uint32_t magic, version, seq, active;
int64_t sampled_ns, predicted_ns, period_ns;
DroidDeckXrPose head;
DroidDeckXrEye eyes[2];
DroidDeckXrHand hands[2];
};
static_assert(sizeof(DroidDeckXrTracking) == 296, "XR tracking IPC layout");
#define DROIDDECK_XR_HAPTIC_MAGIC 0x48584444u
#define DROIDDECK_XR_HAPTIC_FILE "droiddeck-xr-haptics"
struct DroidDeckXrPulse {
uint64_t id;
int64_t duration_ns;
float amplitude, frequency;
};
struct DroidDeckXrHaptics {
uint32_t magic, version, seq, reserved;
DroidDeckXrPulse hands[2];
};
static_assert(sizeof(DroidDeckXrHaptics) == 64, "XR haptics IPC layout");
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include "xr_tracking.h"
#include <cstring>
#include <cmath>
#include <mutex>
#include <string>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
class XrTrackingFile {
public:
explicit XrTrackingFile(const char *path = "/run/droiddeck/" DROIDDECK_XR_FILE) : path_(path) {}
~XrTrackingFile() { if (mapping_) munmap(mapping_, sizeof(*mapping_)); }
bool Read(DroidDeckXrTracking *packet) {
std::lock_guard<std::mutex> lock(mutex_);
if (!mapping_) {
int descriptor = open(path_.c_str(), O_RDONLY | O_CLOEXEC);
if (descriptor < 0) return false;
struct stat status{};
if (fstat(descriptor, &status) != 0 || status.st_size != sizeof(*mapping_)) {
close(descriptor); return false;
}
void *mapping = mmap(nullptr, sizeof(*mapping_), PROT_READ, MAP_SHARED, descriptor, 0);
close(descriptor);
if (mapping == MAP_FAILED) return false;
mapping_ = static_cast<DroidDeckXrTracking *>(mapping);
}
for (int attempt = 0; attempt < 8; attempt++) {
uint32_t sequence = __atomic_load_n(&mapping_->seq, __ATOMIC_ACQUIRE);
if (sequence & 1u) continue;
std::memcpy(packet, mapping_, sizeof(*packet));
__atomic_thread_fence(__ATOMIC_ACQUIRE);
if (__atomic_load_n(&mapping_->seq, __ATOMIC_RELAXED) == sequence)
return packet->magic == DROIDDECK_XR_MAGIC && packet->version == DROIDDECK_XR_VERSION;
}
return false;
}
static int64_t Now() {
timespec now{}; clock_gettime(CLOCK_MONOTONIC, &now);
return (int64_t)now.tv_sec * 1000000000LL + now.tv_nsec;
}
static double PoseTimeOffset(int64_t predicted, int64_t now) {
return predicted > 0 ? (static_cast<double>(predicted) - static_cast<double>(now)) / 1000000000.0 : 0;
}
static bool Fresh(const DroidDeckXrTracking &packet) {
int64_t age = Now() - packet.sampled_ns;
return packet.active && age >= 0 && age < 500000000LL && packet.period_ns >= 1000000LL
&& 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_;
std::string path_;
};
+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};
};
@@ -0,0 +1,23 @@
#include "../src/presenter.h"
#include <cassert>
#include <chrono>
#include <cstdlib>
#include <cstdio>
#include <thread>
#include <vector>
int main() {
setenv("DROIDDECK_VR_PRESENT", "questframes", 1);
setenv("VK_ICD_FILENAMES", "/droiddeck-test-no-icd.json", 1);
setenv("VK_DRIVER_FILES", "/droiddeck-test-no-icd.json", 1);
auto started = std::chrono::steady_clock::now();
std::vector<std::thread> callers;
for (int index = 0; index < 8; index++) callers.emplace_back([] {
droiddeck::EyeFrame frame{};
for (int count = 0; count < 1000; count++) droiddeck::Presenter::Submit(frame);
});
for (auto &caller : callers) caller.join();
droiddeck::Presenter::Shutdown();
assert(std::chrono::steady_clock::now() - started < std::chrono::seconds(5));
std::puts("Presenter validation passed: unavailable GPU releases concurrent blocking callers and shutdown");
}
+143
View File
@@ -0,0 +1,143 @@
#define _GNU_SOURCE
#include <assert.h>
#include <stdatomic.h>
#include "../src/vk_layer_droiddeck_vr.c"
static VkDevice test_device;
static VkQueue test_queue;
static VkSemaphore gate;
static VkCommandBuffer commands[2];
static atomic_int second_returned;
static atomic_int second_entered;
static void *signal_gate(void *argument) {
(void)argument;
usleep(30000);
VkSemaphoreSignalInfo signal_info = {VK_STRUCTURE_TYPE_SEMAPHORE_SIGNAL_INFO, NULL, gate, 1};
assert(vkSignalSemaphore(test_device, &signal_info) == VK_SUCCESS);
return NULL;
}
static void *second_submit(void *argument) {
(void)argument;
VkSubmitInfo submit = {VK_STRUCTURE_TYPE_SUBMIT_INFO, NULL, 0, NULL, NULL, 1, &commands[1], 0, NULL};
atomic_store(&second_entered, 1);
assert(QueueSubmit(test_queue, 1, &submit, VK_NULL_HANDLE) == VK_SUCCESS);
atomic_store(&second_returned, 1);
return NULL;
}
static void check_pixels(const uint32_t *pixels, uint32_t expected) {
for (unsigned index = 0; index < 1024; index++) assert(pixels[index] == expected);
}
int main(void) {
setenv("DROIDDECK_VR_LAYER_QUIET", "1", 1);
VkApplicationInfo application = {VK_STRUCTURE_TYPE_APPLICATION_INFO, NULL, "DroidDeck producer GPU test", 1, NULL, 0, VK_API_VERSION_1_2};
VkInstanceCreateInfo create_instance = {VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO, NULL, 0, &application, 0, NULL, 0, NULL};
VkInstance instance;
assert(vkCreateInstance(&create_instance, NULL, &instance) == VK_SUCCESS);
uint32_t count = 1;
VkPhysicalDevice physical;
VkResult enumeration = vkEnumeratePhysicalDevices(instance, &count, &physical);
assert((enumeration == VK_SUCCESS || enumeration == VK_INCOMPLETE) && count);
VkQueueFamilyProperties families[32];
count = 32;
vkGetPhysicalDeviceQueueFamilyProperties(physical, &count, families);
uint32_t family = UINT32_MAX;
for (uint32_t index = 0; index < count; index++)
if (families[index].queueFlags & VK_QUEUE_GRAPHICS_BIT) { family = index; break; }
assert(family != UINT32_MAX);
float priority = 1;
VkDeviceQueueCreateInfo queue_info = {VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO, NULL, 0, family, 1, &priority};
VkPhysicalDeviceTimelineSemaphoreFeatures timeline_feature = {VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_TIMELINE_SEMAPHORE_FEATURES, NULL, VK_TRUE};
VkDeviceCreateInfo create_device = {VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO, &timeline_feature, 0, 1, &queue_info, 0, NULL, 0, NULL, NULL};
assert(vkCreateDevice(physical, &create_device, NULL, &test_device) == VK_SUCCESS);
vkGetDeviceQueue(test_device, family, 0, &test_queue);
g_dev[0] = (struct dev){test_device, vkGetDeviceProcAddr, NULL, 1, 0, 1, 0};
g_producer_started = 1;
VkBufferCreateInfo create_buffer = {VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, NULL, 0, 4096, VK_BUFFER_USAGE_TRANSFER_DST_BIT,
VK_SHARING_MODE_EXCLUSIVE, 0, NULL};
VkBuffer buffer;
assert(vkCreateBuffer(test_device, &create_buffer, NULL, &buffer) == VK_SUCCESS);
VkMemoryRequirements requirements;
vkGetBufferMemoryRequirements(test_device, buffer, &requirements);
VkPhysicalDeviceMemoryProperties properties;
vkGetPhysicalDeviceMemoryProperties(physical, &properties);
uint32_t memory_type = UINT32_MAX;
VkMemoryPropertyFlags required = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT;
for (uint32_t index = 0; index < properties.memoryTypeCount; index++)
if ((requirements.memoryTypeBits & (1u << index)) && (properties.memoryTypes[index].propertyFlags & required) == required) {
memory_type = index;
break;
}
assert(memory_type != UINT32_MAX);
VkMemoryAllocateInfo allocate = {VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, NULL, requirements.size, memory_type};
VkDeviceMemory memory;
assert(vkAllocateMemory(test_device, &allocate, NULL, &memory) == VK_SUCCESS);
assert(vkBindBufferMemory(test_device, buffer, memory, 0) == VK_SUCCESS);
uint32_t *pixels;
assert(vkMapMemory(test_device, memory, 0, VK_WHOLE_SIZE, 0, (void **)&pixels) == VK_SUCCESS);
VkCommandPoolCreateInfo create_pool = {VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO, NULL, 0, family};
VkCommandPool pool;
assert(vkCreateCommandPool(test_device, &create_pool, NULL, &pool) == VK_SUCCESS);
VkCommandBufferAllocateInfo allocate_commands = {VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, NULL, pool, VK_COMMAND_BUFFER_LEVEL_PRIMARY, 2};
assert(vkAllocateCommandBuffers(test_device, &allocate_commands, commands) == VK_SUCCESS);
const uint32_t patterns[2] = {0x13579bdf, 0x2468ace0};
for (unsigned index = 0; index < 2; index++) {
VkCommandBufferBeginInfo begin = {VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, NULL, 0, NULL};
assert(vkBeginCommandBuffer(commands[index], &begin) == VK_SUCCESS);
vkCmdFillBuffer(commands[index], buffer, 0, 4096, patterns[index]);
VkMemoryBarrier barrier = {VK_STRUCTURE_TYPE_MEMORY_BARRIER, NULL, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_HOST_READ_BIT};
vkCmdPipelineBarrier(commands[index], VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_HOST_BIT, 0, 1, &barrier, 0, NULL, 0, NULL);
assert(vkEndCommandBuffer(commands[index]) == VK_SUCCESS);
}
VkSemaphoreTypeCreateInfo type = {VK_STRUCTURE_TYPE_SEMAPHORE_TYPE_CREATE_INFO, NULL, VK_SEMAPHORE_TYPE_TIMELINE, 0};
VkSemaphoreCreateInfo create_gate = {VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO, &type, 0};
assert(vkCreateSemaphore(test_device, &create_gate, NULL, &gate) == VK_SUCCESS);
uint64_t value = 1;
VkTimelineSemaphoreSubmitInfo timeline = {VK_STRUCTURE_TYPE_TIMELINE_SEMAPHORE_SUBMIT_INFO, NULL, 1, &value, 0, NULL};
VkPipelineStageFlags stage = VK_PIPELINE_STAGE_TRANSFER_BIT;
VkSubmitInfo first = {VK_STRUCTURE_TYPE_SUBMIT_INFO, &timeline, 1, &gate, &stage, 1, &commands[0], 0, NULL};
assert(QueueSubmit(test_queue, 1, &first, VK_NULL_HANDLE) == VK_SUCCESS);
pthread_t signal_thread;
int64_t began = droiddeck_producer_now();
assert(pthread_create(&signal_thread, NULL, signal_gate, NULL) == 0);
assert(producer_acquire(began + 1000000000LL));
assert(droiddeck_producer_now() - began >= 20000000LL);
assert(pthread_join(signal_thread, NULL) == 0);
check_pixels(pixels, patterns[0]);
pthread_t writer;
assert(pthread_create(&writer, NULL, second_submit, NULL) == 0);
while (!atomic_load(&second_entered)) usleep(100);
usleep(50000);
assert(!atomic_load(&second_returned));
check_pixels(pixels, patterns[0]);
producer_release();
assert(pthread_join(writer, NULL) == 0);
assert(vkQueueWaitIdle(test_queue) == VK_SUCCESS);
check_pixels(pixels, patterns[1]);
value = 2;
assert(QueueSubmit(test_queue, 1, &first, VK_NULL_HANDLE) == VK_SUCCESS);
assert(!producer_acquire(droiddeck_producer_now() + 20000000LL));
VkFence pending = g_producer_queues[0].fence;
assert(pending && vkGetFenceStatus(test_device, pending) == VK_NOT_READY);
assert(!producer_acquire(droiddeck_producer_now() + 20000000LL));
assert(g_producer_queues[0].fence == pending);
VkSemaphoreSignalInfo release_gate = {VK_STRUCTURE_TYPE_SEMAPHORE_SIGNAL_INFO, NULL, gate, 2};
assert(vkSignalSemaphore(test_device, &release_gate) == VK_SUCCESS);
assert(vkQueueWaitIdle(test_queue) == VK_SUCCESS);
assert(producer_acquire(droiddeck_producer_now() + 1000000000LL));
producer_release();
check_pixels(pixels, patterns[0]);
producer_destroy_device(&g_dev[0], NULL);
vkDestroySemaphore(test_device, gate, NULL);
vkDestroyCommandPool(test_device, pool, NULL);
vkUnmapMemory(test_device, memory);
vkDestroyBuffer(test_device, buffer, NULL);
vkFreeMemory(test_device, memory, NULL);
vkDestroyDevice(test_device, NULL);
vkDestroyInstance(instance, NULL);
puts("producer GPU: actual completion, frozen overwrite, timeout retention and 1024 buffer values passed");
return 0;
}
+115
View File
@@ -0,0 +1,115 @@
#define _GNU_SOURCE
#define DROIDDECK_PRODUCER_SERVER
#include "../src/producer_lease.h"
#include <assert.h>
#include <stdatomic.h>
static pthread_mutex_t queue_lock = PTHREAD_MUTEX_INITIALIZER;
static atomic_int completed;
static atomic_int released;
static atomic_int quarantined;
static atomic_int available;
static atomic_int slow;
static int acquire(int64_t deadline) {
assert(deadline > droiddeck_producer_now());
if (!atomic_load(&available)) return 0;
assert(pthread_mutex_lock(&queue_lock) == 0);
atomic_fetch_add(&completed, 1);
if (atomic_load(&slow)) usleep(170000);
return 1;
}
static void release(void) {
atomic_fetch_add(&released, 1);
assert(pthread_mutex_unlock(&queue_lock) == 0);
}
static void quarantine(void) {
atomic_store(&quarantined, 1);
}
static void wait_count(atomic_int *counter, int expected) {
int64_t deadline = droiddeck_producer_now() + 1000000000LL;
while (atomic_load(counter) != expected && droiddeck_producer_now() < deadline) usleep(1000);
assert(atomic_load(counter) == expected);
}
int main(void) {
char directory[] = "/tmp/droiddeck-producer-XXXXXX";
assert(mkdtemp(directory));
setenv("XDG_RUNTIME_DIR", directory, 1);
char parent[128];
assert(snprintf(parent, sizeof(parent), "%s/droiddeck-steamvr", directory) > 0);
assert(mkdir(parent, 0755) == 0);
struct droiddeck_producer_server server = {.acquire = acquire, .release = release, .quarantine = quarantine};
assert(!droiddeck_producer_start(&server));
assert(chmod(parent, 0700) == 0);
char path[128];
assert(droiddeck_producer_path(path, sizeof(path), getpid()));
int regular = open(path, O_CREAT | O_RDWR, 0600);
assert(regular >= 0); close(regular);
assert(!droiddeck_producer_start(&server));
assert(unlink(path) == 0);
int stale = socket(AF_UNIX, SOCK_SEQPACKET, 0);
struct sockaddr_un address = {.sun_family = AF_UNIX};
strcpy(address.sun_path, path);
assert(bind(stale, (struct sockaddr *)&address, sizeof(address)) == 0);
close(stale);
assert(droiddeck_producer_start(&server));
struct stat metadata;
assert(stat(server.path, &metadata) == 0 && (metadata.st_mode & 0777) == 0600);
struct droiddeck_producer_server duplicate = server;
assert(!droiddeck_producer_start(&duplicate));
struct droiddeck_producer_lease lease;
assert(!droiddeck_producer_acquire(getpid(), &lease));
assert(!atomic_load(&completed));
atomic_store(&available, 1);
assert(droiddeck_producer_acquire(getpid(), &lease));
assert(fcntl(lease.descriptor, F_GETFD) & FD_CLOEXEC);
assert(pthread_mutex_trylock(&queue_lock) == EBUSY);
uint64_t first_ticket = lease.ticket;
usleep(300000);
assert(pthread_mutex_trylock(&queue_lock) == EBUSY && !atomic_load(&released));
assert(droiddeck_producer_release(&lease));
assert(lease.descriptor == -1 && atomic_load(&released) == 1);
assert(pthread_mutex_trylock(&queue_lock) == 0);
assert(pthread_mutex_unlock(&queue_lock) == 0);
assert(droiddeck_producer_acquire(getpid(), &lease));
assert(lease.ticket > first_ticket);
assert(droiddeck_producer_release(&lease));
atomic_store(&slow, 1);
assert(droiddeck_producer_acquire(getpid(), &lease));
assert(droiddeck_producer_release(&lease));
atomic_store(&slow, 0);
int abandoned = socket(AF_UNIX, SOCK_SEQPACKET, 0);
assert(connect(abandoned, (struct sockaddr *)&address, sizeof(address)) == 0);
struct droiddeck_producer_message request = {DROIDDECK_PRODUCER_MAGIC, DROIDDECK_PRODUCER_ACQUIRE, 0};
atomic_store(&slow, 1);
int before = atomic_load(&completed);
assert(send(abandoned, &request, sizeof(request), MSG_NOSIGNAL) == sizeof(request));
wait_count(&completed, before + 1);
close(abandoned);
wait_count(&released, 4);
assert(!atomic_load(&quarantined));
atomic_store(&slow, 0);
char wrong_path[128];
assert(droiddeck_producer_path(wrong_path, sizeof(wrong_path), (uint32_t)getpid() + 1));
assert(link(server.path, wrong_path) == 0);
assert(!droiddeck_producer_acquire((uint32_t)getpid() + 1, &lease));
assert(unlink(wrong_path) == 0);
assert(chmod(parent, 0755) == 0);
assert(!droiddeck_producer_acquire(getpid(), &lease));
assert(chmod(parent, 0700) == 0);
assert(droiddeck_producer_acquire(getpid(), &lease));
close(lease.descriptor);
wait_count(&quarantined, 1);
assert(atomic_load(&released) == 4);
assert(pthread_mutex_trylock(&queue_lock) == EBUSY);
assert(pthread_join(server.thread, NULL) == 0);
close(server.listener);
assert(unlink(server.path) == 0);
assert(rmdir(parent) == 0 && rmdir(directory) == 0);
puts("producer lease: peer PID, private socket, completion gate, retained ownership and disconnect quarantine passed");
return 0;
}
+240
View File
@@ -0,0 +1,240 @@
#define _GNU_SOURCE
#include <assert.h>
#include <fcntl.h>
#include "../src/vk_layer_droiddeck_vr.c"
static const VkSubmitInfo *expected_submit;
static const VkSubmitInfo2 *expected_submit2;
static const VkSemaphoreWaitInfo *expected_wait;
static unsigned create_calls;
static unsigned destroyed;
static VkResult submit_result = VK_SUCCESS;
static const void *expected_image_chain;
static const void *expected_external_tail;
static int expect_cloned_external;
static VkResult VKAPI_CALL mock_image(VkDevice device, const VkImageCreateInfo *info,
const VkAllocationCallbacks *allocator, VkImage *output) {
(void)device; (void)allocator; (void)output;
if (expect_cloned_external) {
const VkExternalMemoryImageCreateInfo *external = info->pNext;
assert(external != expected_image_chain);
assert(external->sType == VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_IMAGE_CREATE_INFO);
assert(external->handleTypes == VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT);
assert(external->pNext == expected_external_tail);
} else assert(info->pNext == expected_image_chain);
return VK_ERROR_FORMAT_NOT_SUPPORTED;
}
static void check_shared_images(VkDevice device) {
VkBaseInStructure tail = {VK_STRUCTURE_TYPE_APPLICATION_INFO, NULL};
VkExternalMemoryImageCreateInfo external = {VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_IMAGE_CREATE_INFO,
&tail, VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT};
VkFormat formats[] = {VK_FORMAT_R8G8B8A8_SRGB, VK_FORMAT_R8G8B8A8_UNORM};
VkImageFormatListCreateInfo list = {VK_STRUCTURE_TYPE_IMAGE_FORMAT_LIST_CREATE_INFO, &external, 2, formats};
VkImageCreateInfo image = {0};
image.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
image.pNext = &list;
image.flags = VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT;
image.tiling = VK_IMAGE_TILING_OPTIMAL;
VkImage output;
/* Other targets, nonmutable, linear and DMA-BUF images retain their caller's chain. */
expected_image_chain = &list;
assert(CreateImage(device, &image, NULL, &output) == VK_ERROR_FORMAT_NOT_SUPPORTED);
g_dev[0].quest_images = 1;
image.arrayLayers = 1;
assert(CreateImage(device, &image, NULL, &output) == VK_ERROR_FORMAT_NOT_SUPPORTED);
image.arrayLayers = 2;
image.flags = 0;
assert(CreateImage(device, &image, NULL, &output) == VK_ERROR_FORMAT_NOT_SUPPORTED);
image.flags = VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT;
image.tiling = VK_IMAGE_TILING_LINEAR;
assert(CreateImage(device, &image, NULL, &output) == VK_ERROR_FORMAT_NOT_SUPPORTED);
image.tiling = VK_IMAGE_TILING_OPTIMAL;
external.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT;
assert(CreateImage(device, &image, NULL, &output) == VK_ERROR_FORMAT_NOT_SUPPORTED);
external.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT;
expected_image_chain = &external;
assert(CreateImage(device, &image, NULL, &output) == VK_ERROR_FORMAT_NOT_SUPPORTED);
assert(image.pNext == &list && list.pNext == &external && external.pNext == &tail);
/* The reverse chain order preserves its tail without modifying caller memory. */
external.pNext = &list; list.pNext = &tail; image.pNext = &external;
expect_cloned_external = 1; expected_external_tail = &tail;
assert(CreateImage(device, &image, NULL, &output) == VK_ERROR_FORMAT_NOT_SUPPORTED);
assert(image.pNext == &external && external.pNext == &list && list.pNext == &tail);
expect_cloned_external = 0; g_dev[0].quest_images = 0;
}
static VkResult VKAPI_CALL mock_submit(VkQueue queue, uint32_t count, const VkSubmitInfo *submits, VkFence fence) {
assert(queue && count == 1 && fence == VK_NULL_HANDLE);
assert(submits == expected_submit);
assert(submits->waitSemaphoreCount == 1);
return submit_result;
}
static VkResult VKAPI_CALL mock_submit2(VkQueue queue, uint32_t count, const VkSubmitInfo2 *submits, VkFence fence) {
assert(queue && count == 1 && fence == VK_NULL_HANDLE);
assert(submits == expected_submit2);
assert(submits->waitSemaphoreInfoCount == 1);
return submit_result;
}
static VkResult VKAPI_CALL mock_wait(VkDevice device, const VkSemaphoreWaitInfo *info, uint64_t timeout) {
assert(device && info == expected_wait && timeout == 7654321);
assert(info->semaphoreCount == 65);
return VK_TIMEOUT;
}
static VkResult VKAPI_CALL mock_create(VkDevice device, const VkSemaphoreCreateInfo *info,
const VkAllocationCallbacks *allocator, VkSemaphore *output) {
(void)device;
(void)allocator;
create_calls++;
for (const VkBaseInStructure *item = info->pNext; item; item = item->pNext)
if (item->sType == VK_STRUCTURE_TYPE_EXPORT_SEMAPHORE_CREATE_INFO) return VK_ERROR_INVALID_EXTERNAL_HANDLE;
*output = (VkSemaphore)(uintptr_t)(create_calls + 100);
return VK_SUCCESS;
}
static void VKAPI_CALL mock_destroy(VkDevice device, VkSemaphore semaphore, const VkAllocationCallbacks *allocator) {
(void)device;
(void)semaphore;
(void)allocator;
destroyed++;
}
static int expect_queue_lock;
static VkQueue other_queue;
static _Atomic int other_submitted;
static void *signal_from_other_queue(void *unused) {
(void)unused;
assert(QueueSubmit(other_queue, 1, expected_submit, VK_NULL_HANDLE) == VK_SUCCESS);
atomic_store(&other_submitted, 1);
return NULL;
}
static void check_queue_lock(void) {
int acquired = pthread_mutex_trylock(&g_submission_lock);
assert(acquired == 0);
if (!acquired) pthread_mutex_unlock(&g_submission_lock);
assert(g_producer_idle_calls == (unsigned)expect_queue_lock);
if (expect_queue_lock) assert(!producer_acquire(droiddeck_producer_now() + 10000000LL));
}
static VkResult VKAPI_CALL mock_queue_idle(VkQueue queue) {
assert(queue); check_queue_lock();
if (expect_queue_lock) {
VkSubmitInfo signal = {VK_STRUCTURE_TYPE_SUBMIT_INFO, NULL, 1, NULL, NULL, 0, NULL, 0, NULL};
expected_submit = &signal;
pthread_t worker; atomic_store(&other_submitted, 0);
assert(pthread_create(&worker, NULL, signal_from_other_queue, NULL) == 0);
int64_t deadline = droiddeck_producer_now() + 1000000000LL;
while (!atomic_load(&other_submitted) && droiddeck_producer_now() < deadline) usleep(1000);
assert(atomic_load(&other_submitted)); pthread_join(worker, NULL);
}
return VK_ERROR_DEVICE_LOST;
}
static VkResult VKAPI_CALL mock_device_idle(VkDevice device) {
assert(device); check_queue_lock(); return VK_ERROR_DEVICE_LOST;
}
static PFN_vkVoidFunction VKAPI_CALL mock_proc(VkDevice device, const char *name) {
(void)device;
if (!strcmp(name, "vkCreateImage")) return (PFN_vkVoidFunction)mock_image;
if (!strcmp(name, "vkQueueWaitIdle")) return (PFN_vkVoidFunction)mock_queue_idle;
if (!strcmp(name, "vkDeviceWaitIdle")) return (PFN_vkVoidFunction)mock_device_idle;
if (!strcmp(name, "vkQueueSubmit")) return (PFN_vkVoidFunction)mock_submit;
if (!strcmp(name, "vkQueueSubmit2")) return (PFN_vkVoidFunction)mock_submit2;
if (!strcmp(name, "vkWaitSemaphores")) return (PFN_vkVoidFunction)mock_wait;
if (!strcmp(name, "vkCreateSemaphore")) return (PFN_vkVoidFunction)mock_create;
if (!strcmp(name, "vkDestroySemaphore")) return (PFN_vkVoidFunction)mock_destroy;
assert(0);
return NULL;
}
int main(void) {
setenv("DROIDDECK_VR_LAYER_QUIET", "1", 1);
void *dispatch = &dispatch;
VkDevice device = (VkDevice)&dispatch;
VkQueue queue = (VkQueue)&dispatch;
void *other_dispatch = dispatch;
other_queue = (VkQueue)&other_dispatch;
g_producer_retry_at = INT64_MAX;
g_dev[0] = (struct dev){device, mock_proc, NULL, 1, 0, 0, 0};
check_shared_images(device);
g_poller_started = 1;
PFN_vkQueueWaitIdle queue_idle = (PFN_vkQueueWaitIdle)semaphore_hook("vkQueueWaitIdle");
PFN_vkDeviceWaitIdle device_idle = (PFN_vkDeviceWaitIdle)semaphore_hook("vkDeviceWaitIdle");
assert(queue_idle && device_idle);
for (int producer = 0; producer <= 1; producer++) {
g_dev[0].producer = producer;
expect_queue_lock = producer;
assert(queue_idle(queue) == VK_ERROR_DEVICE_LOST);
assert(device_idle(device) == VK_ERROR_DEVICE_LOST);
assert(g_producer_idle_calls == 0);
}
g_dev[0].producer = 0;
VkExportSemaphoreCreateInfo export_info = {VK_STRUCTURE_TYPE_EXPORT_SEMAPHORE_CREATE_INFO, NULL,
VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT};
VkSemaphoreCreateInfo create = {VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO, &export_info, 0};
VkSemaphore semaphore = VK_NULL_HANDLE;
assert(CreateSemaphore(device, &create, NULL, &semaphore) == VK_ERROR_INVALID_EXTERNAL_HANDLE);
assert(create_calls == 1 && !semaphore);
VkSemaphoreTypeCreateInfo type = {VK_STRUCTURE_TYPE_SEMAPHORE_TYPE_CREATE_INFO, &export_info,
VK_SEMAPHORE_TYPE_TIMELINE, 0};
create.pNext = &type;
assert(CreateSemaphore(device, &create, NULL, &semaphore) == VK_SUCCESS);
struct bridged *bridge = bridged_find(semaphore);
assert(bridge && atomic_load(bridge->counter) == 0);
VkPipelineStageFlags stage = VK_PIPELINE_STAGE_ALL_COMMANDS_BIT;
uint64_t value = 9;
VkTimelineSemaphoreSubmitInfo timeline = {VK_STRUCTURE_TYPE_TIMELINE_SEMAPHORE_SUBMIT_INFO, NULL, 1, &value, 0, NULL};
VkSubmitInfo submit = {VK_STRUCTURE_TYPE_SUBMIT_INFO, &timeline, 1, &semaphore, &stage, 0, NULL, 0, NULL};
expected_submit = &submit;
assert(QueueSubmit(queue, 1, &submit, VK_NULL_HANDLE) == VK_SUCCESS);
assert(atomic_load(bridge->counter) == 0);
VkSemaphoreSubmitInfo wait2 = {VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO, NULL, semaphore, value,
VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT, 0};
VkSubmitInfo2 submit2 = {VK_STRUCTURE_TYPE_SUBMIT_INFO_2, NULL, 0, 1, &wait2, 0, NULL, 0, NULL};
expected_submit2 = &submit2;
assert(QueueSubmit2(queue, 1, &submit2, VK_NULL_HANDLE) == VK_SUCCESS);
submit_result = VK_ERROR_DEVICE_LOST;
assert(QueueSubmit(queue, 1, &submit, VK_NULL_HANDLE) == VK_ERROR_DEVICE_LOST);
assert(QueueSubmit2(queue, 1, &submit2, VK_NULL_HANDLE) == VK_ERROR_DEVICE_LOST);
VkSemaphore semaphores[65];
uint64_t values[65];
for (unsigned index = 0; index < 65; index++) { semaphores[index] = semaphore; values[index] = value; }
VkSemaphoreWaitInfo wait = {VK_STRUCTURE_TYPE_SEMAPHORE_WAIT_INFO, NULL, VK_SEMAPHORE_WAIT_ANY_BIT, 65, semaphores, values};
expected_wait = &wait;
assert(WaitSemaphores(device, &wait, 7654321) == VK_TIMEOUT);
int descriptor = dup(bridge->fd);
VkSemaphore binary = (VkSemaphore)(uintptr_t)999;
VkImportSemaphoreFdInfoKHR import_info = {VK_STRUCTURE_TYPE_IMPORT_SEMAPHORE_FD_INFO_KHR, NULL, binary, 0,
VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT, descriptor};
assert(ImportSemaphoreFdKHR(device, &import_info) == VK_ERROR_INVALID_EXTERNAL_HANDLE);
assert(fcntl(descriptor, F_GETFD) >= 0);
timeline_note(binary, 1);
assert(ftruncate(descriptor, 32) == 0);
assert(ImportSemaphoreFdKHR(device, &import_info) == VK_ERROR_INVALID_EXTERNAL_HANDLE);
assert(ftruncate(descriptor, 4096) == 0);
import_info.flags = VK_SEMAPHORE_IMPORT_TEMPORARY_BIT;
assert(ImportSemaphoreFdKHR(device, &import_info) == VK_ERROR_INVALID_EXTERNAL_HANDLE);
import_info.flags = 0;
assert(ImportSemaphoreFdKHR(device, &import_info) == VK_SUCCESS);
VkSemaphoreGetFdInfoKHR get_info = {VK_STRUCTURE_TYPE_SEMAPHORE_GET_FD_INFO_KHR, NULL, binary,
VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT};
int reexported = -1;
assert(GetSemaphoreFdKHR(device, &get_info, &reexported) == VK_SUCCESS && reexported >= 0);
assert(fd_ino(reexported) == fd_ino(descriptor));
close(reexported);
get_info.handleType = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_SYNC_FD_BIT;
assert(GetSemaphoreFdKHR(device, &get_info, &reexported) == VK_ERROR_INVALID_EXTERNAL_HANDLE);
DestroySemaphore(device, binary, NULL);
assert(fcntl(descriptor, F_GETFD) == -1 && errno == EBADF);
DestroySemaphore(device, semaphore, NULL);
assert(destroyed == 2 && !bridged_find(semaphore));
puts("semaphore layer: pending waits retained, errors propagated, unsupported binary imports refused, fd ownership passed");
return 0;
}
+95
View File
@@ -0,0 +1,95 @@
#include "../src/wsi_capture.cpp"
#include <cassert>
static VkResult resetResult = VK_SUCCESS;
static unsigned submitted, presented, copied, blitted;
static bool gpuTest;
static unsigned acquired, released;
static VkSemaphore originalWaits[2]{(VkSemaphore)10, (VkSemaphore)11};
static VkSemaphore expectedPresent;
static VkResult VKAPI_CALL Present(VkQueue, const VkPresentInfoKHR *info) {
presented++;
if (expectedPresent) assert(info->waitSemaphoreCount == 1 && info->pWaitSemaphores[0] == expectedPresent);
else assert(info->waitSemaphoreCount == 2 && info->pWaitSemaphores[0] == originalWaits[0]);
return VK_SUCCESS;
}
int main() {
char path[] = "/tmp/droiddeck-wsi-XXXXXX"; int fd = mkstemp(path); assert(fd >= 0);
DroidDeckXrTracking tracking{};
tracking.magic = DROIDDECK_XR_MAGIC; tracking.version = DROIDDECK_XR_VERSION; tracking.active = 1;
tracking.sampled_ns = xr_frame_now(); tracking.period_ns = 11111111;
for (auto &eye : tracking.eyes) {
eye.width = eye.height = 4; eye.pose.orientation[3] = 1; eye.pose.flags = 15;
eye.fov[0] = eye.fov[3] = -0.7f; eye.fov[1] = eye.fov[2] = 0.7f;
}
assert(write(fd, &tracking, sizeof(tracking)) == sizeof(tracking)); close(fd);
auto device = std::make_unique<Device>();
device->ResetCommandBuffer = [](VkCommandBuffer, VkCommandBufferResetFlags) { return resetResult; };
device->BeginCommandBuffer = [](VkCommandBuffer, const VkCommandBufferBeginInfo *) { return VK_SUCCESS; };
device->EndCommandBuffer = [](VkCommandBuffer) { return VK_SUCCESS; };
device->ResetFences = [](VkDevice, uint32_t, const VkFence *) { return VK_SUCCESS; };
device->CmdPipelineBarrier = [](VkCommandBuffer, VkPipelineStageFlags, VkPipelineStageFlags,
VkDependencyFlags, uint32_t, const VkMemoryBarrier *, uint32_t buffers, const VkBufferMemoryBarrier *host,
uint32_t images, const VkImageMemoryBarrier *image) {
if (gpuTest && !images) {
assert(buffers == 1 && host->srcQueueFamilyIndex == VK_QUEUE_FAMILY_FOREIGN_EXT
&& host->dstQueueFamilyIndex == 7 && host->dstAccessMask == VK_ACCESS_TRANSFER_WRITE_BIT);
acquired++; return;
}
assert(images == 1 && image->srcQueueFamilyIndex == VK_QUEUE_FAMILY_IGNORED);
if (buffers) {
if (gpuTest) {
assert(host->srcQueueFamilyIndex == 7 && host->dstQueueFamilyIndex == VK_QUEUE_FAMILY_FOREIGN_EXT
&& !host->dstAccessMask); released++;
} else assert(host->dstAccessMask == VK_ACCESS_HOST_READ_BIT);
}
};
device->CmdBlitImage = [](VkCommandBuffer, VkImage, VkImageLayout source, VkImage, VkImageLayout destination,
uint32_t count, const VkImageBlit *blit, VkFilter filter) {
assert(source == VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL && destination == VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL);
assert(count == 1 && filter == VK_FILTER_NEAREST && blit->srcOffsets[1].x == 8); blitted++;
};
device->CmdCopyImageToBuffer = [](VkCommandBuffer, VkImage, VkImageLayout layout, VkBuffer,
uint32_t count, const VkBufferImageCopy *copy) {
assert(layout == VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL && count == 1 && copy->imageExtent.width == 8); copied++;
};
device->QueueSubmit = [](VkQueue, uint32_t count, const VkSubmitInfo *info, VkFence) {
assert(count == 1 && info->waitSemaphoreCount == 2 && info->signalSemaphoreCount == 1);
for (int i = 0; i < 2; i++) assert(info->pWaitSemaphores[i] == originalWaits[i]
&& info->pWaitDstStageMask[i] == VK_PIPELINE_STAGE_TRANSFER_BIT);
submitted++; return VK_SUCCESS;
};
device->DestroySemaphore = [](VkDevice, VkSemaphore, const VkAllocationCallbacks *) {};
VkDevice handle = (VkDevice)1; VkQueue queue = (VkQueue)2;
device->handle = handle; device->queues[queue] = 7;
auto chain = std::make_unique<Swapchain>(path);
chain->device = device.get(); chain->family = 7; chain->initialized = true; chain->extent = {8, 4};
chain->images = {(VkImage)20, (VkImage)21, (VkImage)22};
chain->ready = {(VkSemaphore)30, (VkSemaphore)31, (VkSemaphore)32};
devices[handle] = std::move(device);
VkSwapchainKHR swapchain = (VkSwapchainKHR)3; swapchains[swapchain] = std::move(chain);
VkPresentInfoKHR info{VK_STRUCTURE_TYPE_PRESENT_INFO_KHR};
uint32_t index = 0; info.pImageIndices = &index; info.swapchainCount = 1; info.pSwapchains = &swapchain;
info.waitSemaphoreCount = 2; info.pWaitSemaphores = originalWaits;
for (index = 0; index < 3; index++) {
expectedPresent = (VkSemaphore)(uintptr_t)(30 + index);
assert(droiddeck_wsi_present(handle, queue, &info, Present) == VK_SUCCESS);
}
index = 0; expectedPresent = VK_NULL_HANDLE;
assert(droiddeck_wsi_present(handle, queue, &info, Present) == VK_SUCCESS);
assert(submitted == 3 && presented == 4 && copied == 3 && blitted == 3);
for (const auto &slot : swapchains[swapchain]->slots) assert(slot.busy && slot.queued && xr_frame_valid(slot.header));
gpuTest = swapchains[swapchain]->gpu = true;
swapchains[swapchain]->slots[0].busy = false;
expectedPresent = (VkSemaphore)30;
assert(droiddeck_wsi_present(handle, queue, &info, Present) == VK_SUCCESS);
assert(acquired == 1 && released == 1 && copied == 4 && blitted == 3);
swapchains[swapchain]->slots[0].busy = false;
resetResult = VK_ERROR_DEVICE_LOST;
assert(droiddeck_wsi_present(handle, queue, &info, Present) == VK_ERROR_DEVICE_LOST);
assert(droiddeck_wsi_present(handle, queue, &info, Present) == VK_ERROR_DEVICE_LOST);
assert(submitted == 4 && presented == 5);
droiddeck_wsi_destroy(handle); unlink(path);
std::puts("WSI synchronization passed: original waits retained, per-image completion, bounded in-flight buffers and fatal error propagation");
}
+30
View File
@@ -0,0 +1,30 @@
#include "../src/xr_chaperone.h"
#include <cassert>
#include <cstdio>
int main() {
DroidDeckXrPose head{{0, 0, 0, 1}, {2, 1.6f, -3}, 15};
XrChaperoneOrigin deferred;
DroidDeckXrTracking tracking{};
tracking.active = 1; tracking.period_ns = 11111111; tracking.sampled_ns = XrTrackingFile::Now();
tracking.eyes[0].width = tracking.eyes[0].height = 4;
unsigned publications = 0;
auto publish = [&](const auto &pose) { publications++; assert(pose.position[1] == 1.6f); return true; };
deferred.Update(tracking, publish); assert(publications == 0);
tracking.head = head; tracking.head.flags = 0;
deferred.Update(tracking, publish); assert(publications == 0);
tracking.head = head;
deferred.Update(tracking, publish); assert(publications == 1);
tracking.head.position[1] = 2;
deferred.Update(tracking, publish); assert(publications == 1);
deferred.Reset(); tracking.head = head;
deferred.Update(tracking, [&](const auto &) { publications++; return false; });
deferred.Update(tracking, publish); assert(publications == 3);
auto origin = xr_seated_origin(head);
assert(origin.translation[0] == -2 && std::abs(origin.translation[1] + 1.6) < 1e-6 && origin.translation[2] == 3);
head.orientation[1] = head.orientation[3] = std::sqrt(0.5f);
origin = xr_seated_origin(head);
assert(std::abs(origin.yaw + 1.57079632679) < 1e-6);
assert(std::abs(origin.translation[0] + 3) < 1e-6 && std::abs(origin.translation[2] + 2) < 1e-6);
std::puts(xr_chaperone_json(head, "2026-10-15T21:00:00Z").c_str());
}
+142
View File
@@ -0,0 +1,142 @@
#include "../src/xr_frame_channel.h"
#include "../src/xr_frame_math.h"
#include <cassert>
#include <cstdio>
#include <limits>
static DroidDeckXrFrameHeader Header(uint64_t id = 1) {
DroidDeckXrFrameHeader header{};
header.magic = DROIDDECK_XR_FRAME_MAGIC; header.version = DROIDDECK_XR_FRAME_VERSION;
header.width = 8; header.height = 4; header.bytes = 128; header.color_space = DROIDDECK_XR_COLOR_LINEAR_RGBA8;
header.frame_id = id; header.captured_ns = xr_frame_now();
for (int eye = 0; eye < 2; eye++) {
for (int axis = 0; axis < 3; axis++) header.eye_pose[eye][axis][axis] = 1;
header.eye_pose[eye][0][3] = eye == 0 ? -0.032f : 0.032f;
header.eye_pose[eye][1][3] = 1.6f;
header.projection[eye][0][0] = header.projection[eye][1][1] = 1;
header.projection[eye][2][2] = -1.002f; header.projection[eye][2][3] = -0.1001f;
header.projection[eye][3][2] = -1;
}
return header;
}
static int Connect(const std::string &path) {
int descriptor = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
assert(descriptor >= 0);
sockaddr_un address{}; address.sun_family = AF_UNIX;
std::strcpy(address.sun_path, path.c_str());
assert(connect(descriptor, reinterpret_cast<sockaddr *>(&address), sizeof(address)) == 0);
return descriptor;
}
static void WaitRejected(XrFrameReceiver &receiver, uint64_t count) {
int64_t deadline = xr_frame_now() + 1000000000LL;
while (receiver.Rejected() < count && xr_frame_now() < deadline) usleep(1000);
assert(receiver.Rejected() == count);
}
int main(int argc, char **argv) {
char directory[256];
std::snprintf(directory, sizeof(directory), "%s/droiddeck-frames-XXXXXX", argc > 1 ? argv[1] : "/tmp");
assert(mkdtemp(directory));
std::string socketPath = std::string(directory) + "/frames";
auto valid = Header(); assert(xr_frame_valid(valid));
valid.color_space = DROIDDECK_XR_COLOR_SRGB_RGBA8; assert(xr_frame_valid(valid));
auto unknownColor = valid; unknownColor.color_space = 3; assert(!xr_frame_valid(unknownColor));
auto invalid = valid; invalid.version = 3; assert(!xr_frame_valid(invalid));
invalid = valid; invalid.width = 3; assert(!xr_frame_valid(invalid));
invalid = valid; invalid.bytes--; assert(!xr_frame_valid(invalid));
invalid = valid; invalid.width = 8192; invalid.height = 4096; assert(!xr_frame_valid(invalid));
invalid = valid; invalid.eye_pose[0][0][0] = -1; assert(!xr_frame_valid(invalid));
invalid = valid; invalid.eye_pose[0][1][1] = 2; assert(!xr_frame_valid(invalid));
invalid = valid; invalid.projection[0][0][0] = std::numeric_limits<float>::quiet_NaN(); assert(!xr_frame_valid(invalid));
invalid = valid; invalid.projection[0][3][2] = 0; assert(!xr_frame_valid(invalid));
invalid = valid; invalid.projection[0][1][1] = -1; assert(!xr_frame_valid(invalid));
auto view = xr_frame_view(valid, 0);
assert(view.orientation[3] == 1 && view.position[0] == -0.032f && view.position[1] == 1.6f);
assert(std::abs(view.fov[0] + 0.785398f) < 0.00001f && std::abs(view.fov[2] - 0.785398f) < 0.00001f);
auto rotated = valid;
rotated.eye_pose[0][0][0] = rotated.eye_pose[0][2][2] = 0;
rotated.eye_pose[0][0][2] = 1; rotated.eye_pose[0][2][0] = -1;
assert(xr_frame_valid(rotated)); view = xr_frame_view(rotated, 0);
assert(std::abs(view.orientation[1] - 0.70710678f) < 0.00001f);
float eyeToHead[3][4]{{1, 0, 0, 0.032f}, {0, 1, 0, 0}, {0, 0, 1, 0}};
auto paired = rotated;
xr_frame_eye_pose(paired.eye_pose[0], rotated.eye_pose[0], eyeToHead);
rotated.eye_pose[0][1][3] = 9;
assert(paired.eye_pose[0][1][3] == 1.6f && paired.eye_pose[0][2][3] == -0.032f);
assert(xr_frame_valid(paired));
for (int axis = 0; axis < 3; axis++) {
rotated = valid;
for (int other = 0; other < 3; other++) if (other != axis) rotated.eye_pose[0][other][other] = -1;
assert(xr_frame_valid(rotated)); view = xr_frame_view(rotated, 0);
assert(view.orientation[axis] == 1 && view.orientation[3] == 0);
}
valid.projection[0][0][2] = 0.2f; valid.projection[0][1][2] = -0.1f;
view = xr_frame_view(valid, 0);
assert(std::abs(std::tan(view.fov[0]) + 0.8f) < 0.00001f && std::abs(std::tan(view.fov[2]) - 0.9f) < 0.00001f);
XrFrameReceiver receiver; assert(receiver.Start(socketPath));
struct stat status{}; assert(stat(socketPath.c_str(), &status) == 0 && (status.st_mode & 0777) == 0600);
XrFrameReceiver other; assert(!other.Start(socketPath));
XrFrameSender sender;
std::vector<unsigned char> pixels(valid.bytes, 17);
assert(sender.Send(socketPath, valid, pixels.data()));
pixels.assign(valid.bytes, 99);
DroidDeckXrPixels taken; assert(receiver.Take(&taken));
assert(taken.header.frame_id == 1 && taken.pixels[0] == 17 && taken.header.eye_pose[0][0][3] == -0.032f);
assert(taken.header.color_space == DROIDDECK_XR_COLOR_SRGB_RGBA8);
assert(!receiver.Take(&taken));
usleep(350000);
for (uint64_t id = 2; id <= 100; id++) {
auto header = Header(id); header.eye_pose[0][0][3] = static_cast<float>(id);
pixels.assign(header.bytes, static_cast<unsigned char>(id));
assert(sender.Send(socketPath, header, pixels.data()));
}
assert(receiver.Take(&taken));
assert(taken.header.frame_id == 100 && taken.header.eye_pose[0][0][3] == 100 && taken.pixels[0] == 100);
assert(receiver.Received() == 100);
assert(!sender.Send(socketPath, Header(100), pixels.data())); WaitRejected(receiver, 1);
assert(sender.Send(socketPath, Header(101), pixels.data())); assert(receiver.Take(&taken));
receiver.Stop(); assert(!sender.Send(socketPath, Header(102), pixels.data()));
assert(receiver.Start(socketPath)); assert(sender.Send(socketPath, Header(1), pixels.data()));
assert(receiver.Take(&taken));
receiver.Stop(); assert(receiver.Start(socketPath));
int delayed = Connect(socketPath);
usleep(140000);
auto delayedHeader = Header();
assert(xr_frame_io(delayed, &delayedHeader, sizeof(delayedHeader), true, xr_frame_now() + 200000000LL));
usleep(110000);
assert(xr_frame_io(delayed, pixels.data(), pixels.size(), true, xr_frame_now() + 200000000LL));
uint64_t acknowledged = 0;
assert(xr_frame_io(delayed, &acknowledged, sizeof(acknowledged), false, xr_frame_now() + 200000000LL));
assert(acknowledged == delayedHeader.frame_id && receiver.Take(&taken));
close(delayed);
receiver.Stop(); assert(receiver.Start(socketPath));
int descriptor = Connect(socketPath); auto header = Header(); header.version = 3;
assert(xr_frame_io(descriptor, &header, sizeof(header), true, xr_frame_now() + 200000000LL));
close(descriptor); WaitRejected(receiver, 1); assert(!receiver.Take(&taken));
descriptor = Connect(socketPath); header = Header(); header.captured_ns += 1000000000LL;
assert(xr_frame_io(descriptor, &header, sizeof(header), true, xr_frame_now() + 200000000LL));
close(descriptor); WaitRejected(receiver, 2);
descriptor = Connect(socketPath); header = Header(); header.captured_ns -= 1000000000LL;
assert(xr_frame_io(descriptor, &header, sizeof(header), true, xr_frame_now() + 200000000LL));
close(descriptor); WaitRejected(receiver, 3);
descriptor = Connect(socketPath); header = Header();
assert(xr_frame_io(descriptor, &header, sizeof(header), true, xr_frame_now() + 200000000LL));
assert(send(descriptor, pixels.data(), 1, MSG_NOSIGNAL) == 1);
close(descriptor); WaitRejected(receiver, 4); assert(!receiver.Take(&taken));
descriptor = Connect(socketPath); header = Header();
assert(xr_frame_io(descriptor, &header, sizeof(header), true, xr_frame_now() + 200000000LL));
WaitRejected(receiver, 5); close(descriptor);
assert(!sender.Send(socketPath, Header(200), pixels.data()));
assert(sender.Send(socketPath, Header(201), pixels.data()));
receiver.Stop();
int stale = socket(AF_UNIX, SOCK_STREAM, 0); sockaddr_un address{}; address.sun_family = AF_UNIX;
std::strcpy(address.sun_path, socketPath.c_str());
assert(bind(stale, reinterpret_cast<sockaddr *>(&address), sizeof(address)) == 0); close(stale);
assert(receiver.Start(socketPath)); receiver.Stop();
int regular = open(socketPath.c_str(), O_CREAT | O_RDWR, 0600); assert(regular >= 0); close(regular);
assert(!receiver.Start(socketPath)); assert(stat(socketPath.c_str(), &status) == 0 && S_ISREG(status.st_mode));
unlink(socketPath.c_str()); unlink((socketPath + ".lock").c_str()); rmdir(directory);
std::puts("XR frame validation passed: owned pixels, paired render pose, latest mailbox, bounds, projection, restart, stale socket, replay, malformed and timeout rejection");
}
+80
View File
@@ -0,0 +1,80 @@
#include "../src/xr_gpu_channel.h"
#include <cassert>
#include <cstdio>
#include <dirent.h>
static unsigned descriptors() {
DIR *directory = opendir("/proc/self/fd"); assert(directory);
unsigned count = 0; while (readdir(directory)) count++; closedir(directory); return count;
}
int main() {
assert(xr_gpu_dimensions(3360, 1760));
assert(!xr_gpu_dimensions(3, 4) && !xr_gpu_dimensions(8192, 4096));
int pair[2]; assert(!socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0, pair));
int fd = memfd_create("xr-gpu-test", MFD_CLOEXEC); assert(fd >= 0 && !ftruncate(fd, 4096));
XrGpuPoolInfo pool; int sent[3]{fd, fd, fd}; std::array<int, 3> received{{-1, -1, -1}};
assert(xr_gpu_send(pair[0], &pool, sizeof(pool), sent, 3));
assert(xr_gpu_receive(pair[1], &pool, sizeof(pool), received.data(), 3));
for (int handle : received) { assert(fcntl(handle, F_GETFD) & FD_CLOEXEC); close(handle); }
auto count = descriptors();
assert(xr_gpu_send(pair[0], &pool, sizeof(pool), sent, 3));
assert(!xr_gpu_receive(pair[1], &pool, sizeof(pool)) && descriptors() == count);
assert(xr_gpu_send(pair[0], &pool, sizeof(pool), sent, 2));
assert(!xr_gpu_receive(pair[1], &pool, sizeof(pool), received.data(), 3) && descriptors() == count);
assert(xr_gpu_send(pair[0], &pool, sizeof(pool), sent, 3));
assert(!xr_gpu_receive(pair[1], &pool, sizeof(pool) - 1, received.data(), 3) && descriptors() == count);
close(pair[0]); close(pair[1]);
char directory[] = "/tmp/dd-xr-gpu-XXXXXX"; assert(mkdtemp(directory));
std::string filename = std::string(directory) + "/gpu";
int listener = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0); assert(listener >= 0);
sockaddr_un address{}; address.sun_family = AF_UNIX; strcpy(address.sun_path, filename.c_str());
assert(!bind(listener, reinterpret_cast<sockaddr *>(&address), sizeof(address)) && !listen(listener, 1));
for (unsigned attempt = 0; attempt < 3; attempt++) {
std::thread server([&] {
int peer = accept4(listener, nullptr, nullptr, SOCK_CLOEXEC); assert(peer >= 0 && xr_frame_peer(peer));
XrGpuRequest request; assert(xr_gpu_wait(peer, 1000) && xr_gpu_receive(peer, &request, sizeof(request)));
assert(request.width == 32 && request.height == 32);
XrGpuPoolInfo reply; reply.width = reply.height = 32;
for (auto &size : reply.sizes) size = attempt == 2 ? 4095 : 4096;
assert(xr_gpu_send(peer, &reply, sizeof(reply), sent, 3));
if (attempt != 2) {
XrGpuFrame frame; assert(xr_gpu_wait(peer, 1000) && xr_gpu_receive(peer, &frame, sizeof(frame)));
assert(frame.slot == 2 && frame.header.frame_id == 7);
auto now = xr_frame_now();
frame.header.captured_ns = now;
assert(xr_gpu_frame_state(frame, 32, 32, 0, now) == XrGpuFrameState::Ready);
assert(xr_gpu_frame_state(frame, 32, 32, 7, now) == XrGpuFrameState::Invalid);
frame.header.captured_ns = now - 1000000000LL;
assert(xr_gpu_frame_state(frame, 32, 32, 0, now) == XrGpuFrameState::Stale);
frame.slot = 3;
assert(xr_gpu_frame_state(frame, 32, 32, 0, now) == XrGpuFrameState::Invalid);
if (!attempt) std::this_thread::sleep_for(std::chrono::milliseconds(700));
uint64_t ack = attempt ? 8 : 7;
assert(xr_gpu_send(peer, &ack, sizeof(ack)));
}
close(peer);
});
XrGpuSender sender; received.fill(-1);
bool connected = sender.Connect(filename.c_str(), 32, 32, pool, received);
assert(connected == (attempt != 2));
if (connected) {
for (int handle : received) close(handle);
DroidDeckXrFrameHeader header{};
header.magic = DROIDDECK_XR_FRAME_MAGIC; header.version = DROIDDECK_XR_FRAME_VERSION;
header.width = header.height = 32; header.bytes = 4096; header.color_space = DROIDDECK_XR_COLOR_SRGB_RGBA8;
header.frame_id = 7; header.captured_ns = xr_frame_now();
for (int eye = 0; eye < 2; eye++) {
for (int axis = 0; axis < 3; axis++) header.eye_pose[eye][axis][axis] = 1;
header.projection[eye][0][0] = header.projection[eye][1][1] = 1;
header.projection[eye][3][2] = -1;
}
assert(sender.Send(header, 2) == (attempt == 0));
if (attempt) assert(!sender.Send(header, 2));
} else for (int handle : received) assert(handle == -1);
server.join();
}
close(fd); close(listener); unlink(filename.c_str()); rmdir(directory);
puts("XR GPU transport passed: atomic FD ownership, malformed pool rejection, truncation cleanup and exact completion acknowledgement");
}
+67
View File
@@ -0,0 +1,67 @@
#include "../src/xr_frame_channel.h"
#include <cassert>
#include <cstdio>
#include <sys/syscall.h>
static DroidDeckXrFrameHeader Header() {
DroidDeckXrFrameHeader header{};
header.magic = DROIDDECK_XR_FRAME_MAGIC; header.version = DROIDDECK_XR_FRAME_VERSION;
header.width = 8; header.height = 4; header.bytes = 128;
header.color_space = DROIDDECK_XR_COLOR_SRGB_RGBA8; header.frame_id = 1; header.captured_ns = xr_frame_now();
for (int eye = 0; eye < 2; eye++) {
for (int axis = 0; axis < 3; axis++) header.eye_pose[eye][axis][axis] = 1;
header.projection[eye][0][0] = header.projection[eye][1][1] = 1;
header.projection[eye][3][2] = -1;
}
return header;
}
int main() {
char directory[] = "/tmp/droiddeck-shared-XXXXXX"; assert(mkdtemp(directory));
std::string path = std::string(directory) + "/frames";
int listener = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); assert(listener >= 0);
sockaddr_un address{}; address.sun_family = AF_UNIX; std::strcpy(address.sun_path, path.c_str());
assert(bind(listener, reinterpret_cast<sockaddr *>(&address), sizeof(address)) == 0);
assert(listen(listener, 1) == 0);
int buffer = syscall(SYS_memfd_create, "frame-test", 1); assert(buffer >= 0);
assert(ftruncate(buffer, 128) == 0);
unsigned char pixels[128]; std::memset(pixels, 27, sizeof(pixels));
assert(pwrite(buffer, pixels, sizeof(pixels), 0) == sizeof(pixels));
auto header = Header();
XrFrameSender sender;
std::thread peer([&] {
int client = accept(listener, nullptr, nullptr); assert(client >= 0);
DroidDeckXrFrameHeader received{}; XrFrameDescriptor shared;
assert(xr_frame_header(client, received, shared, xr_frame_now() + 1000000000LL));
assert(received.version == DROIDDECK_XR_FRAME_SHARED_VERSION && xr_frame_valid(received));
assert(shared.fd >= 0 && (fcntl(shared.fd, F_GETFD) & FD_CLOEXEC));
unsigned char copy[128]; assert(pread(shared.fd, copy, sizeof(copy), 0) == sizeof(copy));
for (auto value : copy) assert(value == 27);
assert(xr_frame_io(client, &received.frame_id, sizeof(received.frame_id), true, xr_frame_now() + 1000000000LL));
close(client);
});
assert(sender.SendShared(path, header, buffer)); peer.join();
assert(!sender.SharedLeaseLost());
XrFrameSender lost;
std::thread disconnected([&] {
int client = accept(listener, nullptr, nullptr); assert(client >= 0);
DroidDeckXrFrameHeader received{}; XrFrameDescriptor shared;
assert(xr_frame_header(client, received, shared, xr_frame_now() + 1000000000LL));
assert(shared.fd >= 0);
close(client);
});
assert(!lost.SendShared(path, header, buffer)); disconnected.join();
assert(lost.SharedLeaseLost());
assert(!lost.SendShared(path, header, buffer));
close(listener); unlink(path.c_str());
XrFrameSender absent;
assert(!absent.SendShared(path, header, buffer)); assert(!absent.SharedLeaseLost());
XrFrameReceiver receiver; assert(receiver.Start(path));
XrFrameSender invalid;
// An ordinary file is not a DMA-BUF and must never be acknowledged as one.
assert(!invalid.SendShared(path, Header(), buffer));
assert(invalid.SharedLeaseLost() && receiver.Rejected() == 1);
DroidDeckXrPixels frame; assert(!receiver.Take(&frame));
receiver.Stop(); close(buffer); unlink((path + ".lock").c_str()); rmdir(directory);
std::puts("XR shared handoff passed: descriptor ownership, copied payload, acknowledgement, disconnect quarantine, absent peer and invalid buffer rejection");
}
+51
View File
@@ -0,0 +1,51 @@
#include "../src/xr_touch_input.h"
#include <cassert>
#include <cstdio>
#include <limits>
int main() {
DroidDeckXrHand hand{};
hand.active = 1;
hand.trigger = 0.7f; hand.squeeze = 0.8f;
hand.stick[0] = -0.4f; hand.stick[1] = 0.9f;
auto input = xr_touch_input(hand, false);
assert(input.trigger == hand.trigger && input.grip == hand.squeeze && input.triggerClick && input.gripClick);
assert(input.stickX == -0.4f && input.stickY == 0.9f);
assert(!input.stickTouch && !input.stickClick && !input.primaryClick && !input.secondaryClick && !input.systemClick);
hand.buttons = DROIDDECK_XR_PRIMARY_CLICK;
input = xr_touch_input(hand, false);
assert(input.primaryClick && !input.secondaryClick && !input.stickClick && !input.systemClick && !input.primaryTouch);
hand.buttons = DROIDDECK_XR_SECONDARY_CLICK;
input = xr_touch_input(hand, true);
assert(input.secondaryClick && !input.primaryClick && !input.systemClick && !input.stickClick);
hand.buttons = DROIDDECK_XR_STICK_CLICK;
input = xr_touch_input(hand, true);
assert(input.stickClick && !input.primaryClick && !input.stickTouch);
hand.buttons = DROIDDECK_XR_MENU_CLICK;
assert(xr_touch_input(hand, false).systemClick);
assert(!xr_touch_input(hand, true).systemClick);
hand.buttons = 0;
assert(!xr_touch_input(hand, false).systemClick);
hand.buttons = DROIDDECK_XR_TRIGGER_TOUCH | DROIDDECK_XR_STICK_TOUCH | DROIDDECK_XR_PRIMARY_TOUCH |
DROIDDECK_XR_SECONDARY_TOUCH | DROIDDECK_XR_THUMBREST_TOUCH;
hand.trigger = hand.squeeze = 0;
input = xr_touch_input(hand, false);
assert(input.triggerTouch && input.stickTouch && input.primaryTouch && input.secondaryTouch && input.thumbrestTouch);
assert(!input.triggerClick && !input.gripClick && !input.primaryClick && !input.secondaryClick && !input.stickClick);
hand.trigger = 2; hand.squeeze = -2; hand.stick[0] = -3; hand.stick[1] = 3;
input = xr_touch_input(hand, false);
assert(input.trigger == 1 && input.grip == 0 && input.stickX == -1 && input.stickY == 1);
hand.trigger = std::numeric_limits<float>::quiet_NaN();
hand.squeeze = std::numeric_limits<float>::infinity();
hand.stick[0] = hand.trigger; hand.stick[1] = -hand.squeeze;
input = xr_touch_input(hand, false);
assert(input.trigger == 0 && input.grip == 0 && input.stickX == 0 && input.stickY == 0);
hand.active = 0;
hand.buttons = UINT32_MAX;
hand.trigger = hand.squeeze = hand.stick[0] = hand.stick[1] = 1;
input = xr_touch_input(hand, false);
assert(input.trigger == 0 && input.grip == 0 && input.stickX == 0 && input.stickY == 0);
assert(!input.triggerClick && !input.gripClick && !input.stickClick && !input.primaryClick && !input.secondaryClick && !input.systemClick);
assert(!input.triggerTouch && !input.stickTouch && !input.primaryTouch && !input.secondaryTouch && !input.thumbrestTouch);
std::puts("Touch input: independent face/stick/system, real capacitive touch, analog grip, clamping and inactive reset passed");
}
+73
View File
@@ -0,0 +1,73 @@
#include "../src/xr_tracking_file.h"
#include "../src/xr_display_mode.h"
#include <cassert>
#include <cstdio>
#include <limits>
int main(int argc, char **argv) {
assert(XrTrackingFile::PoseTimeOffset(1010000000LL, 1000000000LL) == 0.01);
assert(XrTrackingFile::PoseTimeOffset(990000000LL, 1000000000LL) == -0.01);
assert(XrTrackingFile::PoseTimeOffset(0, 1000000000LL) == 0);
assert(XrTrackingFile::PoseTimeOffset(-1, 1000000000LL) == 0);
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);
auto *writer = static_cast<DroidDeckXrTracking *>(mmap(nullptr, sizeof(DroidDeckXrTracking),
PROT_READ | PROT_WRITE, MAP_SHARED, descriptor, 0));
assert(writer != MAP_FAILED);
{
XrTrackingFile reader(filename);
DroidDeckXrTracking packet{};
assert(!reader.Read(&packet));
writer->magic = DROIDDECK_XR_MAGIC; writer->version = DROIDDECK_XR_VERSION;
writer->active = 1; writer->sampled_ns = XrTrackingFile::Now(); writer->period_ns = 13888889;
writer->eyes[0].width = 1680; writer->eyes[0].height = 1760;
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));
assert(XrDisplayMode(packet) == "3360x1760@72");
auto displayTracking = packet;
displayTracking.period_ns = 11111111;
assert(XrDisplayMode(displayTracking) == "3360x1760@90");
displayTracking.period_ns = 8333547;
assert(XrDisplayMode(displayTracking) == "3360x1760@120");
displayTracking.active = 0;
assert(XrDisplayMode(displayTracking).empty());
displayTracking = packet; displayTracking.sampled_ns -= 1000000000LL;
assert(XrDisplayMode(displayTracking).empty());
displayTracking = packet; displayTracking.period_ns = 0;
assert(XrDisplayMode(displayTracking).empty());
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<float>::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);
writer->version++;
assert(!reader.Read(&packet));
writer->version = DROIDDECK_XR_VERSION;
writer->sampled_ns = XrTrackingFile::Now() - 1000000000LL;
assert(reader.Read(&packet)); assert(!XrTrackingFile::Fresh(packet));
writer->sampled_ns = XrTrackingFile::Now() + 1000000000LL;
assert(reader.Read(&packet)); assert(!XrTrackingFile::Fresh(packet));
writer->sampled_ns = XrTrackingFile::Now(); writer->active = 0;
assert(reader.Read(&packet)); assert(!XrTrackingFile::Fresh(packet));
writer->active = 1; writer->eyes[0].width = 9000;
assert(reader.Read(&packet)); assert(!XrTrackingFile::Fresh(packet));
}
munmap(writer, sizeof(DroidDeckXrTracking));
assert(ftruncate(descriptor, 16) == 0);
{
XrTrackingFile reader(filename);
DroidDeckXrTracking packet{};
assert(!reader.Read(&packet));
}
close(descriptor); unlink(filename);
std::puts("XR IPC validation passed: valid, seqlock, version, stale, future, inactive, dimensions, truncated");
}
+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");
}