From b187efbedff0acebe2e3aa905fdadf7bf9425fc1 Mon Sep 17 00:00:00 2001 From: komaruworld Date: Sun, 13 Sep 2026 18:20:58 +0300 Subject: [PATCH] Full support camera NOT TESTED --- CMakeLists.txt | 3 + cmake/MocktailVR.cmake | 35 +- config/mocktail.example.yaml | 16 +- config/roblox_compatibility.json | 11 +- include/compat/build_profile.h | 17 + include/mocktail/vr/openxr_backend.h | 286 +++ include/mocktail/vr/openxr_probe.h | 4 + include/mocktail/vr/roblox_vr_device_bridge.h | 113 + include/mocktail/vr/vr_mirror.h | 105 + include/mocktail/vr/vr_mirror_shm.h | 42 + include/mocktail/vr/vr_perf.h | 97 + include/mocktail/vr/vr_pose_math.h | 171 ++ include/runtime/performance_policy.h | 5 + include/runtime/runtime_config.h | 7 +- scripts/vr_simulated.py | 187 -- src/compat/build_profile.cc | 56 +- src/graphics/bionic_vulkan_loader_adapter.cc | 394 ++- src/legacy/legacy_runtime.cc | 15 + src/main.cc | 69 +- src/runtime/command_line.cc | 7 +- src/runtime/performance_policy.cc | 22 + src/runtime/runtime_config.cc | 2 +- src/runtime/runtime_config_bootstrap.cc | 16 +- src/vr/openxr_backend.cc | 2194 +++++++++++++++++ src/vr/openxr_backend_mode.cc | 53 + src/vr/openxr_disabled.cc | 12 + src/vr/openxr_preview.cc | 3 +- src/vr/openxr_probe.cc | 2 + src/vr/roblox_vr_device_bridge.cc | 885 +++++++ src/vr/roblox_vr_device_bridge_internal.h | 82 + src/vr/vr_mirror.cc | 268 ++ src/vr/vr_perf.cc | 220 ++ tests/CMakeLists.txt | 60 +- tests/command_line_test.cc | 14 + tests/performance_policy_test.cc | 16 + tests/runtime_config_file_test.cc | 6 +- tests/runtime_config_test.cc | 4 +- tests/vr/openxr_backend_test.cc | 191 ++ tests/vr/preview_contract_test.py | 4 +- tests/vr/roblox_vr_device_bridge_test.cc | 503 ++++ tests/vr/simulator_launcher_test.py | 89 - tests/vr/vr_evidence_layer.cc | 1005 ++++++++ tests/vr/vr_evidence_layer_test.cc | 131 + tests/vr/vr_mirror_test.cc | 200 ++ tests/vr/vr_pose_math_test.cc | 187 ++ 45 files changed, 7476 insertions(+), 333 deletions(-) create mode 100644 include/mocktail/vr/openxr_backend.h create mode 100644 include/mocktail/vr/roblox_vr_device_bridge.h create mode 100644 include/mocktail/vr/vr_mirror.h create mode 100644 include/mocktail/vr/vr_mirror_shm.h create mode 100644 include/mocktail/vr/vr_perf.h create mode 100644 include/mocktail/vr/vr_pose_math.h delete mode 100644 scripts/vr_simulated.py create mode 100644 src/vr/openxr_backend.cc create mode 100644 src/vr/openxr_backend_mode.cc create mode 100644 src/vr/roblox_vr_device_bridge.cc create mode 100644 src/vr/roblox_vr_device_bridge_internal.h create mode 100644 src/vr/vr_mirror.cc create mode 100644 src/vr/vr_perf.cc create mode 100644 tests/vr/openxr_backend_test.cc create mode 100644 tests/vr/roblox_vr_device_bridge_test.cc delete mode 100644 tests/vr/simulator_launcher_test.py create mode 100644 tests/vr/vr_evidence_layer.cc create mode 100644 tests/vr/vr_evidence_layer_test.cc create mode 100644 tests/vr/vr_mirror_test.cc create mode 100644 tests/vr/vr_pose_math_test.cc diff --git a/CMakeLists.txt b/CMakeLists.txt index a060d5b..60c27c1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -305,6 +305,9 @@ target_include_directories(mocktail_runtime PUBLIC include) target_include_directories(mocktail_runtime PUBLIC ${MOCKTAIL_JNI_INCLUDE_DIRS} ) +target_compile_definitions(mocktail_runtime PUBLIC + MOCKTAIL_VR_DEFAULT_ENABLED=$ +) target_compile_definitions(mocktail_runtime PRIVATE "MOCKTAIL_DISCORD_APPLICATION_ID=\"${MOCKTAIL_DISCORD_APPLICATION_ID}\"" ) diff --git a/cmake/MocktailVR.cmake b/cmake/MocktailVR.cmake index 5c7f4b6..f71f7cb 100644 --- a/cmake/MocktailVR.cmake +++ b/cmake/MocktailVR.cmake @@ -3,11 +3,23 @@ include_guard(GLOBAL) -option(MOCKTAIL_ENABLE_VR "Build experimental native OpenXR support" OFF) +option(MOCKTAIL_ENABLE_VR "Build experimental native OpenXR support (VR branch default)" ON) -add_library(mocktail_vr STATIC src/vr/diagnostics.cc) +add_library(mocktail_vr STATIC + src/vr/diagnostics.cc + src/vr/openxr_backend_mode.cc + src/vr/vr_perf.cc + src/vr/vr_mirror.cc + src/vr/roblox_vr_device_bridge.cc) add_library(Mocktail::VR ALIAS mocktail_vr) target_include_directories(mocktail_vr PUBLIC "${CMAKE_SOURCE_DIR}/include") +target_include_directories(mocktail_vr PRIVATE "${CMAKE_SOURCE_DIR}/src") +target_link_libraries(mocktail_vr PUBLIC Vulkan::Headers) +# shm_open for the desktop eye mirror lives in librt on glibc. +find_library(MOCKTAIL_RT_LIBRARY rt) +if(MOCKTAIL_RT_LIBRARY) + target_link_libraries(mocktail_vr PUBLIC ${MOCKTAIL_RT_LIBRARY}) +endif() mocktail_apply_compile_options(mocktail_vr) if(MOCKTAIL_ENABLE_VR) @@ -34,11 +46,12 @@ if(MOCKTAIL_ENABLE_VR) "${CMAKE_BINARY_DIR}/third_party/OpenXR-SDK" EXCLUDE_FROM_ALL) endfunction() mocktail_add_openxr_sdk() - target_sources(mocktail_vr PRIVATE src/vr/openxr_probe.cc src/vr/openxr_preview.cc) - # Error unwinding is confined to this native session implementation; its - # public entry point catches errors before returning to the guest runtime. - set_source_files_properties(src/vr/openxr_preview.cc PROPERTIES - COMPILE_OPTIONS -fexceptions) + target_sources(mocktail_vr PRIVATE src/vr/openxr_probe.cc src/vr/openxr_preview.cc + src/vr/openxr_backend.cc) + # Error unwinding is confined to these native session implementations; their + # public entry points catch errors before returning to the guest runtime. + set_source_files_properties(src/vr/openxr_preview.cc src/vr/openxr_backend.cc + PROPERTIES COMPILE_OPTIONS -fexceptions) target_compile_definitions(mocktail_vr PRIVATE XR_USE_GRAPHICS_API_VULKAN) target_link_libraries(mocktail_vr PRIVATE OpenXR::openxr_loader Vulkan::Headers ${CMAKE_DL_LIBS}) else() @@ -50,11 +63,3 @@ set_target_properties(mocktail_vr_probe PROPERTIES OUTPUT_NAME mocktail-vr-probe target_link_libraries(mocktail_vr_probe PRIVATE Mocktail::VR) mocktail_apply_compile_options(mocktail_vr_probe) install(TARGETS mocktail_vr_probe RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") - -# Development launcher for an independently built Monado runtime. --prepare -# keeps its shared library and a relative manifest under this build directory. -configure_file("${CMAKE_SOURCE_DIR}/scripts/vr_simulated.py" - "${CMAKE_BINARY_DIR}/mocktail-vr-simulated" COPYONLY) -file(CHMOD "${CMAKE_BINARY_DIR}/mocktail-vr-simulated" - PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE GROUP_READ GROUP_EXECUTE - WORLD_READ WORLD_EXECUTE) diff --git a/config/mocktail.example.yaml b/config/mocktail.example.yaml index aa10b5a..b151470 100644 --- a/config/mocktail.example.yaml +++ b/config/mocktail.example.yaml @@ -49,12 +49,16 @@ graphics: # Optional presentation synchronization override: auto, on, or off. # vsync: off -vr: - # Boolean (default: false): launch the Roblox experimental VR path. - # Only Build ID 2998 is currently accepted; eye resources and OpenXR output - # are NOT yet connected. Standalone scene: mocktail-vr-probe --scene. - # Use --no-vr to launch Roblox normally. - enabled: false +# vr: + # Boolean (default: enabled in VR builds, disabled in non-VR builds). + # Experimental VR for the exact Build ID 2998 + # payload. Mocktail initializes the guest DebugDeviceVR eye resources (two + # distinct 500x500 eye framebuffers), the Roblox renderer draws both eyes + # every frame, and the frames are submitted to the active OpenXR runtime as + # a stereo projection layer with head pose (runtime-tracked, or scripted for + # desktop testing). Controllers are NOT connected yet. Standalone OpenXR + # test scene: mocktail-vr-probe --scene. Use --no-vr to launch normally. + # enabled: false performance: # Boolean (default: false): size Roblox scheduler/render queues from every diff --git a/config/roblox_compatibility.json b/config/roblox_compatibility.json index 67ff302..40883da 100644 --- a/config/roblox_compatibility.json +++ b/config/roblox_compatibility.json @@ -81,7 +81,16 @@ "current_method_rva": "0x32d0f30", "select_method_rva": "0x32d0cb4" }, - "reason": "Verified 2026-09-06: exact Build-ID profile passed two isolated no-recovery Tier C Vulkan canaries and real game sessions, including fullscreen state synchronization and host FMOD output-device interposition; legacy executable patches and signal recovery remain disabled." + "vr_debug_device_bridge": { + "vtable_rva": "0x6c87b38", + "constructor_rva": "0x3810614", + "state_getter_rva": "0x38109f6", + "eye_getter_rva": "0x38109e6", + "eye_initializer_rva": "0x3810a18", + "emulator_flag_storage_rva": "0x7369920", + "device_create_framebuffer_vtable_offset": "0x180" + }, + "reason": "Verified 2026-09-06: exact Build-ID profile passed two isolated no-recovery Tier C Vulkan canaries and real game sessions, including fullscreen state synchronization and host FMOD output-device interposition; legacy executable patches and signal recovery remain disabled. Experimental vr_debug_device_bridge metadata (DebugDeviceVR constructor/state/eye/initializer RVAs) is machine-validated at activation and gated behind --vr; native stereo output is not yet verified end-to-end." } ] } diff --git a/include/compat/build_profile.h b/include/compat/build_profile.h index 40e3f2a..5483662 100644 --- a/include/compat/build_profile.h +++ b/include/compat/build_profile.h @@ -24,6 +24,21 @@ struct FmodOutputDeviceBridgeProfile { std::uintptr_t select_method_rva = 0; }; +// Exact-build metadata for the experimental native VR bridge. The bridge +// interposes only DebugDeviceVR virtual slots (state getter and eye +// framebuffer getter) and invokes the guest eye-resource initializer through +// its own vtable; executable guest code is never modified. All RVAs are +// validated against machine-code contracts before the first use. +struct VrDebugDeviceBridgeProfile { + std::uintptr_t vtable_rva = 0; + std::uintptr_t constructor_rva = 0; + std::uintptr_t state_getter_rva = 0; + std::uintptr_t eye_getter_rva = 0; + std::uintptr_t eye_initializer_rva = 0; + std::uintptr_t emulator_flag_storage_rva = 0; + std::uintptr_t device_create_framebuffer_vtable_offset = 0; +}; + struct BuildProfile { std::string version_name; int version_code = 0; @@ -41,6 +56,8 @@ struct BuildProfile { // output-device virtual slots and restores them during controlled teardown; // executable guest code is never modified. std::optional fmod_output_device_bridge; + // Optional exact-build experimental VR capability; see the struct above. + std::optional vr_debug_device_bridge; std::string reason; }; diff --git a/include/mocktail/vr/openxr_backend.h b/include/mocktail/vr/openxr_backend.h new file mode 100644 index 0000000..bed4428 --- /dev/null +++ b/include/mocktail/vr/openxr_backend.h @@ -0,0 +1,286 @@ +#ifndef MOCKTAIL_VR_OPENXR_BACKEND_H_ +#define MOCKTAIL_VR_OPENXR_BACKEND_H_ + +#include + +#include +#include +#include +#include +#include +#include + +#include "mocktail/status.h" +#include "mocktail/vr/vr_mirror.h" + +namespace mocktail::vr { + +// Resolved --vr output mode. kXrOutput drives the real Roblox stereo images +// into an OpenXR projection layer; kNativeOnly keeps the diagnostic native +// stereo prototype without any runtime requirement. Selected through +// MOCKTAIL_VR_BACKEND ("xr" default, "native" diagnostic). +enum class VrBackendMode { + kDisabled, + kNativeOnly, + kXrOutput, +}; + +// Explicit XR_RUNTIME_JSON wins. Otherwise prefer a native WiVRn manifest, +// then let the OpenXR loader use the registered runtime (including Flatpak WiVRn). +std::string SelectVrRuntimeManifest(const std::string& explicit_manifest, + const std::vector& candidates); +std::string VrRuntimeUnavailableHint(bool user_selected, const std::string& manifest); +VrBackendMode ResolveVrBackendMode(bool vr_enabled); +const char* VrBackendModeName(VrBackendMode mode); + +// One deterministic head pose sample. Position is metres, orientation is a +// quaternion (x, y, z, w) in the OpenXR LOCAL reference space. +struct ScriptedPoseSample { + float position[3] = {0.f, 0.f, 0.f}; + float orientation[4] = {0.f, 0.f, 0.f, 1.f}; + float eye_offset[2][3] = {}; // metres in head space + float eye_fov[2][4] = {}; // left, right, up, down angles + std::uint64_t frame = 0; + bool valid = false; +}; + +// Production OpenXR backend for the native stereo prototype. +// +// Lifecycle: main arms the backend before Roblox startup (xrCreateInstance +// and xrGetSystem fail fast with a clear message when no runtime exists). +// The Vulkan adapter delegates the guest's vkCreateInstance/vkCreateDevice +// through xrCreateVulkanInstanceKHR/xrCreateVulkanDeviceKHR so the runtime's +// extension and GPU requirements are satisfied by the very device Roblox +// renders with. The session is created on that device; per-frame XR work runs +// on the render thread from the adapter's host-present notification: +// xrEndFrame(N) with the two eye images of frame N copied into the +// projection swapchains, then xrWaitFrame/xrBeginFrame/xrLocateViews for +// frame N+1 and publishing the scripted/runtime head pose that the device +// bridge injects into Roblox before its eye passes run. +// +// Eye image provenance is recorded in the adapter during the guest eye +// initializer (owner = DebugDeviceVR object) and bound to an eye index only +// through the real per-frame eye-getter request plus the actual render-pass +// attachment, mirroring the validated evidence-layer semantics. Creation +// order or size alone never selects an eye. +// +// All public entry points are exception-free at their boundary; the +// implementation is compiled with exceptions like openxr_preview.cc and +// translates failures into Status/log outcomes. +class OpenXrBackend final { + public: + OpenXrBackend() = default; + ~OpenXrBackend(); + OpenXrBackend(const OpenXrBackend&) = delete; + OpenXrBackend& operator=(const OpenXrBackend&) = delete; + + // Claims the process-wide backend slot and creates XrInstance/system. + Status Arm(); + // Idempotent full teardown (XR resources first, then the instance). + void Disarm(); + bool armed() const { return armed_.load(std::memory_order_acquire); } + + // Adapter delegation. Return false when the backend does not own creation + // (caller falls back to the plain host loader path). + bool CreateVulkanInstance(const VkInstanceCreateInfo* create_info, + const VkAllocationCallbacks* allocator, + VkInstance* instance, VkResult* result); + bool CreateVulkanDevice(VkPhysicalDevice physical_device, + const VkDeviceCreateInfo* create_info, + const VkAllocationCallbacks* allocator, + VkDevice* device, VkResult* result); + void NoteDeviceDestroyed(VkDevice device); + void NoteInstanceDestroyed(VkInstance instance); + void NoteQueue(VkDevice device, VkQueue queue, std::uint32_t family, + std::uint32_t index); + + // Resource provenance recording (adapter wrappers). + bool WantsResourceRecords() const { + return recording_.load(std::memory_order_acquire); + } + void RecordImage(VkDevice device, VkImage image, + const VkImageCreateInfo* info); + void RecordImageView(VkImageView view, VkImage image); + void RecordFramebuffer(VkFramebuffer framebuffer, + const VkFramebufferCreateInfo* info); + void RecordRenderPass(VkRenderPass render_pass, + const VkRenderPassCreateInfo* info); + void NoteDestroyImage(VkImage image); + void NoteDestroyImageView(VkImageView view); + void NoteDestroyFramebuffer(VkFramebuffer framebuffer); + void NoteRenderPassBegin(VkCommandBuffer command_buffer, + const VkRenderPassBeginInfo* info); + bool EyeBindingComplete() const { + return eyes_bound_.load(std::memory_order_acquire); + } + + // Render-thread frame cycle at host present begin. + void NoteHostPresent(VkQueue queue, VkDevice device); + + // Pose published for the frame that is about to render. The device bridge + // reads it once per frame on the render thread and injects it into the + // guest. valid=false until the XR session located a usable head pose. + ScriptedPoseSample PublishedHeadPose() const; + void NotePoseApplied(void* owner, std::uint64_t frame); + + // Diagnostics. + std::uint64_t submitted_frames() const { + return submitted_frames_.load(std::memory_order_relaxed); + } + std::uint64_t skipped_frames() const { + return skipped_frames_.load(std::memory_order_relaxed); + } + bool session_running() const { + return session_running_.load(std::memory_order_relaxed); + } + std::string runtime_name() const; + + private: + friend struct OpenXrBackendTestAccess; + struct ImageRecord { + VkImage image = VK_NULL_HANDLE; + VkDevice device = VK_NULL_HANDLE; + std::uint32_t width = 0; + std::uint32_t height = 0; + VkFormat format = VK_FORMAT_UNDEFINED; + VkImageUsageFlags usage = 0; + void* owner = nullptr; + std::uint64_t order = 0; + }; + struct EyeBinding { + bool valid = false; + std::uint64_t frame = 0; + VkImage image = VK_NULL_HANDLE; + VkFramebuffer framebuffer = VK_NULL_HANDLE; + VkImageLayout final_layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + void* owner = nullptr; + std::uint32_t width = 0; + std::uint32_t height = 0; + VkFormat format = VK_FORMAT_UNDEFINED; + }; + struct EyeSwapchain { + void* swapchain = nullptr; // XrSwapchain (opaque to keep the header XR-free) + std::vector images; + std::uint32_t acquired_index = 0; + bool image_acquired = false; + std::uint32_t width = 0; + std::uint32_t height = 0; + VkFormat format = VK_FORMAT_UNDEFINED; + }; + + bool InitializeSessionLocked(VkPhysicalDevice physical_device, + VkDevice device, + const VkDeviceCreateInfo* create_info, + std::string* error); + void DisarmLocked(); + void* ResolveFunction(const char* name) const; + void SetupDeviceAfterCreationLocked(VkPhysicalDevice physical_device, + VkDevice device, + const VkDeviceCreateInfo* create_info); + void TeardownSessionLocked(const char* reason, bool preserve_device = false); + void HandleSessionState(int state); + void HandleReferenceSpaceChange(std::int64_t change_time); + bool RecoverSessionLocked(std::uint64_t now_ns); + void PollSessionEvents(); + // Drops the pose/images of the frame currently in flight without touching + // guest ownership, so the next xrEndFrame submits no layer instead of an + // image paired with a pose that no longer describes the same origin. + void InvalidateInFlightPose(const char* reason); + void RunFrameCycle(VkQueue queue, VkDevice device); + bool CreateSwapchains(std::string* error); + void DestroySwapchains(); + bool EnsureCopyResources(VkDevice device, std::uint32_t queue_family, + std::string* error); + void DestroyCopyResources(); + // Records both eye copies (and, when the mirror is enabled, both staging + // readbacks) into one command buffer, submits once and waits on one fence. + // Replaces the previous per-eye submit+fence pair. + bool CopyEyesIntoSwapchains(VkQueue queue); + bool CaptureEyeEvidence(VkQueue queue, int eye); + ScriptedPoseSample ComputeScriptedPose(std::uint64_t frame) const; + void EnsureMirrorForExtent(std::uint32_t width, std::uint32_t height); + MirrorVkProcs MirrorProcs() const; + + mutable std::mutex mutex_; + std::atomic armed_{false}; + std::atomic recording_{false}; + std::atomic eyes_bound_{false}; + std::atomic session_running_{false}; + std::atomic submitted_frames_{0}; + std::atomic skipped_frames_{0}; + + // XR handles are stored as opaque pointers in the header; the implementation + // casts them back. This keeps vulkan-only consumers free of OpenXR headers. + void* xr_instance_ = nullptr; + std::uint64_t xr_system_ = 0; + void* xr_session_ = nullptr; + void* xr_local_space_ = nullptr; + void* xr_view_space_ = nullptr; + EyeSwapchain eye_swapchains_[2]; + int xr_blend_mode_ = 0; + // Runtime name captured at xrCreateInstance so the bind-time diagnostics line + // can report it without re-entering runtime_name() under the held mutex. + std::string runtime_name_; + + VkInstance vk_instance_ = VK_NULL_HANDLE; + VkDevice vk_device_ = VK_NULL_HANDLE; + VkPhysicalDevice vk_physical_device_ = VK_NULL_HANDLE; + std::uint32_t vk_queue_family_ = 0; + void* vk_loader_ = nullptr; + struct VkProcs; + VkProcs* vk_ = nullptr; + + VkCommandPool copy_pool_ = VK_NULL_HANDLE; + VkCommandBuffer copy_command_ = VK_NULL_HANDLE; + VkFence copy_fence_ = VK_NULL_HANDLE; + + // Optional desktop two-eye mirror (MOCKTAIL_VR_MIRROR_SHM). Publishes the + // latest complete pair through shared memory; inert when unset. + VrMirror mirror_; + + std::unordered_map images_; + std::unordered_map image_views_; + std::unordered_map> framebuffer_views_; + std::unordered_map framebuffer_render_pass_; + std::unordered_map> render_pass_final_layouts_; + std::unordered_map queue_families_; + std::uint64_t image_order_ = 0; + EyeBinding eyes_[2]; + + // Frame cycle state (render thread only). + bool frame_open_ = false; + bool frame_should_render_ = false; + // A running session is hidden in SYNCHRONIZED; shouldRender still governs rendering. + bool visibility_lost_ = false; + bool session_recovery_pending_ = false; + bool test_session_loss_injected_ = false; + std::uint64_t next_session_retry_ns_ = 0; + std::uint64_t frame_display_time_ = 0; + struct ViewState { + float position[3]; + float orientation[4]; + float fov[4]; // angleLeft, angleRight, angleUp, angleDown (radians) + bool valid; + }; + ViewState frame_views_[2] = {}; + struct PoseStorage { + float position[3] = {0.f, 0.f, 0.f}; + float orientation[4] = {0.f, 0.f, 0.f, 1.f}; + }; + PoseStorage frame_view_poses_[2]; + bool frame_views_valid_ = false; + // One-shot guard so a canted-view runtime reports the rejection reason once + // instead of once per frame per eye. + bool canted_rejection_logged_ = false; + mutable ScriptedPoseSample published_pose_; + std::uint64_t xr_frame_counter_ = 0; + std::unordered_map applied_poses_; +}; + +// Process-wide single backend owned by main. Adapter hooks resolve these +// exported C symbols; they are inert while no backend is armed. +OpenXrBackend* ActiveVrBackend(); + +} // namespace mocktail::vr + +#endif // MOCKTAIL_VR_OPENXR_BACKEND_H_ diff --git a/include/mocktail/vr/openxr_probe.h b/include/mocktail/vr/openxr_probe.h index c406ad2..8eb7681 100644 --- a/include/mocktail/vr/openxr_probe.h +++ b/include/mocktail/vr/openxr_probe.h @@ -43,6 +43,10 @@ ProbeReport ProbeOpenXr(); std::string FormatProbeReport(const ProbeReport& report); int ProbeExitCode(ProbeStatus status); +// False in builds configured without MOCKTAIL_ENABLE_VR; --vr fails closed +// early in that case instead of starting Roblox without VR support. +bool IsOpenXrSupportCompiled(); + } // namespace mocktail::vr #endif // MOCKTAIL_VR_OPENXR_PROBE_H_ diff --git a/include/mocktail/vr/roblox_vr_device_bridge.h b/include/mocktail/vr/roblox_vr_device_bridge.h new file mode 100644 index 0000000..dddc0c4 --- /dev/null +++ b/include/mocktail/vr/roblox_vr_device_bridge.h @@ -0,0 +1,113 @@ +#ifndef MOCKTAIL_VR_ROBLOX_VR_DEVICE_BRIDGE_H_ +#define MOCKTAIL_VR_ROBLOX_VR_DEVICE_BRIDGE_H_ + +#include +#include +#include + +#include "compat/build_profile.h" +#include "mocktail/status.h" + +namespace mocktail::vr { +namespace internal { +struct VrBridgeTestAccess; +} + +// Experimental native stereo bring-up for the exact Build-ID-scoped +// RBX::Graphics::DebugDeviceVR object of the guest client. +// +// The bridge interposes the state and eye getters of the exact guest build. +// Resource readiness is checked on the live object during each state call; +// initialization never dereferences cached guest object/device pointers. The +// guest owns resource destruction. Recreated objects and reused addresses +// therefore need no cache invalidation. Initialization happens only on a thread +// observed presenting. OpenXR image submission and poses are not connected yet. +class RobloxVrDeviceBridge final { +public: + using StateGetterFn = void *(*)(void *result_buffer, void *device_object); + using EyeGetterFn = void *(*)(void *device_object, int eye_index); + using EyeInitializerFn = void (*)(void *device_object, void *graphics_device); + + RobloxVrDeviceBridge() = default; + ~RobloxVrDeviceBridge(); + + RobloxVrDeviceBridge(const RobloxVrDeviceBridge &) = delete; + RobloxVrDeviceBridge &operator=(const RobloxVrDeviceBridge &) = delete; + + // Arms the bridge for an exact-build profile that carries + // vr_debug_device_bridge metadata. Claims the process-wide bridge slot. + Status Install(const compat::BuildProfile &profile); + + // Validates machine contracts at the loaded image base and interposes the + // two DebugDeviceVR vtable slots. Called once, after libroblox relocations + // and before any guest startup code runs. + Status Activate(std::uintptr_t image_base); + + // Restores the interposed vtable slots and disarms. Safe to call twice. + void Shutdown(); + + bool installed() const { return installed_; } + bool active() const { return active_.load(std::memory_order_acquire); } + + // Render-thread notification from the Vulkan adapter's host present path. + void NoteHostPresent(); + + // Guest-side hooks invoked by the interposed vtable stubs. + void OnStateGetterCall(void *result_buffer, void *device_object); + void OnEyeGetterCall(void *device_object, int eye_index, void *framebuffer); + + StateGetterFn original_state_getter() const { + return original_state_getter_.load(std::memory_order_acquire); + } + EyeGetterFn original_eye_getter() const { + return original_eye_getter_.load(std::memory_order_acquire); + } + + // Cumulative initialization diagnostics (not a count of live devices). + std::uintptr_t image_base() const { + return image_base_.load(std::memory_order_acquire); + } + int captured_device_count() const; + int initialized_device_count() const; + std::uint64_t present_count() const { + return present_count_.load(std::memory_order_relaxed); + } + std::uint64_t eye_getter_call_count(int eye_index) const; + +private: + friend struct internal::VrBridgeTestAccess; + static constexpr std::uint64_t kEyeGetterSummaryInterval = 600; + bool ValidateObjectHeader(void *self) const; + void *ValidatedGraphicsDevice(void *self) const; + void TryInitializeEyes(void *self); + bool RestoreVtableLocked(); + + mutable std::mutex mutex_; + compat::VrDebugDeviceBridgeProfile profile_{}; + bool installed_ = false; + std::atomic active_{false}; + std::atomic image_base_{0}; + std::atomic image_size_{0}; + std::uintptr_t *vtable_ = nullptr; + std::uintptr_t original_state_slot_ = 0; + std::uintptr_t original_eye_slot_ = 0; + std::atomic original_state_getter_{nullptr}; + std::atomic original_eye_getter_{nullptr}; + std::atomic present_thread_{0}; + std::atomic present_count_{0}; + std::atomic flag_warning_logged_{false}; + std::atomic captured_count_{0}; + std::atomic initialized_count_{0}; + mutable std::atomic eye_call_totals_[2] = {}; +}; + +// Process-wide single active bridge, mirroring the audio output-device +// bridge ownership model. Install claims the slot; the destructor releases +// it. legacy_runtime forwards the loaded libroblox image base through +// NotifyRobloxVrImageLoaded; it is a successful no-op while no bridge is +// armed, and fail-closed once one is. +Status NotifyRobloxVrImageLoaded(std::uintptr_t image_base); + +} // namespace mocktail::vr + +#endif // MOCKTAIL_VR_ROBLOX_VR_DEVICE_BRIDGE_H_ diff --git a/include/mocktail/vr/vr_mirror.h b/include/mocktail/vr/vr_mirror.h new file mode 100644 index 0000000..4201ffa --- /dev/null +++ b/include/mocktail/vr/vr_mirror.h @@ -0,0 +1,105 @@ +#ifndef MOCKTAIL_VR_MIRROR_H_ +#define MOCKTAIL_VR_MIRROR_H_ + +#include + +#include +#include + +#include "mocktail/vr/vr_mirror_shm.h" + +namespace mocktail::vr { + +// The subset of device/instance procs the mirror needs, supplied by the XR +// backend from its already-resolved guest-device table. Keeping this explicit +// avoids a second proc table and keeps the mirror independently testable. +struct MirrorVkProcs { + PFN_vkCreateBuffer CreateBuffer = nullptr; + PFN_vkDestroyBuffer DestroyBuffer = nullptr; + PFN_vkGetBufferMemoryRequirements GetBufferMemoryRequirements = nullptr; + PFN_vkAllocateMemory AllocateMemory = nullptr; + PFN_vkFreeMemory FreeMemory = nullptr; + PFN_vkBindBufferMemory BindBufferMemory = nullptr; + PFN_vkMapMemory MapMemory = nullptr; + PFN_vkUnmapMemory UnmapMemory = nullptr; + PFN_vkCmdCopyImageToBuffer CmdCopyImageToBuffer = nullptr; + PFN_vkCmdPipelineBarrier CmdPipelineBarrier = nullptr; + PFN_vkGetPhysicalDeviceMemoryProperties GetPhysicalDeviceMemoryProperties = + nullptr; +}; + +// Producer side of the desktop two-eye mirror. Maps a POSIX shared-memory +// segment (created with the name from MOCKTAIL_VR_MIRROR_SHM) and publishes +// the latest complete eye pair under a seqlock. It owns two persistent, +// bounded host-visible staging buffers reused across frames; the GPU->staging +// copies are recorded into the caller's single batched command buffer so the +// mirror adds no extra queue submission or fence wait. +// +// The mirror never touches the filesystem on the frame path and keeps only the +// newest pair (one latest slot), so a slow consumer drops rather than queues +// old frames. +class VrMirror { + public: + VrMirror() = default; + ~VrMirror(); + VrMirror(const VrMirror&) = delete; + VrMirror& operator=(const VrMirror&) = delete; + + bool enabled() const { return mapping_ != nullptr; } + + // Opens the shared segment. Returns false (and stays disabled) when the name + // is empty or mapping fails; the XR path then runs without a mirror. + bool Open(const std::string& shm_name, std::uint32_t width, + std::uint32_t height, std::string* error); + void Close(); + + // Ensures the two staging buffers match width*height*4 bytes on this device. + // Allocates once; a size/device change recreates them. Existing contents are + // discarded on resize. + bool EnsureStaging(const MirrorVkProcs& vk, VkDevice device, + VkPhysicalDevice physical, std::uint32_t width, + std::uint32_t height, std::string* error); + void DestroyStaging(const MirrorVkProcs& vk); + + // Records a source-eye -> staging-buffer copy for one eye into cmd, adding + // the layout barriers around it. The source is returned to final_layout so + // the next guest render pass sees it unchanged. + void RecordEyeCopy(const MirrorVkProcs& vk, VkCommandBuffer cmd, int eye, + VkImage source, VkImageLayout final_layout, + std::uint32_t width, std::uint32_t height); + + // After the batched fence wait completed, copies both staging buffers into + // the shared segment and bumps the seqlock with the frame id and a monotonic + // timestamp. No-op when staging does not cover width*height. + void Publish(std::uint64_t frame, VkFormat format, std::uint32_t width, + std::uint32_t height, std::uint64_t now_ns); + + std::uint64_t published_pairs() const { return published_pairs_; } + std::uint32_t staging_width() const { return staging_width_; } + std::uint32_t staging_height() const { return staging_height_; } + const void* mapping() const { return mapping_; } + + // Test seam: publish a pair from caller-supplied staging bytes without any + // Vulkan resources, exercising the same seqlock/segment write as the GPU + // path. Both buffers must hold width*height*4 bytes. + void PublishFromBytesForTest(std::uint64_t frame, VkFormat format, + std::uint32_t width, std::uint32_t height, + std::uint64_t now_ns, const void* eye0, + const void* eye1); + + private: + void* mapping_ = nullptr; + std::uint64_t mapping_bytes_ = 0; + std::string shm_name_; + VkBuffer staging_[2] = {VK_NULL_HANDLE, VK_NULL_HANDLE}; + VkDeviceMemory staging_memory_[2] = {VK_NULL_HANDLE, VK_NULL_HANDLE}; + void* staging_map_[2] = {nullptr, nullptr}; + std::uint32_t staging_width_ = 0; + std::uint32_t staging_height_ = 0; + VkDevice staging_device_ = VK_NULL_HANDLE; + std::uint64_t published_pairs_ = 0; +}; + +} // namespace mocktail::vr + +#endif // MOCKTAIL_VR_MIRROR_H_ diff --git a/include/mocktail/vr/vr_mirror_shm.h b/include/mocktail/vr/vr_mirror_shm.h new file mode 100644 index 0000000..db842a7 --- /dev/null +++ b/include/mocktail/vr/vr_mirror_shm.h @@ -0,0 +1,42 @@ +#ifndef MOCKTAIL_VR_MIRROR_SHM_H_ +#define MOCKTAIL_VR_MIRROR_SHM_H_ + +#include + +namespace mocktail::vr { + +// Shared-memory transport for the desktop two-eye mirror. The producer (XR +// backend, inside the client process) publishes the latest complete eye pair +// under a seqlock; the consumer (desktop viewer) reads the newest pair without +// any per-frame filesystem transport, image encode/decode, or allocation. +// +// Layout is fixed and little-endian. +// sizeof(VrMirrorHeader) == kVrMirrorHeaderBytes. +struct VrMirrorHeader { + std::uint32_t magic; // kVrMirrorMagic + std::uint32_t header_bytes; // kVrMirrorHeaderBytes (pixel area offset) + std::uint64_t seq; // seqlock: odd while writing, even when stable + std::uint64_t frame; // XR frame id the pair belongs to + std::uint32_t width; // eye width in pixels + std::uint32_t height; // eye height in pixels + std::uint32_t format; // VkFormat of the source eye images + std::uint32_t flags; // bit0: a complete, valid pair is present + std::uint64_t producer_ns; // CLOCK_MONOTONIC publish timestamp + std::uint64_t published_pairs; + std::uint64_t reserved; +}; + +inline constexpr std::uint32_t kVrMirrorMagic = 0x3152564D; // 'MVR1' +inline constexpr std::uint32_t kVrMirrorHeaderBytes = 64; +inline constexpr std::uint32_t kVrMirrorFlagValid = 1u << 0; + +// Total mapped bytes for a given eye extent (header + two RGBA/BGRA images). +inline std::uint64_t VrMirrorBytes(std::uint32_t width, std::uint32_t height) { + return static_cast(kVrMirrorHeaderBytes) + + 2ull * static_cast(width) * + static_cast(height) * 4ull; +} + +} // namespace mocktail::vr + +#endif // MOCKTAIL_VR_MIRROR_SHM_H_ diff --git a/include/mocktail/vr/vr_perf.h b/include/mocktail/vr/vr_perf.h new file mode 100644 index 0000000..6fe8ef6 --- /dev/null +++ b/include/mocktail/vr/vr_perf.h @@ -0,0 +1,97 @@ +#ifndef MOCKTAIL_VR_PERF_H_ +#define MOCKTAIL_VR_PERF_H_ + +#include +#include +#include + +namespace mocktail::vr::perf { + +// Opt-in, low-overhead stage timing and counter instrumentation. Collection is +// compiled out at runtime unless MOCKTAIL_VR_PERF is set, so the steady-state +// path only pays a single cached boolean test per record. +bool Enabled(); + +enum class Stage { + kFrameCycle, + kXrWaitFrame, + kXrBeginFrame, + kXrLocateViews, + kSwapchainAcquire, + kSwapchainWait, + kCopyRecord, + kCopySubmit, + kCopyFenceWait, + kMirrorPublish, + kEvidence, + kStageCount, +}; + +enum class Counter { + kPresents, + kFramesBegun, + kFramesSubmitted, + kFramesSkipped, + kSkipShouldRender, + kSkipPoseInvalid, + kSkipEyesUnbound, + kSkipEyeFrameMismatch, + kSubmits, + kFenceWaits, + kMirrorPairs, + kMirrorDrops, + kSkipVisibilityLost, + kReferenceSpaceChanges, + kVisibilityLost, + kVisibilityGained, + kFramesInvalidated, + kCounterCount, +}; + +const char* StageName(Stage stage); +const char* CounterName(Counter counter); + +// Bounded reservoir per stage: the most recent N samples are kept and ranked +// only when a report is emitted (once per interval), never on the frame path. +class Collector { + public: + static constexpr std::size_t kReservoir = 4096; + static constexpr std::uint64_t kIntervalNs = 1'000'000'000ULL; + + void Record(Stage stage, std::uint64_t nanos); + void Count(Counter counter, std::uint64_t delta = 1); + + // Emits one distribution line per active stage plus a counter line when at + // least kIntervalNs elapsed since the previous emission. Cheap no-op + // otherwise. now_ns is a CLOCK_MONOTONIC timestamp supplied by the caller so + // the collector stays clock-agnostic and testable. + void EmitIfDue(std::uint64_t now_ns, const char* context); + + // Test/inspection accessors. + std::uint64_t counter(Counter counter_id) const; + std::vector Percentiles(Stage stage) const; // p50,p95,p99,max + std::size_t Samples(Stage stage) const; + void Reset(); + + private: + struct StageStat { + std::vector samples; // ring, bounded by kReservoir + std::size_t head = 0; + std::size_t count = 0; + std::uint64_t total_ns = 0; + }; + StageStat stages_[static_cast(Stage::kStageCount)]{}; + std::uint64_t counters_[static_cast(Counter::kCounterCount)]{}; + std::uint64_t last_emit_ns_ = 0; + std::uint64_t window_start_ns_ = 0; +}; + +// Process-wide collector used by the XR backend frame cycle. +Collector& ProcessCollector(); + +// Monotonic nanoseconds (CLOCK_MONOTONIC), 0 on failure. +std::uint64_t NowNs(); + +} // namespace mocktail::vr::perf + +#endif // MOCKTAIL_VR_PERF_H_ diff --git a/include/mocktail/vr/vr_pose_math.h b/include/mocktail/vr/vr_pose_math.h new file mode 100644 index 0000000..e66b143 --- /dev/null +++ b/include/mocktail/vr/vr_pose_math.h @@ -0,0 +1,171 @@ +#ifndef MOCKTAIL_VR_POSE_MATH_H_ +#define MOCKTAIL_VR_POSE_MATH_H_ + +#include +#include + +namespace mocktail::vr::pose { + +// Coordinate spaces and units used across the whole camera path, documented so +// every stage is auditable: +// +// OpenXR head/views metres, right-handed, +Y up, -Z forward, LOCAL space +// -> published pose metres, quaternion (x, y, z, w), unchanged units +// -> guest state metres; the exact-build native helper converts metres +// to studs itself (factor 10/3). This layer must never +// pre-apply that scale. +// -> camera engine composes +// out.rotation = base.rotation * head.rotation +// out.position = base.position +// + base.rotation * head.position * HeadScale +// where HeadScale is an engine value distinct from the +// metres-to-studs conversion factor. +// -> render one pass per eye using the per-eye offset/FOV +// -> XR submission metres again, the very pose used to render the pair. + +struct Vec3 { + float x = 0.f, y = 0.f, z = 0.f; +}; + +struct Quat { + float x = 0.f, y = 0.f, z = 0.f, w = 1.f; +}; + +struct Pose { + Vec3 position{}; + Quat orientation{}; +}; + +inline constexpr float kPi = 3.14159265358979323846f; + +inline Quat QuatMultiply(const Quat& a, const Quat& b) { + return Quat{ + a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y, + a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x, + a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w, + a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z, + }; +} + +inline Quat QuatConjugate(const Quat& q) { return Quat{-q.x, -q.y, -q.z, q.w}; } + +inline float QuatNorm(const Quat& q) { + return std::sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w); +} + +// Returns false for a non-finite or near-zero quaternion; the output is left +// untouched in that case so callers never publish a fabricated rotation. +inline bool QuatNormalize(const Quat& in, Quat* out) { + if (out == nullptr) { + return false; + } + if (!std::isfinite(in.x) || !std::isfinite(in.y) || !std::isfinite(in.z) || + !std::isfinite(in.w)) { + return false; + } + const float norm = QuatNorm(in); + if (!(norm > 1e-6f) || !std::isfinite(norm)) { + return false; + } + const float inverse = 1.f / norm; + *out = Quat{in.x * inverse, in.y * inverse, in.z * inverse, in.w * inverse}; + return true; +} + +// q and -q denote the same rotation. Used to prove the path is sign-invariant. +inline bool QuatSameRotation(const Quat& a, const Quat& b, float tolerance) { + const float dot = a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w; + return std::abs(std::abs(dot) - 1.f) <= tolerance; +} + +inline Vec3 QuatRotate(const Quat& q, const Vec3& v) { + // t = 2 * cross(q.xyz, v); v' = v + q.w * t + cross(q.xyz, t) + const Vec3 u{q.x, q.y, q.z}; + const Vec3 cross1{u.y * v.z - u.z * v.y, u.z * v.x - u.x * v.z, + u.x * v.y - u.y * v.x}; + const Vec3 t{2.f * cross1.x, 2.f * cross1.y, 2.f * cross1.z}; + const Vec3 cross2{u.y * t.z - u.z * t.y, u.z * t.x - u.x * t.z, + u.x * t.y - u.y * t.x}; + return Vec3{v.x + q.w * t.x + cross2.x, v.y + q.w * t.y + cross2.y, + v.z + q.w * t.z + cross2.z}; +} + +// result = a * b in SE(3): apply b first, then a. +inline Pose PoseMultiply(const Pose& a, const Pose& b) { + Pose result; + result.orientation = QuatMultiply(a.orientation, b.orientation); + const Vec3 rotated = QuatRotate(a.orientation, b.position); + result.position = Vec3{a.position.x + rotated.x, a.position.y + rotated.y, + a.position.z + rotated.z}; + return result; +} + +inline Pose PoseInverse(const Pose& p) { + Pose result; + result.orientation = QuatConjugate(p.orientation); + const Vec3 neg{-p.position.x, -p.position.y, -p.position.z}; + result.position = QuatRotate(result.orientation, neg); + return result; +} + +// Rotation for a head turned by yaw about +Y then pitched about +X, matching +// the scripted diagnostic motion. Quaternion order is (x, y, z, w). +inline Quat QuatFromYawPitch(float yaw, float pitch) { + const float cy = std::cos(yaw * 0.5f), sy = std::sin(yaw * 0.5f); + const float cp = std::cos(pitch * 0.5f), sp = std::sin(pitch * 0.5f); + return Quat{cy * sp, sy * cp, -sy * sp, cy * cp}; +} + +inline Quat QuatFromAxisAngle(const Vec3& axis, float angle) { + const float half = angle * 0.5f; + const float s = std::sin(half); + return Quat{axis.x * s, axis.y * s, axis.z * s, std::cos(half)}; +} + +// Per-eye FOV as the runtime reports it: angleLeft, angleRight, angleUp, +// angleDown in radians, each signed relative to the eye's forward axis. +struct EyeFov { + float angle_left = 0.f; + float angle_right = 0.f; + float angle_up = 0.f; + float angle_down = 0.f; +}; + +// The guest state stores projection tangents, not angles. The 2998 layout is +// up, down, left, right in that order; down/left are negated because the +// guest's tangent space measures them in the opposite direction. Getting this +// order or a sign wrong flips or shears the image, so it is pinned by test. +struct ProjectionTangents { + float up = 0.f; + float down = 0.f; + float left = 0.f; + float right = 0.f; +}; + +inline ProjectionTangents FovToTangents(const EyeFov& fov) { + return ProjectionTangents{std::tan(fov.angle_up), std::tan(-fov.angle_down), + std::tan(-fov.angle_left), std::tan(fov.angle_right)}; +} + +// The pinned guest state can only represent two parallel eye orientations. +// A canted (rotated) relative eye pose needs a different camera ABI, so it is +// rejected explicitly rather than silently submitted with a projection that +// does not match how the image was rendered. +inline constexpr float kCantedTolerance = 0.001f; + +inline bool EyeOrientationIsParallel(const Quat& relative_orientation) { + return std::abs(relative_orientation.x) <= kCantedTolerance && + std::abs(relative_orientation.y) <= kCantedTolerance && + std::abs(relative_orientation.z) <= kCantedTolerance; +} + +// Metres-to-studs factor the exact-build native helper applies itself. Exposed +// only so tests can prove this layer does NOT pre-apply it. +inline constexpr float kMetresToStuds = 10.f / 3.f; + +// Expected stud value for a metre offset, for numerical unit checks. +inline float MetresToStuds(float metres) { return metres * kMetresToStuds; } + +} // namespace mocktail::vr::pose + +#endif // MOCKTAIL_VR_POSE_MATH_H_ diff --git a/include/runtime/performance_policy.h b/include/runtime/performance_policy.h index 44894eb..9d03830 100644 --- a/include/runtime/performance_policy.h +++ b/include/runtime/performance_policy.h @@ -62,6 +62,11 @@ bool MergeRuntimeClientSettingsOverrides(const FrameRatePolicy& frame_rate, std::string* merged_json, std::string* error); +// The resolved VR mode owns this flag in both client-settings channels. +bool MergeVrClientSettingsOverrides(bool enabled, std::string_view base_json, + std::string* merged_json, + std::string* error); + // Keeps the legacy test bypass disabled. The host PermissionsProtocol now // answers microphone authorization from capture configuration. No permission // query opens a recording device. diff --git a/include/runtime/runtime_config.h b/include/runtime/runtime_config.h index faa6f5c..948c6f3 100644 --- a/include/runtime/runtime_config.h +++ b/include/runtime/runtime_config.h @@ -15,6 +15,11 @@ namespace mocktail { namespace runtime { +#ifndef MOCKTAIL_VR_DEFAULT_ENABLED +#define MOCKTAIL_VR_DEFAULT_ENABLED 0 +#endif +inline constexpr bool kDefaultVrEnabled = MOCKTAIL_VR_DEFAULT_ENABLED != 0; + enum class GraphicsBackend { kAuto, kSystem, @@ -137,7 +142,7 @@ class RuntimeConfig { private: bool headless_ = false; - bool vr_enabled_ = false; + bool vr_enabled_ = kDefaultVrEnabled; bool vr_valid_ = true; std::filesystem::path roblox_library_path_ = "rbx_bin/libroblox.so"; GraphicsBackend graphics_backend_ = GraphicsBackend::kVulkan; diff --git a/scripts/vr_simulated.py b/scripts/vr_simulated.py deleted file mode 100644 index 0d4c958..0000000 --- a/scripts/vr_simulated.py +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env python3 -"""Launch the VR preview with a separately built Monado simulated runtime.""" - -import argparse -import json -import os -from pathlib import Path -import select -import shutil -import signal -import subprocess -import sys -import tempfile - - -def positive_integer(value): - number = int(value) - if not 0 < number <= 0xFFFFFFFF: - raise argparse.ArgumentTypeError("must be a positive 32-bit integer") - return number - - -def prepare_runtime(source, destination): - """Keep the development runtime beside the build, independent of /tmp.""" - source = source.resolve() - document = json.loads(source.read_text()) - library = Path(document["runtime"]["library_path"]) - if not library.is_absolute(): - library = source.parent / library - if not library.is_file(): - raise ValueError(f"Runtime library does not exist: {library}") - destination.mkdir(parents=True, exist_ok=True) - output_library = destination / "libopenxr_monado.so" - if library.resolve() != output_library.resolve(): - shutil.copy2(library, output_library) - output_manifest = destination / "runtime.json" - output_manifest.write_text(json.dumps({ - "file_format_version": "1.0.0", - "runtime": {"name": "Monado (Mocktail simulator)", - "library_path": "./libopenxr_monado.so"}, - }, indent=2) + "\n") - print(f"Prepared simulator: {output_manifest}") - - -def simulator_environment(parent, manifest, display, wayland, software): - environment = parent.copy() - # These overrides belong to this child process; no active_runtime.json is - # written and the user's WiVRn/system runtime selection remains intact. - environment.update( - XR_RUNTIME_JSON=str(manifest.resolve()), - SIMULATED_ENABLE="1", - XRT_COMPOSITOR_NULL="0", - XRT_COMPOSITOR_FORCE_XCB="1" if display else "0", - XRT_COMPOSITOR_FORCE_WAYLAND="1" if not display and wayland else "0", - XRT_COMPOSITOR_FORCE_NVIDIA="0", - XRT_COMPOSITOR_FORCE_RANDR="0", - XRT_COMPOSITOR_FORCE_WAYLAND_DIRECT="0", - XRT_COMPOSITOR_FORCE_VK_DISPLAY="-1", - XRT_COMPOSITOR_XCB_FULLSCREEN="0", - ) - environment.setdefault("XRT_COMPOSITOR_DEFAULT_FRAMERATE", "30") - if display: - environment["DISPLAY"] = display - environment.pop("WAYLAND_DISPLAY", None) - if software: - candidates = [directory / "lvp_icd.json" for directory in - (Path("/usr/share/vulkan/icd.d"), Path("/usr/local/share/vulkan/icd.d"))] - manifest_path = next((path for path in candidates if path.is_file()), None) - if manifest_path is None: - raise ValueError("Mesa Lavapipe is not installed; omit --software to use your GPU") - environment["VK_DRIVER_FILES"] = str(manifest_path) - environment.pop("VK_ICD_FILENAMES", None) - environment["XRT_COMPOSITOR_FORCE_GPU_INDEX"] = "0" - environment["XRT_COMPOSITOR_FORCE_CLIENT_GPU_INDEX"] = "0" - return environment - - -def main(): - location = Path(__file__).resolve().parent - default_build = location.parent / "build" if location.name == "scripts" else location - parser = argparse.ArgumentParser( - description="Show the Mocktail VR test scene on the desktop using a simulated Monado headset.") - parser.add_argument("--build-dir", type=Path, default=default_build, - help="Mocktail build directory (defaults to the directory of this launcher)") - parser.add_argument("--runtime", type=Path, - help="Manifest of an in-process Monado build with the simulated driver enabled") - parser.add_argument("--prepare", type=Path, metavar="MONADO_MANIFEST", - help="Copy an already built Monado runtime next to this build, then exit") - parser.add_argument("--frames", type=positive_integer, - help="Request exit after this many frames; otherwise run until Ctrl+C") - parser.add_argument("--software", action="store_true", help="Use Mesa Lavapipe instead of a physical GPU") - parser.add_argument("--headless", action="store_true", - help="Use a private Xvfb display for automated checks (defaults to 180 frames)") - args = parser.parse_args() - build = args.build_dir.resolve() - if args.prepare: - prepare_runtime(args.prepare, build / "vr-simulator") - return 0 - manifest = args.runtime or build / "vr-simulator/runtime.json" - if not manifest.is_file(): - parser.error("No simulated Monado runtime is prepared. Use --runtime /path/to/openxr_monado-dev.json " - "or --prepare /path/to/openxr_monado-dev.json with a separately built Monado runtime.") - probe = build / "mocktail-vr-probe" - if not probe.is_file(): - parser.error(f"Build mocktail_vr_probe with MOCKTAIL_ENABLE_VR=ON first: {probe}") - display = os.environ.get("DISPLAY") - wayland = os.environ.get("WAYLAND_DISPLAY") - if not args.headless and not (display or wayland): - parser.error("Run from a graphical desktop terminal to see the scene, or use --headless for an automated check.") - - xvfb = None - try: - if args.headless: - if not shutil.which("Xvfb"): - parser.error("--headless requires Xvfb") - read_fd, write_fd = os.pipe() - try: - xvfb = subprocess.Popen( - ["Xvfb", "-displayfd", str(write_fd), "-screen", "0", "1280x720x24", "-nolisten", "tcp"], - pass_fds=(write_fd,), stdout=subprocess.DEVNULL, start_new_session=True) - except OSError: - os.close(read_fd) - raise - finally: - os.close(write_fd) - with os.fdopen(read_fd) as number: - if not select.select([number], [], [], 10)[0]: - raise ValueError("Xvfb did not start within 10 seconds") - display_number = number.readline().strip() - if not display_number.isdigit(): - raise ValueError("Xvfb failed to allocate a display") - display = ":" + display_number - wayland = None - env = simulator_environment(os.environ, manifest, display, wayland, args.software) - # Keep simulator settings and runtime files private and temporary. The - # desktop's XDG_RUNTIME_DIR is retained for Wayland authentication. - with tempfile.TemporaryDirectory(prefix="mocktail-vr-simulated-") as directory: - root = Path(directory) - env["XDG_CONFIG_HOME"] = str(root / "config") - env["XDG_DATA_HOME"] = str(root / "data") - if not env.get("XDG_RUNTIME_DIR"): - env["XDG_RUNTIME_DIR"] = str(root) - command = [str(probe), "--scene"] - frames = args.frames or (180 if args.headless else None) - if frames: - command += ["--frames", str(frames)] - print(f"Simulated Monado headset: {manifest.resolve()}", flush=True) - print("The test scene will appear in a desktop window. Ctrl+C exits." if not args.headless - else "Checking the test scene on a private Xvfb display.", flush=True) - child = None - previous = {} - - def forward_signal(signum, _frame): - if child is not None and child.poll() is None: - child.send_signal(signum) - - try: - child = subprocess.Popen(command, env=env, start_new_session=True) - for signum in (signal.SIGINT, signal.SIGTERM): - previous[signum] = signal.signal(signum, forward_signal) - return child.wait() - finally: - if child is not None and child.poll() is None: - child.terminate() - try: - child.wait(timeout=10) - except subprocess.TimeoutExpired: - child.kill() - child.wait() - for signum, handler in previous.items(): - signal.signal(signum, handler) - finally: - if xvfb is not None: - xvfb.terminate() - try: - xvfb.wait(timeout=5) - except subprocess.TimeoutExpired: - xvfb.kill() - xvfb.wait() - - -if __name__ == "__main__": - try: - sys.exit(main()) - except (OSError, ValueError, KeyError) as error: - print(f"VR simulator: {error}", file=sys.stderr) - sys.exit(1) diff --git a/src/compat/build_profile.cc b/src/compat/build_profile.cc index 2e2b0eb..ee5aaa8 100644 --- a/src/compat/build_profile.cc +++ b/src/compat/build_profile.cc @@ -126,6 +126,57 @@ bool ParseOptionalFmodOutputDeviceBridge( return true; } +bool ParseOptionalVrDebugDeviceBridge( + const Json& object, + std::optional* bridge_profile) { + if (bridge_profile == nullptr) { + return false; + } + bridge_profile->reset(); + const auto field = object.find("vr_debug_device_bridge"); + if (field == object.end() || field->is_null()) { + return true; + } + if (!field->is_object()) { + return false; + } + + std::optional vtable_rva; + std::optional constructor_rva; + std::optional state_getter_rva; + std::optional eye_getter_rva; + std::optional eye_initializer_rva; + std::optional emulator_flag_storage_rva; + std::optional device_create_framebuffer_vtable_offset; + if (!ParseOptionalRva(*field, "vtable_rva", &vtable_rva) || + !ParseOptionalRva(*field, "constructor_rva", &constructor_rva) || + !ParseOptionalRva(*field, "state_getter_rva", &state_getter_rva) || + !ParseOptionalRva(*field, "eye_getter_rva", &eye_getter_rva) || + !ParseOptionalRva(*field, "eye_initializer_rva", &eye_initializer_rva) || + !ParseOptionalRva(*field, "emulator_flag_storage_rva", + &emulator_flag_storage_rva) || + !ParseOptionalRva(*field, "device_create_framebuffer_vtable_offset", + &device_create_framebuffer_vtable_offset) || + !vtable_rva.has_value() || !constructor_rva.has_value() || + !state_getter_rva.has_value() || !eye_getter_rva.has_value() || + !eye_initializer_rva.has_value() || + !emulator_flag_storage_rva.has_value() || + !device_create_framebuffer_vtable_offset.has_value()) { + return false; + } + + *bridge_profile = VrDebugDeviceBridgeProfile{ + *vtable_rva, + *constructor_rva, + *state_getter_rva, + *eye_getter_rva, + *eye_initializer_rva, + *emulator_flag_storage_rva, + *device_create_framebuffer_vtable_offset, + }; + return true; +} + } // namespace std::string_view BuildStatusName(BuildStatus status) noexcept { @@ -189,6 +240,7 @@ ProfileLookupResult FindBuildProfile(const std::string& manifest_path, const Json* reason = RequiredField(entry, "reason"); std::optional fullscreen_setter_rva; std::optional fmod_output_device_bridge; + std::optional vr_debug_device_bridge; if (version_name == nullptr || !version_name->is_string() || version_code == nullptr || !version_code->is_number_integer() || profile_build_id == nullptr || !profile_build_id->is_string() || @@ -205,7 +257,8 @@ ProfileLookupResult FindBuildProfile(const std::string& manifest_path, "user_game_settings_fullscreen_setter_rva", &fullscreen_setter_rva) || !ParseOptionalFmodOutputDeviceBridge( - entry, &fmod_output_device_bridge)) { + entry, &fmod_output_device_bridge) || + !ParseOptionalVrDebugDeviceBridge(entry, &vr_debug_device_bridge)) { return Failure("compatibility profile has missing or invalid fields"); } @@ -240,6 +293,7 @@ ProfileLookupResult FindBuildProfile(const std::string& manifest_path, allow_host_constructor_replay->get(), fullscreen_setter_rva, fmod_output_device_bridge, + vr_debug_device_bridge, reason->get_ref(), }; } diff --git a/src/graphics/bionic_vulkan_loader_adapter.cc b/src/graphics/bionic_vulkan_loader_adapter.cc index 89ac60f..7fe7f4b 100644 --- a/src/graphics/bionic_vulkan_loader_adapter.cc +++ b/src/graphics/bionic_vulkan_loader_adapter.cc @@ -121,6 +121,33 @@ VKAPI_ATTR VkResult VKAPI_CALL vkGetPhysicalDeviceSurfaceCapabilitiesKHR( VKAPI_ATTR VkResult VKAPI_CALL vkGetPhysicalDeviceSurfacePresentModesKHR( VkPhysicalDevice physical_device, VkSurfaceKHR surface, std::uint32_t* present_mode_count, VkPresentModeKHR* present_modes); +VKAPI_ATTR VkResult VKAPI_CALL vkCreateImage( + VkDevice device, const VkImageCreateInfo* create_info, + const VkAllocationCallbacks* allocator, VkImage* image); +VKAPI_ATTR void VKAPI_CALL vkDestroyImage(VkDevice device, VkImage image, + const VkAllocationCallbacks* allocator); +VKAPI_ATTR VkResult VKAPI_CALL vkCreateImageView( + VkDevice device, const VkImageViewCreateInfo* create_info, + const VkAllocationCallbacks* allocator, VkImageView* view); +VKAPI_ATTR void VKAPI_CALL +vkDestroyImageView(VkDevice device, VkImageView image_view, + const VkAllocationCallbacks* allocator); +VKAPI_ATTR VkResult VKAPI_CALL vkCreateFramebuffer( + VkDevice device, const VkFramebufferCreateInfo* create_info, + const VkAllocationCallbacks* allocator, VkFramebuffer* framebuffer); +VKAPI_ATTR void VKAPI_CALL +vkDestroyFramebuffer(VkDevice device, VkFramebuffer framebuffer, + const VkAllocationCallbacks* allocator); +VKAPI_ATTR VkResult VKAPI_CALL vkCreateRenderPass( + VkDevice device, const VkRenderPassCreateInfo* create_info, + const VkAllocationCallbacks* allocator, VkRenderPass* render_pass); +VKAPI_ATTR void VKAPI_CALL +vkDestroyRenderPass(VkDevice device, VkRenderPass render_pass, + const VkAllocationCallbacks* allocator); +VKAPI_ATTR void VKAPI_CALL +vkCmdBeginRenderPass(VkCommandBuffer command_buffer, + const VkRenderPassBeginInfo* begin_info, + VkSubpassContents contents); } namespace { @@ -130,6 +157,30 @@ using UsesDirectVulkanFn = bool (*)(); using NotePresentFn = void (*)(); using NoteHostPresentBeginFn = void (*)(); using NoteHostPresentEndFn = void (*)(std::int32_t); +using NoteVrPresentBeginFn = void (*)(); +using VrXrCreateInstanceFn = bool (*)(const VkInstanceCreateInfo*, + const VkAllocationCallbacks*, + VkInstance*, VkResult*); +using VrXrCreateDeviceFn = bool (*)(VkPhysicalDevice, + const VkDeviceCreateInfo*, + const VkAllocationCallbacks*, VkDevice*, + VkResult*); +using VrXrNoteDeviceDestroyedFn = void (*)(VkDevice); +using VrXrNoteQueueFn = void (*)(VkDevice, VkQueue, std::uint32_t, + std::uint32_t); +using VrXrRecordImageFn = void (*)(VkDevice, VkImage, + const VkImageCreateInfo*); +using VrXrRecordImageViewFn = void (*)(VkImageView, VkImage); +using VrXrRecordFramebufferFn = void (*)(VkFramebuffer, + const VkFramebufferCreateInfo*); +using VrXrRecordRenderPassFn = void (*)(VkRenderPass, + const VkRenderPassCreateInfo*); +using VrXrNoteDestroyImageFn = void (*)(VkImage); +using VrXrNoteDestroyImageViewFn = void (*)(VkImageView); +using VrXrNoteDestroyFramebufferFn = void (*)(VkFramebuffer); +using VrXrNoteRenderPassBeginFn = void (*)(VkCommandBuffer, + const VkRenderPassBeginInfo*); +using VrXrNotePresentFn = void (*)(VkQueue, VkDevice); using NoteVulkanCallBeginFn = std::uint64_t (*)(const char*); using NoteVulkanCallEndFn = void (*)(std::uint64_t, std::int32_t); using NoteSurfaceOutOfDateFn = void (*)(); @@ -160,6 +211,15 @@ struct AdapterState { PFN_vkBeginCommandBuffer begin_command_buffer = nullptr; PFN_vkEndCommandBuffer end_command_buffer = nullptr; PFN_vkResetCommandBuffer reset_command_buffer = nullptr; + PFN_vkCreateImage create_image = nullptr; + PFN_vkDestroyImage destroy_image = nullptr; + PFN_vkCreateImageView create_image_view = nullptr; + PFN_vkDestroyImageView destroy_image_view = nullptr; + PFN_vkCreateFramebuffer create_framebuffer = nullptr; + PFN_vkDestroyFramebuffer destroy_framebuffer = nullptr; + PFN_vkCreateRenderPass create_render_pass = nullptr; + PFN_vkDestroyRenderPass destroy_render_pass = nullptr; + PFN_vkCmdBeginRenderPass cmd_begin_render_pass = nullptr; }; struct QueueBinding { @@ -191,6 +251,21 @@ struct AdapterState { std::atomic note_present{nullptr}; std::atomic note_host_present_begin{nullptr}; std::atomic note_host_present_end{nullptr}; + std::atomic note_vr_present_begin{nullptr}; + std::atomic vr_create_instance{nullptr}; + std::atomic vr_create_device{nullptr}; + std::atomic vr_note_device_destroyed{nullptr}; + std::atomic vr_note_queue{nullptr}; + std::atomic vr_record_image{nullptr}; + std::atomic vr_record_image_view{nullptr}; + std::atomic vr_record_framebuffer{nullptr}; + std::atomic vr_record_render_pass{nullptr}; + std::atomic vr_note_destroy_image{nullptr}; + std::atomic vr_note_destroy_image_view{nullptr}; + std::atomic + vr_note_destroy_framebuffer{nullptr}; + std::atomic vr_note_render_pass_begin{nullptr}; + std::atomic vr_note_present{nullptr}; std::atomic note_vulkan_call_begin{nullptr}; std::atomic note_vulkan_call_end{nullptr}; std::atomic note_surface_out_of_date{nullptr}; @@ -234,6 +309,59 @@ bool EnsureInitialized() { ResolveProcessFunction( "mocktail_window_note_vulkan_host_present_end"), std::memory_order_release); + state.note_vr_present_begin.store( + ResolveProcessFunction( + "mocktail_vr_note_host_present_begin"), + std::memory_order_release); + state.vr_create_instance.store( + ResolveProcessFunction( + "mocktail_vr_xr_create_vulkan_instance"), + std::memory_order_release); + state.vr_create_device.store( + ResolveProcessFunction( + "mocktail_vr_xr_create_vulkan_device"), + std::memory_order_release); + state.vr_note_device_destroyed.store( + ResolveProcessFunction( + "mocktail_vr_xr_note_device_destroyed"), + std::memory_order_release); + state.vr_note_queue.store( + ResolveProcessFunction("mocktail_vr_xr_note_queue"), + std::memory_order_release); + state.vr_record_image.store( + ResolveProcessFunction("mocktail_vr_xr_record_image"), + std::memory_order_release); + state.vr_record_image_view.store( + ResolveProcessFunction( + "mocktail_vr_xr_record_image_view"), + std::memory_order_release); + state.vr_record_framebuffer.store( + ResolveProcessFunction( + "mocktail_vr_xr_record_framebuffer"), + std::memory_order_release); + state.vr_record_render_pass.store( + ResolveProcessFunction( + "mocktail_vr_xr_record_render_pass"), + std::memory_order_release); + state.vr_note_destroy_image.store( + ResolveProcessFunction( + "mocktail_vr_xr_note_destroy_image"), + std::memory_order_release); + state.vr_note_destroy_image_view.store( + ResolveProcessFunction( + "mocktail_vr_xr_note_destroy_image_view"), + std::memory_order_release); + state.vr_note_destroy_framebuffer.store( + ResolveProcessFunction( + "mocktail_vr_xr_note_destroy_framebuffer"), + std::memory_order_release); + state.vr_note_render_pass_begin.store( + ResolveProcessFunction( + "mocktail_vr_xr_note_render_pass_begin"), + std::memory_order_release); + state.vr_note_present.store( + ResolveProcessFunction("mocktail_vr_xr_note_present"), + std::memory_order_release); const auto call_begin = ResolveProcessFunction( "mocktail_window_note_vulkan_call_begin"); const auto call_end = ResolveProcessFunction( @@ -489,6 +617,24 @@ void RegisterHostDeviceDispatch(VkDevice device, get_device_proc_addr(device, "vkEndCommandBuffer")); dispatch.reset_command_buffer = reinterpret_cast( get_device_proc_addr(device, "vkResetCommandBuffer")); + dispatch.create_image = reinterpret_cast( + get_device_proc_addr(device, "vkCreateImage")); + dispatch.destroy_image = reinterpret_cast( + get_device_proc_addr(device, "vkDestroyImage")); + dispatch.create_image_view = reinterpret_cast( + get_device_proc_addr(device, "vkCreateImageView")); + dispatch.destroy_image_view = reinterpret_cast( + get_device_proc_addr(device, "vkDestroyImageView")); + dispatch.create_framebuffer = reinterpret_cast( + get_device_proc_addr(device, "vkCreateFramebuffer")); + dispatch.destroy_framebuffer = reinterpret_cast( + get_device_proc_addr(device, "vkDestroyFramebuffer")); + dispatch.create_render_pass = reinterpret_cast( + get_device_proc_addr(device, "vkCreateRenderPass")); + dispatch.destroy_render_pass = reinterpret_cast( + get_device_proc_addr(device, "vkDestroyRenderPass")); + dispatch.cmd_begin_render_pass = reinterpret_cast( + get_device_proc_addr(device, "vkCmdBeginRenderPass")); AdapterState& state = State(); std::lock_guard lock(state.mutex); @@ -727,6 +873,15 @@ PFN_vkVoidFunction AdapterProc(const char* name) { MOCKTAIL_VK_PROC(vkQueueWaitIdle) MOCKTAIL_VK_PROC(vkDeviceWaitIdle) MOCKTAIL_VK_PROC(vkQueuePresentKHR) + MOCKTAIL_VK_PROC(vkCreateImage) + MOCKTAIL_VK_PROC(vkDestroyImage) + MOCKTAIL_VK_PROC(vkCreateImageView) + MOCKTAIL_VK_PROC(vkDestroyImageView) + MOCKTAIL_VK_PROC(vkCreateFramebuffer) + MOCKTAIL_VK_PROC(vkDestroyFramebuffer) + MOCKTAIL_VK_PROC(vkCreateRenderPass) + MOCKTAIL_VK_PROC(vkDestroyRenderPass) + MOCKTAIL_VK_PROC(vkCmdBeginRenderPass) #undef MOCKTAIL_VK_PROC return nullptr; } @@ -758,7 +913,16 @@ bool IsDeviceAdapterProc(const char* name) { std::strcmp(name, "vkQueueBindSparse") == 0 || std::strcmp(name, "vkQueueWaitIdle") == 0 || std::strcmp(name, "vkDeviceWaitIdle") == 0 || - std::strcmp(name, "vkQueuePresentKHR") == 0); + std::strcmp(name, "vkQueuePresentKHR") == 0 || + std::strcmp(name, "vkCreateImage") == 0 || + std::strcmp(name, "vkDestroyImage") == 0 || + std::strcmp(name, "vkCreateImageView") == 0 || + std::strcmp(name, "vkDestroyImageView") == 0 || + std::strcmp(name, "vkCreateFramebuffer") == 0 || + std::strcmp(name, "vkDestroyFramebuffer") == 0 || + std::strcmp(name, "vkCreateRenderPass") == 0 || + std::strcmp(name, "vkDestroyRenderPass") == 0 || + std::strcmp(name, "vkCmdBeginRenderPass") == 0); } bool IsGlobalAdapterProc(const char* name) { @@ -988,10 +1152,12 @@ struct FpsWaitTrace { if (count == 0) { return; } - std::fprintf(stderr, " [fps] %s n=%llu avg=%llu us max=%llu us\n", name, + std::fprintf(stderr, " [fps] %s n=%llu avg=%llu us max=%llu us start_ns=%llu end_ns=%llu\n", name, static_cast(count), static_cast(total / count / 1000ULL), - static_cast(peak / 1000ULL)); + static_cast(peak / 1000ULL), + static_cast(window), + static_cast(start_ns)); } }; @@ -1028,6 +1194,16 @@ ObservedHostQueuePresent(VkQueue queue, const VkPresentInfoKHR* present_info) { if (note_begin != nullptr) { note_begin(); } + const NoteVrPresentBeginFn vr_note_begin = + state.note_vr_present_begin.load(std::memory_order_acquire); + if (vr_note_begin != nullptr) { + vr_note_begin(); + } + const VrXrNotePresentFn vr_note_present = + state.vr_note_present.load(std::memory_order_acquire); + if (vr_note_present != nullptr) { + vr_note_present(queue, HostDispatchForQueue(queue).device); + } const bool fps_trace = FpsTraceEnabled(); const std::uint64_t present_start_ns = fps_trace ? MonotonicNanos() : 0; const VkResult result = host_present(queue, present_info); @@ -1102,7 +1278,20 @@ vkCreateInstance(const VkInstanceCreateInfo* create_info, if (host_create == nullptr) { return VK_ERROR_INITIALIZATION_FAILED; } - const VkResult result = host_create(&host_info, allocator, instance); + VkResult result = VK_ERROR_INITIALIZATION_FAILED; + bool vr_handled = false; + const auto vr_create_instance = + State().vr_create_instance.load(std::memory_order_acquire); + if (vr_create_instance != nullptr) { + // The XR backend creates the instance through xrCreateVulkanInstanceKHR + // so the runtime's required instance extensions are enabled on the very + // instance Roblox renders with. + vr_handled = + vr_create_instance(&host_info, allocator, instance, &result); + } + if (!vr_handled) { + result = host_create(&host_info, allocator, instance); + } if (result == VK_SUCCESS) { AdapterState& state = State(); std::lock_guard lock(state.mutex); @@ -1124,6 +1313,10 @@ vkCreateInstance(const VkInstanceCreateInfo* create_info, VKAPI_ATTR void VKAPI_CALL vkDestroyInstance(VkInstance instance, const VkAllocationCallbacks* allocator) { + const auto note = ResolveProcessFunction( + "mocktail_vr_xr_note_instance_destroyed"); + if (note != nullptr) note(instance); + const auto host_destroy = reinterpret_cast( HostInstanceProc(instance, "vkDestroyInstance")); if (host_destroy != nullptr) { @@ -1149,6 +1342,21 @@ vkDestroyInstance(VkInstance instance, const VkAllocationCallbacks* allocator) { state.note_present.store(nullptr, std::memory_order_release); state.note_host_present_begin.store(nullptr, std::memory_order_release); state.note_host_present_end.store(nullptr, std::memory_order_release); + state.note_vr_present_begin.store(nullptr, std::memory_order_release); + state.vr_create_instance.store(nullptr, std::memory_order_release); + state.vr_create_device.store(nullptr, std::memory_order_release); + state.vr_note_device_destroyed.store(nullptr, std::memory_order_release); + state.vr_note_queue.store(nullptr, std::memory_order_release); + state.vr_record_image.store(nullptr, std::memory_order_release); + state.vr_record_image_view.store(nullptr, std::memory_order_release); + state.vr_record_framebuffer.store(nullptr, std::memory_order_release); + state.vr_record_render_pass.store(nullptr, std::memory_order_release); + state.vr_note_destroy_image.store(nullptr, std::memory_order_release); + state.vr_note_destroy_image_view.store(nullptr, std::memory_order_release); + state.vr_note_destroy_framebuffer.store(nullptr, + std::memory_order_release); + state.vr_note_render_pass_begin.store(nullptr, std::memory_order_release); + state.vr_note_present.store(nullptr, std::memory_order_release); state.note_vulkan_call_begin.store(nullptr, std::memory_order_release); state.note_vulkan_call_end.store(nullptr, std::memory_order_release); g_vulkan_call_observation_active.store(false, std::memory_order_release); @@ -1339,8 +1547,20 @@ VKAPI_ATTR VkResult VKAPI_CALL vkCreateDevice( } } - const VkResult result = - host_create(physical_device, &host_info, allocator, device); + VkResult result = VK_ERROR_INITIALIZATION_FAILED; + bool vr_handled = false; + const auto vr_create_device = + State().vr_create_device.load(std::memory_order_acquire); + if (vr_create_device != nullptr) { + // The XR backend creates the device through xrCreateVulkanDeviceKHR and + // binds the OpenXR session to it; falling back afterwards would create a + // second, XR-incompatible device, so the hook result is final. + vr_handled = vr_create_device(physical_device, &host_info, allocator, + device, &result); + } + if (!vr_handled) { + result = host_create(physical_device, &host_info, allocator, device); + } if (result != VK_SUCCESS) { return result; } @@ -1402,6 +1622,13 @@ VKAPI_ATTR void VKAPI_CALL vkDestroyDevice(VkDevice device, const VkAllocationCallbacks* allocator) { const auto host_destroy = reinterpret_cast( HostDeviceProc(device, "vkDestroyDevice")); + const auto vr_note_device_destroyed = + State().vr_note_device_destroyed.load(std::memory_order_acquire); + if (vr_note_device_destroyed != nullptr) { + // Runs while the device is still valid so the XR backend can destroy + // its session and swapchain resources first. + vr_note_device_destroyed(device); + } State().text_overlay.DestroyDevice(device); if (host_destroy != nullptr) { host_destroy(device, allocator); @@ -1423,6 +1650,11 @@ VKAPI_ATTR void VKAPI_CALL vkGetDeviceQueue(VkDevice device, RegisterHostQueueBinding(device, *queue); (void)State().text_overlay.RegisterQueue(device, *queue, queue_family_index, queue_index); + const auto vr_note_queue = + State().vr_note_queue.load(std::memory_order_acquire); + if (vr_note_queue != nullptr) { + vr_note_queue(device, *queue, queue_family_index, queue_index); + } } } @@ -1438,6 +1670,12 @@ VKAPI_ATTR void VKAPI_CALL vkGetDeviceQueue2( RegisterHostQueueBinding(device, *queue); (void)State().text_overlay.RegisterQueue( device, *queue, queue_info->queueFamilyIndex, queue_info->queueIndex); + const auto vr_note_queue = + State().vr_note_queue.load(std::memory_order_acquire); + if (vr_note_queue != nullptr) { + vr_note_queue(device, *queue, queue_info->queueFamilyIndex, + queue_info->queueIndex); + } } } @@ -2025,4 +2263,146 @@ vkQueuePresentKHR(VkQueue queue, const VkPresentInfoKHR* present_info) { return normalized_result; } -} // extern "C" +// ---- VR backend resource provenance wrappers ------------------------------- +// Thin pass-throughs to the host dispatch with optional notifications for the +// experimental OpenXR backend. Creation/destruction calls are rare; the only +// per-frame entry (vkCmdBeginRenderPass) costs one atomic load and stops +// doing work once both eye bindings are established. + +VKAPI_ATTR VkResult VKAPI_CALL vkCreateImage( + VkDevice device, const VkImageCreateInfo* create_info, + const VkAllocationCallbacks* allocator, VkImage* image) { + const PFN_vkCreateImage host_create = + HostDispatchForDevice(device).create_image; + if (host_create == nullptr) { + return VK_ERROR_INITIALIZATION_FAILED; + } + const VkResult result = host_create(device, create_info, allocator, image); + const auto record = State().vr_record_image.load(std::memory_order_acquire); + if (result == VK_SUCCESS && record != nullptr) { + record(device, *image, create_info); + } + return result; +} + +VKAPI_ATTR void VKAPI_CALL vkDestroyImage(VkDevice device, VkImage image, + const VkAllocationCallbacks* allocator) { + const auto note = State().vr_note_destroy_image.load(std::memory_order_acquire); + if (note != nullptr && image != VK_NULL_HANDLE) { + note(image); + } + const PFN_vkDestroyImage host_destroy = + HostDispatchForDevice(device).destroy_image; + if (host_destroy != nullptr) { + host_destroy(device, image, allocator); + } +} + +VKAPI_ATTR VkResult VKAPI_CALL vkCreateImageView( + VkDevice device, const VkImageViewCreateInfo* create_info, + const VkAllocationCallbacks* allocator, VkImageView* view) { + const PFN_vkCreateImageView host_create = + HostDispatchForDevice(device).create_image_view; + if (host_create == nullptr) { + return VK_ERROR_INITIALIZATION_FAILED; + } + const VkResult result = host_create(device, create_info, allocator, view); + const auto record = + State().vr_record_image_view.load(std::memory_order_acquire); + if (result == VK_SUCCESS && record != nullptr && create_info != nullptr) { + record(*view, create_info->image); + } + return result; +} + +VKAPI_ATTR void VKAPI_CALL +vkDestroyImageView(VkDevice device, VkImageView image_view, + const VkAllocationCallbacks* allocator) { + const auto note = + State().vr_note_destroy_image_view.load(std::memory_order_acquire); + if (note != nullptr && image_view != VK_NULL_HANDLE) { + note(image_view); + } + const PFN_vkDestroyImageView host_destroy = + HostDispatchForDevice(device).destroy_image_view; + if (host_destroy != nullptr) { + host_destroy(device, image_view, allocator); + } +} + +VKAPI_ATTR VkResult VKAPI_CALL vkCreateFramebuffer( + VkDevice device, const VkFramebufferCreateInfo* create_info, + const VkAllocationCallbacks* allocator, VkFramebuffer* framebuffer) { + const PFN_vkCreateFramebuffer host_create = + HostDispatchForDevice(device).create_framebuffer; + if (host_create == nullptr) { + return VK_ERROR_INITIALIZATION_FAILED; + } + const VkResult result = host_create(device, create_info, allocator, framebuffer); + const auto record = + State().vr_record_framebuffer.load(std::memory_order_acquire); + if (result == VK_SUCCESS && record != nullptr) { + record(*framebuffer, create_info); + } + return result; +} + +VKAPI_ATTR void VKAPI_CALL +vkDestroyFramebuffer(VkDevice device, VkFramebuffer framebuffer, + const VkAllocationCallbacks* allocator) { + const auto note = + State().vr_note_destroy_framebuffer.load(std::memory_order_acquire); + if (note != nullptr && framebuffer != VK_NULL_HANDLE) { + note(framebuffer); + } + const PFN_vkDestroyFramebuffer host_destroy = + HostDispatchForDevice(device).destroy_framebuffer; + if (host_destroy != nullptr) { + host_destroy(device, framebuffer, allocator); + } +} + +VKAPI_ATTR VkResult VKAPI_CALL vkCreateRenderPass( + VkDevice device, const VkRenderPassCreateInfo* create_info, + const VkAllocationCallbacks* allocator, VkRenderPass* render_pass) { + const PFN_vkCreateRenderPass host_create = + HostDispatchForDevice(device).create_render_pass; + if (host_create == nullptr) { + return VK_ERROR_INITIALIZATION_FAILED; + } + const VkResult result = host_create(device, create_info, allocator, render_pass); + const auto record = + State().vr_record_render_pass.load(std::memory_order_acquire); + if (result == VK_SUCCESS && record != nullptr) { + record(*render_pass, create_info); + } + return result; +} + +VKAPI_ATTR void VKAPI_CALL +vkDestroyRenderPass(VkDevice device, VkRenderPass render_pass, + const VkAllocationCallbacks* allocator) { + const PFN_vkDestroyRenderPass host_destroy = + HostDispatchForDevice(device).destroy_render_pass; + if (host_destroy != nullptr) { + host_destroy(device, render_pass, allocator); + } +} + +VKAPI_ATTR void VKAPI_CALL +vkCmdBeginRenderPass(VkCommandBuffer command_buffer, + const VkRenderPassBeginInfo* begin_info, + VkSubpassContents contents) { + const auto note = + State().vr_note_render_pass_begin.load(std::memory_order_acquire); + if (note != nullptr) { + note(command_buffer, begin_info); + } + const PFN_vkCmdBeginRenderPass host_begin = + HostDispatchForCommandBuffer(command_buffer).cmd_begin_render_pass; + if (host_begin != nullptr) { + host_begin(command_buffer, begin_info, contents); + } +} + +} // extern "C" \ No newline at end of file diff --git a/src/legacy/legacy_runtime.cc b/src/legacy/legacy_runtime.cc index 4c93dc3..157ad94 100644 --- a/src/legacy/legacy_runtime.cc +++ b/src/legacy/legacy_runtime.cc @@ -56,6 +56,7 @@ #include "libc_shim/libc_shim.h" #include "linker/linker.h" #include "mocktail/graphics/bionic_egl_bridge.h" +#include "mocktail/vr/roblox_vr_device_bridge.h" #include "runtime/environment.h" #include "runtime/discord_rpc.h" #include "runtime/jnivm_platform_web_callbacks.h" @@ -5610,6 +5611,20 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options, std::cout << " [linker] Bionic unwind metadata validated for libroblox\n" << std::flush; + // The experimental VR bridge is armed only for --vr on an exact supported + // Build ID. It validates its machine contracts and interposes the + // DebugDeviceVR vtable slots after relocations and before guest startup + // can create the object. A no-op success while no bridge is armed. + if (const mocktail::Status vr_activation = + mocktail::vr::NotifyRobloxVrImageLoaded( + linker::FindLoadedAndroidLibraryBase("libroblox")); + !vr_activation.ok()) { + std::cerr << "\n[FATAL] Experimental Roblox VR bridge activation " + "failed: " + << vr_activation.message() << '\n'; + return EXIT_FAILURE; + } + // The pre-constructor hook owns installation; repeating arena setup here is // not idempotent. if (HostAbiExperimentRequested() && diff --git a/src/main.cc b/src/main.cc index 1f9352c..1e7a23c 100644 --- a/src/main.cc +++ b/src/main.cc @@ -23,6 +23,9 @@ #include "mocktail/audio/fmod_jni_audio_bridge.h" #include "mocktail/audio/webrtc_jni_audio_bridge.h" #include "mocktail/audio/roblox_output_device_bridge.h" +#include "mocktail/vr/openxr_backend.h" +#include "mocktail/vr/openxr_probe.h" +#include "mocktail/vr/roblox_vr_device_bridge.h" #include "runtime/auth_runtime_composition.h" #include "runtime/command_line.h" #include "runtime/crash_report_policy.h" @@ -332,11 +335,28 @@ int main(int argc, char* argv[]) { return EXIT_FAILURE; } // The standalone OpenXR scene belongs to mocktail-vr-probe --scene. - // Continue through Roblox startup to inspect its native VR device. + // --vr continues through Roblox startup as a native stereo prototype. if (runtime_config.config.vr_enabled()) { - std::cerr << " [vr] Roblox VR initialization requested (experimental); " - "stereo eye resources and OpenXR output are not yet connected. " - "Standalone scene: mocktail-vr-probe --scene\n"; + if (!mocktail::vr::IsOpenXrSupportCompiled()) { + std::cerr << " [vr] This build does not include OpenXR support. " + "Use a VR-enabled build of Mocktail; refusing to start " + "Roblox with --vr in a non-VR build.\n"; + return EXIT_FAILURE; + } + const mocktail::vr::VrBackendMode vr_backend_mode = + mocktail::vr::ResolveVrBackendMode(true); + std::cerr << " [vr] experimental backend=" + << mocktail::vr::VrBackendModeName(vr_backend_mode) + << ": Roblox renders both eyes through its DebugDeviceVR " + "debug device (exact Build ID 2998 only)"; + if (vr_backend_mode == mocktail::vr::VrBackendMode::kXrOutput) { + std::cerr << "; eye images are submitted to the active OpenXR runtime " + "as a projection layer. Start WiVRn and connect the headset " + "before launching.\n"; + } else { + std::cerr << "; OpenXR output is disabled in this diagnostic mode " + "(MOCKTAIL_VR_BACKEND=native).\n"; + } } mocktail::runtime::SessionLog session_log; mocktail::runtime::FailureSupportBundleGuard support_bundle_guard( @@ -755,6 +775,12 @@ int main(int argc, char* argv[]) { << command_line_error << '\n'; return EXIT_FAILURE; } + if (!mocktail::runtime::MergeVrClientSettingsOverrides( + runtime_config.config.vr_enabled(), audio_capture_overrides, + &audio_capture_overrides, &command_line_error)) { + std::cerr << "[FATAL] Cannot apply VR policy: " << command_line_error << '\n'; + return EXIT_FAILURE; + } if (setenv("MOCKTAIL_CLIENT_SETTINGS_OVERRIDES_JSON", audio_capture_overrides.c_str(), 1) != 0) { std::cerr << "[FATAL] Cannot export runtime client-settings policy\n"; @@ -770,6 +796,12 @@ int main(int argc, char* argv[]) { << command_line_error << '\n'; return EXIT_FAILURE; } + if (!mocktail::runtime::MergeVrClientSettingsOverrides( + runtime_config.config.vr_enabled(), fast_flags_overrides, + &fast_flags_overrides, &command_line_error)) { + std::cerr << "[FATAL] Cannot apply VR policy: " << command_line_error << '\n'; + return EXIT_FAILURE; + } if (setenv("MOCKTAIL_FAST_FLAGS_JSON", fast_flags_overrides.c_str(), 1) != 0) { std::cerr << "[FATAL] Cannot export crash-report fast-flags policy\n"; @@ -828,6 +860,8 @@ int main(int argc, char* argv[]) { mocktail::runtime::RobloxFullscreenRuntimeBridge fullscreen_bridge; mocktail::audio::RobloxOutputDeviceBridge output_device_bridge; + mocktail::vr::RobloxVrDeviceBridge vr_device_bridge; + mocktail::vr::OpenXrBackend xr_backend; if (command_line.options.mode == mocktail::runtime::CommandMode::kRun) { const std::string compatibility_manifest = environment.GetOr("MOCKTAIL_COMPATIBILITY_MANIFEST", @@ -855,6 +889,31 @@ int main(int argc, char* argv[]) { << output_device_status.message() << '\n'; return EXIT_FAILURE; } + if (runtime_config.config.vr_enabled()) { + if (mocktail::vr::ResolveVrBackendMode(true) == + mocktail::vr::VrBackendMode::kXrOutput) { + // Fail closed and loudly when no OpenXR runtime is reachable; the + // native-stereo diagnostic mode must be requested explicitly. + const mocktail::Status xr_status = xr_backend.Arm(); + if (!xr_status.ok()) { + std::cerr << "[FATAL] Cannot arm the OpenXR backend for --vr: " + << xr_status.message() << '\n' + << " Start WiVRn, connect your headset, then run mocktail -vr again.\n" + << " For a custom runtime set XR_RUNTIME_JSON to its manifest.\n" + << " Set MOCKTAIL_VR_BACKEND=native for the " + "native-stereo diagnostic mode without a runtime.\n"; + return EXIT_FAILURE; + } + } + const mocktail::Status vr_device_status = + vr_device_bridge.Install(compatibility.profile); + if (!vr_device_status.ok()) { + std::cerr << "[FATAL] Cannot install experimental Roblox VR device " + "bridge: " + << vr_device_status.message() << '\n'; + return EXIT_FAILURE; + } + } } mocktail::legacy::RuntimeDependencies dependencies; @@ -1046,6 +1105,8 @@ int main(int argc, char* argv[]) { std::to_string(runtime_status) + "."); } support_bundle_guard.SetExitCode(runtime_status); + xr_backend.Disarm(); + vr_device_bridge.Shutdown(); output_device_bridge.Shutdown(); fullscreen_bridge.Shutdown(); memory_limit_watchdog.Stop(); diff --git a/src/runtime/command_line.cc b/src/runtime/command_line.cc index 08b9acf..9534e5b 100644 --- a/src/runtime/command_line.cc +++ b/src/runtime/command_line.cc @@ -74,8 +74,8 @@ CommandLineParseResult ParseCommandLine(int argc, const char* const argv[]) { &result.options.graphics_backend, &result.error)) { return result; } - } else if (argument == "--vr" || argument == "--no-vr") { - result.options.vr_enabled = argument == "--vr"; + } else if (argument == "--vr" || argument == "-vr" || argument == "--no-vr") { + result.options.vr_enabled = argument != "--no-vr"; } else if (argument == "--allow-unverified-build") { result.options.allow_unverified_build = true; } else if (argument == "--force-run-latest") { @@ -256,8 +256,9 @@ std::string CommandLineUsage(const std::string& program_name) { "x86_64 libroblox.so\n" << " --headless Run without creating an SDL window\n" << " --windowed Force windowed startup (default)\n" - << " --vr / --no-vr Launch Roblox in experimental VR mode or " + << " -vr / --vr / --no-vr Launch Roblox via OpenXR (WiVRn) or " "disable vr.enabled\n" + << " VR builds enable VR by default; use --no-vr to opt out.\n" << " --graphics direct-vulkan | opengl | system | " "angle-vulkan (default: direct-vulkan)\n" << " --allow-unverified-build Run a known but unverified Build-ID " diff --git a/src/runtime/performance_policy.cc b/src/runtime/performance_policy.cc index ed11940..e14c98a 100644 --- a/src/runtime/performance_policy.cc +++ b/src/runtime/performance_policy.cc @@ -367,6 +367,28 @@ bool MergeRuntimeClientSettingsOverrides(const FrameRatePolicy& frame_rate, merged_json, error); } +bool MergeVrClientSettingsOverrides(bool enabled, std::string_view base_json, + std::string* merged_json, + std::string* error) { + if (merged_json == nullptr) { + if (error != nullptr) { + *error = "VR client-settings output is required"; + } + return false; + } + auto overrides = nlohmann::json::parse(base_json.empty() ? "{}" : base_json, + nullptr, false, true); + if (overrides.is_discarded() || !overrides.is_object()) { + if (error != nullptr) { + *error = "VR client-settings input must be a JSON object"; + } + return false; + } + overrides["FFlagDebugEnableVREmulator"] = enabled ? "True" : "False"; + *merged_json = overrides.dump(); + return true; +} + bool MergeAudioCaptureClientSettingsOverrides(bool microphone_enabled, std::string_view base_json, std::string* merged_json, diff --git a/src/runtime/runtime_config.cc b/src/runtime/runtime_config.cc index 18e0913..fbd5e69 100644 --- a/src/runtime/runtime_config.cc +++ b/src/runtime/runtime_config.cc @@ -171,7 +171,7 @@ RuntimeConfig RuntimeConfig::FromEnvironment(const Environment& environment) { RuntimeConfig config; config.headless_ = LegacyEnabled(environment, "MOCKTAIL_HEADLESS"); config.vr_enabled_ = - ReadBoolean(environment, "MOCKTAIL_VR_ENABLED", false, &config.vr_valid_); + ReadBoolean(environment, "MOCKTAIL_VR_ENABLED", kDefaultVrEnabled, &config.vr_valid_); config.roblox_library_path_ = environment.GetOr( "ROBLOX_LIB_PATH", config.roblox_library_path_.string()); config.graphics_backend_name_ = environment.GetOr( diff --git a/src/runtime/runtime_config_bootstrap.cc b/src/runtime/runtime_config_bootstrap.cc index 3905077..892ffd2 100644 --- a/src/runtime/runtime_config_bootstrap.cc +++ b/src/runtime/runtime_config_bootstrap.cc @@ -67,12 +67,16 @@ graphics: # Optional presentation synchronization override: auto, on, or off. # vsync: off -vr: - # Boolean (default: false): launch the Roblox experimental VR path. - # Only Build ID 2998 is currently accepted; eye resources and OpenXR output - # are NOT yet connected. Standalone scene: mocktail-vr-probe --scene. - # Use --no-vr to launch Roblox normally. - enabled: false +# vr: + # Boolean (default: enabled in VR builds, disabled in non-VR builds). + # Experimental VR for the exact Build ID 2998 + # payload. Mocktail initializes the guest DebugDeviceVR eye resources (two + # distinct 500x500 eye framebuffers), the Roblox renderer draws both eyes + # every frame, and the frames are submitted to the active OpenXR runtime as + # a stereo projection layer with head pose (runtime-tracked, or scripted for + # desktop testing). Controllers are NOT connected yet. Standalone OpenXR + # test scene: mocktail-vr-probe --scene. Use --no-vr to launch normally. + # enabled: false performance: # Boolean (default: false): size Roblox scheduler/render queues from every diff --git a/src/vr/openxr_backend.cc b/src/vr/openxr_backend.cc new file mode 100644 index 0000000..89a3b84 --- /dev/null +++ b/src/vr/openxr_backend.cc @@ -0,0 +1,2194 @@ +#include "mocktail/vr/openxr_backend.h" +#include "mocktail/vr/vr_perf.h" +#include "mocktail/vr/vr_pose_math.h" + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +namespace mocktail::vr { +namespace { + +using Clock = std::chrono::steady_clock; + +void Log(const char* format, ...) { + va_list arguments; + va_start(arguments, format); + std::vfprintf(stderr, format, arguments); + va_end(arguments); + std::fflush(stderr); +} + +void Require(bool condition, const std::string& message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +template +T XrInfo(XrStructureType type) { + T value{}; + value.type = type; + return value; +} + +std::uint64_t EnvUnsigned(const char* name, std::uint64_t fallback) { + const char* value = std::getenv(name); + if (value == nullptr || value[0] == '\0') { + return fallback; + } + const unsigned long long parsed = std::strtoull(value, nullptr, 10); + return parsed > 0 ? static_cast(parsed) : fallback; +} + +double EnvDouble(const char* name, double fallback) { + const char* value = std::getenv(name); + if (value == nullptr || value[0] == '\0') { + return fallback; + } + return std::strtod(value, nullptr); +} + +std::string EnvString(const char* name, const char* fallback) { + const char* value = std::getenv(name); + return value != nullptr && value[0] != '\0' ? std::string(value) + : std::string(fallback); +} + +// Coordinate and pose math is shared with the unit tests through +// vr_pose_math.h, which documents every space and unit in the camera path, so +// the audited conventions cannot silently diverge from the backend. +using pose::Pose; +using pose::PoseInverse; +using pose::PoseMultiply; +using pose::Quat; +using pose::QuatFromYawPitch; +using pose::Vec3; + +Pose XrToPose(const XrPosef& pose) { + Pose result; + result.position = Vec3{pose.position.x, pose.position.y, pose.position.z}; + result.orientation = + Quat{pose.orientation.x, pose.orientation.y, pose.orientation.z, + pose.orientation.w}; + return result; +} + +// Bridge provenance exports (same-process direct linkage; the evidence layer +// resolves the identical symbols through dlsym). +extern "C" void* mocktail_vr_initializing_object(); +extern "C" bool mocktail_vr_current_eye(void** self, int* eye, + void** framebuffer); +extern "C" void mocktail_vr_consume_eye(); + +bool IsColorFormat(VkFormat format) { + switch (format) { + case VK_FORMAT_D16_UNORM: + case VK_FORMAT_D16_UNORM_S8_UINT: + case VK_FORMAT_D24_UNORM_S8_UINT: + case VK_FORMAT_D32_SFLOAT: + case VK_FORMAT_D32_SFLOAT_S8_UINT: + case VK_FORMAT_X8_D24_UNORM_PACK32: + return false; + default: + return true; + } +} + +bool FormatIsCopyCompatible(VkFormat a, VkFormat b) { + return a == b; +} + +OpenXrBackend* g_backend = nullptr; +std::mutex g_backend_slot_mutex; + +} // namespace + +struct OpenXrBackend::VkProcs { + PFN_vkGetInstanceProcAddr get_instance_proc_addr = nullptr; + PFN_vkGetDeviceProcAddr get_device_proc_addr = nullptr; + PFN_vkCreateBuffer CreateBuffer = nullptr; + PFN_vkDestroyBuffer DestroyBuffer = nullptr; + PFN_vkGetBufferMemoryRequirements GetBufferMemoryRequirements = nullptr; + PFN_vkAllocateMemory AllocateMemory = nullptr; + PFN_vkFreeMemory FreeMemory = nullptr; + PFN_vkBindBufferMemory BindBufferMemory = nullptr; + PFN_vkMapMemory MapMemory = nullptr; + PFN_vkUnmapMemory UnmapMemory = nullptr; + PFN_vkCmdCopyImageToBuffer CmdCopyImageToBuffer = nullptr; + PFN_vkCreateImageView CreateImageView = nullptr; + PFN_vkDestroyImageView DestroyImageView = nullptr; + PFN_vkCreateCommandPool CreateCommandPool = nullptr; + PFN_vkDestroyCommandPool DestroyCommandPool = nullptr; + PFN_vkAllocateCommandBuffers AllocateCommandBuffers = nullptr; + PFN_vkResetCommandBuffer ResetCommandBuffer = nullptr; + PFN_vkBeginCommandBuffer BeginCommandBuffer = nullptr; + PFN_vkEndCommandBuffer EndCommandBuffer = nullptr; + PFN_vkCmdPipelineBarrier CmdPipelineBarrier = nullptr; + PFN_vkCmdCopyImage CmdCopyImage = nullptr; + PFN_vkCmdBlitImage CmdBlitImage = nullptr; + PFN_vkCreateFence CreateFence = nullptr; + PFN_vkDestroyFence DestroyFence = nullptr; + PFN_vkResetFences ResetFences = nullptr; + PFN_vkWaitForFences WaitForFences = nullptr; + PFN_vkQueueSubmit QueueSubmit = nullptr; + PFN_vkDeviceWaitIdle DeviceWaitIdle = nullptr; + PFN_vkGetPhysicalDeviceMemoryProperties GetPhysicalDeviceMemoryProperties = + nullptr; + PFN_vkGetPhysicalDeviceQueueFamilyProperties + GetPhysicalDeviceQueueFamilyProperties = nullptr; + PFN_vkGetPhysicalDeviceProperties GetPhysicalDeviceProperties = nullptr; +}; + +namespace { + +// Records elapsed monotonic time into the opt-in perf collector. A no-op +// unless MOCKTAIL_VR_PERF is set, so the steady-state path is unaffected. +class ScopedStageTimer final { + public: + explicit ScopedStageTimer(perf::Stage stage) + : stage_(stage), start_(perf::Enabled() ? perf::NowNs() : 0) {} + ~ScopedStageTimer() { + if (start_ != 0) { + const std::uint64_t end = perf::NowNs(); + if (end >= start_) { + perf::ProcessCollector().Record(stage_, end - start_); + } + } + } + ScopedStageTimer(const ScopedStageTimer&) = delete; + ScopedStageTimer& operator=(const ScopedStageTimer&) = delete; + + private: + perf::Stage stage_; + std::uint64_t start_; +}; + +std::string XrResultText(XrInstance instance, XrResult result) { + char text[XR_MAX_RESULT_STRING_SIZE]{}; + if (instance != XR_NULL_HANDLE) { + (void)xrResultToString(instance, result, text); + } + if (!text[0]) { + return std::to_string(static_cast(result)); + } + return text; +} + +struct XrCallError : std::runtime_error { + XrResult result; + XrCallError(XrResult value, const std::string& message) + : std::runtime_error(message), result(value) {} +}; + +void CheckXr(XrInstance instance, XrResult result, const char* operation) { + if (XR_SUCCEEDED(result)) { + return; + } + std::string message = + std::string(operation) + " failed: " + XrResultText(instance, result); + throw XrCallError(result, message); +} + +} // namespace + +OpenXrBackend::~OpenXrBackend() { Disarm(); } + +OpenXrBackend* ActiveVrBackend() { + std::lock_guard lock(g_backend_slot_mutex); + return g_backend; +} + +Status OpenXrBackend::Arm() { + std::lock_guard lock(mutex_); + if (armed_.load(std::memory_order_acquire)) { + return Status::Ok(); + } + { + std::lock_guard slot_lock(g_backend_slot_mutex); + if (g_backend != nullptr && g_backend != this) { + return Status::Error(StatusCode::kFailedPrecondition, + "another OpenXR backend owns the process"); + } + g_backend = this; + } + try { + const std::string user_manifest = EnvString("XR_RUNTIME_JSON", ""); + const auto manifest = SelectVrRuntimeManifest(user_manifest, + {"/usr/local/share/openxr/1/openxr_wivrn.json", + "/usr/share/openxr/1/openxr_wivrn.json"}); + if (!manifest.empty()) { + Require(setenv("XR_RUNTIME_JSON", manifest.c_str(), 1) == 0, + "Cannot select the OpenXR runtime"); + Log(" [vr-backend] runtime manifest: %s\n", manifest.c_str()); + } else { + Log(" [vr-backend] using registered OpenXR runtime; connect the headset in WiVRn first\n"); + } + vk_loader_ = dlopen("libvulkan.so.1", RTLD_NOW | RTLD_LOCAL); + Require(vk_loader_ != nullptr, + "Cannot load the host Vulkan loader libvulkan.so.1"); + auto loader_gipa = reinterpret_cast( + dlsym(vk_loader_, "vkGetInstanceProcAddr")); + Require(loader_gipa != nullptr, "Host vkGetInstanceProcAddr is missing"); + + const char* extension = XR_KHR_VULKAN_ENABLE2_EXTENSION_NAME; + auto info = XrInfo(XR_TYPE_INSTANCE_CREATE_INFO); + std::strcpy(info.applicationInfo.applicationName, "Mocktail Roblox VR"); + std::strcpy(info.applicationInfo.engineName, "Mocktail"); + info.applicationInfo.apiVersion = XR_MAKE_VERSION(1, 0, 0); + info.enabledExtensionCount = 1; + info.enabledExtensionNames = &extension; + XrInstance instance = XR_NULL_HANDLE; + const XrResult instance_result = xrCreateInstance(&info, &instance); + if (instance_result == XR_ERROR_RUNTIME_UNAVAILABLE) { + throw std::runtime_error("xrCreateInstance: " + + VrRuntimeUnavailableHint(!user_manifest.empty(), manifest)); + } + CheckXr(XR_NULL_HANDLE, instance_result, "xrCreateInstance"); + xr_instance_ = instance; + + XrInstanceProperties properties = + XrInfo(XR_TYPE_INSTANCE_PROPERTIES); + if (XR_SUCCEEDED(xrGetInstanceProperties(instance, &properties))) { + runtime_name_ = properties.runtimeName; + Log(" [vr-backend] OpenXR runtime: %s %u.%u.%u\n", + properties.runtimeName, + XR_VERSION_MAJOR(properties.runtimeVersion), + XR_VERSION_MINOR(properties.runtimeVersion), + XR_VERSION_PATCH(properties.runtimeVersion)); + } + + auto system = XrInfo(XR_TYPE_SYSTEM_GET_INFO); + system.formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY; + const XrResult system_result = + xrGetSystem(instance, &system, &xr_system_); + if (system_result == XR_ERROR_FORM_FACTOR_UNAVAILABLE) { + // The runtime answered but exposes no HMD: with WiVRn this is the exact + // state while the server runs with no headset connected. + throw std::runtime_error( + "The OpenXR runtime is reachable but reports no connected headset " + "(XR_ERROR_FORM_FACTOR_UNAVAILABLE). With WiVRn: start the server " + "and connect the headset first; VR applications can only run while " + "the headset connection is established."); + } + CheckXr(instance, system_result, "xrGetSystem"); + Require(xr_system_ != XR_NULL_SYSTEM_ID, + "The OpenXR runtime exposes no head-mounted display system"); + + auto graphics_requirements = + XrInfo( + XR_TYPE_GRAPHICS_REQUIREMENTS_VULKAN2_KHR); + auto get_requirements = reinterpret_cast( + ResolveFunction("xrGetVulkanGraphicsRequirements2KHR")); + CheckXr(instance, + get_requirements(instance, xr_system_, &graphics_requirements), + "xrGetVulkanGraphicsRequirements2KHR"); + Log(" [vr-backend] runtime Vulkan requirement: %u.%u - %u.%u\n", + XR_VERSION_MAJOR(graphics_requirements.minApiVersionSupported), + XR_VERSION_MINOR(graphics_requirements.minApiVersionSupported), + XR_VERSION_MAJOR(graphics_requirements.maxApiVersionSupported), + XR_VERSION_MINOR(graphics_requirements.maxApiVersionSupported)); + + armed_.store(true, std::memory_order_release); + Log(" [vr-backend] armed: OpenXR instance/system ready; awaiting the " + "guest Vulkan device\n"); + return Status::Ok(); + } catch (const std::exception& error) { + DisarmLocked(); + return Status::Error(StatusCode::kUnavailable, error.what()); + } +} + +void OpenXrBackend::Disarm() { + std::lock_guard lock(mutex_); + DisarmLocked(); +} + +void OpenXrBackend::DisarmLocked() { + TeardownSessionLocked("disarm"); + if (xr_instance_ != nullptr) { + const auto instance = static_cast(xr_instance_); + (void)xrDestroyInstance(instance); + xr_instance_ = nullptr; + } + xr_system_ = 0; + runtime_name_.clear(); + vk_instance_ = VK_NULL_HANDLE; + if (vk_loader_ != nullptr) { + dlclose(vk_loader_); + vk_loader_ = nullptr; + } + armed_.store(false, std::memory_order_release); + recording_.store(false, std::memory_order_release); + eyes_bound_.store(false, std::memory_order_release); + { + std::lock_guard slot_lock(g_backend_slot_mutex); + if (g_backend == this) { + g_backend = nullptr; + } + } +} + +std::string OpenXrBackend::runtime_name() const { + std::lock_guard lock(mutex_); + // Captured once at xrCreateInstance; reporting the cached value avoids a + // second xrGetInstanceProperties call and re-entrancy from the bind-time + // diagnostics that already hold the mutex. + return runtime_name_; +} + +void* OpenXrBackend::ResolveFunction(const char* name) const { + PFN_xrVoidFunction function = nullptr; + CheckXr(static_cast(xr_instance_), + xrGetInstanceProcAddr(static_cast(xr_instance_), name, + &function), + name); + Require(function != nullptr, std::string("Missing OpenXR function: ") + name); + return reinterpret_cast(function); +} + +bool OpenXrBackend::CreateVulkanInstance( + const VkInstanceCreateInfo* create_info, + const VkAllocationCallbacks* allocator, VkInstance* instance, + VkResult* result) { + std::lock_guard lock(mutex_); + if (!armed_.load(std::memory_order_acquire) || xr_instance_ == nullptr || + vk_loader_ == nullptr) { + return false; + } + try { + auto loader_gipa = reinterpret_cast( + dlsym(vk_loader_, "vkGetInstanceProcAddr")); + Require(loader_gipa != nullptr, "Host vkGetInstanceProcAddr is missing"); + auto xr_create = reinterpret_cast( + ResolveFunction("xrCreateVulkanInstanceKHR")); + auto xr_info = XrInfo( + XR_TYPE_VULKAN_INSTANCE_CREATE_INFO_KHR); + xr_info.systemId = xr_system_; + xr_info.pfnGetInstanceProcAddr = loader_gipa; + xr_info.vulkanCreateInfo = create_info; + VkInstance created = VK_NULL_HANDLE; + VkResult vk_result = VK_ERROR_INITIALIZATION_FAILED; + CheckXr(static_cast(xr_instance_), + xr_create(static_cast(xr_instance_), &xr_info, + &created, &vk_result), + "xrCreateVulkanInstanceKHR"); + if (vk_result != VK_SUCCESS) { + *result = vk_result; + *instance = VK_NULL_HANDLE; + return true; + } + vk_instance_ = created; + *instance = created; + *result = VK_SUCCESS; + Log(" [vr-backend] guest VkInstance created through the OpenXR runtime\n"); + return true; + } catch (const std::exception& error) { + Log(" [vr-backend] XR instance creation hook failed: %s; falling back " + "to the plain host loader path\n", + error.what()); + return false; + } +} + +bool OpenXrBackend::CreateVulkanDevice(VkPhysicalDevice physical_device, + const VkDeviceCreateInfo* create_info, + const VkAllocationCallbacks* allocator, + VkDevice* device, VkResult* result) { + std::lock_guard lock(mutex_); + if (!armed_.load(std::memory_order_acquire) || xr_instance_ == nullptr || + vk_instance_ == VK_NULL_HANDLE || vk_loader_ == nullptr) { + return false; + } + try { + auto loader_gipa = reinterpret_cast( + dlsym(vk_loader_, "vkGetInstanceProcAddr")); + Require(loader_gipa != nullptr, "Host vkGetInstanceProcAddr is missing"); + + auto get_graphics_device = + reinterpret_cast( + ResolveFunction("xrGetVulkanGraphicsDevice2KHR")); + auto device_get_info = XrInfo( + XR_TYPE_VULKAN_GRAPHICS_DEVICE_GET_INFO_KHR); + device_get_info.systemId = xr_system_; + device_get_info.vulkanInstance = vk_instance_; + VkPhysicalDevice runtime_device = VK_NULL_HANDLE; + CheckXr(static_cast(xr_instance_), + get_graphics_device(static_cast(xr_instance_), + &device_get_info, &runtime_device), + "xrGetVulkanGraphicsDevice2KHR"); + if (runtime_device != physical_device) { + *device = VK_NULL_HANDLE; + *result = VK_ERROR_INCOMPATIBLE_DRIVER; + Log(" [vr-backend] refusing a guest GPU different from the OpenXR GPU\n"); + return true; + } + + auto xr_create = reinterpret_cast( + ResolveFunction("xrCreateVulkanDeviceKHR")); + auto xr_info = XrInfo( + XR_TYPE_VULKAN_DEVICE_CREATE_INFO_KHR); + xr_info.systemId = xr_system_; + xr_info.pfnGetInstanceProcAddr = loader_gipa; + xr_info.vulkanPhysicalDevice = physical_device; + xr_info.vulkanCreateInfo = create_info; + VkDevice created = VK_NULL_HANDLE; + VkResult vk_result = VK_ERROR_INITIALIZATION_FAILED; + CheckXr(static_cast(xr_instance_), + xr_create(static_cast(xr_instance_), &xr_info, &created, + &vk_result), + "xrCreateVulkanDeviceKHR"); + if (vk_result != VK_SUCCESS) { + *result = vk_result; + *device = VK_NULL_HANDLE; + return true; + } + *device = created; + *result = VK_SUCCESS; + // From here the runtime-created device is the guest device; never fall + // back to a second plain vkCreateDevice. Session setup failures degrade + // to the native-stereo diagnostic path with an explicit log. + try { + SetupDeviceAfterCreationLocked(physical_device, created, create_info); + } catch (const std::exception& setup_error) { + Log(" [vr-backend] XR OUTPUT UNAVAILABLE: %s. Roblox continues in " + "native-stereo diagnostic mode.\n", + setup_error.what()); + } + return true; + } catch (const std::exception& error) { + Log(" [vr-backend] XR device creation hook failed: %s; falling back to " + "the plain host loader path\n", + error.what()); + return false; + } +} + +void OpenXrBackend::SetupDeviceAfterCreationLocked( + VkPhysicalDevice physical_device, VkDevice created, + const VkDeviceCreateInfo* create_info) { + vk_device_ = created; + vk_physical_device_ = physical_device; + auto loader_gipa = reinterpret_cast( + dlsym(vk_loader_, "vkGetInstanceProcAddr")); + Require(loader_gipa != nullptr, "Host vkGetInstanceProcAddr is missing"); + delete vk_; + vk_ = new VkProcs(); + vk_->get_instance_proc_addr = loader_gipa; + vk_->get_device_proc_addr = + reinterpret_cast(loader_gipa( + vk_instance_, "vkGetDeviceProcAddr")); + Require(vk_->get_device_proc_addr != nullptr, + "Host vkGetDeviceProcAddr is missing"); +#define MOCKTAIL_BACKEND_VK_FN(name) \ + vk_->name = reinterpret_cast( \ + vk_->get_device_proc_addr(created, "vk" #name)); \ + Require(vk_->name != nullptr, "Missing Vulkan function: vk" #name); + MOCKTAIL_BACKEND_VK_FN(CreateBuffer) + MOCKTAIL_BACKEND_VK_FN(DestroyBuffer) + MOCKTAIL_BACKEND_VK_FN(GetBufferMemoryRequirements) + MOCKTAIL_BACKEND_VK_FN(AllocateMemory) + MOCKTAIL_BACKEND_VK_FN(FreeMemory) + MOCKTAIL_BACKEND_VK_FN(BindBufferMemory) + MOCKTAIL_BACKEND_VK_FN(MapMemory) + MOCKTAIL_BACKEND_VK_FN(UnmapMemory) + MOCKTAIL_BACKEND_VK_FN(CmdCopyImageToBuffer) + MOCKTAIL_BACKEND_VK_FN(CreateImageView) + MOCKTAIL_BACKEND_VK_FN(DestroyImageView) + MOCKTAIL_BACKEND_VK_FN(CreateCommandPool) + MOCKTAIL_BACKEND_VK_FN(DestroyCommandPool) + MOCKTAIL_BACKEND_VK_FN(AllocateCommandBuffers) + MOCKTAIL_BACKEND_VK_FN(ResetCommandBuffer) + MOCKTAIL_BACKEND_VK_FN(BeginCommandBuffer) + MOCKTAIL_BACKEND_VK_FN(EndCommandBuffer) + MOCKTAIL_BACKEND_VK_FN(CmdPipelineBarrier) + MOCKTAIL_BACKEND_VK_FN(CmdCopyImage) + MOCKTAIL_BACKEND_VK_FN(CmdBlitImage) + MOCKTAIL_BACKEND_VK_FN(CreateFence) + MOCKTAIL_BACKEND_VK_FN(DestroyFence) + MOCKTAIL_BACKEND_VK_FN(ResetFences) + MOCKTAIL_BACKEND_VK_FN(WaitForFences) + MOCKTAIL_BACKEND_VK_FN(QueueSubmit) + MOCKTAIL_BACKEND_VK_FN(DeviceWaitIdle) +#undef MOCKTAIL_BACKEND_VK_FN +#define MOCKTAIL_BACKEND_VK_INSTANCE_FN(name) \ + vk_->name = reinterpret_cast(loader_gipa( \ + vk_instance_, "vk" #name)); \ + Require(vk_->name != nullptr, "Missing Vulkan function: vk" #name); + MOCKTAIL_BACKEND_VK_INSTANCE_FN(GetPhysicalDeviceMemoryProperties) + MOCKTAIL_BACKEND_VK_INSTANCE_FN(GetPhysicalDeviceQueueFamilyProperties) + MOCKTAIL_BACKEND_VK_INSTANCE_FN(GetPhysicalDeviceProperties) +#undef MOCKTAIL_BACKEND_VK_INSTANCE_FN + + VkPhysicalDeviceProperties device_properties{}; + vk_->GetPhysicalDeviceProperties(physical_device, &device_properties); + + std::string error; + if (!InitializeSessionLocked(physical_device, created, create_info, + &error)) { + Log(" [vr-backend] XR OUTPUT UNAVAILABLE on GPU '%s': %s. Roblox " + "continues in native-stereo diagnostic mode.\n", + device_properties.deviceName, error.c_str()); + return; + } + // One-time plain-text diagnostics required for headset acceptance: which + // runtime answered, which pose source drives the camera, and which GPU owns + // the session. No credentials, tokens or launch parameters are included. + Log(" [vr-backend] XR session bound to guest VkDevice: gpu=%s runtime=%s " + "pose_source=%s\n", + device_properties.deviceName, runtime_name_.c_str(), + EnvString("MOCKTAIL_VR_POSE_SOURCE", "runtime").c_str()); +} + +bool OpenXrBackend::InitializeSessionLocked( + VkPhysicalDevice physical_device, VkDevice device, + const VkDeviceCreateInfo* create_info, std::string* error) { + try { + // Pick the queue family the guest gave graphics queues on; the XR binding + // must name a family of this very device. + std::uint32_t family_count = 0; + vk_->GetPhysicalDeviceQueueFamilyProperties(physical_device, &family_count, + nullptr); + Require(family_count > 0 && family_count <= 256, + "the GPU has no usable queue families"); + std::vector families(family_count); + vk_->GetPhysicalDeviceQueueFamilyProperties(physical_device, &family_count, + families.data()); + std::uint32_t chosen_family = create_info ? UINT32_MAX : vk_queue_family_; + for (std::uint32_t index = 0; create_info && index < create_info->queueCreateInfoCount; + ++index) { + const VkDeviceQueueCreateInfo& queue_info = + create_info->pQueueCreateInfos[index]; + if (queue_info.queueFamilyIndex >= family_count || + queue_info.queueCount == 0) { + continue; + } + if (families[queue_info.queueFamilyIndex].queueFlags & + VK_QUEUE_GRAPHICS_BIT) { + chosen_family = queue_info.queueFamilyIndex; + break; + } + } + Require(chosen_family < family_count && (families[chosen_family].queueFlags & VK_QUEUE_GRAPHICS_BIT), + "the guest device requested no graphics queue family"); + vk_queue_family_ = chosen_family; + + auto binding = XrInfo( + XR_TYPE_GRAPHICS_BINDING_VULKAN2_KHR); + binding.instance = vk_instance_; + binding.physicalDevice = physical_device; + binding.device = device; + binding.queueFamilyIndex = chosen_family; + binding.queueIndex = 0; + auto session_info = + XrInfo(XR_TYPE_SESSION_CREATE_INFO); + session_info.next = &binding; + session_info.systemId = xr_system_; + XrSession session = XR_NULL_HANDLE; + CheckXr(static_cast(xr_instance_), + xrCreateSession(static_cast(xr_instance_), + &session_info, &session), + "xrCreateSession"); + xr_session_ = session; + + auto space_info = XrInfo( + XR_TYPE_REFERENCE_SPACE_CREATE_INFO); + space_info.poseInReferenceSpace.orientation.w = 1; + space_info.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL; + XrSpace local_space = XR_NULL_HANDLE; + CheckXr(static_cast(xr_instance_), + xrCreateReferenceSpace(session, &space_info, &local_space), + "xrCreateReferenceSpace LOCAL"); + xr_local_space_ = local_space; + space_info.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW; + XrSpace view_space = XR_NULL_HANDLE; + CheckXr(static_cast(xr_instance_), + xrCreateReferenceSpace(session, &space_info, &view_space), + "xrCreateReferenceSpace VIEW"); + xr_view_space_ = view_space; + + auto system_properties = + XrInfo(XR_TYPE_SYSTEM_PROPERTIES); + CheckXr(static_cast(xr_instance_), + xrGetSystemProperties(static_cast(xr_instance_), + xr_system_, &system_properties), + "xrGetSystemProperties"); + Log(" [vr-backend] system '%s' max swapchain %ux%u views=%u\n", + system_properties.systemName, + system_properties.graphicsProperties.maxSwapchainImageWidth, + system_properties.graphicsProperties.maxSwapchainImageHeight, + system_properties.graphicsProperties.maxLayerCount); + + std::uint32_t blend_count = 0; + CheckXr(static_cast(xr_instance_), + xrEnumerateEnvironmentBlendModes( + static_cast(xr_instance_), xr_system_, + XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, 0, &blend_count, + nullptr), + "xrEnumerateEnvironmentBlendModes count"); + Require(blend_count > 0 && blend_count <= 16, + "the runtime exposes no environment blend modes"); + std::array blends{}; + CheckXr(static_cast(xr_instance_), + xrEnumerateEnvironmentBlendModes( + static_cast(xr_instance_), xr_system_, + XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, blend_count, + &blend_count, blends.data()), + "xrEnumerateEnvironmentBlendModes"); + xr_blend_mode_ = XR_ENVIRONMENT_BLEND_MODE_OPAQUE; + for (std::uint32_t index = 0; index < blend_count; ++index) { + if (blends[index] == XR_ENVIRONMENT_BLEND_MODE_OPAQUE) { + xr_blend_mode_ = XR_ENVIRONMENT_BLEND_MODE_OPAQUE; + break; + } + xr_blend_mode_ = static_cast(blends[index]); + } + + if (!CreateSwapchains(error)) { + TeardownSessionLocked("swapchain failure", session_recovery_pending_); + return false; + } + recording_.store(true, std::memory_order_release); + return true; + } catch (const std::exception& exception) { + *error = exception.what(); + TeardownSessionLocked("session init failure", session_recovery_pending_); + return false; + } +} + +bool OpenXrBackend::CreateSwapchains(std::string* error) { + try { + const auto session = static_cast(xr_session_); + std::uint32_t format_count = 0; + CheckXr(static_cast(xr_instance_), + xrEnumerateSwapchainFormats(session, 0, &format_count, nullptr), + "xrEnumerateSwapchainFormats count"); + Require(format_count > 0 && format_count <= 256, + "the runtime exposes no swapchain formats"); + std::vector formats(format_count); + CheckXr(static_cast(xr_instance_), + xrEnumerateSwapchainFormats(session, format_count, &format_count, + formats.data()), + "xrEnumerateSwapchainFormats"); + // The swapchain format must accept the guest eye content. Prefer the + // common 8-bit formats; the final choice is cross-checked against the + // recorded eye images before the first copy (blit converts if needed). + VkFormat chosen = VK_FORMAT_UNDEFINED; + for (const auto candidate : + {VK_FORMAT_B8G8R8A8_UNORM, VK_FORMAT_R8G8B8A8_UNORM, + VK_FORMAT_B8G8R8A8_SRGB, VK_FORMAT_R8G8B8A8_SRGB}) { + if (std::find(formats.begin(), formats.end(), + static_cast(candidate)) != formats.end()) { + chosen = candidate; + break; + } + } + Require(chosen != VK_FORMAT_UNDEFINED, + "the runtime exposes no 8-bit RGBA swapchain format"); + + // Default eye extent until the guest eye images are bound; the simulator + // runtime advertises 500x500-capable limits, and DebugDeviceVR renders + // 500x500. Re-created lazily if the recorded eye extent differs. + const std::uint32_t width = 500; + const std::uint32_t height = 500; + for (int eye = 0; eye < 2; ++eye) { + auto create_info = + XrInfo(XR_TYPE_SWAPCHAIN_CREATE_INFO); + create_info.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | + XR_SWAPCHAIN_USAGE_TRANSFER_DST_BIT; + if (!EnvString("MOCKTAIL_VR_XR_EVIDENCE_DIR", "").empty()) + create_info.usageFlags |= XR_SWAPCHAIN_USAGE_TRANSFER_SRC_BIT; + create_info.format = static_cast(chosen); + create_info.sampleCount = 1; + create_info.width = width; + create_info.height = height; + create_info.faceCount = 1; + create_info.arraySize = 1; + create_info.mipCount = 1; + XrSwapchain swapchain = XR_NULL_HANDLE; + CheckXr(static_cast(xr_instance_), + xrCreateSwapchain(session, &create_info, &swapchain), + "xrCreateSwapchain"); + std::uint32_t image_count = 0; + CheckXr(static_cast(xr_instance_), + xrEnumerateSwapchainImages(swapchain, 0, &image_count, nullptr), + "xrEnumerateSwapchainImages count"); + Require(image_count > 0 && image_count <= 16, + "the runtime returned an empty swapchain"); + std::vector images(image_count); + for (auto& image : images) { + image.type = XR_TYPE_SWAPCHAIN_IMAGE_VULKAN_KHR; + } + CheckXr(static_cast(xr_instance_), + xrEnumerateSwapchainImages( + swapchain, image_count, &image_count, + reinterpret_cast(images.data())), + "xrEnumerateSwapchainImages"); + EyeSwapchain& slot = eye_swapchains_[eye]; + slot.swapchain = swapchain; + slot.images.clear(); + for (const auto& image : images) { + slot.images.push_back(image.image); + } + slot.width = width; + slot.height = height; + slot.format = chosen; + Log(" [vr-backend] eye%d swapchain: %ux%u format=%d images=%u\n", eye, + width, height, static_cast(chosen), image_count); + } + return true; + } catch (const std::exception& exception) { + *error = exception.what(); + return false; + } +} + +void OpenXrBackend::DestroySwapchains() { + for (EyeSwapchain& slot : eye_swapchains_) { + slot.images.clear(); + if (slot.swapchain != nullptr) { + (void)xrDestroySwapchain(static_cast(slot.swapchain)); + slot.swapchain = nullptr; + } + slot.image_acquired = false; + slot.acquired_index = 0; + } +} + +void OpenXrBackend::TeardownSessionLocked(const char* reason, bool preserve_device) { + if (xr_session_ != nullptr) { + const auto session = static_cast(xr_session_); + if (frame_open_) { + auto end = XrInfo(XR_TYPE_FRAME_END_INFO); + end.displayTime = frame_display_time_; + end.environmentBlendMode = static_cast(xr_blend_mode_); + (void)xrEndFrame(session, &end); + frame_open_ = false; + } + // Destruction also ends a running session; xrEndSession is legal only in + // STOPPING (handled by PollSessionEvents). + if (vk_ && vk_device_) { + (void)vk_->DeviceWaitIdle(vk_device_); + // Mirror staging lives on this device; free it before the device goes. + mirror_.DestroyStaging(MirrorProcs()); + } + mirror_.Close(); + DestroyCopyResources(); + DestroySwapchains(); + if (xr_view_space_ != nullptr) { + (void)xrDestroySpace(static_cast(xr_view_space_)); + xr_view_space_ = nullptr; + } + if (xr_local_space_ != nullptr) { + (void)xrDestroySpace(static_cast(xr_local_space_)); + xr_local_space_ = nullptr; + } + (void)xrDestroySession(session); + xr_session_ = nullptr; + Log(" [vr-backend] XR session torn down (%s)\n", reason); + } + session_running_.store(false, std::memory_order_relaxed); + recording_.store(false, std::memory_order_release); + eyes_bound_.store(false, std::memory_order_release); + frame_open_ = false; + frame_views_valid_ = false; + visibility_lost_ = false; + canted_rejection_logged_ = false; + eyes_[0] = {}; + eyes_[1] = {}; + applied_poses_.clear(); + published_pose_.valid = false; + if (preserve_device) { + // Resource callbacks must continue tracking destruction/recreation while + // disconnected. Keep provenance, never resurrect a destroyed guest image. + recording_.store(true, std::memory_order_release); + session_recovery_pending_ = true; + return; + } + session_recovery_pending_ = false; + next_session_retry_ns_ = 0; + images_.clear(); + image_views_.clear(); + framebuffer_views_.clear(); + framebuffer_render_pass_.clear(); + render_pass_final_layouts_.clear(); + vk_device_ = VK_NULL_HANDLE; + vk_physical_device_ = VK_NULL_HANDLE; + queue_families_.clear(); + delete vk_; + vk_ = nullptr; +} + +void OpenXrBackend::NoteInstanceDestroyed(VkInstance instance) { + std::lock_guard lock(mutex_); + if (instance == vk_instance_) { + TeardownSessionLocked("guest VkInstance destroyed"); + vk_instance_ = VK_NULL_HANDLE; + } +} + +void OpenXrBackend::NoteDeviceDestroyed(VkDevice device) { + std::lock_guard lock(mutex_); + if (device != VK_NULL_HANDLE && device == vk_device_) { + // Tear down while the device is still valid; the adapter notifies before + // the host destroy call. + TeardownSessionLocked("guest VkDevice destroyed"); + } +} + +void OpenXrBackend::NoteQueue(VkDevice device, VkQueue queue, + std::uint32_t family, std::uint32_t index) { + static_cast(index); + std::lock_guard lock(mutex_); + if (device == vk_device_ && queue != VK_NULL_HANDLE) { + queue_families_[queue] = family; + } +} + +void OpenXrBackend::RecordImage(VkDevice device, VkImage image, + const VkImageCreateInfo* info) { + if (!recording_.load(std::memory_order_acquire) || info == nullptr) { + return; + } + void* owner = mocktail_vr_initializing_object(); + if (owner == nullptr) { + return; + } + std::lock_guard lock(mutex_); + ImageRecord record{}; + record.image = image; + record.device = device; + record.width = info->extent.width; + record.height = info->extent.height; + record.format = info->format; + record.usage = info->usage; + record.owner = owner; + record.order = ++image_order_; + images_[image] = record; +} + +void OpenXrBackend::RecordImageView(VkImageView view, VkImage image) { + if (!recording_.load(std::memory_order_acquire)) { + return; + } + std::lock_guard lock(mutex_); + image_views_[view] = image; +} + +void OpenXrBackend::RecordFramebuffer(VkFramebuffer framebuffer, + const VkFramebufferCreateInfo* info) { + if (!recording_.load(std::memory_order_acquire) || info == nullptr) { + return; + } + std::lock_guard lock(mutex_); + std::vector views; + views.reserve(info->attachmentCount); + for (std::uint32_t index = 0; index < info->attachmentCount; ++index) { + views.push_back(info->pAttachments[index]); + } + framebuffer_views_[framebuffer] = std::move(views); + framebuffer_render_pass_[framebuffer] = info->renderPass; +} + +void OpenXrBackend::RecordRenderPass(VkRenderPass render_pass, + const VkRenderPassCreateInfo* info) { + if (!recording_.load(std::memory_order_acquire) || info == nullptr) { + return; + } + std::lock_guard lock(mutex_); + std::vector layouts; + layouts.reserve(info->attachmentCount); + for (std::uint32_t index = 0; index < info->attachmentCount; ++index) { + layouts.push_back(info->pAttachments[index].finalLayout); + } + render_pass_final_layouts_[render_pass] = std::move(layouts); +} + +void OpenXrBackend::NoteDestroyImage(VkImage image) { + if (!recording_.load(std::memory_order_acquire)) { + return; + } + std::lock_guard lock(mutex_); + images_.erase(image); + for (int index = 0; index < 2; ++index) { + if (eyes_[index].valid && eyes_[index].image == image) { + Log(" [vr-backend] eye%d image destroyed; eye binding reset\n", index); + eyes_[index] = {}; + eyes_bound_.store(false, std::memory_order_release); + } + } +} + +void OpenXrBackend::NoteDestroyImageView(VkImageView view) { + if (!recording_.load(std::memory_order_acquire)) { + return; + } + std::lock_guard lock(mutex_); + image_views_.erase(view); +} + +void OpenXrBackend::NoteDestroyFramebuffer(VkFramebuffer framebuffer) { + if (!recording_.load(std::memory_order_acquire)) { + return; + } + std::lock_guard lock(mutex_); + framebuffer_views_.erase(framebuffer); + framebuffer_render_pass_.erase(framebuffer); + for (int index = 0; index < 2; ++index) { + if (eyes_[index].valid && eyes_[index].framebuffer == framebuffer) { + Log(" [vr-backend] eye%d framebuffer destroyed; eye binding reset\n", + index); + eyes_[index] = {}; + eyes_bound_.store(false, std::memory_order_release); + } + } +} + +void OpenXrBackend::NoteRenderPassBegin(VkCommandBuffer command_buffer, + const VkRenderPassBeginInfo* info) { + static_cast(command_buffer); + if (!recording_.load(std::memory_order_acquire) || info == nullptr) { + return; + } + std::lock_guard lock(mutex_); + void* owner = nullptr; + void* guest_framebuffer = nullptr; + int eye_index = -1; + if (!mocktail_vr_current_eye(&owner, &eye_index, &guest_framebuffer) || + eye_index < 0 || eye_index > 1) { + return; + } + const auto attachments = framebuffer_views_.find(info->framebuffer); + if (attachments == framebuffer_views_.end()) { + return; + } + const ImageRecord* target = nullptr; + bool ambiguous = false; + std::size_t target_attachment = 0; + for (std::size_t index = 0; index < attachments->second.size(); ++index) { + const auto source = image_views_.find(attachments->second[index]); + if (source == image_views_.end()) { + continue; + } + const auto image = images_.find(source->second); + if (image == images_.end() || image->second.owner != owner || image->second.device != vk_device_ || + !IsColorFormat(image->second.format) || + (image->second.usage & VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT) == 0 || + (image->second.usage & VK_IMAGE_USAGE_TRANSFER_SRC_BIT) == 0) { + continue; + } + if (target != nullptr) { + ambiguous = true; + } + target = &image->second; + target_attachment = index; + } + if (target == nullptr || ambiguous) { + return; + } + const auto layouts = render_pass_final_layouts_.find(info->renderPass); + if (layouts == render_pass_final_layouts_.end() || + target_attachment >= layouts->second.size() || + layouts->second[target_attachment] == VK_IMAGE_LAYOUT_UNDEFINED) return; + EyeBinding& binding = eyes_[eye_index]; + if ((eyes_[0].valid && eyes_[0].owner != owner) || + (eyes_[1].valid && eyes_[1].owner != owner)) { + // A new DebugDeviceVR generation replaced the old one; drop both eyes and + // rebind from the live provenance. + eyes_[0] = {}; + eyes_[1] = {}; + eyes_bound_.store(false, std::memory_order_release); + } + const bool changed = !binding.valid || binding.image != target->image || + binding.framebuffer != info->framebuffer; + binding.valid = true; + binding.frame = published_pose_.frame; + binding.image = target->image; + binding.framebuffer = info->framebuffer; + binding.owner = owner; + binding.width = target->width; + binding.height = target->height; + binding.format = target->format; + binding.final_layout = layouts->second[target_attachment]; + if (changed) Log(" [vr-backend][evidence] eye-bound eye=%d owner=%p guest_fb=%p " + "vk_fb=%p vk_image=%p extent=%ux%u format=%d final_layout=%d " + "provenance=guest_initializer_and_eye_getter\n", + eye_index, owner, guest_framebuffer, + static_cast(info->framebuffer), static_cast(target->image), + target->width, target->height, static_cast(target->format), + static_cast(binding.final_layout)); + // Evidence layer also observes this request; do not consume it here. + if (eyes_[0].valid && eyes_[1].valid && + eyes_[0].image != eyes_[1].image && eyes_[0].owner == eyes_[1].owner && + eyes_[0].width > 0 && eyes_[1].width > 0 && + eyes_[0].width == eyes_[1].width && + eyes_[0].height == eyes_[1].height) { + eyes_bound_.store(true, std::memory_order_release); + if (changed) Log(" [vr-backend] both eyes bound and verified distinct\n"); + } +} + +void OpenXrBackend::NotePoseApplied(void* owner, std::uint64_t frame) { + std::lock_guard lock(mutex_); + if (published_pose_.valid && published_pose_.frame == frame) + applied_poses_[owner] = frame; +} + +ScriptedPoseSample OpenXrBackend::ComputeScriptedPose( + std::uint64_t frame) const { + // Deterministic head motion: position translates along X and Z, yaw and + // pitch rotate independently. Periods/amplitudes are env-configurable so + // acceptance runs can verify each axis separately. + const double period_position = + static_cast(std::max(1, EnvUnsigned("MOCKTAIL_VR_POSE_PERIOD_POSITION", 240))); + const double period_rotation = + static_cast(std::max(1, EnvUnsigned("MOCKTAIL_VR_POSE_PERIOD_ROTATION", 360))); + const double amplitude_position = + EnvDouble("MOCKTAIL_VR_POSE_AMPLITUDE_POSITION", 0.25); + const double amplitude_yaw = + EnvDouble("MOCKTAIL_VR_POSE_AMPLITUDE_YAW", 0.5); // ~28.6 degrees + const double amplitude_pitch = + EnvDouble("MOCKTAIL_VR_POSE_AMPLITUDE_PITCH", 0.17); // ~10 degrees + const double phase_position = + 2.0 * M_PI * static_cast(frame % static_cast(period_position)) / + period_position; + const double phase_rotation = + 2.0 * M_PI * static_cast(frame % static_cast(period_rotation)) / + period_rotation; + ScriptedPoseSample sample; + sample.position[0] = + static_cast(amplitude_position * std::sin(phase_position)); + sample.position[1] = 0.f; + sample.position[2] = + static_cast(-0.3 * amplitude_position * std::cos(phase_position)); + const float yaw = static_cast(amplitude_yaw * std::sin(phase_rotation)); + const float pitch = + static_cast(amplitude_pitch * std::sin(2.0 * phase_rotation)); + const Quat orientation = QuatFromYawPitch(yaw, pitch); + sample.orientation[0] = orientation.x; + sample.orientation[1] = orientation.y; + sample.orientation[2] = orientation.z; + sample.orientation[3] = orientation.w; + sample.frame = frame; + sample.valid = true; + return sample; +} + +ScriptedPoseSample OpenXrBackend::PublishedHeadPose() const { + std::lock_guard lock(mutex_); + return published_pose_; +} + +void OpenXrBackend::PollSessionEvents() { + if (xr_instance_ == nullptr) { + return; + } + const auto instance = static_cast(xr_instance_); + while (true) { + auto event = XrInfo(XR_TYPE_EVENT_DATA_BUFFER); + const XrResult result = xrPollEvent(instance, &event); + if (result == XR_EVENT_UNAVAILABLE) { + break; + } + if (XR_FAILED(result)) { + Log(" [vr-backend] xrPollEvent failed: %s\n", + XrResultText(instance, result).c_str()); + break; + } + if (event.type == XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING) { + Log(" [vr-backend] OpenXR instance loss pending; XR output stopped\n"); + TeardownSessionLocked("instance loss pending"); + return; + } + if (event.type == XR_TYPE_EVENT_DATA_REFERENCE_SPACE_CHANGE_PENDING) { + const auto& change = + reinterpret_cast(event); + const bool affects_us = + change.session == static_cast(xr_session_) && + (change.referenceSpaceType == XR_REFERENCE_SPACE_TYPE_LOCAL || + change.referenceSpaceType == XR_REFERENCE_SPACE_TYPE_VIEW); + // poseInPreviousSpace is undefined when poseValid is false. No application + // origin offset is applied: xrLocate* uses the requested display time. + Log(" [vr-backend] reference space change pending: type=%d time=%lld poseValid=%d\n", + static_cast(change.referenceSpaceType), + static_cast(change.changeTime), change.poseValid); + if (affects_us) HandleReferenceSpaceChange(change.changeTime); + perf::ProcessCollector().Count(perf::Counter::kReferenceSpaceChanges); + continue; + } + if (event.type == XR_TYPE_EVENT_DATA_INTERACTION_PROFILE_CHANGED) { + // Controllers are out of scope; recorded so the log shows the runtime + // offered a profile change rather than silently ignoring it. + Log(" [vr-backend] interaction profile changed (controller input is not " + "implemented in this build)\n"); + continue; + } + if (event.type != XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) { + continue; + } + const auto& changed = + reinterpret_cast(event); + if (changed.session != static_cast(xr_session_)) { + continue; + } + HandleSessionState(static_cast(changed.state)); + if (xr_session_ == nullptr) return; + } +} + +void OpenXrBackend::HandleSessionState(int state) { + switch (static_cast(state)) { + case XR_SESSION_STATE_READY: { + auto begin = + XrInfo(XR_TYPE_SESSION_BEGIN_INFO); + begin.primaryViewConfigurationType = + XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; + const XrResult begin_result = + xrBeginSession(static_cast(xr_session_), &begin); + if (XR_SUCCEEDED(begin_result)) { + session_running_.store(true, std::memory_order_release); + visibility_lost_ = false; + Log(" [vr-backend] XR session READY; projection submission " + "starts on the next frame\n"); + } else { + Log(" [vr-backend] xrBeginSession failed: %s\n", + XrResultText(static_cast(xr_instance_), begin_result) + .c_str()); + } + break; + } + case XR_SESSION_STATE_STOPPING: { + if (frame_open_) { + auto end = XrInfo(XR_TYPE_FRAME_END_INFO); + end.displayTime = frame_display_time_; + end.environmentBlendMode = + static_cast(xr_blend_mode_); + (void)xrEndFrame(static_cast(xr_session_), &end); + frame_open_ = false; + } + if (session_running_.load(std::memory_order_relaxed)) { + (void)xrEndSession(static_cast(xr_session_)); + } + session_running_.store(false, std::memory_order_release); + Log(" [vr-backend] XR session STOPPING; submission paused\n"); + break; + } + case XR_SESSION_STATE_FOCUSED: + Log(" [vr-backend] XR session FOCUSED\n"); + if (visibility_lost_) { + // Returning from IDLE to a visible state: the frames rendered while + // idle are stale, so the first frame after regain is invalidated. + InvalidateInFlightPose("visibility regained (FOCUSED)"); + } + visibility_lost_ = false; + perf::ProcessCollector().Count(perf::Counter::kVisibilityGained); + break; + case XR_SESSION_STATE_VISIBLE: + Log(" [vr-backend] XR session VISIBLE (compositor shows our layers " + "without input focus)\n"); + if (visibility_lost_) { + InvalidateInFlightPose("visibility regained (VISIBLE)"); + } + visibility_lost_ = false; + perf::ProcessCollector().Count(perf::Counter::kVisibilityGained); + break; + case XR_SESSION_STATE_SYNCHRONIZED: + case XR_SESSION_STATE_IDLE: + Log(" [vr-backend] XR session not visible (IDLE/SYNCHRONIZED; runtime may " + "throttle frames)\n"); + visibility_lost_ = true; + InvalidateInFlightPose("session not visible"); + perf::ProcessCollector().Count(perf::Counter::kVisibilityLost); + break; + case XR_SESSION_STATE_EXITING: + Log(" [vr-backend] XR session EXITING\n"); + session_running_.store(false, std::memory_order_release); + TeardownSessionLocked("session exiting"); + return; + case XR_SESSION_STATE_LOSS_PENDING: + Log(" [vr-backend] XR session LOSS_PENDING\n"); + session_running_.store(false, std::memory_order_release); + TeardownSessionLocked("session loss pending", true); + return; + default: + break; + } +} + +void OpenXrBackend::InvalidateInFlightPose(const char* reason) { + // Mark the published pose invalid and clear the per-owner application record + // so the frame cycle's can_submit gate fails. The rendered images of the + // in-flight frame stay untouched (the guest owns them); xrEndFrame simply + // submits no projection layer for this frame rather than retagging a stale + // image with a pose describing a different origin. + if (frame_open_ || published_pose_.valid) { + Log(" [vr-backend] in-flight frame invalidated (%s); skipping its " + "projection submission\n", + reason); + } + published_pose_.valid = false; + frame_views_valid_ = false; + applied_poses_.clear(); + perf::ProcessCollector().Count(perf::Counter::kFramesInvalidated); +} + +void OpenXrBackend::HandleReferenceSpaceChange(std::int64_t change_time) { + // A future event does NOT invalidate an earlier predicted frame. Runtime + // locates automatically use the new origin for times >= changeTime. Drop + // only a frame already located when a late notification affects its time. + if (frame_open_ && frame_views_valid_ && + static_cast(frame_display_time_) >= change_time) { + InvalidateInFlightPose("late reference-space change affects rendered frame"); + } +} + +bool OpenXrBackend::RecoverSessionLocked(std::uint64_t now_ns) { + if (!session_recovery_pending_ || vk_device_ == VK_NULL_HANDLE || vk_ == nullptr || + xr_instance_ == nullptr || now_ns < next_session_retry_ns_) return false; + next_session_retry_ns_ = now_ns + 1'000'000'000ULL; + std::string error; + // nullptr means reuse the verified queue family of this existing VkDevice. + if (!InitializeSessionLocked(vk_physical_device_, vk_device_, nullptr, &error)) { + Log(" [vr-backend] waiting to restore XR session: %s\n", error.c_str()); + return false; + } + session_recovery_pending_ = false; + Log(" [vr-backend] XR session recreated on the existing guest device; awaiting READY\n"); + return true; +} + +void OpenXrBackend::NoteHostPresent(VkQueue queue, VkDevice device) { + std::lock_guard lock(mutex_); + if (!armed_.load(std::memory_order_acquire) || device != vk_device_) { + return; + } + try { + if (xr_session_ == nullptr && !RecoverSessionLocked(perf::NowNs())) return; + PollSessionEvents(); + if (!session_running_.load(std::memory_order_acquire)) { + return; + } + perf::ProcessCollector().Count(perf::Counter::kPresents); + RunFrameCycle(queue, device); + // Explicit, one-shot fault injection for the isolated correctness canary. + // Never active in a normal authenticated/headset launch. + const auto loss_frame = EnvUnsigned("MOCKTAIL_VR_TEST_SESSION_LOSS_AFTER", 0); + if (!test_session_loss_injected_ && loss_frame > 0 && + EnvString("MOCKTAIL_ISOLATED_CANARY", "") == "1" && + submitted_frames_.load() >= loss_frame) { + test_session_loss_injected_ = true; + Log(" [vr-backend][test] injecting session loss on existing guest device\n"); + HandleSessionState(XR_SESSION_STATE_LOSS_PENDING); + } + perf::ProcessCollector().EmitIfDue(perf::NowNs(), "xr-frame-cycle"); + } catch (const XrCallError& error) { + if (error.result == XR_ERROR_SESSION_LOST) { + Log(" [vr-backend] XR session lost; scheduling recovery on the existing device\n"); + TeardownSessionLocked("XR_ERROR_SESSION_LOST", true); + } else { + Log(" [vr-backend] XR frame failed: %s; restart the client if the runtime instance was lost\n", + error.what()); + TeardownSessionLocked("unrecoverable XR frame error"); + } + } catch (const std::exception& error) { + Log(" [vr-backend] frame cycle failed: %s; XR output stopped, native " + "stereo continues\n", + error.what()); + TeardownSessionLocked("frame cycle failure"); + } +} + +void OpenXrBackend::RunFrameCycle(VkQueue queue, VkDevice device) { + const ScopedStageTimer cycle_timer(perf::Stage::kFrameCycle); + const auto session = static_cast(xr_session_); + const auto instance = static_cast(xr_instance_); + + // ---- finish the XR frame whose predicted display time covered the frame + // Roblox just rendered (eye images are complete at present time). + if (frame_open_) { + auto end = XrInfo(XR_TYPE_FRAME_END_INFO); + end.displayTime = frame_display_time_; + end.environmentBlendMode = + static_cast(xr_blend_mode_); + XrCompositionLayerProjection projection = + XrInfo( + XR_TYPE_COMPOSITION_LAYER_PROJECTION); + std::array views{}; + const XrCompositionLayerBaseHeader* layers[1] = { + reinterpret_cast(&projection)}; + const bool eyes_usable = eyes_bound_.load(std::memory_order_acquire) && + eyes_[0].valid && eyes_[1].valid; + const bool pose_ready = frame_views_valid_ && published_pose_.valid; + const bool eye_frames_match = + eyes_usable && pose_ready && + eyes_[0].frame == published_pose_.frame && + eyes_[1].frame == published_pose_.frame && + applied_poses_[eyes_[0].owner] == published_pose_.frame; + const bool can_submit = frame_should_render_ && + pose_ready && eye_frames_match && eyes_usable; + if (!can_submit) { + // One dominant reason per skipped frame, checked in pipeline order. + if (visibility_lost_ && !frame_should_render_) { + perf::ProcessCollector().Count(perf::Counter::kSkipVisibilityLost); + } else if (!frame_should_render_) { + perf::ProcessCollector().Count(perf::Counter::kSkipShouldRender); + } else if (!pose_ready) { + perf::ProcessCollector().Count(perf::Counter::kSkipPoseInvalid); + } else if (!eyes_usable) { + perf::ProcessCollector().Count(perf::Counter::kSkipEyesUnbound); + } else { + perf::ProcessCollector().Count(perf::Counter::kSkipEyeFrameMismatch); + } + } + bool images_held[2] = {false, false}; + if (can_submit) { + projection.space = static_cast(xr_local_space_); + projection.viewCount = 2; + for (int eye = 0; eye < 2; ++eye) { + std::uint32_t index = 0; + { + const ScopedStageTimer acquire_timer(perf::Stage::kSwapchainAcquire); + auto acquire = XrInfo( + XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO); + CheckXr(instance, + xrAcquireSwapchainImage( + static_cast(eye_swapchains_[eye].swapchain), + &acquire, &index), + "xrAcquireSwapchainImage"); + } + { + const ScopedStageTimer wait_timer(perf::Stage::kSwapchainWait); + auto wait = XrInfo( + XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO); + wait.timeout = 50'000'000; + const auto deadline = Clock::now() + std::chrono::seconds(5); + while (true) { + const XrResult wait_result = xrWaitSwapchainImage( + static_cast(eye_swapchains_[eye].swapchain), &wait); + if (wait_result != XR_TIMEOUT_EXPIRED) { + CheckXr(instance, wait_result, "xrWaitSwapchainImage"); + break; + } + Require(Clock::now() < deadline, + "Timed out waiting for an OpenXR swapchain image"); + } + } + images_held[eye] = true; + eye_swapchains_[eye].acquired_index = index; + eye_swapchains_[eye].image_acquired = true; + } + const bool copied = [&] { + const ScopedStageTimer copy_record(perf::Stage::kCopyRecord); + return CopyEyesIntoSwapchains(queue); + }() && [&] { + const ScopedStageTimer evidence_timer(perf::Stage::kEvidence); + return CaptureEyeEvidence(queue, 0) && CaptureEyeEvidence(queue, 1); + }(); + for (int eye = 0; eye < 2; ++eye) { + if (images_held[eye]) { + auto release = XrInfo( + XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO); + (void)xrReleaseSwapchainImage( + static_cast(eye_swapchains_[eye].swapchain), + &release); + eye_swapchains_[eye].image_acquired = false; + } + } + if (copied) { + for (int eye = 0; eye < 2; ++eye) { + views[eye].type = XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW; + XrPosef submit_pose{}; + submit_pose.position = {frame_view_poses_[eye].position[0], + frame_view_poses_[eye].position[1], + frame_view_poses_[eye].position[2]}; + submit_pose.orientation = {frame_view_poses_[eye].orientation[0], + frame_view_poses_[eye].orientation[1], + frame_view_poses_[eye].orientation[2], + frame_view_poses_[eye].orientation[3]}; + views[eye].pose = submit_pose; + views[eye].fov = XrFovf{frame_views_[eye].fov[0], + frame_views_[eye].fov[1], + frame_views_[eye].fov[2], + frame_views_[eye].fov[3]}; + views[eye].subImage.swapchain = + static_cast(eye_swapchains_[eye].swapchain); + views[eye].subImage.imageRect.offset = {0, 0}; + views[eye].subImage.imageRect.extent = { + static_cast(eye_swapchains_[eye].width), + static_cast(eye_swapchains_[eye].height)}; + views[eye].subImage.imageArrayIndex = 0; + } + projection.views = views.data(); + end.layerCount = 1; + end.layers = layers; + } + } + CheckXr(instance, xrEndFrame(session, &end), "xrEndFrame"); + frame_open_ = false; + if (end.layerCount > 0) { + perf::ProcessCollector().Count(perf::Counter::kFramesSubmitted); + const std::uint64_t submitted = + submitted_frames_.fetch_add(1, std::memory_order_relaxed) + 1; + if (submitted == 1 || submitted % 120 == 0 || + (!EnvString("MOCKTAIL_VR_XR_EVIDENCE_DIR", "").empty() && + published_pose_.frame % std::max(1, + EnvUnsigned("MOCKTAIL_VR_XR_EVIDENCE_INTERVAL", 20)) == 0)) { + Log(" [vr-backend][evidence] projection submitted=%llu frame=%llu eye0_image=%p " + "eye1_image=%p eye0_swapchain_index=%u eye1_swapchain_index=%u\n", + static_cast(submitted), + static_cast(published_pose_.frame), + static_cast(eyes_[0].image), + static_cast(eyes_[1].image), + eye_swapchains_[0].acquired_index, + eye_swapchains_[1].acquired_index); + } + } else { + perf::ProcessCollector().Count(perf::Counter::kFramesSkipped); + skipped_frames_.fetch_add(1, std::memory_order_relaxed); + } + } + + // ---- open the XR frame for the Roblox frame that is about to render. + std::uint64_t predicted_display_time = 0; + bool should_render = false; + { + const ScopedStageTimer wait_timer(perf::Stage::kXrWaitFrame); + auto wait = XrInfo(XR_TYPE_FRAME_WAIT_INFO); + XrFrameState state = XrInfo(XR_TYPE_FRAME_STATE); + CheckXr(instance, xrWaitFrame(session, &wait, &state), "xrWaitFrame"); + { + const ScopedStageTimer begin_timer(perf::Stage::kXrBeginFrame); + auto begin_info = XrInfo(XR_TYPE_FRAME_BEGIN_INFO); + CheckXr(instance, xrBeginFrame(session, &begin_info), "xrBeginFrame"); + } + predicted_display_time = state.predictedDisplayTime; + should_render = state.shouldRender; + } + frame_open_ = true; + frame_display_time_ = predicted_display_time; + frame_should_render_ = should_render; + applied_poses_.clear(); + frame_views_valid_ = false; + perf::ProcessCollector().Count(perf::Counter::kFramesBegun); + + if (should_render) { + auto locate_info = XrInfo(XR_TYPE_VIEW_LOCATE_INFO); + locate_info.viewConfigurationType = + XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; + locate_info.displayTime = predicted_display_time; + locate_info.space = static_cast(xr_local_space_); + XrViewState view_state = XrInfo(XR_TYPE_VIEW_STATE); + std::array xr_views{}; + xr_views[0].type = XR_TYPE_VIEW; + xr_views[1].type = XR_TYPE_VIEW; + std::uint32_t view_count = 0; + { + const ScopedStageTimer locate_timer(perf::Stage::kXrLocateViews); + CheckXr(instance, + xrLocateViews(session, &locate_info, &view_state, 2, &view_count, + xr_views.data()), + "xrLocateViews"); + } + constexpr XrViewStateFlags required_view_flags = + XR_VIEW_STATE_POSITION_VALID_BIT | XR_VIEW_STATE_ORIENTATION_VALID_BIT; + if (view_count == 2 && + (view_state.viewStateFlags & required_view_flags) == + required_view_flags) { + { + // Head pose in LOCAL space for the guest camera injection. + XrSpaceLocation head = XrInfo(XR_TYPE_SPACE_LOCATION); + const XrResult head_result = xrLocateSpace( + static_cast(xr_view_space_), + static_cast(xr_local_space_), predicted_display_time, + &head); + constexpr XrSpaceLocationFlags required_location = + XR_SPACE_LOCATION_ORIENTATION_VALID_BIT | + XR_SPACE_LOCATION_POSITION_VALID_BIT; + const bool head_valid = + XR_SUCCEEDED(head_result) && + (head.locationFlags & required_location) == required_location; + const std::string pose_source = + EnvString("MOCKTAIL_VR_POSE_SOURCE", "runtime"); + Pose head_pose = head_valid ? XrToPose(head.pose) : Pose{}; + if (pose_source == "script") { + const ScriptedPoseSample scripted = + ComputeScriptedPose(++xr_frame_counter_); + head_pose = Pose{Vec3{scripted.position[0], scripted.position[1], + scripted.position[2]}, + Quat{scripted.orientation[0], scripted.orientation[1], + scripted.orientation[2], + scripted.orientation[3]}}; + published_pose_ = scripted; + published_pose_.valid = head_valid; + published_pose_.frame = xr_frame_counter_; + } else { + ScriptedPoseSample sample; + sample.position[0] = head_pose.position.x; + sample.position[1] = head_pose.position.y; + sample.position[2] = head_pose.position.z; + sample.orientation[0] = head_pose.orientation.x; + sample.orientation[1] = head_pose.orientation.y; + sample.orientation[2] = head_pose.orientation.z; + sample.orientation[3] = head_pose.orientation.w; + sample.frame = ++xr_frame_counter_; + sample.valid = head_valid; + published_pose_ = sample; + } + for (int eye = 0; eye < 2; ++eye) { + const Pose runtime_eye = XrToPose(xr_views[eye].pose); + // Preserve the runtime's inter-lens offsets while the scripted (or + // runtime) head drives the motion. + const Pose relative = head_valid || pose_source != "script" + ? PoseMultiply(PoseInverse(head_valid + ? XrToPose(head.pose) + : Pose{}), + runtime_eye) + : runtime_eye; + // Without a valid head pose `relative` degenerates to the full + // LOCAL-space eye pose, which is not an inter-lens offset. Publish + // explicit zeroes so no consumer can read a stale tracking value. + if (head_valid) { + published_pose_.eye_offset[eye][0] = relative.position.x; + published_pose_.eye_offset[eye][1] = relative.position.y; + published_pose_.eye_offset[eye][2] = relative.position.z; + } else { + published_pose_.eye_offset[eye][0] = 0.f; + published_pose_.eye_offset[eye][1] = 0.f; + published_pose_.eye_offset[eye][2] = 0.f; + } + // The pinned guest state represents parallel eye orientations. A + // canted runtime needs a different camera ABI; do not submit an + // image with a projection pose that the guest cannot represent. + // Reject once per binding with the measured components so a tester + // sees why nothing reaches the headset instead of a silent freeze. + // Only judged while the head pose itself is valid: during tracking + // loss `relative` degenerates to the full LOCAL-space eye pose and + // its rotation is not a canted-eye signal. + if (head_valid && + !pose::EyeOrientationIsParallel(relative.orientation)) { + published_pose_.valid = false; + if (!canted_rejection_logged_) { + canted_rejection_logged_ = true; + Log(" [vr-backend] UNSUPPORTED CONFIGURATION: the runtime " + "reports canted eye orientations (eye%d relative rotation " + "xyz=(%.5f,%.5f,%.5f), tolerance %.3f). The exact-build " + "guest camera ABI represents only parallel eye orientations, " + "so XR submission is rejected rather than showing images " + "with a mismatched projection. This is a known unmet " + "compatibility criterion for this headset/runtime " + "combination.\n", + eye, relative.orientation.x, relative.orientation.y, + relative.orientation.z, pose::kCantedTolerance); + } + } + const Pose final_eye = PoseMultiply(head_pose, relative); + frame_view_poses_[eye].position[0] = final_eye.position.x; + frame_view_poses_[eye].position[1] = final_eye.position.y; + frame_view_poses_[eye].position[2] = final_eye.position.z; + frame_view_poses_[eye].orientation[0] = final_eye.orientation.x; + frame_view_poses_[eye].orientation[1] = final_eye.orientation.y; + frame_view_poses_[eye].orientation[2] = final_eye.orientation.z; + frame_view_poses_[eye].orientation[3] = final_eye.orientation.w; + frame_views_[eye].position[0] = final_eye.position.x; + frame_views_[eye].position[1] = final_eye.position.y; + frame_views_[eye].position[2] = final_eye.position.z; + frame_views_[eye].orientation[0] = final_eye.orientation.x; + frame_views_[eye].orientation[1] = final_eye.orientation.y; + frame_views_[eye].orientation[2] = final_eye.orientation.z; + frame_views_[eye].orientation[3] = final_eye.orientation.w; + frame_views_[eye].fov[0] = xr_views[eye].fov.angleLeft; + frame_views_[eye].fov[1] = xr_views[eye].fov.angleRight; + frame_views_[eye].fov[2] = xr_views[eye].fov.angleUp; + frame_views_[eye].fov[3] = xr_views[eye].fov.angleDown; + std::copy_n(frame_views_[eye].fov, 4, published_pose_.eye_fov[eye]); + frame_views_[eye].valid = true; + } + frame_views_valid_ = published_pose_.valid; + // Head-pose evidence cadence is tunable so the scripted-motion canary + // can observe at least two distinct samples within a short run; the + // production default stays coarse to avoid per-frame log spam. + static const std::uint64_t pose_log_interval = [] { + const std::uint64_t value = + EnvUnsigned("MOCKTAIL_VR_POSE_LOG_INTERVAL", 240); + return value == 0 ? 240 : value; + }(); + if (xr_frame_counter_ == 1 || xr_frame_counter_ % pose_log_interval == 0) { + Log(" [vr-backend][evidence] head-pose frame=%llu pos=(%.4f,%.4f," + "%.4f) ori=(%.4f,%.4f,%.4f,%.4f) source=%s predicted=%lld\n", + static_cast(xr_frame_counter_), + published_pose_.position[0], published_pose_.position[1], + published_pose_.position[2], published_pose_.orientation[0], + published_pose_.orientation[1], published_pose_.orientation[2], + published_pose_.orientation[3], pose_source.c_str(), + static_cast(predicted_display_time)); + } + } + } + if (!frame_views_valid_) { + // Invalid tracking is never published as a fresh valid pose. + published_pose_.valid = false; + } + } else { + published_pose_.valid = false; + } +} + +bool OpenXrBackend::EnsureCopyResources(VkDevice device, + std::uint32_t queue_family, + std::string* error) { + try { + if (copy_pool_ != VK_NULL_HANDLE && copy_command_ != VK_NULL_HANDLE && + copy_fence_ != VK_NULL_HANDLE) { + return true; + } + if (copy_pool_ == VK_NULL_HANDLE) { + VkCommandPoolCreateInfo pool_info{}; + pool_info.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; + pool_info.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + pool_info.queueFamilyIndex = queue_family; + Require(vk_->CreateCommandPool(device, &pool_info, nullptr, + ©_pool_) == VK_SUCCESS, + "vkCreateCommandPool failed for XR copies"); + } + if (copy_command_ == VK_NULL_HANDLE) { + VkCommandBufferAllocateInfo allocate_info{}; + allocate_info.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; + allocate_info.commandPool = copy_pool_; + allocate_info.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + allocate_info.commandBufferCount = 1; + Require(vk_->AllocateCommandBuffers(device, &allocate_info, + ©_command_) == VK_SUCCESS, + "vkAllocateCommandBuffers failed for XR copies"); + } + if (copy_fence_ == VK_NULL_HANDLE) { + VkFenceCreateInfo fence_info{}; + fence_info.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO; + Require(vk_->CreateFence(device, &fence_info, nullptr, ©_fence_) == + VK_SUCCESS, + "vkCreateFence failed for XR copies"); + } + return true; + } catch (const std::exception& exception) { + *error = exception.what(); + return false; + } +} + +void OpenXrBackend::DestroyCopyResources() { + if (vk_ == nullptr || vk_device_ == VK_NULL_HANDLE) { + copy_pool_ = VK_NULL_HANDLE; + copy_command_ = VK_NULL_HANDLE; + copy_fence_ = VK_NULL_HANDLE; + return; + } + if (copy_fence_ != VK_NULL_HANDLE) { + vk_->DestroyFence(vk_device_, copy_fence_, nullptr); + copy_fence_ = VK_NULL_HANDLE; + } + if (copy_pool_ != VK_NULL_HANDLE) { + // Frees copy_command_ as well. + vk_->DestroyCommandPool(vk_device_, copy_pool_, nullptr); + copy_pool_ = VK_NULL_HANDLE; + copy_command_ = VK_NULL_HANDLE; + } +} + +bool OpenXrBackend::CopyEyesIntoSwapchains(VkQueue queue) { + std::string error; + const auto family_entry = queue_families_.find(queue); + const std::uint32_t family = + family_entry != queue_families_.end() ? family_entry->second + : vk_queue_family_; + if (!EnsureCopyResources(vk_device_, family, &error)) { + Log(" [vr-backend] copy resources unavailable: %s\n", error.c_str()); + return false; + } + // Both eyes must match their swapchain extents; a mismatch is a hard skip + // of the whole batched submission, never a half-copied frame. + const VkImage source[2] = {eyes_[0].image, eyes_[1].image}; + for (int eye = 0; eye < 2; ++eye) { + const EyeSwapchain& swapchain = eye_swapchains_[eye]; + if (!swapchain.image_acquired || swapchain.images.empty() || + swapchain.acquired_index >= swapchain.images.size()) { + return false; + } + if (eyes_[eye].width != swapchain.width || + eyes_[eye].height != swapchain.height) { + Log(" [vr-backend] eye%d extent %ux%u does not match swapchain %ux%u\n", + eye, eyes_[eye].width, eyes_[eye].height, swapchain.width, + swapchain.height); + return false; + } + } + const VkImage destination[2] = { + eye_swapchains_[0].images[eye_swapchains_[0].acquired_index], + eye_swapchains_[1].images[eye_swapchains_[1].acquired_index]}; + EnsureMirrorForExtent(eyes_[0].width, eyes_[0].height); + + if (vk_->ResetCommandBuffer(copy_command_, 0) != VK_SUCCESS) { + return false; + } + VkCommandBufferBeginInfo begin{}; + begin.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + if (vk_->BeginCommandBuffer(copy_command_, &begin) != VK_SUCCESS) { + return false; + } + + // Phase 1: transition both sources to TRANSFER_SRC and both swapchain + // destinations to TRANSFER_DST in one barrier batch. + std::array prepare{}; + for (int eye = 0; eye < 2; ++eye) { + VkImageMemoryBarrier& source_barrier = prepare[eye]; + source_barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + source_barrier.srcAccessMask = + VK_ACCESS_MEMORY_READ_BIT | VK_ACCESS_MEMORY_WRITE_BIT; + source_barrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + source_barrier.oldLayout = eyes_[eye].final_layout; + source_barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + source_barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + source_barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + source_barrier.image = source[eye]; + source_barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + + VkImageMemoryBarrier& destination_barrier = prepare[2 + eye]; + destination_barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + destination_barrier.srcAccessMask = 0; + destination_barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + destination_barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; + destination_barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + destination_barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + destination_barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + destination_barrier.image = destination[eye]; + destination_barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, + 1}; + } + vk_->CmdPipelineBarrier(copy_command_, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, + nullptr, static_cast(prepare.size()), + prepare.data()); + + // Phase 2: both eye copies (plus the mirror readbacks when enabled) in one + // recording. + const VkImageSubresourceLayers subresource{VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, + 1}; + for (int eye = 0; eye < 2; ++eye) { + const EyeBinding& binding = eyes_[eye]; + const EyeSwapchain& swapchain = eye_swapchains_[eye]; + if (FormatIsCopyCompatible(binding.format, swapchain.format)) { + VkImageCopy region{}; + region.srcSubresource = subresource; + region.dstSubresource = subresource; + region.extent = {binding.width, binding.height, 1}; + vk_->CmdCopyImage(copy_command_, source[eye], + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, destination[eye], + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion); + } else { + VkImageBlit region{}; + region.srcSubresource = subresource; + region.dstSubresource = subresource; + region.srcOffsets[1] = {static_cast(binding.width), + static_cast(binding.height), 1}; + region.dstOffsets[1] = region.srcOffsets[1]; + vk_->CmdBlitImage(copy_command_, source[eye], + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, destination[eye], + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion, + VK_FILTER_NEAREST); + } + if (mirror_.enabled()) { + // Reads the source while it is already TRANSFER_SRC; the restore + // barrier below returns it to the guest's final layout afterwards. + mirror_.RecordEyeCopy(MirrorProcs(), copy_command_, eye, source[eye], + binding.final_layout, binding.width, + binding.height); + } + } + + // Phase 3: restore the eye images to their render-pass final layouts and + // leave the swapchain images as color attachments for the compositor. + std::array finish{}; + for (int eye = 0; eye < 2; ++eye) { + VkImageMemoryBarrier& restore_source = finish[eye]; + restore_source.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + restore_source.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + restore_source.dstAccessMask = + VK_ACCESS_MEMORY_READ_BIT | VK_ACCESS_MEMORY_WRITE_BIT; + restore_source.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + restore_source.newLayout = eyes_[eye].final_layout; + restore_source.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + restore_source.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + restore_source.image = source[eye]; + restore_source.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + + VkImageMemoryBarrier& finish_destination = finish[2 + eye]; + finish_destination.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + finish_destination.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + finish_destination.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; + finish_destination.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + finish_destination.newLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + finish_destination.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + finish_destination.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + finish_destination.image = destination[eye]; + finish_destination.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, + 1}; + } + vk_->CmdPipelineBarrier(copy_command_, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, 0, nullptr, 0, + nullptr, static_cast(finish.size()), + finish.data()); + + if (vk_->EndCommandBuffer(copy_command_) != VK_SUCCESS) { + return false; + } + if (vk_->ResetFences(vk_device_, 1, ©_fence_) != VK_SUCCESS) { + return false; + } + VkSubmitInfo submit{}; + submit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + submit.commandBufferCount = 1; + submit.pCommandBuffers = ©_command_; + // Same queue as the guest's frame submission: queue-order guarantees the + // batched copies run after the eye render passes of this frame. One frame in + // flight; the single fence wait bounds reuse of the copy command buffer and + // of the mirror staging buffers. + VkResult submit_result = VK_SUCCESS; + { + const ScopedStageTimer submit_timer(perf::Stage::kCopySubmit); + submit_result = vk_->QueueSubmit(queue, 1, &submit, copy_fence_); + } + perf::ProcessCollector().Count(perf::Counter::kSubmits); + if (submit_result != VK_SUCCESS) { + return false; + } + VkResult fence_result = VK_SUCCESS; + { + const ScopedStageTimer fence_timer(perf::Stage::kCopyFenceWait); + fence_result = vk_->WaitForFences(vk_device_, 1, ©_fence_, VK_TRUE, + 2'000'000'000ULL); + } + perf::ProcessCollector().Count(perf::Counter::kFenceWaits); + if (fence_result != VK_SUCCESS) { + (void)vk_->DeviceWaitIdle(vk_device_); + throw std::runtime_error("XR copy fence wait failed"); + } + if (mirror_.enabled()) { + const ScopedStageTimer publish_timer(perf::Stage::kMirrorPublish); + mirror_.Publish(published_pose_.frame, eyes_[0].format, eyes_[0].width, + eyes_[0].height, perf::NowNs()); + perf::ProcessCollector().Count(perf::Counter::kMirrorPairs); + } + return true; +} + +void OpenXrBackend::EnsureMirrorForExtent(std::uint32_t width, + std::uint32_t height) { + const std::string name = EnvString("MOCKTAIL_VR_MIRROR_SHM", ""); + if (name.empty()) { + return; + } + { + const bool was_enabled = mirror_.enabled(); + std::string error; + if (!mirror_.Open(name, width, height, &error)) { + Log(" [vr-backend] mirror unavailable: %s\n", error.c_str()); + return; + } + if (!was_enabled) Log(" [vr-backend] desktop mirror publishing to %s (%ux%u)\n", + name.c_str(), width, height); + } + std::string error; + if (!mirror_.EnsureStaging(MirrorProcs(), vk_device_, vk_physical_device_, + width, height, &error)) { + Log(" [vr-backend] mirror staging unavailable: %s\n", error.c_str()); + mirror_.Close(); + } +} + +MirrorVkProcs OpenXrBackend::MirrorProcs() const { + MirrorVkProcs procs{}; + if (vk_ == nullptr) { + return procs; + } + procs.CreateBuffer = vk_->CreateBuffer; + procs.DestroyBuffer = vk_->DestroyBuffer; + procs.GetBufferMemoryRequirements = vk_->GetBufferMemoryRequirements; + procs.AllocateMemory = vk_->AllocateMemory; + procs.FreeMemory = vk_->FreeMemory; + procs.BindBufferMemory = vk_->BindBufferMemory; + procs.MapMemory = vk_->MapMemory; + procs.UnmapMemory = vk_->UnmapMemory; + procs.CmdCopyImageToBuffer = vk_->CmdCopyImageToBuffer; + procs.CmdPipelineBarrier = vk_->CmdPipelineBarrier; + procs.GetPhysicalDeviceMemoryProperties = + vk_->GetPhysicalDeviceMemoryProperties; + return procs; +} + +// Diagnostic only: read BOTH images on the guest queue while the XR image is +// still acquired. Never read a released image owned by the compositor. +bool OpenXrBackend::CaptureEyeEvidence(VkQueue queue, int eye) { + const std::string directory = EnvString("MOCKTAIL_VR_XR_EVIDENCE_DIR", ""); + const auto interval = std::max(1, + EnvUnsigned("MOCKTAIL_VR_XR_EVIDENCE_INTERVAL", 20)); + if (directory.empty() || published_pose_.frame % interval != 0) return true; + VkBuffer buffers[2] = {}; + VkDeviceMemory memory[2] = {}; + bool pending = false; + auto cleanup = [&] { + if (pending) vk_->DeviceWaitIdle(vk_device_); + for (int i = 0; i < 2; ++i) { + if (buffers[i]) vk_->DestroyBuffer(vk_device_, buffers[i], nullptr); + if (memory[i]) vk_->FreeMemory(vk_device_, memory[i], nullptr); + } + }; + try { + const auto& binding = eyes_[eye]; + const auto& slot = eye_swapchains_[eye]; + const VkDeviceSize size = VkDeviceSize(binding.width)*binding.height*4; + const VkImage images[2] = {binding.image, slot.images[slot.acquired_index]}; + const VkFormat formats[2] = {binding.format, slot.format}; + const VkImageLayout layouts[2] = {binding.final_layout, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL}; + VkPhysicalDeviceMemoryProperties properties{}; + vk_->GetPhysicalDeviceMemoryProperties(vk_physical_device_, &properties); + for (int i = 0; i < 2; ++i) { + Require(formats[i] == VK_FORMAT_R8G8B8A8_UNORM || + formats[i] == VK_FORMAT_B8G8R8A8_UNORM, + "evidence currently requires 8-bit UNORM images"); + VkBufferCreateInfo info{}; + info.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + info.size = size; + info.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT; + Require(vk_->CreateBuffer(vk_device_, &info, nullptr, &buffers[i]) == VK_SUCCESS, + "evidence buffer creation failed"); + VkMemoryRequirements requirements{}; + vk_->GetBufferMemoryRequirements(vk_device_, buffers[i], &requirements); + std::uint32_t type = UINT32_MAX; + const auto flags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT; + for (std::uint32_t t = 0; t < properties.memoryTypeCount; ++t) + if ((requirements.memoryTypeBits & (1u << t)) && + (properties.memoryTypes[t].propertyFlags & flags) == flags) { type = t; break; } + Require(type != UINT32_MAX, "no coherent evidence readback memory"); + VkMemoryAllocateInfo allocation{}; + allocation.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + allocation.allocationSize = requirements.size; + allocation.memoryTypeIndex = type; + Require(vk_->AllocateMemory(vk_device_, &allocation, nullptr, &memory[i]) == VK_SUCCESS, + "evidence memory allocation failed"); + Require(vk_->BindBufferMemory(vk_device_, buffers[i], memory[i], 0) == VK_SUCCESS, + "evidence buffer binding failed"); + } + Require(vk_->ResetCommandBuffer(copy_command_, 0) == VK_SUCCESS, "evidence reset failed"); + VkCommandBufferBeginInfo begin{}; + begin.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + Require(vk_->BeginCommandBuffer(copy_command_, &begin) == VK_SUCCESS, "evidence begin failed"); + for (int i = 0; i < 2; ++i) { + VkImageMemoryBarrier barrier{}; + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.srcAccessMask = VK_ACCESS_MEMORY_WRITE_BIT; + barrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + barrier.oldLayout = layouts[i]; + barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + barrier.srcQueueFamilyIndex = barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = images[i]; + barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + vk_->CmdPipelineBarrier(copy_command_, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + VkBufferImageCopy region{}; + region.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + region.imageExtent = {binding.width, binding.height, 1}; + vk_->CmdCopyImageToBuffer(copy_command_, images[i], VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + buffers[i], 1, ®ion); + barrier.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + barrier.dstAccessMask = VK_ACCESS_MEMORY_READ_BIT | VK_ACCESS_MEMORY_WRITE_BIT; + std::swap(barrier.oldLayout, barrier.newLayout); + vk_->CmdPipelineBarrier(copy_command_, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + } + Require(vk_->EndCommandBuffer(copy_command_) == VK_SUCCESS, "evidence end failed"); + Require(vk_->ResetFences(vk_device_, 1, ©_fence_) == VK_SUCCESS, "evidence fence reset failed"); + VkSubmitInfo submit{}; + submit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + submit.commandBufferCount = 1; + submit.pCommandBuffers = ©_command_; + Require(vk_->QueueSubmit(queue, 1, &submit, copy_fence_) == VK_SUCCESS, "evidence submit failed"); + pending = true; + Require(vk_->WaitForFences(vk_device_, 1, ©_fence_, VK_TRUE, 2000000000ULL) == VK_SUCCESS, + "evidence fence wait failed"); + pending = false; + std::filesystem::create_directories(directory); + const auto stem = std::filesystem::path(directory) / + ("frame-" + std::to_string(published_pose_.frame) + "-eye" + std::to_string(eye)); + for (int i = 0; i < 2; ++i) { + void* mapped = nullptr; + Require(vk_->MapMemory(vk_device_, memory[i], 0, size, 0, &mapped) == VK_SUCCESS, + "evidence map failed"); + const auto* bytes = static_cast(mapped); + std::vector rgb(size/4*3); + const bool bgra = formats[i] == VK_FORMAT_B8G8R8A8_UNORM; + for (std::size_t pixel = 0; pixel < size/4; ++pixel) { + rgb[pixel*3] = bytes[pixel*4 + (bgra ? 2 : 0)]; + rgb[pixel*3+1] = bytes[pixel*4+1]; + rgb[pixel*3+2] = bytes[pixel*4 + (bgra ? 0 : 2)]; + } + vk_->UnmapMemory(vk_device_, memory[i]); + std::ofstream file(stem.string() + (i ? "-destination.ppm" : "-source.ppm"), std::ios::binary); + file << "P6\n" << binding.width << ' ' << binding.height << "\n255\n"; + file.write(reinterpret_cast(rgb.data()), rgb.size()); + Require(file.good(), "evidence image write failed"); + } + std::ofstream meta(stem.string() + ".json"); + meta << "{\"frame\":" << published_pose_.frame << ",\"eye\":" << eye + << ",\"source\":\"" << images[0] << "\",\"destination\":\"" << images[1] + << "\",\"owner\":\"" << binding.owner << "\",\"pose_applied_frame\":" + << applied_poses_[binding.owner] << "}"; + Require(meta.good(), "evidence metadata write failed"); + cleanup(); + return true; + } catch (const std::exception& error) { + cleanup(); + Log(" [vr-backend] XR evidence failed: %s\n", error.what()); + return false; + } +} + +} // namespace mocktail::vr + +// Process symbols resolved by the Vulkan adapter through dlsym. They are +// inert while no backend is armed; the adapter falls back to its plain host +// loader path whenever a hook returns false. +extern "C" { + +bool mocktail_vr_xr_create_vulkan_instance( + const VkInstanceCreateInfo* create_info, + const VkAllocationCallbacks* allocator, VkInstance* instance, + VkResult* result) { + auto* backend = mocktail::vr::ActiveVrBackend(); + return backend != nullptr && + backend->CreateVulkanInstance(create_info, allocator, instance, + result); +} + +bool mocktail_vr_xr_create_vulkan_device( + VkPhysicalDevice physical_device, const VkDeviceCreateInfo* create_info, + const VkAllocationCallbacks* allocator, VkDevice* device, + VkResult* result) { + auto* backend = mocktail::vr::ActiveVrBackend(); + return backend != nullptr && + backend->CreateVulkanDevice(physical_device, create_info, allocator, + device, result); +} + +void mocktail_vr_xr_note_instance_destroyed(VkInstance instance) { + if (auto* backend = mocktail::vr::ActiveVrBackend()) backend->NoteInstanceDestroyed(instance); +} + +void mocktail_vr_xr_note_device_destroyed(VkDevice device) { + auto* backend = mocktail::vr::ActiveVrBackend(); + if (backend != nullptr) { + backend->NoteDeviceDestroyed(device); + } +} + +void mocktail_vr_xr_note_queue(VkDevice device, VkQueue queue, + unsigned int family, unsigned int index) { + auto* backend = mocktail::vr::ActiveVrBackend(); + if (backend != nullptr) { + backend->NoteQueue(device, queue, family, index); + } +} + +bool mocktail_vr_xr_wants_resource_records() { + auto* backend = mocktail::vr::ActiveVrBackend(); + return backend != nullptr && backend->WantsResourceRecords(); +} + +void mocktail_vr_xr_record_image(VkDevice device, VkImage image, + const VkImageCreateInfo* info) { + auto* backend = mocktail::vr::ActiveVrBackend(); + if (backend != nullptr) { + backend->RecordImage(device, image, info); + } +} + +void mocktail_vr_xr_record_image_view(VkImageView view, VkImage image) { + auto* backend = mocktail::vr::ActiveVrBackend(); + if (backend != nullptr) { + backend->RecordImageView(view, image); + } +} + +void mocktail_vr_xr_record_framebuffer(VkFramebuffer framebuffer, + const VkFramebufferCreateInfo* info) { + auto* backend = mocktail::vr::ActiveVrBackend(); + if (backend != nullptr) { + backend->RecordFramebuffer(framebuffer, info); + } +} + +void mocktail_vr_xr_record_render_pass(VkRenderPass render_pass, + const VkRenderPassCreateInfo* info) { + auto* backend = mocktail::vr::ActiveVrBackend(); + if (backend != nullptr) { + backend->RecordRenderPass(render_pass, info); + } +} + +void mocktail_vr_xr_note_destroy_image(VkImage image) { + auto* backend = mocktail::vr::ActiveVrBackend(); + if (backend != nullptr) { + backend->NoteDestroyImage(image); + } +} + +void mocktail_vr_xr_note_destroy_image_view(VkImageView view) { + auto* backend = mocktail::vr::ActiveVrBackend(); + if (backend != nullptr) { + backend->NoteDestroyImageView(view); + } +} + +void mocktail_vr_xr_note_destroy_framebuffer(VkFramebuffer framebuffer) { + auto* backend = mocktail::vr::ActiveVrBackend(); + if (backend != nullptr) { + backend->NoteDestroyFramebuffer(framebuffer); + } +} + +void mocktail_vr_xr_note_render_pass_begin(VkCommandBuffer command_buffer, + const VkRenderPassBeginInfo* info) { + auto* backend = mocktail::vr::ActiveVrBackend(); + if (backend != nullptr) { + backend->NoteRenderPassBegin(command_buffer, info); + } +} + +void mocktail_vr_xr_note_present(VkQueue queue, VkDevice device) { + auto* backend = mocktail::vr::ActiveVrBackend(); + if (backend != nullptr) { + backend->NoteHostPresent(queue, device); + } +} + +} // extern "C" diff --git a/src/vr/openxr_backend_mode.cc b/src/vr/openxr_backend_mode.cc new file mode 100644 index 0000000..c2764ce --- /dev/null +++ b/src/vr/openxr_backend_mode.cc @@ -0,0 +1,53 @@ +#include "mocktail/vr/openxr_backend.h" +#include +#include +namespace mocktail::vr { +std::string SelectVrRuntimeManifest(const std::string& explicit_manifest, + const std::vector& candidates) { + if (!explicit_manifest.empty()) return explicit_manifest; + for (const auto& path : candidates) { + std::error_code error; + if (std::filesystem::is_regular_file(path, error)) return path; + } + return {}; +} + +std::string VrRuntimeUnavailableHint(bool user_selected, const std::string& manifest) { + if (user_selected) { + return "User-selected XR_RUNTIME_JSON '" + manifest + + "' is unavailable. Check the manifest/library and that its runtime server " + "is running with a connected headset; this error alone does not distinguish them."; + } + if (!manifest.empty()) { + return "Auto-selected WiVRn runtime is unavailable. Start WiVRn and connect " + "the headset, then retry. If it remains unavailable, check the runtime installation."; + } + return "The registered OpenXR runtime is unavailable. Install/start WiVRn and " + "connect the headset, or explicitly select an installed runtime with XR_RUNTIME_JSON."; +} + +VrBackendMode ResolveVrBackendMode(bool vr_enabled) { + if (!vr_enabled) { + return VrBackendMode::kDisabled; + } + const char* value = std::getenv("MOCKTAIL_VR_BACKEND"); + const std::string mode = value ? value : "xr"; + if (mode == "native" || mode == "native-stereo") { + return VrBackendMode::kNativeOnly; + } + return VrBackendMode::kXrOutput; +} + +const char* VrBackendModeName(VrBackendMode mode) { + switch (mode) { + case VrBackendMode::kDisabled: + return "disabled"; + case VrBackendMode::kNativeOnly: + return "native-stereo-diagnostic"; + case VrBackendMode::kXrOutput: + return "openxr-output"; + } + return "disabled"; +} + +} diff --git a/src/vr/openxr_disabled.cc b/src/vr/openxr_disabled.cc index f967e10..f8dc382 100644 --- a/src/vr/openxr_disabled.cc +++ b/src/vr/openxr_disabled.cc @@ -1,7 +1,17 @@ #include "mocktail/vr/openxr_probe.h" +#include "mocktail/vr/openxr_backend.h" #include "mocktail/vr/openxr_preview.h" namespace mocktail::vr { +OpenXrBackend::~OpenXrBackend() = default; +Status OpenXrBackend::Arm() { + return Status::Error(StatusCode::kUnavailable, "OpenXR support is disabled in this build"); +} +void OpenXrBackend::Disarm() {} +OpenXrBackend* ActiveVrBackend() { return nullptr; } +void OpenXrBackend::NotePoseApplied(void*, std::uint64_t) {} +ScriptedPoseSample OpenXrBackend::PublishedHeadPose() const { return {}; } + PreviewResult RunOpenXrPreview(const PreviewOptions&) { PreviewResult result; @@ -11,6 +21,8 @@ PreviewResult RunOpenXrPreview(const PreviewOptions&) { return result; } +bool IsOpenXrSupportCompiled() { return false; } + ProbeReport ProbeOpenXr() { ProbeReport report; report.status = ProbeStatus::kBuildDisabled; diff --git a/src/vr/openxr_preview.cc b/src/vr/openxr_preview.cc index 2644d5b..3c28ab9 100644 --- a/src/vr/openxr_preview.cc +++ b/src/vr/openxr_preview.cc @@ -301,8 +301,7 @@ class Preview { " failed: " + (text[0] ? std::string(text) : std::to_string(result)); if (result == XR_ERROR_RUNTIME_UNAVAILABLE) message += ". No active OpenXR runtime was found. Start WiVRn for a real " - "headset. Without a headset, run mocktail-vr-simulated from " - "this build directory."; + "headset, or select an installed runtime using XR_RUNTIME_JSON."; else if (result == XR_ERROR_FORM_FACTOR_UNAVAILABLE) message += ". Connect a headset or select a simulated runtime."; else if (result == XR_ERROR_EXTENSION_NOT_PRESENT) diff --git a/src/vr/openxr_probe.cc b/src/vr/openxr_probe.cc index 1963979..45eba4d 100644 --- a/src/vr/openxr_probe.cc +++ b/src/vr/openxr_probe.cc @@ -73,6 +73,8 @@ XrResult EnumerateProperties(XrStructureType type, Enumerate enumerate, } // namespace +bool IsOpenXrSupportCompiled() { return true; } + ProbeReport ProbeOpenXr() { ProbeReport report; report.sdk_version = Version(XR_CURRENT_API_VERSION); diff --git a/src/vr/roblox_vr_device_bridge.cc b/src/vr/roblox_vr_device_bridge.cc new file mode 100644 index 0000000..ea30375 --- /dev/null +++ b/src/vr/roblox_vr_device_bridge.cc @@ -0,0 +1,885 @@ +#include "mocktail/vr/roblox_vr_device_bridge.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include "mocktail/vr/openxr_backend.h" +#include +#include +#include +#include +#include +#include +#include +#include + +#include "vr/roblox_vr_device_bridge_internal.h" + +namespace mocktail::vr { +namespace { + +using internal::kConstructorContractSize; +using internal::kExpectedObjectType; +using internal::kEyeGetterContractSize; +using internal::kEyeGetterVtableSlot; +using internal::kEyeInitializerVtableSlot; +using internal::kFramebufferSlotOffset; +using internal::kGraphicsDeviceOffset; +using internal::kHeightOffset; +using internal::kInitializerContractSize; +using internal::kObjectNameOffset; +using internal::kObjectTypeOffset; +using internal::kReadyByteOffset; +using internal::kStateGetterContractSize; +using internal::kStateGetterVtableSlot; +using internal::kTextureSlotOffset; +using internal::kWidthOffset; + +std::atomic g_active_bridge{nullptr}; +// Evidence queries expose only the current native call's provenance. These +// pointers are identities for the layer, never retained/dereferenced by it. +thread_local void *g_initializing_object = nullptr; +thread_local void *g_eye_object = nullptr; +thread_local void *g_eye_framebuffer = nullptr; +thread_local int g_eye_index = -1; +thread_local bool g_thread_presented = false; + +Status InvalidArgument(std::string message) { + return Status::Error(StatusCode::kInvalidArgument, std::move(message)); +} + +Status FailedPrecondition(std::string message) { + return Status::Error(StatusCode::kFailedPrecondition, std::move(message)); +} + +std::uint64_t CurrentThreadId() { + return static_cast(syscall(SYS_gettid)); +} + +bool IsValidElfImage(std::uintptr_t image_base) { + if (image_base == 0) { + return false; + } + const auto *header = reinterpret_cast(image_base); + return std::memcmp(header->e_ident, ELFMAG, SELFMAG) == 0 && + header->e_ident[EI_CLASS] == ELFCLASS64 && header->e_phoff != 0 && + header->e_phnum != 0; +} + +bool IsProgramHeaderRange(std::uintptr_t image_base, std::uintptr_t rva, + std::size_t size, std::uint32_t type, + std::uint32_t required_flags) { + if (!IsValidElfImage(image_base) || size == 0 || + rva > std::numeric_limits::max() - size) { + return false; + } + const auto *header = reinterpret_cast(image_base); + const auto *program_headers = reinterpret_cast( + image_base + static_cast(header->e_phoff)); + const std::uintptr_t range_end = rva + size; + for (std::size_t index = 0; index < header->e_phnum; ++index) { + const Elf64_Phdr &segment = program_headers[index]; + if (segment.p_type != type || + (segment.p_flags & required_flags) != required_flags || + segment.p_vaddr > + std::numeric_limits::max() - segment.p_memsz) { + continue; + } + const std::uintptr_t segment_end = + static_cast(segment.p_vaddr + segment.p_memsz); + if (rva >= segment.p_vaddr && range_end <= segment_end) { + return true; + } + } + return false; +} + +bool IsExecutableImageRange(std::uintptr_t image_base, std::uintptr_t rva, + std::size_t size) { + return IsProgramHeaderRange(image_base, rva, size, PT_LOAD, PF_R | PF_X); +} + +bool IsWritableDataImageRange(std::uintptr_t image_base, std::uintptr_t rva, + std::size_t size) { + return IsProgramHeaderRange(image_base, rva, size, PT_LOAD, PF_R | PF_W); +} + +bool IsRelroImageRange(std::uintptr_t image_base, std::uintptr_t rva, + std::size_t size) { + return IsProgramHeaderRange(image_base, rva, size, PT_GNU_RELRO, 0); +} + +std::uintptr_t ImageSize(std::uintptr_t image_base) { + const auto *header = reinterpret_cast(image_base); + const auto *program_headers = reinterpret_cast( + image_base + static_cast(header->e_phoff)); + std::uintptr_t highest = 0; + for (std::size_t index = 0; index < header->e_phnum; ++index) { + const Elf64_Phdr &segment = program_headers[index]; + if (segment.p_type != PT_LOAD || + segment.p_vaddr > + std::numeric_limits::max() - segment.p_memsz) { + continue; + } + highest = std::max(highest, static_cast(segment.p_vaddr + + segment.p_memsz)); + } + return highest; +} + +bool IsCodePointerInImage(std::uintptr_t image_base, std::uintptr_t image_size, + std::uintptr_t address) { + return address > image_base && image_size != 0 && + address - image_base < image_size; +} + +bool SetVtableRangeWritable(std::uintptr_t *first_slot, + std::uintptr_t *last_slot_exclusive, + bool writable) { + if (first_slot == nullptr || last_slot_exclusive <= first_slot) { + return false; + } + const long page_size_value = sysconf(_SC_PAGESIZE); + if (page_size_value <= 0) { + return false; + } + const std::uintptr_t page_size = static_cast(page_size_value); + const std::uintptr_t first = reinterpret_cast(first_slot); + const std::uintptr_t last = + reinterpret_cast(last_slot_exclusive); + const std::uintptr_t page_begin = first - first % page_size; + const std::uintptr_t page_end = + ((last + page_size - 1) / page_size) * page_size; + const int protection = writable ? PROT_READ | PROT_WRITE : PROT_READ; + return mprotect(reinterpret_cast(page_begin), page_end - page_begin, + protection) == 0; +} + +bool ContainsBytes(const std::uint8_t *code, std::size_t size, + const std::uint8_t *expected, std::size_t expected_size) { + if (code == nullptr || expected == nullptr || expected_size == 0 || + expected_size > size) { + return false; + } + for (std::size_t index = 0; index + expected_size <= size; ++index) { + if (std::memcmp(code + index, expected, expected_size) == 0) { + return true; + } + } + return false; +} + +template std::uintptr_t FunctionAddress(Function function) { + return reinterpret_cast(function); +} + +void Log(const char *format, ...) { + std::va_list arguments; + va_start(arguments, format); + std::vfprintf(stderr, format, arguments); + va_end(arguments); + std::fflush(stderr); +} + +} // namespace + +namespace internal { + +EyeResourceState InspectEyeResources(const void *object) { + if (object == nullptr) + return EyeResourceState::kInvalid; + const auto *bytes = static_cast(object); + std::uintptr_t fb[2], tex[2]; + std::memcpy(&fb[0], bytes + kFramebufferSlotOffset, sizeof(fb[0])); + std::memcpy(&fb[1], bytes + kFramebufferSlotOffset + kEyeSlotStride, + sizeof(fb[1])); + std::memcpy(&tex[0], bytes + kTextureSlotOffset, sizeof(tex[0])); + std::memcpy(&tex[1], bytes + kTextureSlotOffset + kEyeSlotStride, + sizeof(tex[1])); + std::uint32_t width, height; + std::memcpy(&width, bytes + kWidthOffset, sizeof(width)); + std::memcpy(&height, bytes + kHeightOffset, sizeof(height)); + if (width == 0 || height == 0 || width > 16384 || height > 16384) + return EyeResourceState::kInvalid; + if (bytes[kReadyByteOffset] == 0 && !fb[0] && !fb[1] && !tex[0] && !tex[1]) + return EyeResourceState::kEmpty; + if (bytes[kReadyByteOffset] == 1 && fb[0] && fb[1] && tex[0] && tex[1] && + fb[0] != fb[1] && tex[0] != tex[1]) + return EyeResourceState::kReady; + return EyeResourceState::kInvalid; +} + +bool HasExpectedVrStateGetterContract(const std::uint8_t *code, + std::size_t size) { + // push rbp; mov rbp,rsp; push rbx; push rax; mov rbx,rdi; add rsi,0x14; + // mov edx,0x138; call memcpy; mov rax,rbx; add rsp,8; pop rbx; pop rbp; ret + static constexpr std::array + kExpected = {0x55, 0x48, 0x89, 0xe5, 0x53, 0x50, 0x48, 0x89, 0xfb, + 0x48, 0x83, 0xc6, 0x14, 0xba, 0x38, 0x01, 0x00, 0x00, + 0xe8, 0xd3, 0xb5, 0x36, 0x03, 0x48, 0x89, 0xd8, 0x48, + 0x83, 0xc4, 0x08, 0x5b, 0x5d, 0xc3}; + return code != nullptr && size >= kExpected.size() && + std::memcmp(code, kExpected.data(), kExpected.size()) == 0; +} + +bool HasExpectedVrEyeGetterContract(const std::uint8_t *code, + std::size_t size) { + // movsxd rax,esi; shl rax,4; mov rax,[rdi+rax+0x150]; ret + static constexpr std::array kExpected = + {0x48, 0x63, 0xc6, 0x48, 0xc1, 0xe0, 0x04, 0x48, + 0x8b, 0x84, 0x07, 0x50, 0x01, 0x00, 0x00, 0xc3}; + return code != nullptr && size >= kExpected.size() && + std::memcmp(code, kExpected.data(), kExpected.size()) == 0; +} + +bool HasExpectedVrConstructorContract(const std::uint8_t *code, + std::size_t size) { + static constexpr std::array kPrologue = { + 0x55, 0x48, 0x89, 0xe5, 0x41, 0x57, 0x41, 0x56, 0x41, 0x55, + 0x41, 0x54, 0x53, 0x48, 0x83, 0xec, 0x28, 0x49, 0x89, 0xcf, + 0x41, 0x89, 0xd4, 0x41, 0x89, 0xf5, 0x48, 0x89, 0xfb}; + // lea rax,[rip -> DebugDeviceVR vtable]; mov [rdi],rax + static constexpr std::array kVtableStore = { + 0x48, 0x8d, 0x05, 0xf2, 0x74, 0x47, 0x03}; + static constexpr std::array kVptrWrite = {0x48, 0x89, 0x07}; + // mov dword ptr [rdi+0x10], 6 + static constexpr std::array kTypeStore = { + 0xc7, 0x47, 0x10, 0x06, 0x00, 0x00, 0x00}; + // lea r14,[rdi+0x14]; mov edx,0x134 (state block memset) + static constexpr std::array kStateMemset = { + 0x4c, 0x8d, 0x77, 0x14, 0xba, 0x34, 0x01, 0x00, 0x00}; + // mov [rbx+0x190],r13d ; mov [rbx+0x194],r12d + static constexpr std::array kWidthHeightStore = { + 0x44, 0x89, 0xab, 0x90, 0x01, 0x00, 0x00, + 0x44, 0x89, 0xa3, 0x94, 0x01, 0x00, 0x00}; + // mov [rbx+0x198],r15 (graphics device captured from rcx) + static constexpr std::array kDeviceStore = { + 0x4c, 0x89, 0xbb, 0x98, 0x01, 0x00, 0x00}; + if (code == nullptr || size < kConstructorContractSize) { + return false; + } + return std::memcmp(code, kPrologue.data(), kPrologue.size()) == 0 && + ContainsBytes(code, size, kVtableStore.data(), kVtableStore.size()) && + ContainsBytes(code, size, kVptrWrite.data(), kVptrWrite.size()) && + ContainsBytes(code, size, kTypeStore.data(), kTypeStore.size()) && + ContainsBytes(code, size, kStateMemset.data(), kStateMemset.size()) && + ContainsBytes(code, size, kWidthHeightStore.data(), + kWidthHeightStore.size()) && + ContainsBytes(code, size, kDeviceStore.data(), kDeviceStore.size()); +} + +bool HasExpectedVrEyeInitializerContract( + const std::uint8_t *code, std::size_t size, + const compat::VrDebugDeviceBridgeProfile &profile) { + static constexpr std::array kPrologue = { + 0x55, 0x48, 0x89, 0xe5, 0x41, 0x57, 0x41, 0x56, 0x41, 0x55, 0x41, 0x54, + 0x53, 0x48, 0x81, 0xec, 0xa8, 0x00, 0x00, 0x00, 0x48, 0x89, 0xb5, 0x50}; + // mov cl,1; mov [rdi+0x147],cl; mov [rdi+0x94],cl; mov [rdi+0x144],cl + static constexpr std::array kReadyBytes = { + 0xb1, 0x01, 0x88, 0x8f, 0x47, 0x01, 0x00, 0x00, 0x88, 0x8f, + 0x94, 0x00, 0x00, 0x00, 0x88, 0x8f, 0x44, 0x01, 0x00, 0x00}; + // lea rax,[rdi+0x170] (texture slots) + static constexpr std::array kTextureSlotBase = { + 0x48, 0x8d, 0x87, 0x70, 0x01, 0x00, 0x00}; + // lea rax,[rdi+0x150] (framebuffer slots) + static constexpr std::array kFramebufferSlotBase = { + 0x48, 0x8d, 0x87, 0x50, 0x01, 0x00, 0x00}; + // mov rsi,[rbp-0xb0]; mov rax,[rsi]; mov rax,[rax+createSlot]; call rax + std::array device_call = {0x48, 0x8b, 0x06, 0x48, 0x8b, + 0x80, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00}; + const std::uint32_t create_slot = static_cast( + profile.device_create_framebuffer_vtable_offset); + device_call[6] = static_cast(create_slot); + device_call[7] = static_cast(create_slot >> 8); + device_call[8] = static_cast(create_slot >> 16); + device_call[9] = static_cast(create_slot >> 24); + // Two-iteration eye loop: mov r15d,1; test [rbp-0xa4],1; mov ecx,0; jne back + static constexpr std::array kEyeLoopTail = { + 0x41, 0xbf, 0x01, 0x00, 0x00, 0x00, 0xf6, 0x85, + 0x5c, 0xff, 0xff, 0xff, 0x01, 0xb9, 0x00}; + if (code == nullptr || size < kInitializerContractSize) { + return false; + } + return std::memcmp(code, kPrologue.data(), kPrologue.size()) == 0 && + ContainsBytes(code, size, kReadyBytes.data(), kReadyBytes.size()) && + ContainsBytes(code, size, kTextureSlotBase.data(), + kTextureSlotBase.size()) && + ContainsBytes(code, size, kFramebufferSlotBase.data(), + kFramebufferSlotBase.size()) && + ContainsBytes(code, size, device_call.data(), 10) && + ContainsBytes(code, size, kEyeLoopTail.data(), kEyeLoopTail.size()); +} + +bool HasExpectedVrDebugDeviceVtable( + const std::uintptr_t *vtable, std::uintptr_t image_base, + const compat::VrDebugDeviceBridgeProfile &profile) { + return vtable != nullptr && image_base != 0 && + vtable[kStateGetterVtableSlot / sizeof(std::uintptr_t)] == + image_base + profile.state_getter_rva && + vtable[kEyeGetterVtableSlot / sizeof(std::uintptr_t)] == + image_base + profile.eye_getter_rva && + vtable[kEyeInitializerVtableSlot / sizeof(std::uintptr_t)] == + image_base + profile.eye_initializer_rva; +} + +} // namespace internal + +namespace { + +// Guest-callable interposed vtable entries. The guest Android ABI for these +// methods is SysV amd64: the state getter is sret (rdi = hidden 0x138-byte +// result buffer, rsi = device object, returns the buffer) and the eye getter +// takes (rdi = device object, esi = eye index) and returns the framebuffer +// object pointer. +extern "C" void *mocktail_vr_debug_device_state_getter(void *result_buffer, + void *device_object) { + RobloxVrDeviceBridge *bridge = + g_active_bridge.load(std::memory_order_acquire); + if (bridge != nullptr) { + bridge->OnStateGetterCall(result_buffer, device_object); + } + return result_buffer; +} + +extern "C" void *mocktail_vr_debug_device_eye_getter(void *device_object, + int eye_index) { + RobloxVrDeviceBridge *bridge = + g_active_bridge.load(std::memory_order_acquire); + const auto original = + bridge != nullptr ? bridge->original_eye_getter() : nullptr; + void *framebuffer = + original != nullptr ? original(device_object, eye_index) : nullptr; + if (bridge != nullptr) { + bridge->OnEyeGetterCall(device_object, eye_index, framebuffer); + } + return framebuffer; +} + +} // namespace + +extern "C" void *mocktail_vr_initializing_object() { + return g_initializing_object; +} + +extern "C" bool mocktail_vr_current_eye(void **self, int *eye, + void **framebuffer) { + *self = g_eye_object; + *eye = g_eye_index; + *framebuffer = g_eye_framebuffer; + return g_eye_object != nullptr && g_eye_index >= 0 && + g_eye_framebuffer != nullptr; +} + +extern "C" void mocktail_vr_consume_eye() { + g_eye_object = nullptr; + g_eye_framebuffer = nullptr; + g_eye_index = -1; +} + +extern "C" void mocktail_vr_note_host_present_begin() { + RobloxVrDeviceBridge *bridge = + g_active_bridge.load(std::memory_order_acquire); + if (bridge != nullptr) { + bridge->NoteHostPresent(); + } +} + +RobloxVrDeviceBridge::~RobloxVrDeviceBridge() { + Shutdown(); + RobloxVrDeviceBridge *expected = this; + (void)g_active_bridge.compare_exchange_strong( + expected, nullptr, std::memory_order_acq_rel, std::memory_order_acquire); +} + +Status RobloxVrDeviceBridge::Install(const compat::BuildProfile &profile) { + if (installed_) { + return FailedPrecondition("Roblox VR device bridge is already armed"); + } + if (!profile.vr_debug_device_bridge.has_value()) { + return FailedPrecondition( + "experimental Roblox VR requires vr_debug_device_bridge metadata in " + "the exact-build compatibility profile"); + } + if (!profile.allow_host_abi_bridges) { + return FailedPrecondition( + "Roblox VR device bridge profile requires host ABI bridges"); + } + + RobloxVrDeviceBridge *expected = nullptr; + if (!g_active_bridge.compare_exchange_strong(expected, this, + std::memory_order_acq_rel, + std::memory_order_acquire)) { + return FailedPrecondition("another Roblox VR device bridge owns the " + "process"); + } + profile_ = *profile.vr_debug_device_bridge; + installed_ = true; + std::fprintf(stderr, + " [vr-device] native stereo bridge armed for Build ID %s " + "(vtable +0x28/+0x48 interposition; init phase: %s)\n", + profile.elf_build_id.c_str(), "live render-thread state getter"); + return Status::Ok(); +} + +Status RobloxVrDeviceBridge::Activate(std::uintptr_t image_base) { + std::lock_guard lock(mutex_); + if (!installed_) { + return FailedPrecondition("Roblox VR device bridge is not armed"); + } + if (active_.load(std::memory_order_acquire)) { + return image_base == image_base_.load(std::memory_order_relaxed) + ? Status::Ok() + : FailedPrecondition("Roblox VR device bridge saw a second " + "image"); + } + if (!IsValidElfImage(image_base)) { + return InvalidArgument("libroblox image base is not a valid ELF64 image"); + } + const std::uintptr_t largest_rva = std::max( + {profile_.vtable_rva + kEyeInitializerVtableSlot + sizeof(std::uintptr_t), + profile_.constructor_rva + kConstructorContractSize, + profile_.state_getter_rva + kStateGetterContractSize, + profile_.eye_getter_rva + kEyeGetterContractSize, + profile_.eye_initializer_rva + kInitializerContractSize, + profile_.emulator_flag_storage_rva + 1}); + if (image_base > std::numeric_limits::max() - largest_rva) { + return InvalidArgument("VR device profile address overflows"); + } + + const std::uintptr_t vtable_rva_size = + kEyeInitializerVtableSlot + sizeof(std::uintptr_t); + if (!IsRelroImageRange(image_base, profile_.vtable_rva, vtable_rva_size)) { + return FailedPrecondition( + "DebugDeviceVR vtable is not inside the verified RELRO range"); + } + if (!IsExecutableImageRange(image_base, profile_.constructor_rva, + kConstructorContractSize) || + !IsExecutableImageRange(image_base, profile_.state_getter_rva, + kStateGetterContractSize) || + !IsExecutableImageRange(image_base, profile_.eye_getter_rva, + kEyeGetterContractSize) || + !IsExecutableImageRange(image_base, profile_.eye_initializer_rva, + kInitializerContractSize)) { + return FailedPrecondition( + "VR device profile functions are not in executable image ranges"); + } + if (!IsWritableDataImageRange(image_base, profile_.emulator_flag_storage_rva, + 1)) { + return FailedPrecondition( + "VR emulator flag storage is not in a writable data range"); + } + + const auto *vtable = reinterpret_cast( + image_base + profile_.vtable_rva); + if (!internal::HasExpectedVrDebugDeviceVtable(vtable, image_base, profile_)) { + return FailedPrecondition( + "DebugDeviceVR vtable does not match the Build-ID profile"); + } + const auto *state_getter_code = reinterpret_cast( + image_base + profile_.state_getter_rva); + const auto *eye_getter_code = reinterpret_cast( + image_base + profile_.eye_getter_rva); + const auto *constructor_code = reinterpret_cast( + image_base + profile_.constructor_rva); + const auto *initializer_code = reinterpret_cast( + image_base + profile_.eye_initializer_rva); + if (!internal::HasExpectedVrStateGetterContract(state_getter_code, + kStateGetterContractSize) || + !internal::HasExpectedVrEyeGetterContract(eye_getter_code, + kEyeGetterContractSize)) { + return FailedPrecondition( + "DebugDeviceVR getter machine contracts do not match the profile"); + } + if (!internal::HasExpectedVrConstructorContract(constructor_code, + kConstructorContractSize) || + !internal::HasExpectedVrEyeInitializerContract( + initializer_code, kInitializerContractSize, profile_)) { + return FailedPrecondition( + "DebugDeviceVR constructor/initializer machine contracts do not " + "match the profile"); + } + + image_base_.store(image_base, std::memory_order_relaxed); + image_size_.store(ImageSize(image_base), std::memory_order_relaxed); + vtable_ = + reinterpret_cast(image_base + profile_.vtable_rva); + original_state_slot_ = vtable_[kStateGetterVtableSlot / sizeof(void *)]; + original_eye_slot_ = vtable_[kEyeGetterVtableSlot / sizeof(void *)]; + + std::uintptr_t *first_slot = vtable_ + kStateGetterVtableSlot / 8; + std::uintptr_t *last_slot_exclusive = + vtable_ + kEyeInitializerVtableSlot / 8 + 1; + if (!SetVtableRangeWritable(first_slot, last_slot_exclusive, true)) { + vtable_ = nullptr; + return Status::Error(StatusCode::kPlatformError, + "cannot make DebugDeviceVR vtable writable"); + } + original_state_getter_.store( + reinterpret_cast(original_state_slot_), + std::memory_order_release); + original_eye_getter_.store(reinterpret_cast(original_eye_slot_), + std::memory_order_release); + __atomic_store_n(&vtable_[kStateGetterVtableSlot / 8], + FunctionAddress(&mocktail_vr_debug_device_state_getter), + __ATOMIC_RELEASE); + __atomic_store_n(&vtable_[kEyeGetterVtableSlot / 8], + FunctionAddress(&mocktail_vr_debug_device_eye_getter), + __ATOMIC_RELEASE); + if (!SetVtableRangeWritable(first_slot, last_slot_exclusive, false)) { + __atomic_store_n(&vtable_[kStateGetterVtableSlot / 8], original_state_slot_, + __ATOMIC_RELEASE); + __atomic_store_n(&vtable_[kEyeGetterVtableSlot / 8], original_eye_slot_, + __ATOMIC_RELEASE); + (void)SetVtableRangeWritable(first_slot, last_slot_exclusive, false); + original_state_getter_.store(nullptr, std::memory_order_release); + original_eye_getter_.store(nullptr, std::memory_order_release); + vtable_ = nullptr; + return Status::Error(StatusCode::kPlatformError, + "cannot restore DebugDeviceVR vtable RELRO " + "protection"); + } + + active_.store(true, std::memory_order_release); + Log(" [vr-device] activated: DebugDeviceVR vtable slots +0x28/+0x48 " + "interposed at image base 0x%" PRIxPTR "\n", + image_base); + return Status::Ok(); +} + +void RobloxVrDeviceBridge::Shutdown() { + if (!installed_) { + return; + } + bool restored = true; + { + std::lock_guard lock(mutex_); + if (active_.load(std::memory_order_acquire)) { + active_.store(false, std::memory_order_release); + restored = RestoreVtableLocked(); + Log(" [vr-device] shutdown: captured=%d initialized=%d " + "eye0_calls=%" PRIu64 " eye1_calls=%" PRIu64 " presents=%" PRIu64 + " vtable_restored=%d\n", + captured_device_count(), initialized_device_count(), + eye_getter_call_count(0), eye_getter_call_count(1), + present_count_.load(std::memory_order_relaxed), restored ? 1 : 0); + vtable_ = nullptr; + image_base_.store(0, std::memory_order_relaxed); + image_size_.store(0, std::memory_order_relaxed); + } + } + if (!restored) { + std::fprintf(stderr, " [vr-device] failed to restore DebugDeviceVR vtable " + "protection\n"); + } + installed_ = false; +} + +bool RobloxVrDeviceBridge::RestoreVtableLocked() { + if (vtable_ == nullptr) { + return false; + } + std::uintptr_t *first_slot = vtable_ + kStateGetterVtableSlot / 8; + std::uintptr_t *last_slot_exclusive = + vtable_ + kEyeInitializerVtableSlot / 8 + 1; + if (!SetVtableRangeWritable(first_slot, last_slot_exclusive, true)) { + return false; + } + __atomic_store_n(&vtable_[kStateGetterVtableSlot / 8], original_state_slot_, + __ATOMIC_RELEASE); + __atomic_store_n(&vtable_[kEyeGetterVtableSlot / 8], original_eye_slot_, + __ATOMIC_RELEASE); + return SetVtableRangeWritable(first_slot, last_slot_exclusive, false); +} + +void RobloxVrDeviceBridge::NoteHostPresent() { + if (!active_.load(std::memory_order_acquire)) { + return; + } + const std::uint64_t thread_id = CurrentThreadId(); + std::uint64_t expected_thread = 0; + if (present_thread_.compare_exchange_strong(expected_thread, thread_id, + std::memory_order_acq_rel)) { + Log(" [vr-device] render thread observed: tid=%" PRIu64 "\n", thread_id); + } + const std::uint64_t presents = + present_count_.fetch_add(1, std::memory_order_relaxed) + 1; + if (presents == 1) { + Log(" [vr-device] first host present completed on tid=%" PRIu64 "\n", + thread_id); + } + g_thread_presented = true; + mocktail_vr_consume_eye(); +} + +bool RobloxVrDeviceBridge::ValidateObjectHeader(void *self) const { + const std::uintptr_t base = image_base_.load(std::memory_order_acquire); + if (self == nullptr || base == 0) { + return false; + } + auto *bytes = static_cast(self); + const std::uintptr_t vptr = *reinterpret_cast(bytes); + if (vptr != base + profile_.vtable_rva) { + return false; + } + const std::uint32_t type = + *reinterpret_cast(bytes + kObjectTypeOffset); + if (type != kExpectedObjectType) { + return false; + } + const char *name = + *reinterpret_cast(bytes + kObjectNameOffset); + return name != nullptr && std::strcmp(name, "DebugDevice") == 0; +} + +void *RobloxVrDeviceBridge::ValidatedGraphicsDevice(void *self) const { + auto *bytes = static_cast(self); + void *device = + *reinterpret_cast(bytes + kGraphicsDeviceOffset); + if (device == nullptr) { + return nullptr; + } + const std::uintptr_t base = image_base_.load(std::memory_order_acquire); + const std::uintptr_t size = image_size_.load(std::memory_order_acquire); + const std::uintptr_t device_vtable = + *reinterpret_cast(device); + if (!IsCodePointerInImage(base, size, device_vtable)) { + return nullptr; + } + const std::uintptr_t create_framebuffer = + *reinterpret_cast( + device_vtable + profile_.device_create_framebuffer_vtable_offset); + if (!IsCodePointerInImage(base, size, create_framebuffer)) { + return nullptr; + } + return device; +} + +bool internal::ApplyXrPose(void* state, const ScriptedPoseSample& pose) { + if (!state) return false; + auto* bytes = static_cast(state); + bytes[0] = 0; + // No tracked controllers are supplied by this backend. + bytes[0x20] = bytes[0x40] = bytes[0x60] = 0; + if (!pose.valid) return false; + float norm = 0; + for (float v : pose.orientation) { if (!std::isfinite(v)) return false; norm += v*v; } + if (norm < 0.99f || norm > 1.01f) return false; + for (float v : pose.position) if (!std::isfinite(v)) return false; + for (int eye = 0; eye < 2; ++eye) { + for (float v : pose.eye_offset[eye]) if (!std::isfinite(v)) return false; + for (float v : pose.eye_fov[eye]) + if (!std::isfinite(v) || std::abs(v) >= 1.56f) return false; + } + // 2998's 0x37eacb8 converts metres to studs itself (factor 10/3). + std::memcpy(bytes + 4, pose.position, sizeof(pose.position)); + std::memcpy(bytes + 0x10, pose.orientation, sizeof(pose.orientation)); + for (int eye = 0; eye < 2; ++eye) { + std::memcpy(bytes + 0xf8 + eye*12, pose.eye_offset[eye], 12); + const auto* fov = pose.eye_fov[eye]; + const float tangents[4] = {std::tan(fov[2]), -std::tan(fov[3]), + -std::tan(fov[0]), std::tan(fov[1])}; + std::memcpy(bytes + 0x110 + eye*16, tangents, sizeof(tangents)); + } + bytes[0] = 1; + return true; +} + +void RobloxVrDeviceBridge::OnStateGetterCall(void *result_buffer, + void *device_object) { + // Serialize initialization with all copies of readiness: the initializer + // raises the guest ready byte before its resources have been allocated. + std::lock_guard lock(mutex_); + if (active_.load(std::memory_order_acquire) && g_thread_presented && + ValidateObjectHeader(device_object) && + internal::InspectEyeResources(device_object) == + internal::EyeResourceState::kEmpty) { + TryInitializeEyes(device_object); + } + const auto original = original_state_getter(); + if (original != nullptr) + original(result_buffer, device_object); + if (active_.load(std::memory_order_acquire) && result_buffer && original) { + if (auto* backend = ActiveVrBackend()) { + const auto pose = backend->PublishedHeadPose(); + if (internal::ApplyXrPose(result_buffer, pose)) { + backend->NotePoseApplied(device_object, pose.frame); + static thread_local std::uint64_t last_logged = 0; + if (pose.frame != last_logged && (pose.frame == 1 || pose.frame % 20 == 0)) { + last_logged = pose.frame; + Log(" [vr-device][evidence] pose-applied frame=%llu owner=%p " + "pos=(%.4f,%.4f,%.4f) ori=(%.4f,%.4f,%.4f,%.4f)\n", + static_cast(pose.frame), device_object, + pose.position[0], pose.position[1], pose.position[2], + pose.orientation[0], pose.orientation[1], pose.orientation[2], pose.orientation[3]); + } + } + } + } + if (active_.load(std::memory_order_acquire) && result_buffer != nullptr && + internal::InspectEyeResources(device_object) != + internal::EyeResourceState::kReady) { + // Fail closed in the returned state, without changing guest ownership or + // manufacturing readiness. Partial/failed resources must never be rendered. + static_cast( + result_buffer)[internal::kStateReadyOffsetInCopy] = 0; + } +} + +void RobloxVrDeviceBridge::TryInitializeEyes(void *self) { + if (self == nullptr || !ValidateObjectHeader(self)) { + Log(" [vr-device] eye-init aborted: object header no longer valid " + "(self=%p)\n", + self); + return; + } + const auto *flag = reinterpret_cast( + image_base_.load(std::memory_order_acquire) + + profile_.emulator_flag_storage_rva); + if (*flag == 0) { + bool expected = false; + if (flag_warning_logged_.compare_exchange_strong(expected, true)) { + Log(" [vr-device] eye-init deferred: FFlagDebugEnableVREmulator " + "storage byte is 0\n"); + } + return; + } + void *device = ValidatedGraphicsDevice(self); + if (device == nullptr) { + Log(" [vr-device] eye-init failed: graphics device at self+0x198 did " + "not pass the machine contract (self=%p)\n", + self); + return; + } + captured_count_.fetch_add(1, std::memory_order_relaxed); + auto *bytes = static_cast(self); + Log(" [vr-device] captured DebugDeviceVR object self=%p device=%p\n", self, + device); + const auto initializer = reinterpret_cast( + image_base_.load(std::memory_order_acquire) + + profile_.eye_initializer_rva); + Log(" [vr-device][evidence] eye-init-begin self=%p device=%p tid=%" PRIu64 + " presents=%" PRIu64 "\n", + self, device, CurrentThreadId(), + present_count_.load(std::memory_order_relaxed)); + g_initializing_object = self; + initializer(self, device); + g_initializing_object = nullptr; + Log(" [vr-device][evidence] eye-init-returned self=%p\n", self); + + const std::uint8_t ready = bytes[kReadyByteOffset]; + const void *framebuffer0 = + *reinterpret_cast(bytes + kFramebufferSlotOffset); + const void *framebuffer1 = *reinterpret_cast( + bytes + kFramebufferSlotOffset + internal::kEyeSlotStride); + const void *texture0 = + *reinterpret_cast(bytes + kTextureSlotOffset); + const void *texture1 = *reinterpret_cast( + bytes + kTextureSlotOffset + internal::kEyeSlotStride); + const std::uint32_t width = + *reinterpret_cast(bytes + kWidthOffset); + const std::uint32_t height = + *reinterpret_cast(bytes + kHeightOffset); + const bool slots_valid = framebuffer0 != nullptr && framebuffer1 != nullptr && + texture0 != nullptr && texture1 != nullptr && + framebuffer0 != framebuffer1 && texture0 != texture1; + if (ready == 1 && slots_valid && + internal::InspectEyeResources(self) == + internal::EyeResourceState::kReady) { + initialized_count_.fetch_add(1, std::memory_order_relaxed); + Log(" [vr-device][evidence] eye-init-complete self=%p ready=1 fb0=%p " + "fb1=%p tex0=%p tex1=%p width=%u height=%u\n", + self, framebuffer0, framebuffer1, texture0, texture1, width, height); + const auto *eye0_view = + reinterpret_cast(bytes + internal::kEye0ViewOffset); + const auto *eye1_view = + reinterpret_cast(bytes + internal::kEye1ViewOffset); + const auto *eye0_projection = reinterpret_cast( + bytes + internal::kEye0ProjectionOffset); + const auto *eye1_projection = reinterpret_cast( + bytes + internal::kEye1ProjectionOffset); + const auto *field_of_view = + reinterpret_cast(bytes + internal::kFieldOfViewOffset); + const float half_ipd = *reinterpret_cast( + bytes + internal::kHalfInterpupillaryOffset); + Log(" [vr-device][evidence] eye-params self=%p eye0_view=(%.4f,%.4f," + "%.4f) eye1_view=(%.4f,%.4f,%.4f) eye0_proj=(%.4f,%.4f,%.4f,%.4f) " + "eye1_proj=(%.4f,%.4f,%.4f,%.4f) fov_deg=(%.1f,%.1f,%.1f,%.1f) " + "half_ipd=%.4f\n", + self, eye0_view[0], eye0_view[1], eye0_view[2], eye1_view[0], + eye1_view[1], eye1_view[2], eye0_projection[0], eye0_projection[1], + eye0_projection[2], eye0_projection[3], eye1_projection[0], + eye1_projection[1], eye1_projection[2], eye1_projection[3], + field_of_view[0], field_of_view[1], field_of_view[2], field_of_view[3], + half_ipd); + } else { + Log(" [vr-device][evidence] eye-init-failed self=%p ready=%u fb0=%p " + "fb1=%p tex0=%p tex1=%p width=%u height=%u\n", + self, ready, framebuffer0, framebuffer1, texture0, texture1, width, + height); + } +} + +void RobloxVrDeviceBridge::OnEyeGetterCall(void *device_object, int eye_index, + void *framebuffer) { + if (!active_.load(std::memory_order_acquire)) { + return; + } + if (eye_index < 0 || eye_index > 1) { + Log(" [vr-device] eye getter called with unexpected index %d self=%p\n", + eye_index, device_object); + return; + } + g_eye_object = device_object; + g_eye_framebuffer = framebuffer; + g_eye_index = eye_index; + const std::uint64_t calls = + eye_call_totals_[eye_index].fetch_add(1, std::memory_order_relaxed) + 1; + if (calls <= 4) { + Log(" [vr-device][evidence] eye-getter self=%p eye=%d framebuffer=%p " + "call=%" PRIu64 " tid=%" PRIu64 "\n", + device_object, eye_index, framebuffer, calls, CurrentThreadId()); + } else if (calls % kEyeGetterSummaryInterval == 0) { + Log(" [vr-device][evidence] eye-getter-summary self=%p eye0=%" PRIu64 + " eye1=%" PRIu64 " last_framebuffer=%p\n", + device_object, eye_getter_call_count(0), eye_getter_call_count(1), + framebuffer); + } +} + +int RobloxVrDeviceBridge::captured_device_count() const { + return captured_count_.load(std::memory_order_relaxed); +} + +int RobloxVrDeviceBridge::initialized_device_count() const { + return initialized_count_.load(std::memory_order_relaxed); +} + +std::uint64_t RobloxVrDeviceBridge::eye_getter_call_count(int eye_index) const { + if (eye_index < 0 || eye_index > 1) { + return 0; + } + return eye_call_totals_[eye_index].load(std::memory_order_relaxed); +} + +Status NotifyRobloxVrImageLoaded(std::uintptr_t image_base) { + RobloxVrDeviceBridge *bridge = + g_active_bridge.load(std::memory_order_acquire); + if (bridge == nullptr) { + return Status::Ok(); + } + if (image_base == 0) { + return FailedPrecondition( + "Roblox VR device bridge is armed but libroblox base is unknown"); + } + return bridge->Activate(image_base); +} + +} // namespace mocktail::vr diff --git a/src/vr/roblox_vr_device_bridge_internal.h b/src/vr/roblox_vr_device_bridge_internal.h new file mode 100644 index 0000000..d7b3987 --- /dev/null +++ b/src/vr/roblox_vr_device_bridge_internal.h @@ -0,0 +1,82 @@ +#ifndef MOCKTAIL_VR_ROBLOX_VR_DEVICE_BRIDGE_INTERNAL_H_ +#define MOCKTAIL_VR_ROBLOX_VR_DEVICE_BRIDGE_INTERNAL_H_ + +#include +#include + +#include "compat/build_profile.h" + +namespace mocktail::vr { struct ScriptedPoseSample; } +namespace mocktail::vr::internal { + +// Writes only a caller-owned state copy; preserves all resource readiness. +bool ApplyXrPose(void* state, const ScriptedPoseSample& pose); + +// Guest ABI layout constants of RBX::Graphics::DebugDeviceVR. They are pinned +// per exact build by the machine-code contracts below; changing payload +// requires re-deriving both. +inline constexpr std::size_t kObjectNameOffset = 0x8; +inline constexpr std::size_t kObjectTypeOffset = 0x10; +inline constexpr std::uint32_t kExpectedObjectType = 6; +inline constexpr std::size_t kStateOffset = 0x14; +inline constexpr std::size_t kStateCopySize = 0x138; +inline constexpr std::size_t kStateReadyOffsetInCopy = 0x133; +inline constexpr std::size_t kReadyByteOffset = 0x147; +inline constexpr std::size_t kFramebufferSlotOffset = 0x150; +inline constexpr std::size_t kTextureSlotOffset = 0x170; +inline constexpr std::size_t kEyeSlotStride = 0x10; +inline constexpr std::size_t kWidthOffset = 0x190; +inline constexpr std::size_t kHeightOffset = 0x194; +inline constexpr std::size_t kGraphicsDeviceOffset = 0x198; +// Per-eye camera parameters filled by the constructor: symmetric +-0.25 stud +// view offsets (half IPD), tan-space projection values and FOV degrees. +inline constexpr std::size_t kEye0ViewOffset = 0x10c; +inline constexpr std::size_t kEye1ViewOffset = 0x118; +inline constexpr std::size_t kEye0ProjectionOffset = 0x124; +inline constexpr std::size_t kEye1ProjectionOffset = 0x134; +inline constexpr std::size_t kFieldOfViewOffset = 0x1a0; +inline constexpr std::size_t kHalfInterpupillaryOffset = 0x1b8; + +inline constexpr std::size_t kStateGetterVtableSlot = 0x28; +inline constexpr std::size_t kEyeGetterVtableSlot = 0x48; +inline constexpr std::size_t kEyeInitializerVtableSlot = 0xd0; + +inline constexpr std::size_t kStateGetterContractSize = 33; +inline constexpr std::size_t kEyeGetterContractSize = 16; +inline constexpr std::size_t kConstructorContractSize = 664; +inline constexpr std::size_t kInitializerContractSize = 700; + +enum class EyeResourceState { kEmpty, kReady, kInvalid }; +// Reads only the current live object's ABI bytes. A ready byte alone is +// insufficient; dimensions and both distinct framebuffer/texture slots count. +EyeResourceState InspectEyeResources(const void *object); + +// Exact 33-byte state getter: sret ABI (rdi = hidden 0x138-byte result +// buffer, rsi = device), copies state from self+0x14 and returns the buffer. +bool HasExpectedVrStateGetterContract(const std::uint8_t *code, + std::size_t size); + +// Exact 16-byte eye framebuffer getter: self + 0x150 + eyeIndex * 16. +bool HasExpectedVrEyeGetterContract(const std::uint8_t *code, std::size_t size); + +// Constructor: captures rcx (graphics device) into self+0x198, width/height +// into +0x190/+0x194, type dword 6 into +0x10, and clears the state block. +bool HasExpectedVrConstructorContract(const std::uint8_t *code, + std::size_t size); + +// Eye-resource initializer: raises ready byte self+0x147 plus +0x94/+0x144 +// and creates the per-eye resources through the graphics-device vtable slot +// taken from the profile (2998 uses +0x180; other builds differ). +bool HasExpectedVrEyeInitializerContract( + const std::uint8_t *code, std::size_t size, + const compat::VrDebugDeviceBridgeProfile &profile); + +// DebugDeviceVR vtable must point the three interposed/used slots at the +// exact profile RVAs before any interposition. +bool HasExpectedVrDebugDeviceVtable( + const std::uintptr_t *vtable, std::uintptr_t image_base, + const compat::VrDebugDeviceBridgeProfile &profile); + +} // namespace mocktail::vr::internal + +#endif // MOCKTAIL_VR_ROBLOX_VR_DEVICE_BRIDGE_INTERNAL_H_ diff --git a/src/vr/vr_mirror.cc b/src/vr/vr_mirror.cc new file mode 100644 index 0000000..1c3f722 --- /dev/null +++ b/src/vr/vr_mirror.cc @@ -0,0 +1,268 @@ +#include "mocktail/vr/vr_mirror.h" + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace mocktail::vr { +namespace { + +// Seqlock writers/reader fences: the header is plain shared memory, so +// ordering between the sequence counter and the payload is enforced here. +void StoreFence() { std::atomic_thread_fence(std::memory_order_release); } + +std::uint32_t FindHostVisibleMemoryType(const MirrorVkProcs& vk, + VkPhysicalDevice physical, + std::uint32_t type_bits) { + VkPhysicalDeviceMemoryProperties properties{}; + vk.GetPhysicalDeviceMemoryProperties(physical, &properties); + const auto required = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | + VK_MEMORY_PROPERTY_HOST_COHERENT_BIT; + // Prefer HOST_CACHED system memory: CPU reads of device-local BAR1 memory + // cost ~10ms per 2 MB pair on this GPU, while cached RAM readback is ~1ms. + for (std::uint32_t index = 0; index < properties.memoryTypeCount; ++index) { + if ((type_bits & (1u << index)) != 0 && + (properties.memoryTypes[index].propertyFlags & + (required | VK_MEMORY_PROPERTY_HOST_CACHED_BIT)) == + (required | VK_MEMORY_PROPERTY_HOST_CACHED_BIT)) { + return index; + } + } + for (std::uint32_t index = 0; index < properties.memoryTypeCount; ++index) { + if ((type_bits & (1u << index)) != 0 && + (properties.memoryTypes[index].propertyFlags & required) == required) { + return index; + } + } + return UINT32_MAX; +} + +} // namespace + +VrMirror::~VrMirror() { Close(); } + +bool VrMirror::Open(const std::string& shm_name, std::uint32_t width, + std::uint32_t height, std::string* error) { + if (mapping_ != nullptr) { + const auto* header = static_cast(mapping_); + if (shm_name_ == shm_name && header->width == width && header->height == height) { + return true; + } + Close(); + } + if (shm_name.empty() || shm_name[0] != '/' || width == 0 || height == 0) { + if (error != nullptr) { + *error = "invalid mirror segment name or extent"; + } + return false; + } + const std::uint64_t bytes = VrMirrorBytes(width, height); + const int descriptor = + shm_open(shm_name.c_str(), O_CREAT | O_RDWR | O_CLOEXEC, 0600); + if (descriptor < 0) { + if (error != nullptr) { + *error = "shm_open failed: " + std::string(std::strerror(errno)); + } + return false; + } + if (ftruncate(descriptor, static_cast(bytes)) != 0) { + if (error != nullptr) { + *error = "ftruncate failed: " + std::string(std::strerror(errno)); + } + close(descriptor); + (void)shm_unlink(shm_name.c_str()); + return false; + } + void* mapping = mmap(nullptr, bytes, PROT_READ | PROT_WRITE, MAP_SHARED, + descriptor, 0); + close(descriptor); + if (mapping == MAP_FAILED) { + if (error != nullptr) { + *error = "mmap failed: " + std::string(std::strerror(errno)); + } + (void)shm_unlink(shm_name.c_str()); + return false; + } + mapping_ = mapping; + mapping_bytes_ = bytes; + shm_name_ = shm_name; + auto* header = static_cast(mapping_); + std::memset(header, 0, sizeof(*header)); + header->magic = kVrMirrorMagic; + header->header_bytes = kVrMirrorHeaderBytes; + header->width = width; + header->height = height; + StoreFence(); + return true; +} + +void VrMirror::Close() { + if (mapping_ != nullptr) { + munmap(mapping_, mapping_bytes_); + mapping_ = nullptr; + mapping_bytes_ = 0; + } + if (!shm_name_.empty()) { + (void)shm_unlink(shm_name_.c_str()); + shm_name_.clear(); + } + published_pairs_ = 0; +} + +bool VrMirror::EnsureStaging(const MirrorVkProcs& vk, VkDevice device, + VkPhysicalDevice physical, std::uint32_t width, + std::uint32_t height, std::string* error) { + if (staging_device_ == device && staging_[0] != VK_NULL_HANDLE && + staging_[1] != VK_NULL_HANDLE && staging_width_ == width && + staging_height_ == height) { + return true; + } + DestroyStaging(vk); + staging_device_ = device; // Also owns partially constructed allocations. + const VkDeviceSize size = + static_cast(width) * height * 4u; + VkPhysicalDeviceMemoryProperties properties{}; + vk.GetPhysicalDeviceMemoryProperties(physical, &properties); + for (int eye = 0; eye < 2; ++eye) { + VkBufferCreateInfo buffer_info{}; + buffer_info.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + buffer_info.size = size; + buffer_info.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT; + if (vk.CreateBuffer(device, &buffer_info, nullptr, &staging_[eye]) != + VK_SUCCESS) { + if (error != nullptr) { + *error = "mirror staging buffer creation failed"; + } + DestroyStaging(vk); + return false; + } + VkMemoryRequirements requirements{}; + vk.GetBufferMemoryRequirements(device, staging_[eye], &requirements); + const std::uint32_t type = + FindHostVisibleMemoryType(vk, physical, requirements.memoryTypeBits); + if (type == UINT32_MAX) { + if (error != nullptr) { + *error = "no host-visible coherent memory for the mirror"; + } + DestroyStaging(vk); + return false; + } + VkMemoryAllocateInfo allocation{}; + allocation.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + allocation.allocationSize = requirements.size; + allocation.memoryTypeIndex = type; + if (vk.AllocateMemory(device, &allocation, nullptr, + &staging_memory_[eye]) != VK_SUCCESS) { + if (error != nullptr) { + *error = "mirror staging allocation failed"; + } + DestroyStaging(vk); + return false; + } + if (vk.BindBufferMemory(device, staging_[eye], staging_memory_[eye], 0) != + VK_SUCCESS || + vk.MapMemory(device, staging_memory_[eye], 0, size, 0, + &staging_map_[eye]) != VK_SUCCESS) { + if (error != nullptr) { + *error = "mirror staging bind/map failed"; + } + DestroyStaging(vk); + return false; + } + } + staging_device_ = device; + staging_width_ = width; + staging_height_ = height; + return true; +} + +void VrMirror::DestroyStaging(const MirrorVkProcs& vk) { + for (int eye = 0; eye < 2; ++eye) { + if (staging_map_[eye] != nullptr && staging_memory_[eye] != VK_NULL_HANDLE) { + vk.UnmapMemory(staging_device_, staging_memory_[eye]); + staging_map_[eye] = nullptr; + } + if (staging_memory_[eye] != VK_NULL_HANDLE) { + vk.FreeMemory(staging_device_, staging_memory_[eye], nullptr); + staging_memory_[eye] = VK_NULL_HANDLE; + } + if (staging_[eye] != VK_NULL_HANDLE) { + vk.DestroyBuffer(staging_device_, staging_[eye], nullptr); + staging_[eye] = VK_NULL_HANDLE; + } + } + staging_width_ = 0; + staging_height_ = 0; + staging_device_ = VK_NULL_HANDLE; +} + +void VrMirror::RecordEyeCopy(const MirrorVkProcs& vk, VkCommandBuffer cmd, + int eye, VkImage source, + VkImageLayout /*final_layout*/, + std::uint32_t width, std::uint32_t height) { + // The caller's batched command buffer already holds the source in + // TRANSFER_SRC_OPTIMAL for the XR swapchain copy and restores its final + // layout afterwards; recording only the copy here keeps one transition pair + // per eye per frame. + if (eye < 0 || eye > 1 || staging_[eye] == VK_NULL_HANDLE || + staging_width_ != width || staging_height_ != height) { + return; + } + VkBufferImageCopy region{}; + region.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + region.imageExtent = {width, height, 1}; + vk.CmdCopyImageToBuffer(cmd, source, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + staging_[eye], 1, ®ion); +} + +void VrMirror::Publish(std::uint64_t frame, VkFormat format, + std::uint32_t width, std::uint32_t height, + std::uint64_t now_ns) { + if (mapping_ == nullptr || staging_map_[0] == nullptr || + staging_map_[1] == nullptr || staging_width_ != width || + staging_height_ != height) { + return; + } + PublishFromBytesForTest(frame, format, width, height, now_ns, staging_map_[0], + staging_map_[1]); +} + +void VrMirror::PublishFromBytesForTest(std::uint64_t frame, VkFormat format, + std::uint32_t width, + std::uint32_t height, + std::uint64_t now_ns, const void* eye0, + const void* eye1) { + if (mapping_ == nullptr || eye0 == nullptr || eye1 == nullptr) { + return; + } + auto* header = static_cast(mapping_); + auto* pixels = static_cast(mapping_) + kVrMirrorHeaderBytes; + const std::uint64_t eye_bytes = + static_cast(width) * height * 4u; + if (kVrMirrorHeaderBytes + 2 * eye_bytes > mapping_bytes_) { + return; + } + // Seqlock: odd sequence marks the payload unstable for readers. + const std::uint64_t sequence = header->seq + 1; + header->seq = sequence | 1ull; + StoreFence(); + header->width = width; + header->height = height; + header->format = static_cast(format); + header->frame = frame; + header->producer_ns = now_ns; + std::memcpy(pixels, eye0, eye_bytes); + std::memcpy(pixels + eye_bytes, eye1, eye_bytes); + header->flags = kVrMirrorFlagValid; + header->published_pairs = ++published_pairs_; + StoreFence(); + header->seq = sequence + 1; +} + +} // namespace mocktail::vr diff --git a/src/vr/vr_perf.cc b/src/vr/vr_perf.cc new file mode 100644 index 0000000..933cd5b --- /dev/null +++ b/src/vr/vr_perf.cc @@ -0,0 +1,220 @@ +#include "mocktail/vr/vr_perf.h" + +#include + +#include +#include + +namespace mocktail::vr::perf { +namespace { + +Collector& Global() { + static Collector collector; + return collector; +} + +} // namespace + +bool Enabled() { + static const bool enabled = [] { + const char* value = std::getenv("MOCKTAIL_VR_PERF"); + return value != nullptr && value[0] != '\0' && *value != '0'; + }(); + if (!enabled) return false; + static const std::uint64_t start = [] { + const char* value = std::getenv("MOCKTAIL_VR_MEASURE_START_NS"); + return value ? std::strtoull(value, nullptr, 10) : 0ULL; + }(); + static const std::uint64_t end = [] { + const char* value = std::getenv("MOCKTAIL_VR_MEASURE_END_NS"); + return value ? std::strtoull(value, nullptr, 10) : ~0ULL; + }(); + if (start == 0 && end == ~0ULL) return true; + const auto now = NowNs(); + return now >= start && now <= end; +} + +std::uint64_t NowNs() { + timespec ts{}; + if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) { + return 0; + } + return static_cast(ts.tv_sec) * 1'000'000'000ULL + + static_cast(ts.tv_nsec); +} + +const char* StageName(Stage stage) { + switch (stage) { + case Stage::kFrameCycle: return "frame_cycle"; + case Stage::kXrWaitFrame: return "xr_wait_frame"; + case Stage::kXrBeginFrame: return "xr_begin_frame"; + case Stage::kXrLocateViews: return "xr_locate_views"; + case Stage::kSwapchainAcquire: return "swapchain_acquire"; + case Stage::kSwapchainWait: return "swapchain_wait"; + case Stage::kCopyRecord: return "copy_record"; + case Stage::kCopySubmit: return "copy_submit"; + case Stage::kCopyFenceWait: return "copy_fence_wait"; + case Stage::kMirrorPublish: return "mirror_publish"; + case Stage::kEvidence: return "evidence"; + case Stage::kStageCount: break; + } + return "unknown"; +} + +const char* CounterName(Counter counter) { + switch (counter) { + case Counter::kPresents: return "presents"; + case Counter::kFramesBegun: return "frames_begun"; + case Counter::kFramesSubmitted: return "frames_submitted"; + case Counter::kFramesSkipped: return "frames_skipped"; + case Counter::kSkipShouldRender: return "skip_should_render"; + case Counter::kSkipPoseInvalid: return "skip_pose_invalid"; + case Counter::kSkipEyesUnbound: return "skip_eyes_unbound"; + case Counter::kSkipEyeFrameMismatch: return "skip_eye_frame_mismatch"; + case Counter::kSubmits: return "submits"; + case Counter::kFenceWaits: return "fence_waits"; + case Counter::kMirrorPairs: return "mirror_pairs"; + case Counter::kMirrorDrops: return "mirror_drops"; + case Counter::kSkipVisibilityLost: return "skip_visibility_lost"; + case Counter::kReferenceSpaceChanges: return "reference_space_changes"; + case Counter::kVisibilityLost: return "visibility_lost"; + case Counter::kVisibilityGained: return "visibility_gained"; + case Counter::kFramesInvalidated: return "frames_invalidated"; + case Counter::kCounterCount: break; + } + return "unknown"; +} + +void Collector::Record(Stage stage, std::uint64_t nanos) { + if (!Enabled()) { + return; + } + const std::size_t index = static_cast(stage); + if (index >= static_cast(Stage::kStageCount)) { + return; + } + StageStat& stat = stages_[index]; + if (stat.samples.size() < kReservoir) { + stat.samples.resize(kReservoir, 0); + } + if (stat.count == kReservoir) { + stat.total_ns -= stat.samples[stat.head]; + } + stat.samples[stat.head] = nanos; + stat.head = (stat.head + 1) % kReservoir; + if (stat.count < kReservoir) { + ++stat.count; + } + stat.total_ns += nanos; +} + +void Collector::Count(Counter counter, std::uint64_t delta) { + if (!Enabled()) { + return; + } + const std::size_t index = static_cast(counter); + if (index >= static_cast(Counter::kCounterCount)) { + return; + } + counters_[index] += delta; +} + +std::vector Collector::Percentiles(Stage stage) const { + const std::size_t index = static_cast(stage); + if (index >= static_cast(Stage::kStageCount)) { + return {}; + } + const StageStat& stat = stages_[index]; + if (stat.count == 0) { + return {0, 0, 0, 0}; + } + std::vector sorted(stat.samples.begin(), + stat.samples.begin() + stat.count); + std::sort(sorted.begin(), sorted.end()); + auto pick = [&sorted](double fraction) { + const std::size_t position = static_cast( + fraction * static_cast(sorted.size() - 1)); + return sorted[position]; + }; + return {pick(0.50), pick(0.95), pick(0.99), sorted.back()}; +} + +std::size_t Collector::Samples(Stage stage) const { + const std::size_t index = static_cast(stage); + if (index >= static_cast(Stage::kStageCount)) { + return 0; + } + return stages_[index].count; +} + +std::uint64_t Collector::counter(Counter counter_id) const { + const std::size_t index = static_cast(counter_id); + if (index >= static_cast(Counter::kCounterCount)) { + return 0; + } + return counters_[index]; +} + +void Collector::Reset() { + for (StageStat& stat : stages_) { + stat.head = 0; + stat.count = 0; + stat.total_ns = 0; + } + for (std::uint64_t& value : counters_) { + value = 0; + } + last_emit_ns_ = 0; + window_start_ns_ = 0; +} + +void Collector::EmitIfDue(std::uint64_t now_ns, const char* context) { + if (!Enabled() || now_ns == 0) { + return; + } + if (window_start_ns_ == 0) { + window_start_ns_ = now_ns; + last_emit_ns_ = now_ns; + return; + } + if (now_ns - last_emit_ns_ < kIntervalNs) { + return; + } + const double window_seconds = + static_cast(now_ns - window_start_ns_) / 1e9; + std::fprintf(stderr, " [vr-perf] window=%.1fs context=%s\n", + window_seconds, context != nullptr ? context : ""); + for (std::size_t index = 0; + index < static_cast(Stage::kStageCount); ++index) { + const StageStat& stat = stages_[index]; + if (stat.count == 0) { + continue; + } + const std::vector ranked = + Percentiles(static_cast(index)); + std::fprintf(stderr, + " [vr-perf] stage=%-16s n=%zu avg=%.2fms p50=%.2fms " + "p95=%.2fms p99=%.2fms max=%.2fms\n", + StageName(static_cast(index)), stat.count, + static_cast(stat.total_ns) / + static_cast(stat.count) / 1e6, + ranked[0] / 1e6, ranked[1] / 1e6, ranked[2] / 1e6, + ranked[3] / 1e6); + } + for (std::size_t index = 0; + index < static_cast(Counter::kCounterCount); ++index) { + const std::uint64_t value = counters_[index]; + if (value == 0) { + continue; + } + std::fprintf(stderr, " [vr-perf] counter=%-22s total=%llu rate=%.2f/s\n", + CounterName(static_cast(index)), + static_cast(value), + static_cast(value) / std::max(window_seconds, 1e-9)); + } + last_emit_ns_ = now_ns; +} + +Collector& ProcessCollector() { return Global(); } + +} // namespace mocktail::vr::perf diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 21bb1e0..863b8e1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -3,10 +3,60 @@ # Licensed under the Apache License, Version 2.0 (the "License") find_package(Python3 COMPONENTS Interpreter REQUIRED) -add_test(NAME OpenXrSimulatorLauncher - COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/vr/simulator_launcher_test.py" - "${CMAKE_BINARY_DIR}/mocktail-vr-simulated") + +# Test-only implicit Vulkan layer that captures GPU-level native stereo +# evidence (eye render-pass counts and per-eye pixel readback). +# Never linked into production code. +add_library(mocktail_vr_evidence_layer MODULE vr/vr_evidence_layer.cc) +target_link_libraries(mocktail_vr_evidence_layer PRIVATE Vulkan::Headers + ${CMAKE_DL_LIBS}) +mocktail_apply_compile_options(mocktail_vr_evidence_layer) +set_target_properties(mocktail_vr_evidence_layer PROPERTIES + PREFIX "lib" OUTPUT_NAME "mocktail_vr_evidence_layer") +file(GENERATE + OUTPUT "${CMAKE_BINARY_DIR}/tests/vr/VkLayer_mocktail_vr_evidence.json" + CONTENT [=[{ + "file_format_version": "1.2.0", + "layer": { + "name": "VK_LAYER_MOCKTAIL_vr_evidence", + "type": "GLOBAL", + "library_path": "$", + "api_version": "1.3.280", + "implementation_version": "1", + "description": "Mocktail VR native stereo evidence capture (test-only)", + "disable_environment": {"MOCKTAIL_VR_EVIDENCE_LAYER_DISABLE": "1"} + } +} +]=]) + +add_executable(vr_mirror_test vr/vr_mirror_test.cc) +target_link_libraries(vr_mirror_test PRIVATE Mocktail::VR GTest::gtest_main) +mocktail_apply_compile_options(vr_mirror_test) +add_executable(vr_pose_math_test vr/vr_pose_math_test.cc) +target_include_directories(vr_pose_math_test PRIVATE "${PROJECT_SOURCE_DIR}/include") +target_link_libraries(vr_pose_math_test PRIVATE GTest::gtest_main) +mocktail_apply_compile_options(vr_pose_math_test) +add_executable(vr_evidence_layer_test vr/vr_evidence_layer_test.cc) +target_link_libraries(vr_evidence_layer_test PRIVATE Vulkan::Headers + ${CMAKE_DL_LIBS} GTest::gtest_main) +mocktail_apply_compile_options(vr_evidence_layer_test) + +add_executable(roblox_vr_device_bridge_test vr/roblox_vr_device_bridge_test.cc) +target_include_directories(roblox_vr_device_bridge_test PRIVATE + "${PROJECT_SOURCE_DIR}/src") +target_compile_definitions(roblox_vr_device_bridge_test PRIVATE + "MOCKTAIL_TEST_BINARY_DIRECTORY=\"${CMAKE_CURRENT_BINARY_DIR}\"") +target_link_libraries(roblox_vr_device_bridge_test PRIVATE + Mocktail::VR + Mocktail::Compat + GTest::gtest_main +) +mocktail_apply_compile_options(roblox_vr_device_bridge_test) if(MOCKTAIL_ENABLE_VR) + add_executable(openxr_backend_test vr/openxr_backend_test.cc) + target_include_directories(openxr_backend_test PRIVATE "${PROJECT_SOURCE_DIR}/third_party/OpenXR-SDK/include") + target_link_libraries(openxr_backend_test PRIVATE Mocktail::VR GTest::gtest_main) + gtest_discover_tests(openxr_backend_test) add_library(mocktail_test_openxr_runtime MODULE vr/fake_openxr_runtime.cc vr/fake_openxr_preview.cc) target_link_libraries(mocktail_test_openxr_runtime PRIVATE @@ -888,6 +938,10 @@ target_link_libraries(headless_graphics_test PRIVATE ) include(GoogleTest) +gtest_discover_tests(roblox_vr_device_bridge_test) +gtest_discover_tests(vr_evidence_layer_test) +gtest_discover_tests(vr_mirror_test) +gtest_discover_tests(vr_pose_math_test) gtest_discover_tests(jnivm_test) gtest_discover_tests(runtime_config_test) gtest_discover_tests(runtime_config_file_test) diff --git a/tests/command_line_test.cc b/tests/command_line_test.cc index ee69e95..e36ac00 100644 --- a/tests/command_line_test.cc +++ b/tests/command_line_test.cc @@ -12,6 +12,20 @@ namespace mocktail { namespace runtime { namespace { +TEST(CommandLineTest, ShortVrAliasSurvivesReexecAndLastOverrideWins) { + const char* arguments[] = {"mocktail", "-vr"}; + const auto parsed = ParseCommandLine(2, arguments); + ASSERT_TRUE(parsed); + ASSERT_TRUE(parsed.options.vr_enabled.has_value()); + EXPECT_TRUE(*parsed.options.vr_enabled); + std::vector reexec; + std::string error; + ASSERT_TRUE(BuildCommandLineReexecArguments(parsed.options, 2, arguments, &reexec, &error)); + EXPECT_EQ(reexec, (std::vector{"-vr"})); + const char* disabled[] = {"mocktail", "-vr", "--no-vr"}; + EXPECT_FALSE(*ParseCommandLine(3, disabled).options.vr_enabled); +} + TEST(CommandLineTest, VrOverrideSurvivesReexecAndCanDisableEnvironmentDefault) { const std::array arguments = {"mocktail", "--vr", "--headless", "--no-vr"}; diff --git a/tests/performance_policy_test.cc b/tests/performance_policy_test.cc index 819f54a..16bef54 100644 --- a/tests/performance_policy_test.cc +++ b/tests/performance_policy_test.cc @@ -283,6 +283,22 @@ TEST(PerformancePolicyTest, PermissionProtocolDoesNotUseTheTestBypass) { EXPECT_EQ(parsed.at("DFFlagVoiceChatSkipPermissionCheckForTests"), "False"); } +TEST(PerformancePolicyTest, + VrModeOverridesConflictingFlagAndPreservesSettings) { + std::string merged, error; + ASSERT_TRUE(MergeVrClientSettingsOverrides( + true, R"({"FFlagDebugEnableVREmulator":"False","FIntUnrelated":"42"})", + &merged, &error)); + auto json = nlohmann::json::parse(merged); + EXPECT_EQ(json["FFlagDebugEnableVREmulator"], "True"); + EXPECT_EQ(json["FIntUnrelated"], "42"); + ASSERT_TRUE(MergeVrClientSettingsOverrides(false, merged, &merged, &error)); + EXPECT_EQ(nlohmann::json::parse(merged)["FFlagDebugEnableVREmulator"], + "False"); + EXPECT_FALSE(MergeVrClientSettingsOverrides(true, "[]", &merged, &error)); + EXPECT_FALSE(MergeVrClientSettingsOverrides(true, "bad", &merged, &error)); +} + } // namespace } // namespace runtime } // namespace mocktail diff --git a/tests/runtime_config_file_test.cc b/tests/runtime_config_file_test.cc index e4ce0b2..e4a6cf0 100644 --- a/tests/runtime_config_file_test.cc +++ b/tests/runtime_config_file_test.cc @@ -99,7 +99,7 @@ TEST(RuntimeConfigBootstrapTest, CreatesCompletePrivateFirstRunFile) { LoadRuntimeConfig(MapEnvironment(), file); ASSERT_TRUE(loaded) << loaded.error; EXPECT_TRUE(loaded.file_loaded); - EXPECT_FALSE(loaded.config.vr_enabled()); + EXPECT_EQ(loaded.config.vr_enabled(), kDefaultVrEnabled); EXPECT_EQ(loaded.config.graphics_backend(), GraphicsBackend::kVulkan); EXPECT_EQ(loaded.config.theme_mode(), "roblox"); EXPECT_EQ(loaded.config.frame_rate().mode, FrameRateLimitMode::kUnmanaged); @@ -156,12 +156,12 @@ TEST(RuntimeConfigFileTest, ExportsResolvedVrStateAndRejectsInvalidState) { EXPECT_TRUE(ExportRuntimeConfigEnvironment( RuntimeConfig::FromEnvironment(MapEnvironment()), &error)) << error; - EXPECT_STREQ(std::getenv("MOCKTAIL_VR_ENABLED"), "0"); + EXPECT_STREQ(std::getenv("MOCKTAIL_VR_ENABLED"), kDefaultVrEnabled ? "1" : "0"); EXPECT_FALSE(ExportRuntimeConfigEnvironment( RuntimeConfig::FromEnvironment( MapEnvironment({{"MOCKTAIL_VR_ENABLED", "invalid"}})), &error)); - EXPECT_STREQ(std::getenv("MOCKTAIL_VR_ENABLED"), "0"); + EXPECT_STREQ(std::getenv("MOCKTAIL_VR_ENABLED"), kDefaultVrEnabled ? "1" : "0"); if (saved) setenv("MOCKTAIL_VR_ENABLED", saved->c_str(), 1); else diff --git a/tests/runtime_config_test.cc b/tests/runtime_config_test.cc index 74ac297..3de6121 100644 --- a/tests/runtime_config_test.cc +++ b/tests/runtime_config_test.cc @@ -37,7 +37,7 @@ TEST(RuntimeConfigTest, UsesSupportedDefaults) { const RuntimeConfig config = RuntimeConfig::FromEnvironment(environment); EXPECT_FALSE(config.headless()); - EXPECT_FALSE(config.vr_enabled()); + EXPECT_EQ(config.vr_enabled(), kDefaultVrEnabled); EXPECT_TRUE(config.vr_valid()); EXPECT_EQ(config.roblox_library_path(), "rbx_bin/libroblox.so"); EXPECT_EQ(config.graphics_backend(), GraphicsBackend::kVulkan); @@ -95,7 +95,7 @@ TEST(RuntimeConfigTest, RejectsMalformedVrEnvironmentInsteadOfEnablingIt) { const auto config = RuntimeConfig::FromEnvironment( MapEnvironment({{"MOCKTAIL_VR_ENABLED", "maybe"}})); EXPECT_FALSE(config.vr_valid()); - EXPECT_FALSE(config.vr_enabled()); + EXPECT_EQ(config.vr_enabled(), kDefaultVrEnabled); } TEST(RuntimeConfigTest, ReadsTypedRuntimeValues) { diff --git a/tests/vr/openxr_backend_test.cc b/tests/vr/openxr_backend_test.cc new file mode 100644 index 0000000..be772a5 --- /dev/null +++ b/tests/vr/openxr_backend_test.cc @@ -0,0 +1,191 @@ +#include +#include +#include "mocktail/vr/openxr_backend.h" +#include "mocktail/vr/roblox_vr_device_bridge.h" + +namespace mocktail::vr { +namespace internal { +struct VrBridgeTestAccess { + static void Enable(RobloxVrDeviceBridge& bridge) { bridge.active_ = true; } +}; +} +struct OpenXrBackendTestAccess { + template static T Handle(std::uintptr_t n) { return reinterpret_cast(n); } + static void Configure(OpenXrBackend& backend) { + backend.vk_instance_ = Handle(1); + backend.vk_device_ = Handle(2); + backend.recording_ = true; + backend.published_pose_.frame = 7; + backend.published_pose_.valid = true; + } + static VkInstance Instance(const OpenXrBackend& b) { return b.vk_instance_; } + static void Attachment(OpenXrBackend& b, int id, void* owner, VkDevice device) { + auto image = Handle(id); + auto& r = b.images_[image]; + r.image = image; r.device = device; r.owner = owner; + r.width = r.height = 500; r.format = VK_FORMAT_R8G8B8A8_UNORM; + r.usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT; + b.image_views_[Handle(id)] = image; + b.framebuffer_views_[Handle(id)] = {Handle(id)}; + } + static void Pass(OpenXrBackend& b, RobloxVrDeviceBridge& bridge, int eye, int id, void* owner, + VkImageLayout layout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL) { + bridge.OnEyeGetterCall(owner, eye, Handle(id)); + VkRenderPassBeginInfo info{}; + info.framebuffer = Handle(id); + info.renderPass = Handle(99); + b.render_pass_final_layouts_[info.renderPass] = {layout}; + b.NoteRenderPassBegin(VK_NULL_HANDLE, &info); + } + static VkImage Image(const OpenXrBackend& b, int eye) { return b.eyes_[eye].image; } + static VkImageLayout Layout(const OpenXrBackend& b, int eye) { return b.eyes_[eye].final_layout; } + static void Invalidate(OpenXrBackend& b) { + b.InvalidateInFlightPose("test"); + } + static void SetFrameOpen(OpenXrBackend& b, bool open) { b.frame_open_ = open; } + static void SetVisibilityLost(OpenXrBackend& b, bool lost) { b.visibility_lost_ = lost; } + static bool VisibilityLost(const OpenXrBackend& b) { return b.visibility_lost_; } + static void NotePose(OpenXrBackend& b, void* owner) { + b.NotePoseApplied(owner, b.published_pose_.frame); + } + static std::size_t AppliedPoses(const OpenXrBackend& b) { + return b.applied_poses_.size(); + } + static void State(OpenXrBackend& b, XrSessionState state) { b.HandleSessionState(state); } + static void ReferenceChange(OpenXrBackend& b, std::int64_t time) { b.HandleReferenceSpaceChange(time); } + static void FrameAt(OpenXrBackend& b, std::uint64_t time) { + b.frame_open_ = true; b.frame_views_valid_ = true; b.frame_display_time_ = time; + b.published_pose_.valid = true; + } + static bool Recovering(OpenXrBackend& b) { return b.session_recovery_pending_; } + static VkDevice Device(OpenXrBackend& b) { return b.vk_device_; } + static std::size_t Resources(OpenXrBackend& b) { return b.images_.size(); } + static void SeedResource(OpenXrBackend& b) { b.images_[Handle(99)] = {}; } + static void LatchCanted(OpenXrBackend& b) { b.canted_rejection_logged_ = true; } + static bool CantedLogged(const OpenXrBackend& b) { return b.canted_rejection_logged_; } +}; +using Access = OpenXrBackendTestAccess; +TEST(OpenXrBackendLifecycle, DeviceDestructionPreservesLiveInstance) { + OpenXrBackend b; + Access::Configure(b); + b.NoteDeviceDestroyed(Access::Handle(3)); + EXPECT_TRUE(b.WantsResourceRecords()); + b.NoteDeviceDestroyed(Access::Handle(2)); + EXPECT_EQ(Access::Instance(b), Access::Handle(1)); + EXPECT_FALSE(b.WantsResourceRecords()); + EXPECT_FALSE(b.PublishedHeadPose().valid); + b.NoteInstanceDestroyed(Access::Handle(1)); + EXPECT_EQ(Access::Instance(b), VK_NULL_HANDLE); +} +TEST(OpenXrBackendBinding, SwitchesOwnerWithoutWaitingForOldResourcesToDie) { + OpenXrBackend b; + RobloxVrDeviceBridge bridge; + internal::VrBridgeTestAccess::Enable(bridge); + Access::Configure(b); + auto* first = Access::Handle(100); + auto* second = Access::Handle(200); + for (int id = 10; id < 14; ++id) + Access::Attachment(b, id, id < 12 ? first : second, Access::Handle(2)); + Access::Pass(b, bridge, 0, 10, first); + Access::Pass(b, bridge, 1, 11, first); + ASSERT_TRUE(b.EyeBindingComplete()); + Access::Pass(b, bridge, 0, 12, second); + EXPECT_FALSE(b.EyeBindingComplete()); + Access::Pass(b, bridge, 1, 13, second); + EXPECT_TRUE(b.EyeBindingComplete()); + EXPECT_EQ(Access::Image(b, 0), Access::Handle(12)); + EXPECT_EQ(Access::Image(b, 1), Access::Handle(13)); + Access::Pass(b, bridge, 0, 12, second, VK_IMAGE_LAYOUT_GENERAL); + EXPECT_EQ(Access::Layout(b, 0), VK_IMAGE_LAYOUT_GENERAL); +} +TEST(OpenXrBackendBinding, RejectsAnotherDeviceImages) { + OpenXrBackend b; + RobloxVrDeviceBridge bridge; + internal::VrBridgeTestAccess::Enable(bridge); + Access::Configure(b); + auto* owner = Access::Handle(100); + Access::Attachment(b, 10, owner, Access::Handle(3)); + Access::Pass(b, bridge, 0, 10, owner); + EXPECT_EQ(Access::Image(b, 0), VK_NULL_HANDLE); + EXPECT_FALSE(b.EyeBindingComplete()); +} + +TEST(OpenXrBackendLifecycle, FutureRecenterPreservesEarlierFrameAndLateChangeDropsIt) { + OpenXrBackend b; + Access::Configure(b); + Access::FrameAt(b, 100); + Access::ReferenceChange(b, 200); + EXPECT_TRUE(b.PublishedHeadPose().valid); + Access::ReferenceChange(b, 100); + EXPECT_FALSE(b.PublishedHeadPose().valid); + Access::FrameAt(b, 300); + Access::ReferenceChange(b, 200); + EXPECT_FALSE(b.PublishedHeadPose().valid); +} + +TEST(OpenXrBackendLifecycle, SynchronizedVisibilityTransitionUsesActualStateHandler) { + OpenXrBackend b; + Access::Configure(b); + Access::State(b, XR_SESSION_STATE_VISIBLE); + Access::State(b, XR_SESSION_STATE_SYNCHRONIZED); + EXPECT_TRUE(Access::VisibilityLost(b)); + EXPECT_FALSE(b.PublishedHeadPose().valid); + Access::FrameAt(b, 100); + Access::State(b, XR_SESSION_STATE_VISIBLE); + EXPECT_FALSE(Access::VisibilityLost(b)); + EXPECT_FALSE(b.PublishedHeadPose().valid); +} + +TEST(OpenXrBackendLifecycle, SessionLossKeepsLiveDeviceProvenanceButDeviceDestructionCancelsRecovery) { + OpenXrBackend b; + Access::Configure(b); + Access::SeedResource(b); + Access::State(b, XR_SESSION_STATE_LOSS_PENDING); + EXPECT_TRUE(Access::Recovering(b)); + EXPECT_EQ(Access::Device(b), Access::Handle(2)); + EXPECT_EQ(Access::Resources(b), 1u); + EXPECT_TRUE(b.WantsResourceRecords()); + EXPECT_FALSE(b.PublishedHeadPose().valid); + b.NoteDestroyImage(Access::Handle(99)); + EXPECT_EQ(Access::Resources(b), 0u); + b.NoteDeviceDestroyed(Access::Handle(2)); + EXPECT_FALSE(Access::Recovering(b)); + EXPECT_EQ(Access::Device(b), VK_NULL_HANDLE); +} + +TEST(OpenXrBackendLifecycle, InvalidationDropsPoseAndAppliedRecord) { + OpenXrBackend b; + Access::Configure(b); + Access::SetFrameOpen(b, true); + auto* owner = Access::Handle(100); + Access::NotePose(b, owner); + ASSERT_EQ(Access::AppliedPoses(b), 1u); + ASSERT_TRUE(b.PublishedHeadPose().valid); + // A reference-space change / visibility transition must drop the pose and + // the per-owner application record so the frame cycle cannot retag the + // in-flight images with a pose from the previous origin. + Access::Invalidate(b); + EXPECT_FALSE(b.PublishedHeadPose().valid); + EXPECT_EQ(Access::AppliedPoses(b), 0u); +} + +TEST(OpenXrBackendLifecycle, VisibilityLostStateSurvivesUntilSessionReset) { + OpenXrBackend b; + Access::Configure(b); + Access::SetVisibilityLost(b, true); + EXPECT_TRUE(Access::VisibilityLost(b)); + // Teardown (device destroyed) resets the flag so a recreated session starts + // visible; it must not inherit IDLE state from the previous session. + b.NoteDeviceDestroyed(Access::Handle(2)); + EXPECT_FALSE(Access::VisibilityLost(b)); +} + +TEST(OpenXrBackendLifecycle, TeardownClearsCantedRejectionLatch) { + OpenXrBackend b; + Access::Configure(b); + Access::LatchCanted(b); + ASSERT_TRUE(Access::CantedLogged(b)); + b.NoteDeviceDestroyed(Access::Handle(2)); + EXPECT_FALSE(Access::CantedLogged(b)); +} +} // namespace mocktail::vr diff --git a/tests/vr/preview_contract_test.py b/tests/vr/preview_contract_test.py index ce67dc3..7afbe57 100644 --- a/tests/vr/preview_contract_test.py +++ b/tests/vr/preview_contract_test.py @@ -137,5 +137,5 @@ with tempfile.TemporaryDirectory(prefix="mocktail-xr-preview-") as directory: capture_output=True, text=True, timeout=5) assert result.returncode == 1, result assert "XR_ERROR_RUNTIME_UNAVAILABLE" in result.stdout, result.stdout - assert "mocktail-vr-simulated" in result.stdout, result.stdout - print("missing runtime diagnostic offers simulator: passed") + assert "XR_RUNTIME_JSON" in result.stdout, result.stdout + print("missing runtime diagnostic explains runtime selection: passed") diff --git a/tests/vr/roblox_vr_device_bridge_test.cc b/tests/vr/roblox_vr_device_bridge_test.cc new file mode 100644 index 0000000..6f9cbe3 --- /dev/null +++ b/tests/vr/roblox_vr_device_bridge_test.cc @@ -0,0 +1,503 @@ +// Contract tests for the experimental DebugDeviceVR bridge. They pin the +// machine-code contracts of the exact 2998 payload ABI and the fail-closed +// bridge lifecycle; they do not execute any guest code. +#include + +#include +#include +#include +#include +#include +#include "mocktail/vr/openxr_backend.h" +#include +#include +#include +#include +#include + +#include "compat/build_profile.h" +#include "mocktail/vr/roblox_vr_device_bridge.h" +#include "vr/roblox_vr_device_bridge_internal.h" + +namespace mocktail::vr { +namespace internal { +// Inject host fixture callbacks without executing or relaxing production ELF +// activation checks. Objects retain the guest byte layout, never host C++ ABI. +struct VrBridgeTestAccess { + static void Configure(RobloxVrDeviceBridge &bridge, std::uintptr_t base, + std::uintptr_t size, + const compat::VrDebugDeviceBridgeProfile &profile, + RobloxVrDeviceBridge::StateGetterFn getter) { + bridge.profile_ = profile; + bridge.image_base_ = base; + bridge.image_size_ = size; + bridge.original_state_getter_ = getter; + bridge.active_ = true; + } +}; +} // namespace internal +namespace { + +using internal::kConstructorContractSize; +using internal::kEyeGetterContractSize; +using internal::kInitializerContractSize; +using internal::kStateGetterContractSize; + +// Real 2998 machine code, transcribed from the verified payload disassembly +// (ade08266c67aee88ec9c1d00902150e1684dad3a). +const std::vector kStateGetter = { + 0x55, 0x48, 0x89, 0xe5, 0x53, 0x50, 0x48, 0x89, 0xfb, 0x48, 0x83, + 0xc6, 0x14, 0xba, 0x38, 0x01, 0x00, 0x00, 0xe8, 0xd3, 0xb5, 0x36, + 0x03, 0x48, 0x89, 0xd8, 0x48, 0x83, 0xc4, 0x08, 0x5b, 0x5d, 0xc3}; + +const std::vector kEyeGetter = {0x48, 0x63, 0xc6, 0x48, 0xc1, 0xe0, + 0x04, 0x48, 0x8b, 0x84, 0x07, 0x50, + 0x01, 0x00, 0x00, 0xc3}; + +std::vector ValidConstructor() { + std::vector code(kConstructorContractSize, 0x90); + const std::vector prologue = { + 0x55, 0x48, 0x89, 0xe5, 0x41, 0x57, 0x41, 0x56, 0x41, 0x55, + 0x41, 0x54, 0x53, 0x48, 0x83, 0xec, 0x28, 0x49, 0x89, 0xcf, + 0x41, 0x89, 0xd4, 0x41, 0x89, 0xf5, 0x48, 0x89, 0xfb}; + std::memcpy(code.data(), prologue.data(), prologue.size()); + const std::vector> markers = { + {0x48, 0x8d, 0x05, 0xf2, 0x74, 0x47, 0x03}, + {0x48, 0x89, 0x07}, + {0xc7, 0x47, 0x10, 0x06, 0x00, 0x00, 0x00}, + {0x4c, 0x8d, 0x77, 0x14, 0xba, 0x34, 0x01, 0x00, 0x00}, + {0x44, 0x89, 0xab, 0x90, 0x01, 0x00, 0x00, 0x44, 0x89, 0xa3, 0x94, 0x01, + 0x00, 0x00}, + {0x4c, 0x89, 0xbb, 0x98, 0x01, 0x00, 0x00}, + }; + size_t offset = 64; + for (const auto &marker : markers) { + std::memcpy(code.data() + offset, marker.data(), marker.size()); + offset += marker.size() + 8; + } + return code; +} + +compat::VrDebugDeviceBridgeProfile TestProfile() { + compat::VrDebugDeviceBridgeProfile profile{}; + profile.vtable_rva = 0x6c87b38; + profile.constructor_rva = 0x3810614; + profile.state_getter_rva = 0x38109f6; + profile.eye_getter_rva = 0x38109e6; + profile.eye_initializer_rva = 0x3810a18; + profile.emulator_flag_storage_rva = 0x7369920; + profile.device_create_framebuffer_vtable_offset = 0x180; + return profile; +} + +std::vector +ValidInitializer(const compat::VrDebugDeviceBridgeProfile &profile) { + std::vector code(kInitializerContractSize, 0x90); + const std::vector prologue = { + 0x55, 0x48, 0x89, 0xe5, 0x41, 0x57, 0x41, 0x56, 0x41, 0x55, 0x41, 0x54, + 0x53, 0x48, 0x81, 0xec, 0xa8, 0x00, 0x00, 0x00, 0x48, 0x89, 0xb5, 0x50}; + std::memcpy(code.data(), prologue.data(), prologue.size()); + const uint32_t slot = + static_cast(profile.device_create_framebuffer_vtable_offset); + const std::vector> markers = { + {0xb1, 0x01, 0x88, 0x8f, 0x47, 0x01, 0x00, 0x00, 0x88, 0x8f, + 0x94, 0x00, 0x00, 0x00, 0x88, 0x8f, 0x44, 0x01, 0x00, 0x00}, + {0x48, 0x8d, 0x87, 0x70, 0x01, 0x00, 0x00}, + {0x48, 0x8d, 0x87, 0x50, 0x01, 0x00, 0x00}, + {0x48, 0x8b, 0x06, 0x48, 0x8b, 0x80, static_cast(slot), + static_cast(slot >> 8), static_cast(slot >> 16), + static_cast(slot >> 24)}, + {0x41, 0xbf, 0x01, 0x00, 0x00, 0x00, 0xf6, 0x85, 0x5c, 0xff, 0xff, 0xff, + 0x01, 0xb9, 0x00}, + }; + size_t offset = 64; + for (const auto &marker : markers) { + std::memcpy(code.data() + offset, marker.data(), marker.size()); + offset += marker.size() + 8; + } + return code; +} + +compat::BuildProfile MakeBuildProfile(bool with_vr, bool allow_bridges) { + compat::BuildProfile profile{}; + profile.version_name = "2.736.1408"; + profile.version_code = 2998; + profile.elf_build_id = "ade08266c67aee88ec9c1d00902150e1684dad3a"; + profile.status = compat::BuildStatus::kSupported; + profile.default_allowed = true; + profile.allow_host_abi_bridges = allow_bridges; + if (with_vr) { + profile.vr_debug_device_bridge = TestProfile(); + } + return profile; +} + +struct alignas(8) GuestObject { + std::array bytes{}; + template void Put(std::size_t offset, T value) { + std::memcpy(bytes.data() + offset, &value, sizeof(value)); + } +}; +std::uint8_t fixture_flag = 1; +std::uintptr_t fixture_device_vtable[1]; +int fixture_initializations = 0; +bool fixture_partial = false; +void FixtureInitialize(void *object, void *) { + auto *guest = static_cast(object); + ++fixture_initializations; + guest->bytes[internal::kReadyByteOffset] = 1; + guest->Put(internal::kFramebufferSlotOffset, 0x100); + if (fixture_partial) + return; + guest->Put(internal::kFramebufferSlotOffset + 16, 0x200); + guest->Put(internal::kTextureSlotOffset, 0x300); + guest->Put(internal::kTextureSlotOffset + 16, 0x400); +} +void *FixtureState(void *output, void *object) { + std::memcpy(output, + static_cast(object)->bytes.data() + + internal::kStateOffset, + internal::kStateCopySize); + return output; +} + +TEST(VrBridgeLifecycle, ReinitializesReusedAddressesAndMoreThanFourObjects) { + RobloxVrDeviceBridge bridge; + fixture_initializations = 0; + fixture_partial = false; + const auto init = reinterpret_cast(&FixtureInitialize); + const auto flag = reinterpret_cast(&fixture_flag); + const auto vtable = reinterpret_cast(fixture_device_vtable); + const auto base = std::min({init, flag, vtable}) - 8; + auto profile = TestProfile(); + profile.eye_initializer_rva = init - base; + profile.emulator_flag_storage_rva = flag - base; + profile.device_create_framebuffer_vtable_offset = 0; + fixture_device_vtable[0] = init; + std::uintptr_t device = vtable; + internal::VrBridgeTestAccess::Configure( + bridge, base, std::max({init, flag, vtable}) - base + 64, profile, + &FixtureState); + bridge.NoteHostPresent(); + std::array output{}; + std::array guests; + for (int cycle = 0; cycle < 2; ++cycle) { + for (auto &guest : guests) { + guest = {}; // Constructor at a new or previously used address. + guest.Put(0, base + profile.vtable_rva); + guest.Put(internal::kObjectNameOffset, "DebugDevice"); + guest.Put(internal::kObjectTypeOffset, 6); + guest.Put(internal::kWidthOffset, 500); + guest.Put(internal::kHeightOffset, 500); + guest.Put(internal::kGraphicsDeviceOffset, &device); + const int before = fixture_initializations; + bridge.OnStateGetterCall(output.data(), &guest); + EXPECT_EQ(fixture_initializations, before + 1); + EXPECT_EQ(output[internal::kStateReadyOffsetInCopy], 1); + bridge.OnStateGetterCall(output.data(), &guest); + EXPECT_EQ(fixture_initializations, before + 1); + } + } + EXPECT_EQ(bridge.initialized_device_count(), 16); + // A partially constructed device must not expose ready to the renderer or + // repeatedly invoke the initializer on top of its existing allocations. + auto &guest = guests[0]; + for (std::size_t i = internal::kFramebufferSlotOffset; + i < internal::kWidthOffset; ++i) + guest.bytes[i] = 0; + guest.bytes[internal::kReadyByteOffset] = 0; + fixture_partial = true; + bridge.OnStateGetterCall(output.data(), &guest); + EXPECT_EQ(output[internal::kStateReadyOffsetInCopy], 0); + const int before = fixture_initializations; + bridge.OnStateGetterCall(output.data(), &guest); + EXPECT_EQ(fixture_initializations, before); + EXPECT_EQ(output[internal::kStateReadyOffsetInCopy], 0); + fixture_partial = false; +} + +TEST(VrResourceReadiness, + RejectsReadyByteAloneAliasedEyesAndInvalidDimensions) { + GuestObject guest; + guest.Put(internal::kWidthOffset, 500); + guest.Put(internal::kHeightOffset, 500); + EXPECT_EQ(internal::InspectEyeResources(&guest), + internal::EyeResourceState::kEmpty); + guest.bytes[internal::kReadyByteOffset] = 1; + EXPECT_EQ(internal::InspectEyeResources(&guest), + internal::EyeResourceState::kInvalid); + FixtureInitialize(&guest, nullptr); + EXPECT_EQ(internal::InspectEyeResources(&guest), + internal::EyeResourceState::kReady); + guest.Put(internal::kFramebufferSlotOffset + 16, 0x100); + EXPECT_EQ(internal::InspectEyeResources(&guest), + internal::EyeResourceState::kInvalid); + FixtureInitialize(&guest, nullptr); + guest.Put(internal::kWidthOffset, 0); + EXPECT_EQ(internal::InspectEyeResources(&guest), + internal::EyeResourceState::kInvalid); +} + +TEST(VrStateGetterContract, AcceptsExact2998Bytes) { + ASSERT_EQ(kStateGetter.size(), kStateGetterContractSize); + EXPECT_TRUE(internal::HasExpectedVrStateGetterContract(kStateGetter.data(), + kStateGetter.size())); +} + +TEST(VrStateGetterContract, RejectsCorruptionAndShortInput) { + EXPECT_FALSE(internal::HasExpectedVrStateGetterContract(nullptr, 0)); + EXPECT_FALSE(internal::HasExpectedVrStateGetterContract( + kStateGetter.data(), kStateGetter.size() - 1)); + std::vector corrupted = kStateGetter; + // The sret copy size (0x138) must be pinned; corrupt one byte of it. + corrupted[13] ^= 0xff; + EXPECT_FALSE(internal::HasExpectedVrStateGetterContract(corrupted.data(), + corrupted.size())); +} + +TEST(VrEyeGetterContract, AcceptsExact2998Bytes) { + ASSERT_EQ(kEyeGetter.size(), kEyeGetterContractSize); + EXPECT_TRUE(internal::HasExpectedVrEyeGetterContract(kEyeGetter.data(), + kEyeGetter.size())); +} + +TEST(VrEyeGetterContract, RejectsDifferentEyeSlotOffset) { + std::vector shifted = kEyeGetter; + // +0x150 -> +0x160: a different eye slot layout must not pass. + shifted[11] = 0x60; + EXPECT_FALSE( + internal::HasExpectedVrEyeGetterContract(shifted.data(), shifted.size())); +} + +TEST(VrConstructorContract, AcceptsMarkersAndRejectsEachMissingOne) { + const std::vector valid = ValidConstructor(); + EXPECT_TRUE( + internal::HasExpectedVrConstructorContract(valid.data(), valid.size())); + EXPECT_FALSE(internal::HasExpectedVrConstructorContract(valid.data(), + valid.size() - 1)); + // Corrupting the graphics-device capture (+0x198 store) must fail closed. + std::vector no_device_store = valid; + for (size_t index = 0; index + 7 <= no_device_store.size(); ++index) { + if (no_device_store[index] == 0x4c && no_device_store[index + 1] == 0x89 && + no_device_store[index + 2] == 0xbb) { + no_device_store[index + 3] ^= 0xff; + break; + } + } + EXPECT_FALSE(internal::HasExpectedVrConstructorContract( + no_device_store.data(), no_device_store.size())); +} + +TEST(VrInitializerContract, PinsDeviceVtableSlotFromProfile) { + const compat::VrDebugDeviceBridgeProfile profile = TestProfile(); + const std::vector valid = ValidInitializer(profile); + EXPECT_TRUE(internal::HasExpectedVrEyeInitializerContract( + valid.data(), valid.size(), profile)); + + // The 3092 payload uses +0x188; a profile mismatch must fail closed. + compat::VrDebugDeviceBridgeProfile other = profile; + other.device_create_framebuffer_vtable_offset = 0x188; + EXPECT_FALSE(internal::HasExpectedVrEyeInitializerContract( + valid.data(), valid.size(), other)); + + std::vector no_ready_byte = valid; + for (size_t index = 0; index + 8 <= no_ready_byte.size(); ++index) { + if (no_ready_byte[index] == 0xb1 && no_ready_byte[index + 1] == 0x01 && + no_ready_byte[index + 2] == 0x88) { + no_ready_byte[index + 4] ^= 0xff; + break; + } + } + EXPECT_FALSE(internal::HasExpectedVrEyeInitializerContract( + no_ready_byte.data(), no_ready_byte.size(), profile)); +} + +TEST(VrVtableContract, RequiresExactProfileRvas) { + const compat::VrDebugDeviceBridgeProfile profile = TestProfile(); + const std::uintptr_t base = 0x10000000; + std::vector vtable(internal::kEyeInitializerVtableSlot / 8 + + 1); + vtable[internal::kStateGetterVtableSlot / 8] = + base + profile.state_getter_rva; + vtable[internal::kEyeGetterVtableSlot / 8] = base + profile.eye_getter_rva; + vtable[internal::kEyeInitializerVtableSlot / 8] = + base + profile.eye_initializer_rva; + EXPECT_TRUE( + internal::HasExpectedVrDebugDeviceVtable(vtable.data(), base, profile)); + vtable[internal::kEyeInitializerVtableSlot / 8] += 16; + EXPECT_FALSE( + internal::HasExpectedVrDebugDeviceVtable(vtable.data(), base, profile)); +} + +TEST(VrBridgeLifecycle, InstallRejectsMissingMetadataAndWrongPermissions) { + { + RobloxVrDeviceBridge bridge; + const compat::BuildProfile profile = MakeBuildProfile(false, true); + const Status status = bridge.Install(profile); + EXPECT_FALSE(status.ok()); + EXPECT_EQ(status.code(), StatusCode::kFailedPrecondition); + } + { + RobloxVrDeviceBridge bridge; + const compat::BuildProfile profile = MakeBuildProfile(true, false); + const Status status = bridge.Install(profile); + EXPECT_FALSE(status.ok()); + EXPECT_EQ(status.code(), StatusCode::kFailedPrecondition); + } +} + +TEST(VrBridgeLifecycle, SingleProcessOwnerAndFailClosedActivation) { + RobloxVrDeviceBridge first; + ASSERT_TRUE(first.Install(MakeBuildProfile(true, true)).ok()); + { + RobloxVrDeviceBridge second; + const Status status = second.Install(MakeBuildProfile(true, true)); + EXPECT_FALSE(status.ok()); + EXPECT_EQ(status.code(), StatusCode::kFailedPrecondition); + } + // A non-ELF image base must be rejected without touching memory. + const Status bad_image = first.Activate(0); + EXPECT_FALSE(bad_image.ok()); + EXPECT_EQ(bad_image.code(), StatusCode::kInvalidArgument); + EXPECT_FALSE(first.active()); + // While armed, a failed activation must surface to the caller. + const Status notified = NotifyRobloxVrImageLoaded(0); + EXPECT_FALSE(notified.ok()); + first.Shutdown(); +} + +TEST(VrBridgeLifecycle, NotificationIsNoOpWithoutArmedBridge) { + EXPECT_TRUE(NotifyRobloxVrImageLoaded(0).ok()); +} + +TEST(VrProfileParsing, ParsesAndRejectsBridgeSection) { + const std::string directory = std::string(MOCKTAIL_TEST_BINARY_DIRECTORY); + const auto write_manifest = [&directory](const char *name, + const std::string &contents) { + const std::string path = directory + "/" + name; + std::ofstream output(path); + output << contents; + output.close(); + return path; + }; + const std::string base_profile = R"({ + "schema_version": 1, + "profiles": [{ + "version_name": "2.736.1408", + "version_code": 2998, + "elf_build_id": "ade08266c67aee88ec9c1d00902150e1684dad3a", + "status": "supported", + "default_allowed": true, + "allow_legacy_binary_patches": false, + "allow_host_abi_bridges": true, + "allow_host_constructor_replay": true, + "reason": "test" + }] + })"; + { + const std::string path = + write_manifest("vr_profile_absent.json", base_profile); + const auto result = compat::FindBuildProfile( + path, "ade08266c67aee88ec9c1d00902150e1684dad3a"); + ASSERT_TRUE(result); + ASSERT_TRUE(result.profile.has_value()); + EXPECT_FALSE(result.profile->vr_debug_device_bridge.has_value()); + std::remove(path.c_str()); + } + { + std::string manifest = base_profile; + const std::string anchor = R"("reason": "test")"; + manifest.replace(manifest.find(anchor), anchor.size(), + R"("vr_debug_device_bridge": { + "vtable_rva": "0x6c87b38", + "constructor_rva": "0x3810614", + "state_getter_rva": "0x38109f6", + "eye_getter_rva": "0x38109e6", + "eye_initializer_rva": "0x3810a18", + "emulator_flag_storage_rva": "0x7369920", + "device_create_framebuffer_vtable_offset": "0x180" + }, + "reason": "test")"); + const std::string path = write_manifest("vr_profile_valid.json", manifest); + const auto result = compat::FindBuildProfile( + path, "ade08266c67aee88ec9c1d00902150e1684dad3a"); + ASSERT_TRUE(result); + ASSERT_TRUE(result.profile.has_value()); + ASSERT_TRUE(result.profile->vr_debug_device_bridge.has_value()); + const auto &bridge = *result.profile->vr_debug_device_bridge; + EXPECT_EQ(bridge.vtable_rva, 0x6c87b38u); + EXPECT_EQ(bridge.eye_initializer_rva, 0x3810a18u); + EXPECT_EQ(bridge.device_create_framebuffer_vtable_offset, 0x180u); + std::remove(path.c_str()); + } + { + std::string manifest = base_profile; + const std::string anchor = R"("reason": "test")"; + manifest.replace(manifest.find(anchor), anchor.size(), + R"("vr_debug_device_bridge": { + "vtable_rva": "0x6c87b38" + }, + "reason": "test")"); + const std::string path = + write_manifest("vr_profile_incomplete.json", manifest); + const auto result = compat::FindBuildProfile( + path, "ade08266c67aee88ec9c1d00902150e1684dad3a"); + EXPECT_FALSE(result); + std::remove(path.c_str()); + } +} + +} // namespace +} // namespace mocktail::vr + +namespace mocktail::vr { +TEST(VrPoseBridge, ConvertsRuntimePoseAndAsymmetricProjectionWithoutChangingReadiness) { + std::array state; + state.fill(0xa5); + ScriptedPoseSample pose; + pose.valid = true; + pose.position[0] = 0.4f; + pose.position[1] = 1.6f; + pose.orientation[1] = std::sin(0.2f); + pose.orientation[3] = std::cos(0.2f); + for (int eye = 0; eye < 2; ++eye) { + pose.eye_offset[eye][0] = eye ? 0.032f : -0.032f; + const float fov[] = {-0.7f, 0.8f, 0.9f, -0.6f}; + std::copy_n(fov, 4, pose.eye_fov[eye]); + } + ASSERT_TRUE(internal::ApplyXrPose(state.data(), pose)); + EXPECT_EQ(state[0], 1); + EXPECT_EQ(state[0x40], 0); + EXPECT_EQ(state[internal::kStateReadyOffsetInCopy], 0xa5); + float position[3], orientation[4], left[3], right[3], projection[4]; + std::memcpy(position, state.data()+4, 12); + std::memcpy(orientation, state.data()+0x10, 16); + std::memcpy(left, state.data()+0xf8, 12); + std::memcpy(right, state.data()+0x104, 12); + std::memcpy(projection, state.data()+0x110, 16); + EXPECT_FLOAT_EQ(position[0], 0.4f); // no double metre-to-stud conversion + EXPECT_FLOAT_EQ(position[1], 1.6f); + EXPECT_FLOAT_EQ(orientation[1], std::sin(0.2f)); + EXPECT_FLOAT_EQ(left[0], -0.032f); + EXPECT_FLOAT_EQ(right[0], 0.032f); + EXPECT_FLOAT_EQ(projection[0], std::tan(0.9f)); // up/down/left/right + EXPECT_FLOAT_EQ(projection[1], std::tan(0.6f)); + EXPECT_FLOAT_EQ(projection[2], std::tan(0.7f)); + EXPECT_FLOAT_EQ(projection[3], std::tan(0.8f)); +} +TEST(VrPoseBridge, InvalidTrackingAndNonfiniteValuesNeverBecomeValid) { + std::array state{}; + state[0] = state[internal::kStateReadyOffsetInCopy] = 1; + ScriptedPoseSample pose; + EXPECT_FALSE(internal::ApplyXrPose(state.data(), pose)); + EXPECT_EQ(state[0], 0); + EXPECT_EQ(state[internal::kStateReadyOffsetInCopy], 1); + pose.valid = true; + pose.position[2] = std::numeric_limits::quiet_NaN(); + EXPECT_FALSE(internal::ApplyXrPose(state.data(), pose)); + EXPECT_EQ(state[0], 0); + pose.position[2] = 0; + pose.orientation[3] = 0; + EXPECT_FALSE(internal::ApplyXrPose(state.data(), pose)); +} +} // namespace mocktail::vr diff --git a/tests/vr/simulator_launcher_test.py b/tests/vr/simulator_launcher_test.py deleted file mode 100644 index 3a43bc1..0000000 --- a/tests/vr/simulator_launcher_test.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Check process-local runtime selection and preparation independently of VR hardware.""" - -import json -import os -from pathlib import Path -import subprocess -import sys -import tempfile - - -launcher = Path(sys.argv[1]).resolve() -with tempfile.TemporaryDirectory(prefix="mocktail-simulator-launcher-") as directory: - root = Path(directory) - build = root / "build with spaces" - build.mkdir() - source = root / "source" - source.mkdir() - library = source / "runtime.so" - library.write_bytes(b"runtime fixture for preparation only") - manifest = source / "manifest.json" - manifest.write_text(json.dumps({"runtime": {"library_path": "./runtime.so"}})) - command = [sys.executable, str(launcher), "--build-dir", str(build)] - result = subprocess.run([*command, "--prepare", str(manifest)], - capture_output=True, text=True, timeout=10) - assert result.returncode == 0, result.stderr - prepared = build / "vr-simulator/runtime.json" - document = json.loads(prepared.read_text()) - relative_library = Path(document["runtime"]["library_path"]) - assert not relative_library.is_absolute() - assert (prepared.parent / relative_library).read_bytes() == library.read_bytes() - - # Moving the prepared build must not depend on the original /tmp/source path. - relocated = root / "relocated build" - build.rename(relocated) - build = relocated - prepared = build / "vr-simulator/runtime.json" - command = [sys.executable, str(launcher), "--build-dir", str(build)] - probe = build / "mocktail-vr-probe" - probe.write_text("""#!/usr/bin/env python3 -import json, os, sys -names = ('XR_RUNTIME_JSON', 'SIMULATED_ENABLE', 'XRT_COMPOSITOR_NULL', - 'XRT_COMPOSITOR_FORCE_XCB', 'XRT_COMPOSITOR_FORCE_WAYLAND', - 'XDG_CONFIG_HOME', 'XDG_RUNTIME_DIR') -print(json.dumps({'args': sys.argv[1:], 'env': {key: os.environ.get(key) for key in names}})) -sys.exit(7) -""") - probe.chmod(0o755) - env = os.environ.copy() - env.update(DISPLAY=":123", WAYLAND_DISPLAY="wayland-test", - XR_RUNTIME_JSON=str(root / "real-headset.json"), - XRT_COMPOSITOR_NULL="1", XRT_COMPOSITOR_FORCE_WAYLAND="1", - XDG_CONFIG_HOME=str(root / "user-config"), XDG_RUNTIME_DIR=str(root / "desktop-runtime")) - original_selection = env["XR_RUNTIME_JSON"] - original_config = Path(env["XDG_CONFIG_HOME"]) - original_config.mkdir() - marker = original_config / "keep" - marker.write_text("preserve user settings") - - for graphical_backend in ("xcb", "wayland"): - if graphical_backend == "wayland": - env.pop("DISPLAY") - result = subprocess.run([*command, "--frames", "4"], env=env, - capture_output=True, text=True, timeout=10) - assert result.returncode == 7, (result.stdout, result.stderr) - child = json.loads(result.stdout.splitlines()[-1]) - assert child["args"] == ["--scene", "--frames", "4"] - selected = child["env"] - assert selected["XR_RUNTIME_JSON"] == str(prepared) - assert selected["SIMULATED_ENABLE"] == "1" and selected["XRT_COMPOSITOR_NULL"] == "0" - assert selected["XRT_COMPOSITOR_FORCE_XCB"] == ("1" if graphical_backend == "xcb" else "0") - assert selected["XRT_COMPOSITOR_FORCE_WAYLAND"] == ("0" if graphical_backend == "xcb" else "1") - assert selected["XDG_CONFIG_HOME"] != str(original_config) - assert selected["XDG_RUNTIME_DIR"] == env["XDG_RUNTIME_DIR"] - assert env["XR_RUNTIME_JSON"] == original_selection - assert marker.read_text() == "preserve user settings" - assert not (original_config / "openxr").exists() - print(graphical_backend + " process-local selection: passed") - - env.pop("WAYLAND_DISPLAY") - result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=10) - assert result.returncode == 2 and "graphical desktop" in result.stderr, result - result = subprocess.run([*command, "--runtime", str(root / "missing.json")], env=env, - capture_output=True, text=True, timeout=10) - assert result.returncode == 2 and "No simulated Monado runtime" in result.stderr, result - for count in ("0", "-1", "4294967296"): - result = subprocess.run([*command, "--frames", count], env=env, - capture_output=True, text=True, timeout=10) - assert result.returncode == 2 and "positive 32-bit integer" in result.stderr, result - print("missing runtime/display and frame limit diagnostics: passed") diff --git a/tests/vr/vr_evidence_layer.cc b/tests/vr/vr_evidence_layer.cc new file mode 100644 index 0000000..b229e74 --- /dev/null +++ b/tests/vr/vr_evidence_layer.cc @@ -0,0 +1,1005 @@ +// Test-only implicit Vulkan layer that produces GPU-level evidence for the +// experimental native stereo bring-up: +// - records images only inside the bridge's guest eye initializer; +// - identifies left/right from the guest eye getter and the subsequent +// render pass's actual attachments, checking the initializer's owner; +// - counts vkCmdBeginRenderPass invocations per eye framebuffer (proof that +// the Roblox renderer issues separate GPU passes into both eyes); +// - at a configurable present index, reads both eye images back on the +// presenting queue and writes eye0.ppm / eye1.ppm plus summary.txt into +// MOCKTAIL_VR_EVIDENCE_DIR, including a pixel-difference verdict that +// distinguishes real per-eye cameras from a duplicated single image. +// +// The layer is inert unless MOCKTAIL_VR_EVIDENCE_DIR is set. It never aborts +// the application: every evidence operation is best-effort and logged with +// the [vr-evidence-layer] prefix. +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__GNUC__) +#define MOCKTAIL_VR_LAYER_EXPORT __attribute__((visibility("default"))) +#else +#define MOCKTAIL_VR_LAYER_EXPORT +#endif + +namespace { + +void Log(const char *format, ...) { + va_list arguments; + va_start(arguments, format); + std::vfprintf(stderr, format, arguments); + va_end(arguments); + std::fflush(stderr); +} + +std::string EvidenceDirectory() { + const char *value = std::getenv("MOCKTAIL_VR_EVIDENCE_DIR"); + return value != nullptr ? std::string(value) : std::string(); +} + +uint32_t EnvUnsigned(const char *name, uint32_t fallback) { + const char *value = std::getenv(name); + if (value == nullptr || value[0] == '\0') { + return fallback; + } + const long parsed = std::strtol(value, nullptr, 10); + return parsed > 0 ? static_cast(parsed) : fallback; +} + +const void *FindInChain(const void *chain, VkStructureType type) { + // The loader inserts several nodes sharing the same sType (link info, + // data callbacks, feature flags); only the VK_LAYER_LINK_INFO variant + // carries the pLayerInfo chain a layer must consume. + for (const auto *header = static_cast(chain); + header != nullptr; header = header->pNext) { + if (header->sType != type) { + continue; + } + const auto *link = + reinterpret_cast(header); + if (link->function == VK_LAYER_LINK_INFO) { + return header; + } + } + return nullptr; +} + +struct DeviceDispatch { + VkDevice device = VK_NULL_HANDLE; + VkPhysicalDevice physical_device = VK_NULL_HANDLE; + PFN_vkGetDeviceProcAddr get_device_proc_addr = nullptr; + PFN_vkDestroyDevice destroy_device = nullptr; + PFN_vkGetDeviceQueue get_device_queue = nullptr; + PFN_vkCreateImage create_image = nullptr; + PFN_vkDestroyImage destroy_image = nullptr; + PFN_vkDestroyFramebuffer destroy_framebuffer = nullptr; + PFN_vkCreateImageView create_image_view = nullptr; + PFN_vkCreateFramebuffer create_framebuffer = nullptr; + PFN_vkCreateRenderPass create_render_pass = nullptr; + PFN_vkCmdBeginRenderPass cmd_begin_render_pass = nullptr; + PFN_vkQueuePresentKHR queue_present = nullptr; + PFN_vkQueueSubmit queue_submit = nullptr; + PFN_vkCreateCommandPool create_command_pool = nullptr; + PFN_vkAllocateCommandBuffers allocate_command_buffers = nullptr; + PFN_vkBeginCommandBuffer begin_command_buffer = nullptr; + PFN_vkEndCommandBuffer end_command_buffer = nullptr; + PFN_vkCmdPipelineBarrier cmd_pipeline_barrier = nullptr; + PFN_vkCmdCopyImageToBuffer cmd_copy_image_to_buffer = nullptr; + PFN_vkCreateFence create_fence = nullptr; + PFN_vkWaitForFences wait_for_fences = nullptr; + PFN_vkDestroyFence destroy_fence = nullptr; + PFN_vkCreateBuffer create_buffer = nullptr; + PFN_vkDestroyBuffer destroy_buffer = nullptr; + PFN_vkGetBufferMemoryRequirements get_buffer_memory_requirements = nullptr; + PFN_vkAllocateMemory allocate_memory = nullptr; + PFN_vkFreeMemory free_memory = nullptr; + PFN_vkBindBufferMemory bind_buffer_memory = nullptr; + PFN_vkMapMemory map_memory = nullptr; + PFN_vkUnmapMemory unmap_memory = nullptr; + PFN_vkDestroyCommandPool destroy_command_pool = nullptr; + PFN_vkGetPhysicalDeviceMemoryProperties memory_properties = nullptr; +}; + +struct ImageRecord { + VkImage image = VK_NULL_HANDLE; + DeviceDispatch *dispatch = nullptr; + uint32_t width = 0; + uint32_t height = 0; + VkFormat format = VK_FORMAT_UNDEFINED; + VkImageUsageFlags usage = 0; + void *owner = nullptr; +}; + +struct EyeTarget { + bool valid = false; + ImageRecord image{}; + VkFramebuffer framebuffer = VK_NULL_HANDLE; + VkImageLayout last_final_layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + std::uint64_t render_pass_count = 0; +}; + +bool g_active = false; +uint32_t g_capture_present = 60; +uint32_t g_capture_render_passes = 10; +std::mutex g_mutex; +std::unordered_map g_dispatches; +std::unordered_map g_queue_families; +std::unordered_map g_render_pass_final_layouts; +std::unordered_map g_image_views; +std::unordered_map g_images; +std::unordered_map> g_framebuffer_images; +using InitializingObjectFn = void *(*)(); +using CurrentEyeFn = bool (*)(void **, int *, void **); +using ConsumeEyeFn = void (*)(); +InitializingObjectFn g_initializing_object = nullptr; +CurrentEyeFn g_current_eye = nullptr; +ConsumeEyeFn g_consume_eye = nullptr; +EyeTarget g_eyes[2]; +std::uint64_t g_present_count = 0; +bool g_capture_attempted = false; +PFN_vkGetInstanceProcAddr g_instance_gipa = nullptr; +VkInstance g_instance = VK_NULL_HANDLE; + +void *DispatchKey(void *handle) { + return handle != nullptr ? *reinterpret_cast(handle) : nullptr; +} + +DeviceDispatch *DispatchFor(void *handle) { + void *key = DispatchKey(handle); + if (key == nullptr) { + return nullptr; + } + std::lock_guard lock(g_mutex); + const auto entry = g_dispatches.find(key); + return entry != g_dispatches.end() ? &entry->second : nullptr; +} + +bool IsColorFormat(VkFormat format) { + switch (format) { + case VK_FORMAT_D16_UNORM: + case VK_FORMAT_D16_UNORM_S8_UINT: + case VK_FORMAT_D24_UNORM_S8_UINT: + case VK_FORMAT_D32_SFLOAT: + case VK_FORMAT_D32_SFLOAT_S8_UINT: + case VK_FORMAT_X8_D24_UNORM_PACK32: + return false; + default: + return true; + } +} + +uint32_t BytesPerPixel(VkFormat format) { + switch (format) { + case VK_FORMAT_R8G8B8A8_UNORM: + case VK_FORMAT_R8G8B8A8_SRGB: + case VK_FORMAT_B8G8R8A8_UNORM: + case VK_FORMAT_B8G8R8A8_SRGB: + case VK_FORMAT_R8G8B8A8_UINT: + case VK_FORMAT_R32_SFLOAT: + return 4; + default: + return 0; + } +} + +bool IsBgra(VkFormat format) { + return format == VK_FORMAT_B8G8R8A8_UNORM || + format == VK_FORMAT_B8G8R8A8_SRGB; +} + +bool WritePpm(const std::string &path, const uint8_t *pixels, uint32_t width, + uint32_t height, bool bgra) { + std::FILE *file = std::fopen(path.c_str(), "wb"); + if (file == nullptr) { + return false; + } + std::fprintf(file, "P6\n%u %u\n255\n", width, height); + for (uint32_t row = 0; row < height; ++row) { + for (uint32_t column = 0; column < width; ++column) { + const uint8_t *source = + pixels + (static_cast(row) * width + column) * 4; + const uint8_t red = bgra ? source[2] : source[0]; + const uint8_t green = source[1]; + const uint8_t blue = bgra ? source[0] : source[2]; + const uint8_t rgb[3] = {red, green, blue}; + std::fwrite(rgb, 1, sizeof(rgb), file); + } + } + std::fclose(file); + return true; +} + +int FindMemoryType(const DeviceDispatch &dispatch, uint32_t requirements, + VkMemoryPropertyFlags properties) { + if (dispatch.memory_properties == nullptr) { + return -1; + } + VkPhysicalDeviceMemoryProperties memory{}; + dispatch.memory_properties(dispatch.physical_device, &memory); + for (uint32_t index = 0; index < memory.memoryTypeCount; ++index) { + if ((requirements & (1u << index)) != 0 && + (memory.memoryTypes[index].propertyFlags & properties) == properties) { + return static_cast(index); + } + } + return -1; +} + +uint32_t QueueFamilyFor(VkQueue queue) { + std::lock_guard lock(g_mutex); + const auto entry = g_queue_families.find(DispatchKey(queue)); + return entry != g_queue_families.end() ? entry->second : 0; +} + +void CaptureEyes(DeviceDispatch &dispatch, VkQueue queue) { + // Runs on the presenting (render) thread after the frame that filled both + // eyes was submitted; g_mutex is NOT held here except through helpers. + EyeTarget eyes[2]; + { + std::lock_guard lock(g_mutex); + eyes[0] = g_eyes[0]; + eyes[1] = g_eyes[1]; + } + if (!eyes[0].valid || !eyes[1].valid || eyes[0].image.dispatch != &dispatch || + eyes[1].image.dispatch != &dispatch || + eyes[0].image.owner != eyes[1].image.owner || + eyes[0].image.width != eyes[1].image.width || + eyes[0].image.height != eyes[1].image.height) { + Log(" [vr-evidence-layer] capture skipped: eye targets unknown\n"); + return; + } + if (BytesPerPixel(eyes[0].image.format) != 4 || + BytesPerPixel(eyes[1].image.format) != 4) { + Log(" [vr-evidence-layer] capture skipped: unsupported eye formats " + "%d/%d\n", + static_cast(eyes[0].image.format), + static_cast(eyes[1].image.format)); + return; + } + const uint32_t width = eyes[0].image.width; + const uint32_t height = eyes[0].image.height; + const VkDeviceSize buffer_size = + static_cast(width) * height * 4; + + VkCommandPool pool = VK_NULL_HANDLE; + VkCommandPoolCreateInfo pool_info{}; + pool_info.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; + pool_info.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + pool_info.queueFamilyIndex = QueueFamilyFor(queue); + if (dispatch.create_command_pool(dispatch.device, &pool_info, nullptr, + &pool) != VK_SUCCESS) { + Log(" [vr-evidence-layer] capture: command pool creation failed\n"); + return; + } + + VkCommandBuffer command = VK_NULL_HANDLE; + VkCommandBufferAllocateInfo allocate_info{}; + allocate_info.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; + allocate_info.commandPool = pool; + allocate_info.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + allocate_info.commandBufferCount = 1; + if (dispatch.allocate_command_buffers(dispatch.device, &allocate_info, + &command) != VK_SUCCESS) { + dispatch.destroy_command_pool(dispatch.device, pool, nullptr); + Log(" [vr-evidence-layer] capture: command buffer allocation failed\n"); + return; + } + + VkBuffer buffers[2] = {VK_NULL_HANDLE, VK_NULL_HANDLE}; + VkDeviceMemory memories[2] = {VK_NULL_HANDLE, VK_NULL_HANDLE}; + void *mapped[2] = {nullptr, nullptr}; + VkFence fence = VK_NULL_HANDLE; + VkFenceCreateInfo fence_info{}; + fence_info.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO; + bool prepared = dispatch.create_fence(dispatch.device, &fence_info, nullptr, + &fence) == VK_SUCCESS; + for (int index = 0; prepared && index < 2; ++index) { + VkBufferCreateInfo buffer_info{}; + buffer_info.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + buffer_info.size = buffer_size; + buffer_info.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT; + prepared = dispatch.create_buffer(dispatch.device, &buffer_info, nullptr, + &buffers[index]) == VK_SUCCESS; + if (!prepared) { + break; + } + VkMemoryRequirements requirements{}; + dispatch.get_buffer_memory_requirements(dispatch.device, buffers[index], + &requirements); + const int memory_type = + FindMemoryType(dispatch, requirements.memoryTypeBits, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | + VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + if (memory_type < 0) { + prepared = false; + break; + } + VkMemoryAllocateInfo memory_info{}; + memory_info.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + memory_info.allocationSize = requirements.size; + memory_info.memoryTypeIndex = static_cast(memory_type); + prepared = dispatch.allocate_memory(dispatch.device, &memory_info, nullptr, + &memories[index]) == VK_SUCCESS; + if (!prepared) { + break; + } + prepared = dispatch.bind_buffer_memory(dispatch.device, buffers[index], + memories[index], 0) == VK_SUCCESS; + } + + if (prepared) { + VkCommandBufferBeginInfo begin_info{}; + begin_info.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + begin_info.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + prepared = + dispatch.begin_command_buffer(command, &begin_info) == VK_SUCCESS; + } + if (prepared) { + for (int index = 0; index < 2; ++index) { + VkImageMemoryBarrier to_source{}; + to_source.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + to_source.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; + to_source.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + to_source.oldLayout = eyes[index].last_final_layout; + to_source.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + to_source.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + to_source.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + to_source.image = eyes[index].image.image; + to_source.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + dispatch.cmd_pipeline_barrier( + command, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, + &to_source); + VkBufferImageCopy region{}; + region.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + region.imageExtent = {width, height, 1}; + dispatch.cmd_copy_image_to_buffer(command, eyes[index].image.image, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + buffers[index], 1, ®ion); + VkImageMemoryBarrier restore = to_source; + restore.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + restore.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; + restore.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + restore.newLayout = eyes[index].last_final_layout; + dispatch.cmd_pipeline_barrier( + command, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, 0, 0, nullptr, 0, + nullptr, 1, &restore); + } + prepared = dispatch.end_command_buffer(command) == VK_SUCCESS; + } + if (prepared) { + VkSubmitInfo submit{}; + submit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + submit.commandBufferCount = 1; + submit.pCommandBuffers = &command; + prepared = dispatch.queue_submit(queue, 1, &submit, fence) == VK_SUCCESS && + dispatch.wait_for_fences(dispatch.device, 1, &fence, VK_TRUE, + 2000000000ULL) == VK_SUCCESS; + } + if (prepared) { + for (int index = 0; index < 2; ++index) { + prepared = + dispatch.map_memory(dispatch.device, memories[index], 0, buffer_size, + 0, &mapped[index]) == VK_SUCCESS; + if (!prepared) { + break; + } + } + } + + const std::string directory = EvidenceDirectory(); + bool wrote_images = false; + if (prepared && mapped[0] != nullptr && mapped[1] != nullptr) { + const auto *pixels0 = static_cast(mapped[0]); + const auto *pixels1 = static_cast(mapped[1]); + wrote_images = WritePpm(directory + "/eye0.ppm", pixels0, width, height, + IsBgra(eyes[0].image.format)) && + WritePpm(directory + "/eye1.ppm", pixels1, width, height, + IsBgra(eyes[1].image.format)); + std::size_t differing = 0; + std::uint64_t difference_sum = 0; + uint32_t maximum_difference = 0; + const std::size_t total = static_cast(width) * height; + for (std::size_t pixel = 0; pixel < total; ++pixel) { + const uint8_t *a = pixels0 + pixel * 4; + const uint8_t *b = pixels1 + pixel * 4; + uint32_t largest = 0; + for (int channel = 0; channel < 3; ++channel) { + const uint32_t difference = a[channel] > b[channel] + ? a[channel] - b[channel] + : b[channel] - a[channel]; + largest = difference > largest ? difference : largest; + } + if (largest > 0) { + ++differing; + difference_sum += largest; + maximum_difference = + largest > maximum_difference ? largest : maximum_difference; + } + } + std::FILE *summary = std::fopen((directory + "/summary.txt").c_str(), "w"); + if (summary != nullptr) { + std::fprintf(summary, + "provenance=guest_initializer_and_eye_getter owner=%p\n", + eyes[0].image.owner); + std::fprintf(summary, "capture_present=%llu\n", + static_cast(g_present_count)); + for (int index = 0; index < 2; ++index) { + const EyeTarget &eye = eyes[index]; + std::fprintf( + summary, + "eye%d_image=%p eye%d_format=%d eye%d_width=%u eye%d_height=%u " + "eye%d_usage=0x%x eye%d_framebuffer=%p " + "eye%d_render_pass_count=%llu eye%d_final_layout=%d\n", + index, static_cast(eye.image.image), index, + static_cast(eye.image.format), index, eye.image.width, index, + eye.image.height, index, eye.image.usage, index, + static_cast(eye.framebuffer), index, + static_cast(eye.render_pass_count), index, + static_cast(eye.last_final_layout)); + } + std::fprintf(summary, + "pixels_total=%zu pixels_differing=%zu max_abs_diff=%u " + "mean_abs_diff=%.4f\n", + total, differing, maximum_difference, + differing > 0 ? static_cast(difference_sum) / + static_cast(differing) + : 0.0); + std::fprintf(summary, "EYES_DIFFER=%s\n", differing > 0 ? "yes" : "no"); + std::fclose(summary); + } + Log(" [vr-evidence-layer] capture done: differing=%zu/%u max_diff=%u " + "eye0_rp=%llu eye1_rp=%llu wrote_ppm=%d\n", + differing, static_cast(total), maximum_difference, + static_cast(eyes[0].render_pass_count), + static_cast(eyes[1].render_pass_count), + wrote_images ? 1 : 0); + } else { + Log(" [vr-evidence-layer] capture FAILED (prepared=%d)\n", + prepared ? 1 : 0); + } + + for (int index = 0; index < 2; ++index) { + if (mapped[index] != nullptr) { + dispatch.unmap_memory(dispatch.device, memories[index]); + } + if (buffers[index] != VK_NULL_HANDLE) { + dispatch.destroy_buffer(dispatch.device, buffers[index], nullptr); + } + if (memories[index] != VK_NULL_HANDLE) { + dispatch.free_memory(dispatch.device, memories[index], nullptr); + } + } + if (fence != VK_NULL_HANDLE) { + dispatch.destroy_fence(dispatch.device, fence, nullptr); + } + dispatch.destroy_command_pool(dispatch.device, pool, nullptr); +} + +VKAPI_ATTR VkResult VKAPI_CALL +LayerQueuePresentKHR(VkQueue queue, const VkPresentInfoKHR *info) { + DeviceDispatch *dispatch = DispatchFor(queue); + if (dispatch == nullptr || dispatch->queue_present == nullptr) { + return VK_ERROR_INITIALIZATION_FAILED; + } + if (!g_active) { + return dispatch->queue_present(queue, info); + } + std::uint64_t presents = 0; + bool should_capture = false; + { + std::lock_guard lock(g_mutex); + presents = ++g_present_count; + // Only capture once BOTH eyes have actually been rendered into at least + // g_capture_render_passes times; otherwise the readback could show + // uninitialized contents and a bogus pixel difference. + should_capture = !g_capture_attempted && presents >= g_capture_present && + g_eyes[0].valid && g_eyes[1].valid && + g_eyes[0].image.dispatch == dispatch && + g_eyes[1].image.dispatch == dispatch && + g_eyes[0].framebuffer != VK_NULL_HANDLE && + g_eyes[1].framebuffer != VK_NULL_HANDLE && + g_eyes[0].render_pass_count >= g_capture_render_passes && + g_eyes[1].render_pass_count >= g_capture_render_passes; + if (should_capture) { + g_capture_attempted = true; + } + } + if (should_capture) { + CaptureEyes(*dispatch, queue); + } + if (presents == 1 || presents % 120 == 0) { + std::lock_guard lock(g_mutex); + Log(" [vr-evidence-layer] presents=%llu eye0_rp=%llu eye1_rp=%llu\n", + static_cast(presents), + static_cast(g_eyes[0].render_pass_count), + static_cast(g_eyes[1].render_pass_count)); + } + return dispatch->queue_present(queue, info); +} + +VKAPI_ATTR void VKAPI_CALL LayerCmdBeginRenderPass( + VkCommandBuffer command, const VkRenderPassBeginInfo *info, + VkSubpassContents contents) { + DeviceDispatch *dispatch = DispatchFor(command); + if (dispatch == nullptr || dispatch->cmd_begin_render_pass == nullptr) { + return; + } + if (g_active && info != nullptr) { + std::lock_guard lock(g_mutex); + void *owner = nullptr; + void *guest_framebuffer = nullptr; + int index = -1; + const bool requested = g_current_eye != nullptr && + g_current_eye(&owner, &index, &guest_framebuffer) && + index >= 0 && index < 2; + const auto attachments = g_framebuffer_images.find(info->framebuffer); + if (requested && attachments != g_framebuffer_images.end()) { + const ImageRecord *target = nullptr; + bool ambiguous = false; + for (VkImage image : attachments->second) { + const auto found = g_images.find(image); + if (found != g_images.end() && found->second.owner == owner && + found->second.dispatch == dispatch && + IsColorFormat(found->second.format) && + (found->second.usage & VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT) != 0) { + if (target != nullptr) + ambiguous = true; + target = &found->second; + } + } + if (target != nullptr && !ambiguous) { + if ((g_eyes[0].valid && g_eyes[0].image.owner != owner) || + (g_eyes[1].valid && g_eyes[1].image.owner != owner)) { + g_eyes[0] = {}; + g_eyes[1] = {}; + g_capture_attempted = false; + } + EyeTarget &eye = g_eyes[index]; + if (!eye.valid || eye.image.image != target->image || + eye.framebuffer != info->framebuffer) { + eye = {}; + eye.valid = true; + eye.image = *target; + eye.framebuffer = info->framebuffer; + Log(" [vr-evidence-layer] eye framebuffer bound: eye=%d owner=%p " + "guest_fb=%p fb=%p image=%p " + "provenance=guest_initializer_and_eye_getter\n", + index, owner, guest_framebuffer, + static_cast(info->framebuffer), + static_cast(target->image)); + } + ++eye.render_pass_count; + const auto layout = g_render_pass_final_layouts.find(info->renderPass); + if (layout != g_render_pass_final_layouts.end()) + eye.last_final_layout = layout->second; + if (g_consume_eye != nullptr) + g_consume_eye(); + } + } + } + dispatch->cmd_begin_render_pass(command, info, contents); +} + +VKAPI_ATTR VkResult VKAPI_CALL +LayerCreateImage(VkDevice device, const VkImageCreateInfo *info, + const VkAllocationCallbacks *allocator, VkImage *image) { + DeviceDispatch *dispatch = DispatchFor(device); + if (dispatch == nullptr || dispatch->create_image == nullptr) { + return VK_ERROR_INITIALIZATION_FAILED; + } + const VkResult result = + dispatch->create_image(device, info, allocator, image); + void *owner = + g_initializing_object != nullptr ? g_initializing_object() : nullptr; + if (g_active && owner != nullptr && result == VK_SUCCESS && info != nullptr && + image != nullptr) { + std::lock_guard lock(g_mutex); + ImageRecord record{}; + record.image = *image; + record.dispatch = dispatch; + record.width = info->extent.width; + record.height = info->extent.height; + record.format = info->format; + record.usage = info->usage; + record.owner = owner; + g_images[*image] = record; + } + return result; +} + +VKAPI_ATTR VkResult VKAPI_CALL LayerCreateImageView( + VkDevice device, const VkImageViewCreateInfo *info, + const VkAllocationCallbacks *allocator, VkImageView *view) { + DeviceDispatch *dispatch = DispatchFor(device); + if (dispatch == nullptr || dispatch->create_image_view == nullptr) { + return VK_ERROR_INITIALIZATION_FAILED; + } + const VkResult result = + dispatch->create_image_view(device, info, allocator, view); + if (g_active && result == VK_SUCCESS && info != nullptr && view != nullptr) { + std::lock_guard lock(g_mutex); + g_image_views[*view] = info->image; + } + return result; +} + +VKAPI_ATTR VkResult VKAPI_CALL LayerCreateFramebuffer( + VkDevice device, const VkFramebufferCreateInfo *info, + const VkAllocationCallbacks *allocator, VkFramebuffer *framebuffer) { + DeviceDispatch *dispatch = DispatchFor(device); + if (dispatch == nullptr || dispatch->create_framebuffer == nullptr) { + return VK_ERROR_INITIALIZATION_FAILED; + } + const VkResult result = + dispatch->create_framebuffer(device, info, allocator, framebuffer); + if (g_active && result == VK_SUCCESS && info != nullptr && + framebuffer != nullptr) { + std::lock_guard lock(g_mutex); + auto &images = g_framebuffer_images[*framebuffer]; + images.clear(); + for (uint32_t index = 0; index < info->attachmentCount; ++index) { + const auto source = g_image_views.find(info->pAttachments[index]); + if (source != g_image_views.end()) + images.push_back(source->second); + } + } + return result; +} + +VKAPI_ATTR void VKAPI_CALL LayerDestroyImage( + VkDevice device, VkImage image, const VkAllocationCallbacks *allocator) { + auto *dispatch = DispatchFor(device); + if (dispatch == nullptr) + return; + { + std::lock_guard lock(g_mutex); + g_images.erase(image); + for (auto it = g_image_views.begin(); it != g_image_views.end();) { + if (it->second == image) + it = g_image_views.erase(it); + else + ++it; + } + if (g_eyes[0].image.image == image || g_eyes[1].image.image == image) { + g_eyes[0] = {}; + g_eyes[1] = {}; + g_capture_attempted = false; + } + } + dispatch->destroy_image(device, image, allocator); +} + +VKAPI_ATTR void VKAPI_CALL +LayerDestroyFramebuffer(VkDevice device, VkFramebuffer framebuffer, + const VkAllocationCallbacks *allocator) { + auto *dispatch = DispatchFor(device); + if (dispatch == nullptr) + return; + { + std::lock_guard lock(g_mutex); + g_framebuffer_images.erase(framebuffer); + if (g_eyes[0].framebuffer == framebuffer || + g_eyes[1].framebuffer == framebuffer) { + g_eyes[0] = {}; + g_eyes[1] = {}; + g_capture_attempted = false; + } + } + dispatch->destroy_framebuffer(device, framebuffer, allocator); +} + +VKAPI_ATTR VkResult VKAPI_CALL LayerCreateRenderPass( + VkDevice device, const VkRenderPassCreateInfo *info, + const VkAllocationCallbacks *allocator, VkRenderPass *render_pass) { + DeviceDispatch *dispatch = DispatchFor(device); + if (dispatch == nullptr || dispatch->create_render_pass == nullptr) { + return VK_ERROR_INITIALIZATION_FAILED; + } + const VkResult result = + dispatch->create_render_pass(device, info, allocator, render_pass); + if (g_active && result == VK_SUCCESS && info != nullptr && + render_pass != nullptr && info->attachmentCount > 0) { + std::lock_guard lock(g_mutex); + g_render_pass_final_layouts[*render_pass] = + info->pAttachments[0].finalLayout; + } + return result; +} + +VKAPI_ATTR void VKAPI_CALL LayerGetDeviceQueue(VkDevice device, uint32_t family, + uint32_t index, VkQueue *queue) { + DeviceDispatch *dispatch = DispatchFor(device); + if (dispatch == nullptr || dispatch->get_device_queue == nullptr) { + return; + } + dispatch->get_device_queue(device, family, index, queue); + if (g_active && queue != nullptr && *queue != VK_NULL_HANDLE) { + std::lock_guard lock(g_mutex); + g_queue_families[DispatchKey(*queue)] = family; + } +} + +VKAPI_ATTR void VKAPI_CALL +LayerDestroyDevice(VkDevice device, const VkAllocationCallbacks *allocator) { + DeviceDispatch *dispatch = DispatchFor(device); + if (dispatch == nullptr) { + return; + } + PFN_vkDestroyDevice destroy = dispatch->destroy_device; + { + std::lock_guard lock(g_mutex); + g_dispatches.erase(DispatchKey(device)); + } + if (destroy != nullptr) { + destroy(device, allocator); + } +} + +VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL +LayerGetDeviceProcAddr(VkDevice device, const char *name) { + if (name == nullptr) { + return nullptr; + } + if (g_active) { + if (std::strcmp(name, "vkGetDeviceProcAddr") == 0) { + return reinterpret_cast(LayerGetDeviceProcAddr); + } + if (std::strcmp(name, "vkDestroyImage") == 0) + return reinterpret_cast(LayerDestroyImage); + if (std::strcmp(name, "vkDestroyFramebuffer") == 0) + return reinterpret_cast(LayerDestroyFramebuffer); + if (std::strcmp(name, "vkDestroyDevice") == 0) { + return reinterpret_cast(LayerDestroyDevice); + } + if (std::strcmp(name, "vkGetDeviceQueue") == 0) { + return reinterpret_cast(LayerGetDeviceQueue); + } + if (std::strcmp(name, "vkCreateImage") == 0) { + return reinterpret_cast(LayerCreateImage); + } + if (std::strcmp(name, "vkCreateImageView") == 0) { + return reinterpret_cast(LayerCreateImageView); + } + if (std::strcmp(name, "vkCreateFramebuffer") == 0) { + return reinterpret_cast(LayerCreateFramebuffer); + } + if (std::strcmp(name, "vkCreateRenderPass") == 0) { + return reinterpret_cast(LayerCreateRenderPass); + } + if (std::strcmp(name, "vkCmdBeginRenderPass") == 0) { + return reinterpret_cast(LayerCmdBeginRenderPass); + } + if (std::strcmp(name, "vkQueuePresentKHR") == 0) { + return reinterpret_cast(LayerQueuePresentKHR); + } + } + std::lock_guard lock(g_mutex); + const auto entry = g_dispatches.find(DispatchKey(device)); + if (entry == g_dispatches.end() || + entry->second.get_device_proc_addr == nullptr) { + return nullptr; + } + return entry->second.get_device_proc_addr(device, name); +} + +VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL +LayerGetInstanceProcAddr(VkInstance instance, const char *name); + +VKAPI_ATTR VkResult VKAPI_CALL LayerCreateDevice( + VkPhysicalDevice physical_device, const VkDeviceCreateInfo *info, + const VkAllocationCallbacks *allocator, VkDevice *device) { + auto *link = const_cast( + static_cast( + FindInChain(info != nullptr ? info->pNext : nullptr, + VK_STRUCTURE_TYPE_LOADER_DEVICE_CREATE_INFO))); + if (link == nullptr || link->u.pLayerInfo == nullptr) { + return VK_ERROR_INITIALIZATION_FAILED; + } + const VkLayerDeviceLink *device_link = link->u.pLayerInfo; + PFN_vkGetInstanceProcAddr next_gipa = device_link->pfnNextGetInstanceProcAddr; + PFN_vkGetDeviceProcAddr next_gdpa = device_link->pfnNextGetDeviceProcAddr; + PFN_vkCreateDevice next_create = reinterpret_cast( + next_gipa(VK_NULL_HANDLE, "vkCreateDevice")); + if (next_create == nullptr) { + return VK_ERROR_INITIALIZATION_FAILED; + } + link->u.pLayerInfo = link->u.pLayerInfo->pNext; + const VkResult result = next_create(physical_device, info, allocator, device); + if (result != VK_SUCCESS || device == nullptr) { + return result; + } + DeviceDispatch dispatch{}; + dispatch.device = *device; + dispatch.physical_device = physical_device; + dispatch.get_device_proc_addr = next_gdpa; +#define MOCKTAIL_LAYER_DEVICE_FN(field, name) \ + dispatch.field = reinterpret_cast(next_gdpa(*device, #name)); + MOCKTAIL_LAYER_DEVICE_FN(destroy_device, vkDestroyDevice) + MOCKTAIL_LAYER_DEVICE_FN(get_device_queue, vkGetDeviceQueue) + MOCKTAIL_LAYER_DEVICE_FN(create_image, vkCreateImage) + MOCKTAIL_LAYER_DEVICE_FN(destroy_image, vkDestroyImage) + MOCKTAIL_LAYER_DEVICE_FN(destroy_framebuffer, vkDestroyFramebuffer) + MOCKTAIL_LAYER_DEVICE_FN(create_image_view, vkCreateImageView) + MOCKTAIL_LAYER_DEVICE_FN(create_framebuffer, vkCreateFramebuffer) + MOCKTAIL_LAYER_DEVICE_FN(create_render_pass, vkCreateRenderPass) + MOCKTAIL_LAYER_DEVICE_FN(cmd_begin_render_pass, vkCmdBeginRenderPass) + MOCKTAIL_LAYER_DEVICE_FN(queue_present, vkQueuePresentKHR) + MOCKTAIL_LAYER_DEVICE_FN(queue_submit, vkQueueSubmit) + MOCKTAIL_LAYER_DEVICE_FN(create_command_pool, vkCreateCommandPool) + MOCKTAIL_LAYER_DEVICE_FN(allocate_command_buffers, vkAllocateCommandBuffers) + MOCKTAIL_LAYER_DEVICE_FN(begin_command_buffer, vkBeginCommandBuffer) + MOCKTAIL_LAYER_DEVICE_FN(end_command_buffer, vkEndCommandBuffer) + MOCKTAIL_LAYER_DEVICE_FN(cmd_pipeline_barrier, vkCmdPipelineBarrier) + MOCKTAIL_LAYER_DEVICE_FN(cmd_copy_image_to_buffer, vkCmdCopyImageToBuffer) + MOCKTAIL_LAYER_DEVICE_FN(create_fence, vkCreateFence) + MOCKTAIL_LAYER_DEVICE_FN(wait_for_fences, vkWaitForFences) + MOCKTAIL_LAYER_DEVICE_FN(destroy_fence, vkDestroyFence) + MOCKTAIL_LAYER_DEVICE_FN(create_buffer, vkCreateBuffer) + MOCKTAIL_LAYER_DEVICE_FN(destroy_buffer, vkDestroyBuffer) + MOCKTAIL_LAYER_DEVICE_FN(get_buffer_memory_requirements, + vkGetBufferMemoryRequirements) + MOCKTAIL_LAYER_DEVICE_FN(allocate_memory, vkAllocateMemory) + MOCKTAIL_LAYER_DEVICE_FN(free_memory, vkFreeMemory) + MOCKTAIL_LAYER_DEVICE_FN(bind_buffer_memory, vkBindBufferMemory) + MOCKTAIL_LAYER_DEVICE_FN(map_memory, vkMapMemory) + MOCKTAIL_LAYER_DEVICE_FN(unmap_memory, vkUnmapMemory) + MOCKTAIL_LAYER_DEVICE_FN(destroy_command_pool, vkDestroyCommandPool) +#undef MOCKTAIL_LAYER_DEVICE_FN + dispatch.memory_properties = + reinterpret_cast( + next_gipa(g_instance, "vkGetPhysicalDeviceMemoryProperties")); + if (g_active) { + Log(" [vr-evidence-layer] CreateDevice: g_instance=%p chain-gipa " + "memory-props=%p physdev-props=%p enumerate-pd=%p pd=%p\n", + static_cast(g_instance), + reinterpret_cast(dispatch.memory_properties), + reinterpret_cast( + next_gipa(g_instance, "vkGetPhysicalDeviceProperties")), + reinterpret_cast( + next_gipa(g_instance, "vkEnumeratePhysicalDevices")), + static_cast(physical_device)); + } + if (dispatch.memory_properties == nullptr) { + // NOTE: dlsym(RTLD_DEFAULT) returns the loader's top-level trampoline, + // which rejects below-chain physical device handles; do not use it here. + } + if (dispatch.memory_properties == nullptr) { + Log(" [vr-evidence-layer] warning: memory-properties entry unresolved; " + "pixel readback will be unavailable\n"); + } + { + std::lock_guard lock(g_mutex); + g_dispatches[DispatchKey(*device)] = dispatch; + } + if (g_active) { + Log(" [vr-evidence-layer] device dispatch registered (%p)\n", + static_cast(*device)); + } + return result; +} + +VKAPI_ATTR VkResult VKAPI_CALL LayerCreateInstance( + const VkInstanceCreateInfo *info, const VkAllocationCallbacks *allocator, + VkInstance *instance) { + auto *link = const_cast( + static_cast( + FindInChain(info != nullptr ? info->pNext : nullptr, + VK_STRUCTURE_TYPE_LOADER_INSTANCE_CREATE_INFO))); + if (link == nullptr || link->u.pLayerInfo == nullptr) { + if (g_active) { + Log(" [vr-evidence-layer] CreateInstance: loader chain info missing " + "(link=%p)\n", + static_cast(link)); + } + return VK_ERROR_INITIALIZATION_FAILED; + } + const VkLayerInstanceLink *instance_link = link->u.pLayerInfo; + PFN_vkGetInstanceProcAddr next_gipa = + instance_link->pfnNextGetInstanceProcAddr; + PFN_vkCreateInstance next_create = reinterpret_cast( + next_gipa(VK_NULL_HANDLE, "vkCreateInstance")); + if (next_create == nullptr) { + Log(" [vr-evidence-layer] CreateInstance: next chain entry is null\n"); + return VK_ERROR_INITIALIZATION_FAILED; + } + link->u.pLayerInfo = link->u.pLayerInfo->pNext; + const VkResult result = next_create(info, allocator, instance); + if (g_active) { + Log(" [vr-evidence-layer] CreateInstance result=%d api=%u\n", + static_cast(result), + info != nullptr && info->pApplicationInfo != nullptr + ? info->pApplicationInfo->apiVersion + : 0u); + } + if (result == VK_SUCCESS) { + g_instance_gipa = next_gipa; + if (instance != nullptr) { + g_instance = *instance; + } + } + return result; +} + +VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL +LayerGetInstanceProcAddr(VkInstance instance, const char *name) { + if (name == nullptr) { + return nullptr; + } + if (std::strcmp(name, "vkGetInstanceProcAddr") == 0) { + return reinterpret_cast(LayerGetInstanceProcAddr); + } + if (std::strcmp(name, "vkCreateInstance") == 0) { + return reinterpret_cast(LayerCreateInstance); + } + if (std::strcmp(name, "vkCreateDevice") == 0) { + return reinterpret_cast(LayerCreateDevice); + } + if (g_instance_gipa != nullptr) { + return g_instance_gipa(instance, name); + } + return nullptr; +} + +} // namespace + +extern "C" { + +MOCKTAIL_VR_LAYER_EXPORT VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL +vkGetInstanceProcAddr(VkInstance instance, const char *name) { + return LayerGetInstanceProcAddr(instance, name); +} + +MOCKTAIL_VR_LAYER_EXPORT VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL +vkGetDeviceProcAddr(VkDevice device, const char *name) { + return LayerGetDeviceProcAddr(device, name); +} + +MOCKTAIL_VR_LAYER_EXPORT VKAPI_ATTR VkResult VKAPI_CALL +vkNegotiateLoaderLayerInterfaceVersion(VkNegotiateLayerInterface *version) { + if (version == nullptr || + version->sType != LAYER_NEGOTIATE_INTERFACE_STRUCT) { + return VK_ERROR_INITIALIZATION_FAILED; + } + if (!g_active) { + const std::string directory = EvidenceDirectory(); + if (!directory.empty()) { + g_active = true; + g_initializing_object = reinterpret_cast( + dlsym(RTLD_DEFAULT, "mocktail_vr_initializing_object")); + g_current_eye = reinterpret_cast( + dlsym(RTLD_DEFAULT, "mocktail_vr_current_eye")); + g_consume_eye = reinterpret_cast( + dlsym(RTLD_DEFAULT, "mocktail_vr_consume_eye")); + g_capture_present = + EnvUnsigned("MOCKTAIL_VR_EVIDENCE_CAPTURE_PRESENT", 60); + g_capture_render_passes = + EnvUnsigned("MOCKTAIL_VR_EVIDENCE_CAPTURE_RENDER_PASSES", 10); + Log(" [vr-evidence-layer] activated: dir=%s " + "capture_present=%u capture_render_passes=%u\n", + directory.c_str(), g_capture_present, g_capture_render_passes); + } + } + if (version->loaderLayerInterfaceVersion >= 2) { + version->pfnGetInstanceProcAddr = LayerGetInstanceProcAddr; + version->pfnGetDeviceProcAddr = LayerGetDeviceProcAddr; + version->pfnGetPhysicalDeviceProcAddr = nullptr; + version->loaderLayerInterfaceVersion = 2; + } + return VK_SUCCESS; +} + +} // extern "C" diff --git a/tests/vr/vr_evidence_layer_test.cc b/tests/vr/vr_evidence_layer_test.cc new file mode 100644 index 0000000..4dd7fb1 --- /dev/null +++ b/tests/vr/vr_evidence_layer_test.cc @@ -0,0 +1,131 @@ +// Exercise the layer's provenance joins without a GPU or guest payload. +#include "vr_evidence_layer.cc" +#include + +namespace { +void *test_owner = nullptr; +void *test_request_owner = nullptr; +int test_eye = -1; +std::uintptr_t next_handle = 0x1000; +void *InitializerOwner() { return test_owner; } +bool CurrentEye(void **owner, int *index, void **framebuffer) { + *owner = test_request_owner; + *index = test_eye; + *framebuffer = reinterpret_cast(0x8000); + return test_request_owner != nullptr; +} +void ConsumeEye() { test_request_owner = nullptr; } +VKAPI_ATTR VkResult VKAPI_CALL CreateImageStub(VkDevice, + const VkImageCreateInfo *, + const VkAllocationCallbacks *, + VkImage *image) { + *image = reinterpret_cast(++next_handle); + return VK_SUCCESS; +} +VKAPI_ATTR VkResult VKAPI_CALL CreateFramebufferStub( + VkDevice, const VkFramebufferCreateInfo *, const VkAllocationCallbacks *, + VkFramebuffer *framebuffer) { + *framebuffer = reinterpret_cast(++next_handle); + return VK_SUCCESS; +} +VKAPI_ATTR void VKAPI_CALL RenderPassStub(VkCommandBuffer, + const VkRenderPassBeginInfo *, + VkSubpassContents) {} +VKAPI_ATTR void VKAPI_CALL DestroyImageStub(VkDevice, VkImage, + const VkAllocationCallbacks *) {} +VKAPI_ATTR void VKAPI_CALL DestroyFramebufferStub( + VkDevice, VkFramebuffer, const VkAllocationCallbacks *) {} + +class VrEvidenceProvenance : public ::testing::Test { +protected: + void *key = this; + VkDevice device = reinterpret_cast(&key); + VkCommandBuffer command = reinterpret_cast(&key); + void SetUp() override { + g_active = true; + g_images.clear(); + g_image_views.clear(); + g_framebuffer_images.clear(); + g_dispatches.clear(); + g_eyes[0] = {}; + g_eyes[1] = {}; + g_initializing_object = InitializerOwner; + g_current_eye = CurrentEye; + g_consume_eye = ConsumeEye; + test_owner = nullptr; + test_request_owner = nullptr; + auto &dispatch = g_dispatches[key]; + dispatch.device = device; + dispatch.create_image = CreateImageStub; + dispatch.create_framebuffer = CreateFramebufferStub; + dispatch.cmd_begin_render_pass = RenderPassStub; + dispatch.destroy_image = DestroyImageStub; + dispatch.destroy_framebuffer = DestroyFramebufferStub; + } + VkImage Image() { + VkImageCreateInfo info{}; + info.extent = {500, 500, 1}; + info.format = VK_FORMAT_R8G8B8A8_UNORM; + info.usage = + VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT; + VkImage image; + EXPECT_EQ(LayerCreateImage(device, &info, nullptr, &image), VK_SUCCESS); + return image; + } + VkFramebuffer Framebuffer(VkImage image) { + VkImageView view = reinterpret_cast(++next_handle); + g_image_views[view] = image; + VkFramebufferCreateInfo info{}; + info.attachmentCount = 1; + info.pAttachments = &view; + VkFramebuffer framebuffer; + EXPECT_EQ(LayerCreateFramebuffer(device, &info, nullptr, &framebuffer), + VK_SUCCESS); + return framebuffer; + } + void Render(VkFramebuffer framebuffer, void *owner, int eye) { + test_request_owner = owner; + test_eye = eye; + VkRenderPassBeginInfo info{}; + info.framebuffer = framebuffer; + LayerCmdBeginRenderPass(command, &info, VK_SUBPASS_CONTENTS_INLINE); + } +}; + +TEST_F(VrEvidenceProvenance, + IgnoresSameSizeTargetsOutsideInitializerAndWrongOwner) { + void *owner = reinterpret_cast(0x1234); + auto unrelated = Image(); + Render(Framebuffer(unrelated), owner, 0); + EXPECT_FALSE(g_eyes[0].valid); + EXPECT_TRUE(g_images.empty()); + test_owner = owner; + auto image = Image(); + auto framebuffer = Framebuffer(image); + Render(framebuffer, reinterpret_cast(0x5678), 0); + EXPECT_FALSE(g_eyes[0].valid); + Render(framebuffer, owner, 0); + EXPECT_TRUE(g_eyes[0].valid); + EXPECT_EQ(g_eyes[0].image.image, image); +} + +TEST_F(VrEvidenceProvenance, + EyeIndexComesFromGetterAndDestroyedHandlesAreForgotten) { + test_owner = reinterpret_cast(0x1234); + auto right = Image(); + auto left = Image(); + auto right_fb = Framebuffer(right); + auto left_fb = Framebuffer(left); + Render(right_fb, test_owner, 1); + Render(left_fb, test_owner, 0); + EXPECT_EQ(g_eyes[0].image.image, left); + EXPECT_EQ(g_eyes[1].image.image, right); + LayerDestroyImage(device, left, nullptr); + EXPECT_FALSE(g_eyes[0].valid); + EXPECT_FALSE(g_eyes[1].valid); + Render(left_fb, test_owner, 0); + EXPECT_FALSE(g_eyes[0].valid); + LayerDestroyFramebuffer(device, right_fb, nullptr); + EXPECT_EQ(g_framebuffer_images.count(right_fb), 0u); +} +} // namespace diff --git a/tests/vr/vr_mirror_test.cc b/tests/vr/vr_mirror_test.cc new file mode 100644 index 0000000..f2a8e35 --- /dev/null +++ b/tests/vr/vr_mirror_test.cc @@ -0,0 +1,200 @@ +#include + +#include +#include +#include + +#include +#include +#include "mocktail/vr/vr_perf.h" +#include "mocktail/vr/openxr_backend.h" +#include +#include +#include + +#include "mocktail/vr/vr_mirror.h" +#include "mocktail/vr/vr_mirror_shm.h" + +namespace mocktail::vr { +namespace { + +constexpr std::uint32_t kWidth = 8; +constexpr std::uint32_t kHeight = 4; + +std::vector EyePattern(std::uint8_t seed) { + std::vector bytes(kWidth * kHeight * 4); + for (std::size_t index = 0; index < bytes.size(); ++index) { + bytes[index] = static_cast(seed + index); + } + return bytes; +} + +// Maps the segment read-only exactly like the Python viewer does. +class SegmentReader { + public: + SegmentReader(const std::string& name, std::uint32_t width, + std::uint32_t height, bool* ok) { + *ok = false; + const int descriptor = shm_open(name.c_str(), O_RDONLY, 0); + if (descriptor < 0) { + return; + } + bytes_ = VrMirrorBytes(width, height); + mapping_ = mmap(nullptr, bytes_, PROT_READ, MAP_SHARED, descriptor, 0); + close(descriptor); + *ok = mapping_ != MAP_FAILED; + } + ~SegmentReader() { + if (mapping_ != nullptr && mapping_ != MAP_FAILED) { + munmap(mapping_, bytes_); + } + } + const VrMirrorHeader& header() const { + return *static_cast(mapping_); + } + const std::uint8_t* eye(int index) const { + return static_cast(mapping_) + kVrMirrorHeaderBytes + + static_cast(index) * kWidth * kHeight * 4; + } + SegmentReader(const SegmentReader&) = delete; + SegmentReader& operator=(const SegmentReader&) = delete; + + private: + void* mapping_ = nullptr; + std::uint64_t bytes_ = 0; +}; + +TEST(VrRuntimeSelection, RuntimeErrorsPreserveSelectionProvenance) { + EXPECT_NE(VrRuntimeUnavailableHint(true, "/custom.json").find("User-selected"), std::string::npos); + EXPECT_NE(VrRuntimeUnavailableHint(false, "/usr/share/openxr/1/openxr_wivrn.json").find("Auto-selected WiVRn"), std::string::npos); + EXPECT_NE(VrRuntimeUnavailableHint(false, "").find("registered"), std::string::npos); +} + +TEST(VrRuntimeSelection, ExplicitManifestWinsAndMissingCandidatesUseLoader) { + EXPECT_EQ(SelectVrRuntimeManifest("/explicit/runtime.json", {"/dev/null"}), + "/explicit/runtime.json"); + EXPECT_TRUE(SelectVrRuntimeManifest("", {"/definitely-missing-wivrn/runtime.json"}).empty()); + char path[] = "/tmp/mocktail-wivrn-manifest-XXXXXX"; + const int fd = mkstemp(path); + ASSERT_GE(fd, 0); + close(fd); + EXPECT_EQ(SelectVrRuntimeManifest("", {"/missing/runtime.json", path}), path); + unlink(path); +} + +TEST(VrMirrorProducer, AllocationFailureUsesOwningDevice) { + MirrorVkProcs vk{}; + vk.GetPhysicalDeviceMemoryProperties = [](VkPhysicalDevice, VkPhysicalDeviceMemoryProperties* out) { + *out = {}; + out->memoryTypeCount = 1; + out->memoryTypes[0].propertyFlags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT; + }; + vk.CreateBuffer = [](VkDevice, const VkBufferCreateInfo*, const VkAllocationCallbacks*, VkBuffer* out) { + *out = reinterpret_cast(0x22); + return VK_SUCCESS; + }; + vk.GetBufferMemoryRequirements = [](VkDevice, VkBuffer, VkMemoryRequirements* out) { + *out = {}; out->size = 256; out->memoryTypeBits = 1; + }; + vk.AllocateMemory = [](VkDevice, const VkMemoryAllocateInfo*, const VkAllocationCallbacks*, VkDeviceMemory*) { + return VK_ERROR_OUT_OF_DEVICE_MEMORY; + }; + vk.DestroyBuffer = [](VkDevice device, VkBuffer, const VkAllocationCallbacks*) { + EXPECT_EQ(device, reinterpret_cast(0x11)); + }; + VrMirror mirror; + std::string error; + EXPECT_FALSE(mirror.EnsureStaging(vk, reinterpret_cast(0x11), + VK_NULL_HANDLE, 8, 8, &error)); +} + +TEST(VrMirrorPerf, RingAverageDoesNotAccumulateOverwrittenSamples) { + setenv("MOCKTAIL_VR_PERF", "1", 1); + unsetenv("MOCKTAIL_VR_MEASURE_START_NS"); + perf::Collector collector; + for (int i = 0; i < 8192; ++i) collector.Record(perf::Stage::kFrameCycle, 1000000); + testing::internal::CaptureStderr(); + collector.EmitIfDue(1, "test"); + collector.EmitIfDue(1000000001, "test"); + const auto output = testing::internal::GetCapturedStderr(); + EXPECT_NE(output.find("avg=1.00ms"), std::string::npos) << output; +} + +TEST(VrMirrorProducer, PublishesLatestPairUnderSeqlockAndUnlinksOnClose) { + const std::string name = "/mocktail-vr-mirror-unittest-a"; + { + VrMirror mirror; + std::string error; + ASSERT_TRUE(mirror.Open(name, kWidth, kHeight, &error)) << error; + EXPECT_TRUE(mirror.enabled()); + + bool opened = false; + SegmentReader reader(name, kWidth, kHeight, &opened); + ASSERT_TRUE(opened); + EXPECT_EQ(reader.header().magic, kVrMirrorMagic); + EXPECT_EQ(reader.header().header_bytes, kVrMirrorHeaderBytes); + EXPECT_EQ(reader.header().seq, 0u); + EXPECT_EQ(reader.header().flags & kVrMirrorFlagValid, 0u); + + const auto eye0 = EyePattern(10); + const auto eye1 = EyePattern(90); + mirror.PublishFromBytesForTest(41, VK_FORMAT_R8G8B8A8_UNORM, kWidth, + kHeight, 12345, eye0.data(), eye1.data()); + + // Even sequence: the pair is stable for readers. + EXPECT_EQ(reader.header().seq % 2, 0u); + EXPECT_EQ(reader.header().seq, 2u); + EXPECT_EQ(reader.header().frame, 41u); + EXPECT_EQ(reader.header().producer_ns, 12345u); + EXPECT_EQ(reader.header().width, kWidth); + EXPECT_EQ(reader.header().height, kHeight); + EXPECT_EQ(reader.header().flags & kVrMirrorFlagValid, kVrMirrorFlagValid); + EXPECT_EQ(reader.header().published_pairs, 1u); + EXPECT_EQ(std::memcmp(reader.eye(0), eye0.data(), eye0.size()), 0); + EXPECT_EQ(std::memcmp(reader.eye(1), eye1.data(), eye1.size()), 0); + // Eyes must never alias into a single buffer. + EXPECT_NE(std::memcmp(reader.eye(0), reader.eye(1), eye0.size()), 0); + + // A newer publish replaces the single latest slot; readers see only the + // newest pair and the sequence advances by two per publish. + const auto next0 = EyePattern(33); + const auto next1 = EyePattern(77); + mirror.PublishFromBytesForTest(42, VK_FORMAT_B8G8R8A8_UNORM, kWidth, + kHeight, 54321, next0.data(), next1.data()); + EXPECT_EQ(reader.header().seq, 4u); + EXPECT_EQ(reader.header().frame, 42u); + EXPECT_EQ(reader.header().format, + static_cast(VK_FORMAT_B8G8R8A8_UNORM)); + EXPECT_EQ(std::memcmp(reader.eye(0), next0.data(), next0.size()), 0); + EXPECT_EQ(std::memcmp(reader.eye(1), next1.data(), next1.size()), 0); + EXPECT_EQ(mirror.published_pairs(), 2u); + + mirror.Close(); + EXPECT_FALSE(mirror.enabled()); + } + // The producer owns the segment lifetime: after Close the name is unlinked. + EXPECT_EQ(shm_open(name.c_str(), O_RDONLY, 0), -1); +} + +TEST(VrMirrorProducer, RejectsInvalidNameAndOversizedPublish) { + VrMirror mirror; + std::string error; + EXPECT_FALSE(mirror.Open("", kWidth, kHeight, &error)); + EXPECT_FALSE(mirror.Open("relative-name", kWidth, kHeight, &error)); + EXPECT_FALSE(mirror.Open("/mocktail-vr-mirror-unittest-b", 0, kHeight, + &error)); + EXPECT_FALSE(mirror.enabled()); + + const std::string name = "/mocktail-vr-mirror-unittest-c"; + ASSERT_TRUE(mirror.Open(name, kWidth, kHeight, &error)) << error; + // Publishing an extent larger than the mapped segment is refused, not + // truncated into foreign memory. + const auto big0 = std::vector(kWidth * kHeight * 4 * 4, 7); + mirror.PublishFromBytesForTest(1, VK_FORMAT_R8G8B8A8_UNORM, kWidth * 2, + kHeight * 2, 0, big0.data(), big0.data()); + EXPECT_EQ(mirror.published_pairs(), 0u); + mirror.Close(); +} + +} // namespace +} // namespace mocktail::vr diff --git a/tests/vr/vr_pose_math_test.cc b/tests/vr/vr_pose_math_test.cc new file mode 100644 index 0000000..4c931b1 --- /dev/null +++ b/tests/vr/vr_pose_math_test.cc @@ -0,0 +1,187 @@ +#include + +#include +#include + +#include "mocktail/vr/vr_pose_math.h" + +namespace mocktail::vr { +namespace { + +using namespace pose; + +constexpr float kTol = 1e-5f; + +Vec3 Forward() { return Vec3{0.f, 0.f, -1.f}; } // OpenXR: -Z is forward +Vec3 Up() { return Vec3{0.f, 1.f, 0.f}; } +Vec3 Right() { return Vec3{1.f, 0.f, 0.f}; } + +void ExpectVec(const Vec3& actual, const Vec3& expected, float tolerance) { + EXPECT_NEAR(actual.x, expected.x, tolerance) << "x"; + EXPECT_NEAR(actual.y, expected.y, tolerance) << "y"; + EXPECT_NEAR(actual.z, expected.z, tolerance) << "z"; +} + +// ---- rotation axes verified independently, not only in combination ---- + +TEST(VrPoseMath, YawAloneRotatesForwardWithoutAxisInversion) { + const float yaw = 0.6f; + const Quat q = QuatFromYawPitch(yaw, 0.f); + // Rotation about +Y by yaw takes forward (0,0,-1) to (-sin, 0, -cos). + const Vec3 rotated = QuatRotate(q, Forward()); + ExpectVec(rotated, Vec3{-std::sin(yaw), 0.f, -std::cos(yaw)}, kTol); + // Up is untouched by yaw: an inverted axis would move it. + ExpectVec(QuatRotate(q, Up()), Up(), kTol); +} + +TEST(VrPoseMath, PitchAloneRotatesForwardWithoutAxisInversion) { + const float pitch = 0.4f; + const Quat q = QuatFromYawPitch(0.f, pitch); + // Rotation about +X by pitch takes (0,0,-1) to (0, sin, -cos): looking up + // (positive pitch) raises the forward vector's Y, matching head tilt up. + const Vec3 rotated = QuatRotate(q, Forward()); + ExpectVec(rotated, Vec3{0.f, std::sin(pitch), -std::cos(pitch)}, kTol); + ExpectVec(QuatRotate(q, Right()), Right(), kTol); +} + +TEST(VrPoseMath, RollAloneRotatesUpAroundForwardAxis) { + const float roll = 0.35f; + const Quat q = QuatFromAxisAngle(Vec3{0.f, 0.f, 1.f}, roll); + // Right-handed rotation about +Z takes up (0,1,0) to (-sin, cos, 0): the + // head tilting right rotates the world's up toward the viewer's left. + const Vec3 rotated_up = QuatRotate(q, Up()); + ExpectVec(rotated_up, Vec3{-std::sin(roll), std::cos(roll), 0.f}, kTol); + // The roll axis itself is invariant. + ExpectVec(QuatRotate(q, Vec3{0.f, 0.f, 1.f}), Vec3{0.f, 0.f, 1.f}, kTol); +} + +TEST(VrPoseMath, YawPitchCombinationMatchesIndependentComposition) { + const float yaw = 0.5f, pitch = 0.3f; + const Quat combined = QuatFromYawPitch(yaw, pitch); + // Documented order: R = Ry(yaw) * Rx(pitch). + const Quat yaw_only = QuatFromYawPitch(yaw, 0.f); + const Quat pitch_only = QuatFromYawPitch(0.f, pitch); + const Quat expected = QuatMultiply(yaw_only, pitch_only); + EXPECT_TRUE(QuatSameRotation(combined, expected, kTol)); + // A forward vector must agree between both constructions. + const Vec3 a = QuatRotate(combined, Forward()); + const Vec3 b = QuatRotate(expected, Forward()); + ExpectVec(a, b, kTol); + // And against explicit sequential rotation: pitch first, then yaw. + const Vec3 c = QuatRotate(yaw_only, QuatRotate(pitch_only, Forward())); + ExpectVec(a, c, kTol); +} + +TEST(VrPoseMath, PoseMultiplyAndInverseRoundTrip) { + Pose a; + a.position = Vec3{0.3f, -1.2f, 0.7f}; + a.orientation = QuatFromYawPitch(0.4f, -0.25f); + const Pose identity = PoseMultiply(a, PoseInverse(a)); + ExpectVec(identity.position, Vec3{0.f, 0.f, 0.f}, kTol); + EXPECT_TRUE(QuatSameRotation(identity.orientation, Quat{0.f, 0.f, 0.f, 1.f}, + kTol)); +} + +TEST(VrPoseMath, EyeOffsetIsHeadInverseTimesRuntimeEye) { + // The backend computes relative = inverse(head) * runtime_eye and reprojects + // final_eye = head * relative, which must reproduce the runtime eye exactly. + Pose head; + head.position = Vec3{0.2f, 1.5f, -0.4f}; + head.orientation = QuatFromYawPitch(0.7f, 0.15f); + Pose runtime_eye = head; + runtime_eye.position = PoseMultiply(head, Pose{Vec3{-0.032f, 0.f, 0.f}, + Quat{}}).position; + const Pose relative = PoseMultiply(PoseInverse(head), runtime_eye); + const Pose final_eye = PoseMultiply(head, relative); + ExpectVec(final_eye.position, runtime_eye.position, kTol); +} + +// ---- quaternion sign/normalization contract ---- + +TEST(VrPoseMath, QuaternionSignDoesNotChangeRotation) { + const Quat q = QuatFromYawPitch(0.9f, -0.4f); + const Quat negated{-q.x, -q.y, -q.z, -q.w}; + EXPECT_TRUE(QuatSameRotation(q, negated, kTol)); + ExpectVec(QuatRotate(q, Forward()), QuatRotate(negated, Forward()), kTol); + const Pose p{Vec3{0.1f, 0.2f, 0.3f}, q}; + const Pose pn{Vec3{0.1f, 0.2f, 0.3f}, negated}; + const Pose other{Vec3{-0.5f, 1.f, 0.25f}, QuatFromAxisAngle(Up(), 0.3f)}; + const Pose a = PoseMultiply(p, other); + const Pose b = PoseMultiply(pn, other); + ExpectVec(a.position, b.position, kTol); + EXPECT_TRUE(QuatSameRotation(a.orientation, b.orientation, kTol)); +} + +TEST(VrPoseMath, NormalizeRejectsNonfiniteAndNearZeroWithoutWritingOutput) { + Quat out{9.f, 9.f, 9.f, 9.f}; + Quat input{0.f, 0.f, 0.f, 2.f}; + ASSERT_TRUE(QuatNormalize(input, &out)); + ExpectVec(Vec3{out.x, out.y, out.z}, Vec3{0.f, 0.f, 0.f}, kTol); + EXPECT_NEAR(out.w, 1.f, kTol); + + const float nan = std::numeric_limits::quiet_NaN(); + input = Quat{nan, 0.f, 0.f, 1.f}; + out = Quat{9.f, 9.f, 9.f, 9.f}; + EXPECT_FALSE(QuatNormalize(input, &out)); + EXPECT_FLOAT_EQ(out.x, 9.f); // output untouched on rejection + + input = Quat{0.f, 0.f, 0.f, 0.f}; + EXPECT_FALSE(QuatNormalize(input, &out)); + EXPECT_FALSE(QuatNormalize(input, nullptr)); +} + +// ---- FOV order/sign contract (asymmetric, pinned to the 2998 guest layout) -- + +TEST(VrPoseMath, FovToTangentsPinsUpDownLeftRightOrderAndSigns) { + EyeFov fov; + fov.angle_left = -0.7f; + fov.angle_right = 0.8f; + fov.angle_up = 0.9f; + fov.angle_down = -0.6f; + const ProjectionTangents tangents = FovToTangents(fov); + // The guest state block stores up, down, left, right in this order; down and + // left are negated relative to the OpenXR angle signs. The existing bridge + // test pins the same expectation through ApplyXrPose; this pins the math. + EXPECT_FLOAT_EQ(tangents.up, std::tan(0.9f)); + EXPECT_FLOAT_EQ(tangents.down, std::tan(0.6f)); + EXPECT_FLOAT_EQ(tangents.left, std::tan(0.7f)); + EXPECT_FLOAT_EQ(tangents.right, std::tan(0.8f)); + // Asymmetric FOV must not collapse to a symmetric projection. + EXPECT_NE(tangents.left, tangents.right); + EXPECT_NE(tangents.up, tangents.down); +} + +// ---- canted-view rejection ---- + +TEST(VrPoseMath, ParallelEyeOrientationsAcceptedCantedRejected) { + EXPECT_TRUE(EyeOrientationIsParallel(Quat{0.f, 0.f, 0.f, 1.f})); + // Sign flip only (q == -q) is still parallel. + EXPECT_TRUE(EyeOrientationIsParallel(Quat{0.f, 0.f, 0.f, -1.f})); + // Exactly at tolerance. + EXPECT_TRUE(EyeOrientationIsParallel( + Quat{kCantedTolerance, 0.f, 0.f, 1.f})); + // A canted runtime rotates the eye about its view axis (+Z): must reject. + const Quat canted = QuatFromAxisAngle(Vec3{0.f, 0.f, 1.f}, 0.1f); + EXPECT_FALSE(EyeOrientationIsParallel(canted)); + // Any axis above tolerance rejects. + EXPECT_FALSE(EyeOrientationIsParallel( + Quat{0.f, 2.f * kCantedTolerance, 0.f, 1.f})); +} + +// ---- units: the 0.10 m translation check through the native conversion ---- + +TEST(VrPoseMath, TenthMetreConversionIsDocumentedAndNotPreApplied) { + // The exact-build native helper converts metres to studs itself with factor + // 10/3; 0.10 m must arrive in the guest state as metres (0.10) and become + // 1/3 stud natively. This layer only documents the expected value. + EXPECT_FLOAT_EQ(MetresToStuds(0.10f), 0.10f * kMetresToStuds); + EXPECT_NEAR(MetresToStuds(0.10f), 1.0f / 3.0f, 1e-6f); + // Engine HeadScale multiplies head.position again inside the camera + // composition (out.position = base + base.rotation * head.position * + // HeadScale); it is an engine property distinct from the 10/3 conversion and + // was verified numerically with the native-camera runtime observer. + EXPECT_NE(kMetresToStuds, 1.f); +} + +} // namespace +} // namespace mocktail::vr