mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-18 05:07:22 -04:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9a199285e | ||
|
|
d8de6895c1 | ||
|
|
fd7cb249b1 | ||
|
|
21742403a4 | ||
|
|
46335b7871 | ||
|
|
5e9ebbc7df | ||
|
|
92f2696a0c | ||
|
|
59f752c70d | ||
|
|
828374f236 | ||
|
|
0a6e08d923 | ||
|
|
2f5537acef | ||
|
|
15ef4b064b | ||
|
|
52d351908f | ||
|
|
19a8beea52 | ||
|
|
65c6ca3552 | ||
|
|
220442d634 | ||
|
|
d344b7dcba | ||
|
|
fd6111fc69 | ||
|
|
389bef5ad3 |
No files matched your search
@@ -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 }}-
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -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))
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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, ©);
|
||||
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(); }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, ¤t);
|
||||
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
|
||||
@@ -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)
|
||||
|
||||
@@ -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, ©);
|
||||
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, ©);
|
||||
} catch (...) {
|
||||
std::fprintf(stderr, "[droiddeck-vr-layer] WSI capture failed; restart SteamVR\n");
|
||||
return VK_ERROR_OUT_OF_HOST_MEMORY;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(); }
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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_;
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#include "../src/xr_vsync_timing.h"
|
||||
#include <atomic>
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <initializer_list>
|
||||
#include <limits>
|
||||
#include <thread>
|
||||
|
||||
int main() {
|
||||
using namespace std::chrono;
|
||||
using Clock = XrVsyncTiming::Clock;
|
||||
XrVsyncTiming timing;
|
||||
assert(timing.Deadline(0) == Clock::time_point(XrVsyncTiming::kDefaultPeriod));
|
||||
auto target = Clock::time_point(seconds(1));
|
||||
for (int64_t periodNs : {13888889LL, 11111111LL, 8333333LL}) {
|
||||
auto period = nanoseconds(periodNs);
|
||||
timing.Record(target, period);
|
||||
assert(timing.Deadline(0) == target + period);
|
||||
assert(timing.Deadline(2) == target + period * 3);
|
||||
assert(timing.Deadline(std::numeric_limits<uint32_t>::max()) == target + period * 4294967296LL);
|
||||
auto now = target - milliseconds(2);
|
||||
auto scheduled = target + period;
|
||||
assert(timing.Target(now, scheduled, 1000000000LL, period) == scheduled);
|
||||
assert(timing.Target(now, scheduled, 0, period) == scheduled);
|
||||
assert(timing.Target(now, scheduled, 900000000LL, period) == scheduled);
|
||||
assert(timing.Target(now, scheduled, 2000000000LL, period) == scheduled);
|
||||
auto prediction = target + period * 2;
|
||||
auto predictedNs = duration_cast<nanoseconds>(prediction.time_since_epoch()).count();
|
||||
assert(timing.Target(now, scheduled, predictedNs, period) == prediction);
|
||||
}
|
||||
auto first = Clock::time_point(seconds(1));
|
||||
auto second = Clock::time_point(seconds(2));
|
||||
auto firstPeriod = nanoseconds(13888889);
|
||||
auto secondPeriod = nanoseconds(8333333);
|
||||
timing.Record(first, firstPeriod);
|
||||
std::atomic<bool> finished{false};
|
||||
std::thread writer([&] {
|
||||
for (int count = 0; count < 100000; count++) {
|
||||
timing.Record(second, secondPeriod);
|
||||
timing.Record(first, firstPeriod);
|
||||
}
|
||||
finished = true;
|
||||
});
|
||||
do {
|
||||
auto deadline = timing.Deadline(0);
|
||||
assert(deadline == first + firstPeriod || deadline == second + secondPeriod);
|
||||
} while (!finished);
|
||||
writer.join();
|
||||
timing.Reset();
|
||||
assert(timing.Deadline(0) == Clock::time_point(XrVsyncTiming::kDefaultPeriod));
|
||||
std::puts("XR pacing validation passed: refresh rates, throttling, repeated predictions, atomic tick/period, restart");
|
||||
}
|
||||
Reference in new issue
Block a user