diff --git a/README.md b/README.md index e4d0966..68e114e 100644 --- a/README.md +++ b/README.md @@ -1,131 +1,35 @@ -# Mocktail VR (experimental) +# Mocktail VR -Build this checkout, start WiVRn or SteamVR/ALVR, then connect your headset before launching: +Experimental VR support for [Mocktail](https://github.com/komaruworld/mocktail). +Linux x86_64, WiVRn or SteamVR/ALVR. -```sh -git submodule update --init --recursive +## Build + +```bash +git clone --branch vr --recurse-submodules https://github.com/komaruworld/mocktail.git +cd mocktail cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DMOCKTAIL_ENABLE_VR=ON cmake --build build -j4 +``` + +Build dependencies are listed in the [main README](https://github.com/komaruworld/mocktail#building). + +## Run + +Start WiVRn or SteamVR/ALVR and connect your headset first. + +```bash ./build/mocktail --vr ``` -## Automatic runtime selection +The runtime is detected automatically. To pick one manually: -Normally just run `./build/mocktail --vr`. The default `auto` mode detects a -running SteamVR server (also used by ALVR) or a listening WiVRn IPC socket for -the current desktop user. A stale socket file does not count as a running -server. If exactly one server is detected, its runtime takes priority over an -old registration for the other server. If both or neither are detected, the -registered OpenXR runtime has priority. - -SteamVR is also found through `openvr/openvrpaths.vrpath`, standard Steam -locations, and the running server's executable path. WiVRn development builds -are discovered near the running server executable; a matching registered -WiVRn manifest is preferred, including a Flatpak registration. The selected -path and the reason for selecting it are printed at startup. - -Explicit `XR_RUNTIME_JSON` and `MOCKTAIL_VR_RUNTIME` overrides remain respected. -To return to automatic selection after manually choosing a runtime: - -```sh -unset XR_RUNTIME_JSON MOCKTAIL_VR_RUNTIME -./build/mocktail --vr -``` - -Detection does not start servers, connect a headset, or modify system runtime -registration. It observes the current process/user namespace; container and -host runtimes still need correctly shared libraries and IPC access. - -## WiVRn - -Start WiVRn and connect its headset client first. Run WiVRn and Mocktail as the -same desktop user, with the same runtime environment; do not start Mocktail -from a root shell while WiVRn runs in your user session. For containers/chroots, -the host runtime library and IPC socket must both be accessible inside. - -```sh +```bash MOCKTAIL_VR_RUNTIME=wivrn ./build/mocktail --vr +# or +MOCKTAIL_VR_RUNTIME=alvr MOCKTAIL_GRAPHICS_BACKEND=direct-vulkan ./build/mocktail --vr ``` -If WiVRn disconnects and exits, reconnect/restart it before retrying Mocktail. -`Connection refused` on `wivrn/comp_ipc` means the selected server is not -accepting the connection. Installing `dbus-launch` alone does not fix that. +If you previously set `XR_RUNTIME_JSON`, unset it before switching runtimes. +Use `--no-vr` for desktop mode. -## ALVR through SteamVR - -Install SteamVR and ALVR, register the ALVR driver using its dashboard, then -connect the headset and confirm SteamVR sees it. Follow the upstream -[ALVR Linux instructions](https://github.com/alvr-org/ALVR/wiki/Linux-Troubleshooting), -including the SteamVR launch-options workaround when required. - -```sh -env -u XR_RUNTIME_JSON MOCKTAIL_VR_RUNTIME=alvr \ - MOCKTAIL_GRAPHICS_BACKEND=direct-vulkan ./build/mocktail --vr -``` - -The command clears an inherited WiVRn manifest override. `steamvr` is an alias -for `alvr`. The selection uses SteamVR's OpenXR runtime; -ALVR handles the streaming. If automatic discovery cannot find a custom -installation, specify its actual manifest: - -```sh -XR_RUNTIME_JSON="/path/to/SteamVR/steamxr_linux64.json" ./build/mocktail --vr -``` - -`XR_RUNTIME_JSON` always wins. `MOCKTAIL_VR_RUNTIME=system` delegates selection -to the OpenXR loader. The default `auto` uses the server detection described above, then registered -and installed runtimes. Selection affects only this process. - -Vulkan VR mirrors the left eye into the desktop Roblox window using a GPU -blit, preserving its aspect ratio. DebugDeviceVR otherwise leaves that window -black even when the headset receives eye images. Set -`MOCKTAIL_VR_DESKTOP_MIRROR=0` to disable this copy. This is a view of the eye, -not a separate desktop camera or a replacement for VR menu input. - -## OpenGL ES - -The `opengl`/`system` renderer uses the guest's real EGL/OpenGL ES context: - -```sh -MOCKTAIL_GRAPHICS_BACKEND=opengl ./build/mocktail --vr -``` - -The runtime must provide `XR_KHR_opengl_es_enable` and `XR_MNDX_egl_enable`, and -support sharing images with the guest EGL device. Both eye copies stay on the -GPU; no Python viewer or CPU readback is required. Camera tracking uses the -same OpenXR frame/pose pipeline as Vulkan. If the runtime cannot support this -EGL binding, use `MOCKTAIL_GRAPHICS_BACKEND=direct-vulkan`; the ALVR command -above deliberately selects Vulkan. - -## Current validation and limitations - -- Native VR camera/eye hooks are verified for Roblox build **2998**, Build ID - `ade08266c67aee88ec9c1d00902150e1684dad3a`. Approval of a newer payload for - ordinary rendering does **not** establish VR compatibility. Do not bypass - the Build-ID checks to run the old VR hooks on build 3092. -- GLES framebuffer copying, distinct eyes, state restoration, resource - invalidation, and an EGL OpenXR session with session-loss recovery have been - tested. A local unauthenticated Roblox run on hardware GLES submitted over - 240 projection frames through a simulated Monado headset. -- This host's software GLES/Vulkan combination could create XR texture names - but not usable framebuffer attachments. The copy failure is reported and - XR output stops; this is not counted as a successful rendering test. -- The local Monado test runtime reports `u_graphics_buffer_unref: Bad file - descriptor (fd: 0)` during GLES teardown, including successful hardware - runs. This remains an integration caveat; clean headset teardown is not - claimed from those simulator runs. During auto-selection validation, one - run also failed a later EGL session recreation with - `vkCreateFence: VK_ERROR_OUT_OF_HOST_MEMORY`; a fresh repeat passed. - Runtime selection and initial frame submission succeeded in both runs. -- Real headset acceptance through WiVRn and ALVR/SteamVR is still pending. - Controller input and canted eye orientations are not implemented. -- Use `./build/mocktail --no-vr` for ordinary rendering. No helper script is - needed for normal VR startup. - -The GLES OpenXR integration test is opt-in and needs an already connected -runtime (or a separately prepared simulator). Run from a graphical desktop: - -```sh -MOCKTAIL_TEST_XR_GLES=1 ./build/tests/gles_vr_transport_test \ - --gtest_filter='*OpenXrEglSession*' -``` diff --git a/config/mocktail.example.yaml b/config/mocktail.example.yaml index b151470..208afbe 100644 --- a/config/mocktail.example.yaml +++ b/config/mocktail.example.yaml @@ -51,12 +51,12 @@ graphics: # 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 + # Experimental VR for verified build 3092. 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 + # desktop testing). Controllers use the native VR bridge. Standalone OpenXR # test scene: mocktail-vr-probe --scene. Use --no-vr to launch normally. # enabled: false diff --git a/config/roblox_compatibility.json b/config/roblox_compatibility.json index 40883da..3c755c8 100644 --- a/config/roblox_compatibility.json +++ b/config/roblox_compatibility.json @@ -91,6 +91,36 @@ "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." + }, + { + "allow_host_abi_bridges": true, + "allow_host_constructor_replay": true, + "allow_legacy_binary_patches": false, + "default_allowed": true, + "elf_build_id": "5f0704edd9064f566ee3d6df2bd2fabbcc709f03", + "fmod_output_device_bridge": { + "count_method_rva": "0x320c676", + "current_method_rva": "0x320c6c6", + "info_method_rva": "0x320c716", + "select_method_rva": "0x320bf78", + "string_constructor_rva": "0x1d7a038", + "vtable_layout_version": 2, + "vtable_rva": "0x6cd3ce0" + }, + "reason": "Exact-Build-ID host ABI promoted from the validated probation profile; native stereo, VR state/pointer/haptic contracts and FMOD output-device layout v2 were re-derived for 3092. Physical headset acceptance remains pending.", + "status": "supported", + "user_game_settings_fullscreen_setter_rva": "0x45c5f42", + "version_code": 3092, + "version_name": "2.738.1397", + "vr_debug_device_bridge": { + "vtable_rva": "0x6d08108", + "constructor_rva": "0x37f9fe4", + "state_getter_rva": "0x37fa3c6", + "eye_getter_rva": "0x37fa3b6", + "eye_initializer_rva": "0x37fa3e8", + "emulator_flag_storage_rva": "0x73f4690", + "device_create_framebuffer_vtable_offset": "0x188" + } } ] } diff --git a/include/compat/build_profile.h b/include/compat/build_profile.h index 5483662..4cd0bff 100644 --- a/include/compat/build_profile.h +++ b/include/compat/build_profile.h @@ -22,13 +22,12 @@ struct FmodOutputDeviceBridgeProfile { std::uintptr_t info_method_rva = 0; std::uintptr_t current_method_rva = 0; std::uintptr_t select_method_rva = 0; + std::uint32_t vtable_layout_version = 1; }; // 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. +// interposes DebugDeviceVR slots and a checked world-pointer trampoline. +// All RVAs are validated against machine-code contracts before first use. struct VrDebugDeviceBridgeProfile { std::uintptr_t vtable_rva = 0; std::uintptr_t constructor_rva = 0; diff --git a/include/mocktail/audio/roblox_output_device_bridge.h b/include/mocktail/audio/roblox_output_device_bridge.h index 45c3c12..633a85a 100644 --- a/include/mocktail/audio/roblox_output_device_bridge.h +++ b/include/mocktail/audio/roblox_output_device_bridge.h @@ -15,9 +15,12 @@ namespace mocktail::audio { class NativeInputCapture; +namespace internal { +struct FmodInputCaptureProfile; +} // Replaces the output-device query/select slots of the exact Build-ID-scoped -// FmodAudioDevice vtable, and on Build 2998 its microphone capture slots. +// FmodAudioDevice vtable, including microphone capture on verified builds. // Roblox keeps owning its FMOD engine, while the existing settings UI sees // and selects the SDL host routes that consume Android AudioTrack PCM. class RobloxOutputDeviceBridge final { @@ -59,7 +62,7 @@ class RobloxOutputDeviceBridge final { void ConstructGuestString(void* destination, std::string_view value) const; std::unique_ptr capture_; - bool capture_profile_supported_ = false; + const internal::FmodInputCaptureProfile* capture_profile_ = nullptr; std::array original_capture_methods_{}; mutable std::mutex mutex_; compat::FmodOutputDeviceBridgeProfile profile_; diff --git a/include/mocktail/vr/openxr_backend.h b/include/mocktail/vr/openxr_backend.h index 88e6cd7..bc053d8 100644 --- a/include/mocktail/vr/openxr_backend.h +++ b/include/mocktail/vr/openxr_backend.h @@ -17,6 +17,16 @@ namespace mocktail::vr { +namespace internal { +// Consume binary present dependencies exactly once. On success a later present +// must omit them; on failure the caller must propagate the error, not retry. +VkResult WaitForPresentDependencies(VkDevice device, VkQueue queue, + const VkPresentInfoKHR& present, + VkFence fence, PFN_vkResetFences reset, + PFN_vkQueueSubmit submit, + PFN_vkWaitForFences wait); +} // namespace internal + // 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 @@ -56,7 +66,10 @@ struct ScriptedPoseSample { float eye_fov[2][4] = {}; // left, right, up, down angles // Index 0 = left, 1 = right (Roblox UserCFrame LeftHand/RightHand). VrHandPose hands[2]; - // Exact 2998 DebugDeviceVR input channels, published with the same pose. + float floor_position[3] = {}; + float floor_orientation[4] = {0.f, 0.f, 0.f, 1.f}; + bool floor_valid = false; + // Verified DebugDeviceVR input channels, published with the same pose. float controller_channels[28] = {}; bool controllers_connected = false; std::uint64_t frame = 0; @@ -126,6 +139,24 @@ class OpenXrBackend final { void NoteInstanceDestroyed(VkInstance instance); void NoteQueue(VkDevice device, VkQueue queue, std::uint32_t family, std::uint32_t index); + std::vector DesktopQueueFamilies(VkDevice device) const; + void RecordCommandPool(VkDevice device, VkCommandPool pool, + std::uint32_t family); + void RecordCommandBuffers(VkDevice device, VkCommandPool pool, + std::uint32_t count, + const VkCommandBuffer* buffers); + void ForgetCommandBuffers(std::uint32_t count, + const VkCommandBuffer* buffers); + void ForgetCommandPool(VkCommandPool pool); + void ResetCommandPool(VkCommandPool pool); + void ResetCommandBuffer(VkCommandBuffer buffer); + void NoteCommandBuffersSubmitted(VkQueue queue, std::uint32_t count, + const VkCommandBuffer* buffers); + VkImageLayout NormalizeDesktopBarrier(VkCommandBuffer command, VkImage image, + std::uint32_t* source, + std::uint32_t* destination, + VkImageLayout* old_layout, + VkImageLayout new_layout); // Resource provenance recording (adapter wrappers). bool WantsResourceRecords() const { @@ -149,8 +180,14 @@ class OpenXrBackend final { // Render-thread frame cycle at host present begin. void NoteHostPresent(VkQueue queue, VkDevice device); + // VK_NOT_READY leaves the present untouched until eye submission provenance + // is known. VK_SUCCESS means its waits were consumed on the graphics queue. + VkResult PrepareHostPresent(VkQueue queue, VkDevice device, + const VkPresentInfoKHR* info); void RecordDesktopSwapchain(VkDevice device, VkSwapchainKHR swapchain, - const VkSwapchainCreateInfoKHR* info, const VkImage* images, unsigned count); + const VkSwapchainCreateInfoKHR* info, + const VkImage* images, unsigned count, + bool converted_to_concurrent = false); // On success the original present semaphores have been consumed and the // copy has completed. The caller must present with no wait semaphores. VkResult MirrorDesktop(VkQueue queue, const VkPresentInfoKHR* info); @@ -197,6 +234,7 @@ class OpenXrBackend final { std::uint32_t height = 0; VkFormat format = VK_FORMAT_UNDEFINED; VkImageUsageFlags usage = 0; + VkSampleCountFlagBits samples = VK_SAMPLE_COUNT_1_BIT; void* owner = nullptr; std::uint64_t order = 0; }; @@ -251,6 +289,9 @@ class OpenXrBackend final { void HandleReferenceSpaceChange(std::int64_t change_time); bool RecoverSessionLocked(std::uint64_t now_ns); void PollSessionEvents(); + bool SupportsPresentQueueLocked(VkQueue queue) const; + VkQueue GraphicsQueueForPresentLocked(VkQueue present) const; + bool unsupported_present_queue_logged_ = false; // 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. @@ -290,6 +331,7 @@ class OpenXrBackend final { void* xr_session_ = nullptr; void* xr_local_space_ = nullptr; void* xr_view_space_ = nullptr; + void* xr_stage_space_ = nullptr; std::uint32_t xr_eye_width_[2] = {}; std::uint32_t xr_eye_height_[2] = {}; EyeSwapchain eye_swapchains_[2]; @@ -302,6 +344,8 @@ class OpenXrBackend final { VkDevice vk_device_ = VK_NULL_HANDLE; VkPhysicalDevice vk_physical_device_ = VK_NULL_HANDLE; std::uint32_t vk_queue_family_ = 0; + VkQueue vk_graphics_queue_ = VK_NULL_HANDLE; + std::vector device_queue_families_; void* vk_loader_ = nullptr; struct VkProcs; VkProcs* vk_ = nullptr; @@ -319,6 +363,9 @@ class OpenXrBackend final { VkExtent2D extent; VkFormat format; std::vector images; + VkSharingMode sharing_mode = VK_SHARING_MODE_EXCLUSIVE; + std::vector families; + bool converted_to_concurrent = false; }; std::unordered_map desktop_swapchains_; std::unordered_map image_views_; @@ -326,6 +373,15 @@ class OpenXrBackend final { std::unordered_map framebuffer_render_pass_; std::unordered_map> render_pass_final_layouts_; std::unordered_map queue_families_; + struct CommandRecord { + VkCommandPool pool = VK_NULL_HANDLE; + std::uint32_t family = UINT32_MAX; + std::vector> eye_images; + }; + std::unordered_map command_pool_families_; + std::unordered_map command_records_; + std::unordered_map eye_render_queues_; + std::unordered_map eye_render_frames_; std::uint64_t image_order_ = 0; EyeBinding eyes_[2]; @@ -353,7 +409,7 @@ class OpenXrBackend final { 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; + bool projection_rejection_logged_ = false; mutable ScriptedPoseSample published_pose_; std::uint64_t xr_frame_counter_ = 0; std::unordered_map applied_poses_; diff --git a/include/mocktail/vr/roblox_vr_device_bridge.h b/include/mocktail/vr/roblox_vr_device_bridge.h index c6b45b5..7d214fa 100644 --- a/include/mocktail/vr/roblox_vr_device_bridge.h +++ b/include/mocktail/vr/roblox_vr_device_bridge.h @@ -4,6 +4,7 @@ #include #include #include +#include #include "compat/build_profile.h" #include "mocktail/status.h" @@ -11,8 +12,12 @@ namespace mocktail::vr { namespace internal { struct VrBridgeTestAccess; +struct VrBuildContract; } +const compat::VrDebugDeviceBridgeProfile* FindRobloxVrDeviceProfile( + std::string_view build_id); + // Experimental native stereo bring-up for the exact Build-ID-scoped // RBX::Graphics::DebugDeviceVR object of the guest client. // @@ -21,7 +26,7 @@ struct VrBridgeTestAccess; // 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. +// observed presenting. class RobloxVrDeviceBridge final { public: using StateGetterFn = void *(*)(void *result_buffer, void *device_object); @@ -54,6 +59,7 @@ public: // Guest-side hooks invoked by the interposed vtable stubs. void OnHapticsCall(void* device_object, int hand, float amplitude); + void OnPointerFrameCall(void* result, void* service); void OnStateGetterCall(void *result_buffer, void *device_object); void OnEyeGetterCall(void *device_object, int eye_index, void *framebuffer); @@ -85,6 +91,7 @@ private: mutable std::mutex mutex_; compat::VrDebugDeviceBridgeProfile profile_{}; + const internal::VrBuildContract* contract_ = nullptr; bool installed_ = false; std::atomic active_{false}; std::atomic image_base_{0}; diff --git a/include/mocktail/vr/vr_pose_math.h b/include/mocktail/vr/vr_pose_math.h index e66b143..27a17f2 100644 --- a/include/mocktail/vr/vr_pose_math.h +++ b/include/mocktail/vr/vr_pose_math.h @@ -1,6 +1,7 @@ #ifndef MOCKTAIL_VR_POSE_MATH_H_ #define MOCKTAIL_VR_POSE_MATH_H_ +#include #include #include @@ -147,16 +148,61 @@ inline ProjectionTangents FovToTangents(const EyeFov& fov) { 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 constexpr float kMaxEyeFovAngle = 1.56f; -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; +// Enclose the display frustum in a head-parallel camera for the guest renderer. +// The compositor receives this rendered FOV and pose, not the canted display's. +inline bool ParallelEyeFov(const Quat& eye_orientation, const EyeFov& display, + EyeFov* rendered) { + Quat orientation; + if (!rendered || !QuatNormalize(eye_orientation, &orientation) || + !(display.angle_left < display.angle_right) || + !(display.angle_down < display.angle_up)) { + return false; + } + const float angles[] = {display.angle_left, display.angle_right, + display.angle_up, display.angle_down}; + for (float angle : angles) { + if (!std::isfinite(angle) || std::abs(angle) >= kMaxEyeFovAngle) + return false; + } + if (orientation.x == 0.f && orientation.y == 0.f && orientation.z == 0.f) { + *rendered = display; + return true; + } + const float xs[] = {std::tan(display.angle_left), + std::tan(display.angle_right)}; + const float ys[] = {std::tan(display.angle_down), std::tan(display.angle_up)}; + float left = 0.f, right = 0.f, down = 0.f, up = 0.f; + bool first = true; + for (float x : xs) { + for (float y : ys) { + const Vec3 ray = QuatRotate(orientation, {x, y, -1.f}); + if (!std::isfinite(ray.z) || ray.z >= -1e-6f) return false; + const float horizontal = ray.x / -ray.z; + const float vertical = ray.y / -ray.z; + if (!std::isfinite(horizontal) || !std::isfinite(vertical)) return false; + if (first) { + left = right = horizontal; + down = up = vertical; + first = false; + } else { + left = std::min(left, horizontal); + right = std::max(right, horizontal); + down = std::min(down, vertical); + up = std::max(up, vertical); + } + } + } + const EyeFov result{std::atan(left), std::atan(right), std::atan(up), + std::atan(down)}; + const float bounds[] = {result.angle_left, result.angle_right, + result.angle_up, result.angle_down}; + for (float angle : bounds) { + if (std::abs(angle) >= kMaxEyeFovAngle) return false; + } + *rendered = result; + return true; } // Metres-to-studs factor the exact-build native helper applies itself. Exposed diff --git a/scripts/find_roblox_vr_offsets.py b/scripts/find_roblox_vr_offsets.py new file mode 100755 index 0000000..80a9d0d --- /dev/null +++ b/scripts/find_roblox_vr_offsets.py @@ -0,0 +1,1510 @@ +#!/usr/bin/env python3 +# Copyright 2026 Mocktail Project Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Find native VR bridge offsets in an x86-64 Roblox ELF. + +Usage: + python3 find_roblox_vr_offsets.py --elf libroblox.so --output offsets.json +""" + +from __future__ import annotations + +import argparse +import bisect +from collections.abc import Iterator, Sequence +import dataclasses +import hashlib +import json +import mmap +import os +import pathlib +import re +import stat +import struct +import sys +import tempfile +from typing import Any + +try: + import capstone + from capstone import x86 as capstone_x86 +except ImportError: + capstone = None + capstone_x86 = None + +MAX_ELF_BYTES = 512 * 1024 * 1024 +MAX_RELA_DYN_BYTES = 256 * 1024 * 1024 +MAX_RELOCATIONS = 4 * 1024 * 1024 +MAX_FUNCTION_TABLE_ENTRIES = 2 * 1024 * 1024 +MAX_FUNCTION_BYTES = 0x20000 +MAX_JSON_OUTPUT_BYTES = 4 * 1024 * 1024 +SHA256_CHUNK_BYTES = 1024 * 1024 + +ELF_HEADER = struct.Struct("<16sHHIQQQIHHHHHH") +PROGRAM_HEADER = struct.Struct(" bool: + return (size >= 0 and self.virtual_address <= rva and + rva + size <= self.virtual_address + self.memory_size) + + def contains_file_data(self, rva: int, size: int = 1) -> bool: + return (size >= 0 and self.virtual_address <= rva and + rva + size <= self.virtual_address + self.file_size) + + +@dataclasses.dataclass(frozen=True) +class Section: + name: str + section_type: int + flags: int + address: int + offset: int + size: int + + +@dataclasses.dataclass(frozen=True) +class Relocation: + offset: int + info: int + addend: int + + +def sha256_file(file_object: Any) -> str: + digest = hashlib.sha256() + while True: + chunk = file_object.read(SHA256_CHUNK_BYTES) + if not chunk: + return digest.hexdigest() + digest.update(chunk) + + +class Sleb128Reader: + + def __init__(self, encoded: bytes): + self._encoded = encoded + self.position = 0 + + def pop(self) -> int: + value = 0 + shift = 0 + while True: + if self.position >= len(self._encoded) or shift >= 70: + raise AnalyzerError("invalid or truncated APS2 SLEB128 value") + byte = self._encoded[self.position] + self.position += 1 + value |= (byte & 0x7F) << shift + shift += 7 + if (byte & 0x80) == 0: + break + if shift < 64 and (byte & 0x40): + value -= 1 << shift + if not -(1 << 63) <= value < (1 << 64): + raise AnalyzerError("APS2 SLEB128 value exceeds 64-bit bounds") + return value + + def at_end(self) -> bool: + return self.position == len(self._encoded) + + +def decode_aps2_relocations(encoded: bytes) -> Iterator[Relocation]: + if not encoded.startswith(b"APS2"): + raise AnalyzerError(".rela.dyn does not use the required APS2 encoding") + reader = Sleb128Reader(encoded[4:]) + relocation_count = reader.pop() + relocation_offset = reader.pop() + if relocation_count < 0 or relocation_count > MAX_RELOCATIONS: + raise AnalyzerError("APS2 relocation count is invalid") + relocation_info = 0 + relocation_addend = 0 + emitted = 0 + while emitted < relocation_count: + group_size = reader.pop() + group_flags = reader.pop() + if group_size <= 0 or group_size > relocation_count - emitted: + raise AnalyzerError("APS2 relocation group is invalid") + offset_delta = reader.pop( + ) if group_flags & APS2_GROUPED_BY_OFFSET_DELTA else 0 + if group_flags & APS2_GROUPED_BY_INFO: + relocation_info = reader.pop() + addend_mode = group_flags & (APS2_GROUP_HAS_ADDEND | + APS2_GROUPED_BY_ADDEND) + if addend_mode == (APS2_GROUP_HAS_ADDEND | APS2_GROUPED_BY_ADDEND): + relocation_addend += reader.pop() + elif addend_mode != APS2_GROUP_HAS_ADDEND: + relocation_addend = 0 + for _ in range(group_size): + relocation_offset += (offset_delta if group_flags & + APS2_GROUPED_BY_OFFSET_DELTA else + reader.pop()) + if not group_flags & APS2_GROUPED_BY_INFO: + relocation_info = reader.pop() + if addend_mode == APS2_GROUP_HAS_ADDEND: + relocation_addend += reader.pop() + if (relocation_offset < 0 or relocation_info < 0 or + relocation_addend < 0 or relocation_offset >= 1 << 64 or + relocation_info >= 1 << 64 or relocation_addend >= 1 << 64): + raise AnalyzerError( + "APS2 relocation exceeds unsigned 64-bit bounds") + emitted += 1 + yield Relocation(relocation_offset, relocation_info, + relocation_addend) + if not reader.at_end(): + raise AnalyzerError("APS2 relocation stream contains trailing bytes") + + +class ElfImage: + + def __init__(self, path: pathlib.Path): + self.path = path + self._file = None + self.data = None + self.file_size = 0 + self.sha256 = "" + self.sections: dict[str, Section] = {} + self.segments: tuple[Segment, ...] = () + self.relro_ranges: tuple[tuple[int, int], ...] = () + self.build_id = "" + self._relocations: dict[int, int] | None = None + + def __enter__(self) -> "ElfImage": + try: + file_status = os.lstat(self.path) + except OSError as error: + raise AnalyzerError(f"cannot stat ELF file: {self.path}") from error + if stat.S_ISLNK( + file_status.st_mode) or not stat.S_ISREG(file_status.st_mode): + raise AnalyzerError( + f"ELF input must be a regular non-symlink: {self.path}") + if (file_status.st_size < ELF_HEADER.size or + file_status.st_size > MAX_ELF_BYTES): + raise AnalyzerError(f"ELF input has an invalid size: {self.path}") + self.file_size = file_status.st_size + try: + self._file = self.path.open("rb") + self.sha256 = sha256_file(self._file) + self._file.seek(0) + self.data = mmap.mmap(self._file.fileno(), + 0, + access=mmap.ACCESS_READ) + self._parse() + except AnalyzerError: + self.__exit__(None, None, None) + raise + except (OSError, ValueError, struct.error) as error: + self.__exit__(None, None, None) + raise AnalyzerError( + f"cannot parse ELF file: {self.path}") from error + return self + + def __exit__(self, _type, _value, _traceback) -> None: + if self.data is not None: + self.data.close() + self.data = None + if self._file is not None: + self._file.close() + self._file = None + + def _checked_range(self, offset: int, size: int, description: str) -> None: + if offset < 0 or size < 0 or offset > self.file_size - size: + raise AnalyzerError(f"ELF {description} exceeds file bounds") + + def bytes_at(self, offset: int, size: int, description: str) -> bytes: + self._checked_range(offset, size, description) + return self.data[offset:offset + size] + + def section_bytes(self, section: Section) -> bytes: + return self.bytes_at(section.offset, section.size, + f"section {section.name}") + + def _parse(self) -> None: + header = ELF_HEADER.unpack_from(self.data, 0) + identification = header[0] + if (identification[:4] != b"\x7fELF" or + identification[4] != ELF_CLASS_64 or + identification[5] != ELF_DATA_LITTLE_ENDIAN or + identification[6] != ELF_VERSION_CURRENT or + header[1] != ELF_TYPE_SHARED_OBJECT or + header[2] != ELF_MACHINE_X86_64 or + header[3] != ELF_VERSION_CURRENT): + raise AnalyzerError( + "candidate must be a little-endian x86-64 ET_DYN ELF") + + program_offset = header[5] + section_offset = header[6] + program_entry_size = header[9] + program_count = header[10] + section_entry_size = header[11] + section_count = header[12] + section_names_index = header[13] + if (program_entry_size != PROGRAM_HEADER.size or + section_entry_size != SECTION_HEADER.size or + program_count == 0 or section_count == 0 or + section_names_index == 0 or + section_names_index >= section_count): + raise AnalyzerError("ELF header tables use an unsupported layout") + self._checked_range(program_offset, program_count * program_entry_size, + "program headers") + self._checked_range(section_offset, section_count * section_entry_size, + "section headers") + + segments = [] + relro_ranges = [] + for index in range(program_count): + values = PROGRAM_HEADER.unpack_from( + self.data, program_offset + index * program_entry_size) + if values[0] == PT_GNU_RELRO: + if values[6] <= 0: + raise AnalyzerError("ELF PT_GNU_RELRO range is empty") + relro_ranges.append((values[3], values[6])) + if values[0] != PT_LOAD: + continue + segment = Segment( + offset=values[2], + virtual_address=values[3], + file_size=values[5], + memory_size=values[6], + flags=values[1], + ) + self._checked_range(segment.offset, segment.file_size, + "PT_LOAD data") + if segment.file_size > segment.memory_size: + raise AnalyzerError("ELF PT_LOAD file size exceeds memory size") + segments.append(segment) + if not segments: + raise AnalyzerError("ELF has no PT_LOAD segments") + self.segments = tuple(segments) + self.relro_ranges = tuple(relro_ranges) + + raw_sections = [ + SECTION_HEADER.unpack_from( + self.data, section_offset + index * section_entry_size) + for index in range(section_count) + ] + names_header = raw_sections[section_names_index] + self._checked_range(names_header[4], names_header[5], + "section-name table") + names = self.data[names_header[4]:names_header[4] + names_header[5]] + + by_name: dict[str, Section] = {} + for values in raw_sections: + name_offset = values[0] + if name_offset >= len(names): + raise AnalyzerError("ELF section name exceeds string table") + name_end = names.find(b"\0", name_offset) + if name_end < 0: + raise AnalyzerError("ELF section name is not terminated") + try: + name = names[name_offset:name_end].decode("ascii") + except UnicodeDecodeError as error: + raise AnalyzerError("ELF section name is not ASCII") from error + section = Section( + name=name, + section_type=values[1], + flags=values[2], + address=values[3], + offset=values[4], + size=values[5], + ) + if section.section_type != SHT_NOBITS: + self._checked_range(section.offset, section.size, + f"section {name}") + if name and name in by_name: + raise AnalyzerError(f"ELF contains duplicate section {name}") + if name: + by_name[name] = section + self.sections = by_name + + text = self._require_section(".text", SHT_PROGBITS) + if not text.flags & 2 or not text.flags & 4 or text.flags & 1: + raise AnalyzerError( + "ELF .text section must be read+execute, non-writable") + self._require_section(".rela.dyn", SHT_ANDROID_RELA) + self._require_section(".eh_frame_hdr", SHT_PROGBITS) + self._require_section(".note.gnu.build-id", SHT_NOTE) + self.build_id = self._read_build_id() + if len(self.build_id) != 40: + raise AnalyzerError("ELF GNU build ID is not a 20-byte SHA-1") + + def _require_section(self, name: str, section_type: int) -> Section: + section = self.sections.get(name) + if section is None or section.section_type != section_type: + raise AnalyzerError(f"ELF is missing required {name} section") + return section + + def _read_build_id(self) -> str: + section = self.sections[".note.gnu.build-id"] + encoded = self.section_bytes(section) + offset = 0 + while offset + NOTE_HEADER.size <= len(encoded): + namesz, descsz, note_type = NOTE_HEADER.unpack_from(encoded, offset) + offset += NOTE_HEADER.size + if offset + namesz > len(encoded): + raise AnalyzerError("GNU build-id note name is truncated") + note_name = encoded[offset:offset + namesz] + offset += namesz + offset += (-offset) % 4 + if note_type == NT_GNU_BUILD_ID and note_name.rstrip( + b"\0") == b"GNU": + if offset + descsz > len(encoded): + raise AnalyzerError("GNU build-id note data is truncated") + return encoded[offset:offset + descsz].hex() + offset += descsz + offset += (-offset) % 4 + raise AnalyzerError("ELF has no GNU build-id note") + + def segment_for_rva(self, rva: int, size: int = 1) -> Segment | None: + matches = [ + segment for segment in self.segments + if segment.contains_memory(rva, size) + ] + return matches[0] if len(matches) == 1 else None + + def rva_to_offset(self, rva: int, size: int = 1) -> int: + matches = [ + segment for segment in self.segments + if segment.contains_file_data(rva, size) + ] + if len(matches) != 1: + raise AnalyzerError(f"RVA 0x{rva:x} has no unique file mapping") + segment = matches[0] + offset = segment.offset + rva - segment.virtual_address + self._checked_range(offset, size, "RVA mapping") + return offset + + def offset_to_rva(self, offset: int) -> int | None: + for segment in self.segments: + if segment.offset <= offset < segment.offset + segment.file_size: + return offset - segment.offset + segment.virtual_address + return None + + def require_code_rva(self, rva: int, size: int = 1) -> None: + segment = self.segment_for_rva(rva, size) + if (segment is None or segment.flags & + (PF_READ | PF_EXECUTE) != (PF_READ | PF_EXECUTE) or + segment.flags & PF_WRITE or + not segment.contains_file_data(rva, size)): + raise AnalyzerError( + f"RVA 0x{rva:x} is not in a read/execute segment") + + def require_writable_rva(self, rva: int, size: int = 1) -> None: + segment = self.segment_for_rva(rva, size) + if (segment is None or + segment.flags & (PF_READ | PF_WRITE) != (PF_READ | PF_WRITE) or + segment.flags & PF_EXECUTE): + raise AnalyzerError( + f"RVA 0x{rva:x} is not in a non-executable RW segment") + + def require_relro_rva(self, rva: int, size: int = 1) -> None: + matches = [ + (begin, length) + for begin, length in self.relro_ranges + if size >= 0 and begin <= rva and rva + size <= begin + length + ] + if len(matches) != 1: + raise AnalyzerError( + f"RVA 0x{rva:x} is not in a unique PT_GNU_RELRO range") + + def text_section(self) -> Section: + return self.sections[".text"] + + def find_alloc_string(self, needle: bytes) -> int: + """Return the unique read-only mapped RVA whose bytes equal needle.""" + if b"\0" in needle: + raise AnalyzerError("anchor strings must not contain NUL bytes") + wanted = needle + b"\0" + hits: list[int] = [] + position = 0 + while True: + position = self.data.find(wanted, position) + if position < 0: + break + rva = self.offset_to_rva(position) + if rva is not None: + segment = self.segment_for_rva(rva, len(wanted)) + if segment is not None and not segment.flags & PF_WRITE: + hits.append(rva) + position += 1 + if not hits: + raise AnalyzerError(f"anchor string {needle.decode('ascii')!r}" + " was not found in the ELF") + if len(hits) > 1: + listed = ", ".join(f"0x{hit:x}" for hit in hits) + raise AnalyzerError(f"anchor string {needle.decode('ascii')!r}" + f" is ambiguous: {listed}") + return hits[0] + + def relative_relocations(self) -> dict[int, int]: + if self._relocations is not None: + return self._relocations + section = self.sections[".rela.dyn"] + if section.size > MAX_RELA_DYN_BYTES: + raise AnalyzerError(".rela.dyn section is suspiciously large") + encoded = self.section_bytes(section) + relocations: dict[int, int] = {} + count = 0 + for relocation in decode_aps2_relocations(encoded): + count += 1 + if count > MAX_RELOCATIONS: + raise AnalyzerError("APS2 relocation stream is too large") + if relocation.info & 0xFFFFFFFF in ( + R_X86_64_RELATIVE, + R_X86_64_IRELATIVE, + ): + relocations[relocation.offset] = relocation.addend + self._relocations = relocations + return relocations + + +class FunctionTable: + """Function boundaries from the .eh_frame_hdr binary-search table.""" + + def __init__(self, image: ElfImage): + self.image = image + section = image.sections[".eh_frame_hdr"] + encoded = image.section_bytes(section) + base = section.address + if len(encoded) < 12 or encoded[:4] != bytes((1, 27, 3, 59)): + raise AnalyzerError(".eh_frame_hdr uses an unsupported encoding") + count = struct.unpack_from(" MAX_FUNCTION_TABLE_ENTRIES: + raise AnalyzerError(".eh_frame_hdr table size is invalid") + if len(encoded) < 12 + count * 8: + raise AnalyzerError(".eh_frame_hdr table is truncated") + self.starts = [ + base + struct.unpack_from("= self.starts[i + 1] for i in range(count - 1)): + raise AnalyzerError(".eh_frame_hdr table is not sorted") + text = image.text_section() + self.ends = self.starts[1:] + [text.address + text.size] + + def bounds(self, rva: int) -> tuple[int, int]: + index = bisect.bisect_right(self.starts, rva) - 1 + if index < 0 or self.starts[index] > rva or rva >= self.ends[index]: + raise AnalyzerError( + f"RVA 0x{rva:x} is not covered by .eh_frame_hdr") + return self.starts[index], self.ends[index] + + +class Disassembler: + + def __init__(self, image: ElfImage): + require_capstone() + self.image = image + self.decoder = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_64) + self.decoder.detail = True + self._cache: dict[int, tuple[Any, ...]] = {} + + def function(self, start: int, end: int) -> tuple[Any, ...]: + cached = self._cache.get(start) + if cached is not None: + return cached + size = end - start + if size <= 0 or size > MAX_FUNCTION_BYTES: + raise AnalyzerError(f"function at 0x{start:x} has an invalid size") + offset = self.image.rva_to_offset(start, size) + code = self.image.bytes_at(offset, size, "function body") + instructions = tuple(self.decoder.disasm(code, start)) + if not instructions or instructions[0].address != start: + raise AnalyzerError(f"cannot decode function at 0x{start:x}") + self._cache[start] = instructions + return instructions + + +def require_capstone() -> None: + if capstone is None or capstone_x86 is None: + raise AnalyzerError( + "Python capstone 5 is required for VR offset analysis") + try: + version = capstone.cs_version() + except (AttributeError, TypeError, ValueError) as error: + raise AnalyzerError( + "Python capstone version cannot be verified") from error + if (not isinstance(version, tuple) or len(version) < 2 or + not all(isinstance(component, int) for component in version[:2]) or + version[0] != 5): + raise AnalyzerError("Python capstone major version 5 is required") + + +def rip_target(instruction: Any) -> Iterator[int]: + for operand in instruction.operands: + if (operand.type == capstone_x86.X86_OP_MEM and + operand.mem.base == capstone_x86.X86_REG_RIP): + yield instruction.address + instruction.size + operand.mem.disp + + +def find_text_refs( + image: ElfImage, + table: FunctionTable, + disasm: Disassembler, + targets: set[int], +) -> dict[int, list[tuple[int, int, int]]]: + """Find RIP-relative references at decoded instruction boundaries. + + Returns: + Target RVAs mapped to (instruction RVA, function start, function end). + """ + text = image.text_section() + data = image.section_bytes(text) + base = text.address + combined = re.compile( + b"(?=" + TEXT_REF_OPCODES.pattern + TEXT_REF_MODRMS.pattern + b".{4})", + re.DOTALL, + ) + candidates: dict[int, set[int]] = {} + for match in combined.finditer(data): + position = match.start() + opcode = data[position] + extra = 1 if opcode in (0x80, 0x83, + 0xC6) else 4 if opcode in (0x81, 0xC7) else 0 + target = (base + position + 6 + extra + + struct.unpack_from(" list[int]: + text = image.text_section() + data = image.section_bytes(text) + base = text.address + hits: list[int] = [] + position = 0 + while True: + position = data.find(pattern, position) + if position < 0: + return hits + hits.append(base + position) + position += 1 + + +def ends_with_ret(instructions: Sequence[Any]) -> bool: + return any( + instruction.mnemonic == "ret" for instruction in instructions[-3:]) + + +def is_state_getter(instructions: Sequence[Any]) -> bool: + """mov reg, rdi; add rsi, 0x14; mov edx, 0x138; call memcpy; ret.""" + if not 6 <= len(instructions) <= 24: + return False + calls = [ + instruction for instruction in instructions + if instruction.mnemonic == "call" and instruction.operands and + instruction.operands[0].type == capstone_x86.X86_OP_IMM + ] + if len(calls) != 1: + return False + if not ends_with_ret(instructions): + return False + has_saved_self = False + has_state_offset = False + has_state_size = False + for instruction in instructions: + operands = instruction.operands + if (instruction.mnemonic == "mov" and len(operands) == 2 and + operands[0].type == capstone_x86.X86_OP_REG and + operands[1].type == capstone_x86.X86_OP_REG and + operands[1].reg == capstone_x86.X86_REG_RDI): + has_saved_self = True + if (instruction.mnemonic == "add" and len(operands) == 2 and + operands[0].type == capstone_x86.X86_OP_REG and + operands[0].reg == capstone_x86.X86_REG_RSI and + operands[1].type == capstone_x86.X86_OP_IMM and + operands[1].imm == STATE_OFFSET): + has_state_offset = True + if (instruction.mnemonic == "mov" and len(operands) == 2 and + operands[0].type == capstone_x86.X86_OP_REG and + operands[1].type == capstone_x86.X86_OP_IMM and + operands[1].imm == STATE_COPY_SIZE): + has_state_size = True + return has_saved_self and has_state_offset and has_state_size + + +def is_eye_getter(instructions: Sequence[Any]) -> bool: + """movsxd rax, esi; shl rax, 4; mov rax, [rdi+rax+0x150]; ret.""" + if len(instructions) != 4: + return False + first, second, third, last = instructions + if first.mnemonic != "movsxd" or len(first.operands) != 2: + return False + if (first.operands[0].type != capstone_x86.X86_OP_REG or + first.operands[0].reg != capstone_x86.X86_REG_RAX or + first.operands[1].type != capstone_x86.X86_OP_REG or + first.operands[1].reg != capstone_x86.X86_REG_ESI): + return False + if (second.mnemonic != "shl" or + second.operands[0].type != capstone_x86.X86_OP_REG or + second.operands[0].reg != capstone_x86.X86_REG_RAX or + second.operands[1].type != capstone_x86.X86_OP_IMM or + second.operands[1].imm != 4): + return False + if third.mnemonic != "mov" or len(third.operands) != 2: + return False + memory = third.operands[1] + if memory.type != capstone_x86.X86_OP_MEM: + return False + if (memory.mem.base != capstone_x86.X86_REG_RDI or + memory.mem.index != capstone_x86.X86_REG_RAX or + memory.mem.disp != FRAMEBUFFER_SLOT_OFFSET): + return False + return last.mnemonic == "ret" + + +def is_eye_initializer(instructions: Sequence[Any], eye_label_rva: int) -> bool: + """The eye-resource initializer references 'VR Eye' and the slot arrays.""" + has_label = any(eye_label_rva in rip_target(instruction) + for instruction in instructions) + if not has_label: + return False + ready_hits = set() + has_framebuffer_lea = False + has_texture_lea = False + has_register_call = False + for instruction in instructions: + operands = instruction.operands + if instruction.mnemonic == "mov" and operands and operands[ + 0].type == capstone_x86.X86_OP_MEM: + if operands[0].size == 1 and operands[ + 0].mem.disp in READY_BYTE_OFFSETS: + ready_hits.add(operands[0].mem.disp) + if instruction.mnemonic == "lea" and len(operands) == 2: + memory = operands[1] + if (memory.type == capstone_x86.X86_OP_MEM and + memory.mem.index == capstone_x86.X86_REG_INVALID): + if memory.mem.disp == FRAMEBUFFER_SLOT_OFFSET: + has_framebuffer_lea = True + if memory.mem.disp == TEXTURE_SLOT_OFFSET: + has_texture_lea = True + if (instruction.mnemonic == "call" and operands and + operands[0].type == capstone_x86.X86_OP_REG): + has_register_call = True + return (len(ready_hits) >= 2 and has_framebuffer_lea and has_texture_lea and + has_register_call) + + +def is_constructor(instructions: Sequence[Any], vtable_rva: int, + device_name_rva: int) -> bool: + """The constructor stores the vtable, the 'DebugDevice' name and type 6.""" + has_vtable = any( + instruction.mnemonic == "lea" and vtable_rva in rip_target(instruction) + for instruction in instructions) + has_name = any(instruction.mnemonic == "lea" and + device_name_rva in rip_target(instruction) + for instruction in instructions) + has_type = False + has_state_size = False + has_direct_call = False + for instruction in instructions: + operands = instruction.operands + if (instruction.mnemonic == "mov" and len(operands) == 2 and + operands[0].type == capstone_x86.X86_OP_MEM and + operands[0].size == 4 and + operands[0].mem.disp == OBJECT_TYPE_OFFSET and + operands[0].mem.index == capstone_x86.X86_REG_INVALID and + operands[1].type == capstone_x86.X86_OP_IMM and + operands[1].imm == OBJECT_TYPE_DEBUG_DEVICE): + has_type = True + if (instruction.mnemonic == "mov" and len(operands) == 2 and + operands[0].type == capstone_x86.X86_OP_REG and + operands[1].type == capstone_x86.X86_OP_IMM and + operands[1].imm == CONSTRUCTOR_MEMSET_SIZE): + has_state_size = True + if (instruction.mnemonic == "call" and operands and + operands[0].type == capstone_x86.X86_OP_IMM): + has_direct_call = True + return (has_vtable and has_name and has_type and has_state_size and + has_direct_call) + + +def extract_framebuffer_slot(instructions: Sequence[Any]) -> int | None: + """Find ``mov rax, [rax+disp]`` ... ``call rax`` on a device vtable.""" + hits: set[int] = set() + for index, instruction in enumerate(instructions): + operands = instruction.operands + if instruction.mnemonic != "mov" or len(operands) != 2: + continue + if operands[0].type != capstone_x86.X86_OP_REG or operands[ + 1].type != capstone_x86.X86_OP_MEM: + continue + memory = operands[1].mem + register = operands[0].reg + if (memory.base != register or + memory.index != capstone_x86.X86_REG_INVALID or + memory.disp < 0x40): + continue + has_vtable_load = any( + previous.mnemonic == "mov" and len(previous.operands) == 2 and + previous.operands[0].type == capstone_x86.X86_OP_REG and + previous.operands[1].type == capstone_x86.X86_OP_MEM and + previous.operands[1].mem.index == capstone_x86.X86_REG_INVALID and + previous.operands[1].mem.disp == 0 and + previous.operands[1].mem.base != capstone_x86.X86_REG_INVALID and + previous.operands[1].mem.base != capstone_x86.X86_REG_RIP + for previous in instructions[max(0, index - 6):index]) + has_register_call = any( + later.mnemonic == "call" and later.operands and + later.operands[0].type == capstone_x86.X86_OP_REG and + later.operands[0].reg == register + for later in instructions[index + 1:index + 16]) + if has_vtable_load and has_register_call: + hits.add(memory.disp) + if len(hits) != 1: + return None + return next(iter(hits)) + + +def extract_registration_storage(instructions: Sequence[Any], + flag_name_rva: int) -> int | None: + """lea rsi, [flag]; lea rdx, [storage]; mov ecx, 1; mov r8d, 4; jmp.""" + storage: int | None = None + for index, instruction in enumerate(instructions): + operands = instruction.operands + if instruction.mnemonic != "lea" or len(operands) != 2: + continue + if operands[0].type != capstone_x86.X86_OP_REG or operands[ + 0].reg != capstone_x86.X86_REG_RSI: + continue + if flag_name_rva not in rip_target(instruction): + continue + for follower in instructions[index + 1:index + 4]: + follower_operands = follower.operands + if (follower.mnemonic == "lea" and len(follower_operands) == 2 and + follower_operands[0].type == capstone_x86.X86_OP_REG and + follower_operands[0].reg == capstone_x86.X86_REG_RDX): + for target in rip_target(follower): + storage = target + if storage is None: + return None + has_flag_scope = False + has_category = False + for instruction in instructions: + operands = instruction.operands + if (instruction.mnemonic == "mov" and len(operands) == 2 and + operands[0].type == capstone_x86.X86_OP_REG and + operands[0].reg == capstone_x86.X86_REG_ECX and + operands[1].type == capstone_x86.X86_OP_IMM and + operands[1].imm == 1): + has_flag_scope = True + if (instruction.mnemonic == "mov" and len(operands) == 2 and + operands[0].type == capstone_x86.X86_OP_REG and + operands[0].reg == capstone_x86.X86_REG_R8D and + operands[1].type == capstone_x86.X86_OP_IMM and + operands[1].imm == 4): + has_category = True + if not (has_flag_scope and has_category): + return None + return storage + + +def find_flag_zeroing(instructions: Sequence[Any], storage_rva: int) -> bool: + """mov byte ptr [rip+storage], 0 in the flag descriptor initializer.""" + for instruction in instructions: + operands = instruction.operands + if (instruction.mnemonic == "mov" and len(operands) == 2 and + operands[0].type == capstone_x86.X86_OP_MEM and + operands[0].size == 1 and + operands[1].type == capstone_x86.X86_OP_IMM and + operands[1].imm == 0): + if storage_rva in rip_target(instruction): + return True + return False + + +def extract_hand_pitch(instructions: Sequence[Any], + flag_name_rva: int) -> tuple[int, int] | None: + """Find hand-pitch storage and its default value in the flag initializer.""" + for index, instruction in enumerate(instructions): + operands = instruction.operands + if (instruction.mnemonic != "mov" or len(operands) != 2 or + operands[0].type != capstone_x86.X86_OP_MEM or + operands[1].type != capstone_x86.X86_OP_IMM): + continue + if operands[0].size != 4: + continue + default = operands[1].imm + if not 0 <= default < 360: + continue + storage = next(iter(rip_target(instruction)), None) + if storage is None: + continue + for follower_index in range(index + 1, min(index + 5, + len(instructions))): + follower = instructions[follower_index] + follower_operands = follower.operands + if (follower.mnemonic != "lea" or len(follower_operands) != 2 or + follower_operands[0].type != capstone_x86.X86_OP_REG): + continue + if flag_name_rva not in rip_target(follower): + continue + register = follower_operands[0].reg + if follower_index + 1 >= len(instructions): + continue + store = instructions[follower_index + 1] + store_operands = store.operands + if (store.mnemonic == "mov" and len(store_operands) == 2 and + store_operands[0].type == capstone_x86.X86_OP_MEM and + store_operands[1].type == capstone_x86.X86_OP_REG and + store_operands[1].reg == register): + name_slot = next(iter(rip_target(store)), None) + if name_slot == storage + 8: + return storage, default + return None + + +def match_pointer_getter(instructions: Sequence[Any]) -> bool: + """World-space pointer getter: UserCFrame copy + workspace/camera path.""" + has_selector = False + has_scale = False + has_stride = False + usercframe_loads = 0 + has_workspace_handoff = False + has_camera_slot_call = False + has_head_scale = False + for instruction in instructions: + operands = instruction.operands + if instruction.mnemonic == "movsxd" and len(operands) == 2: + if (operands[1].type == capstone_x86.X86_OP_MEM and + operands[1].mem.base == capstone_x86.X86_REG_RSI and + operands[1].mem.index == capstone_x86.X86_REG_INVALID and + operands[1].mem.disp == USERCFRAME_SELECTOR_OFFSET): + has_selector = True + if instruction.mnemonic == "lea" and len(operands) == 2: + memory = operands[1] + if (memory.type == capstone_x86.X86_OP_MEM and + operands[0].type == capstone_x86.X86_OP_REG and + operands[0].reg == capstone_x86.X86_REG_RAX and + memory.mem.base == capstone_x86.X86_REG_RAX and + memory.mem.index == capstone_x86.X86_REG_RAX and + memory.mem.scale == 2): + has_scale = True + if instruction.mnemonic == "shl" and len(operands) == 2: + if (operands[0].type == capstone_x86.X86_OP_REG and + operands[0].reg == capstone_x86.X86_REG_RAX and + operands[1].type == capstone_x86.X86_OP_IMM and + operands[1].imm == 4): + has_stride = True + if instruction.mnemonic in ("movups", "movupd", "movaps", + "movapd") and len(operands) == 2: + memory = operands[1] + if memory.type == capstone_x86.X86_OP_MEM and ( + memory.mem.base == capstone_x86.X86_REG_RSI and + memory.mem.index == capstone_x86.X86_REG_RAX and + memory.mem.disp in ( + USERCFRAME_BASE_OFFSET, + USERCFRAME_BASE_OFFSET + 0x10, + USERCFRAME_BASE_OFFSET + 0x20, + )): + usercframe_loads += 1 + if instruction.mnemonic == "movss" and len(operands) == 2: + memory = operands[1] + if (memory.type == capstone_x86.X86_OP_MEM and + memory.mem.index == capstone_x86.X86_REG_INVALID and + memory.mem.disp == CAMERA_HEAD_SCALE_OFFSET): + has_head_scale = True + for index, instruction in enumerate(instructions): + operands = instruction.operands + if (instruction.mnemonic == "mov" and len(operands) == 2 and + operands[0].type == capstone_x86.X86_OP_REG and + operands[0].reg == capstone_x86.X86_REG_RDI and + operands[1].type == capstone_x86.X86_OP_REG and + operands[1].reg == capstone_x86.X86_REG_RSI): + for follower in instructions[index + 1:index + 3]: + if (follower.mnemonic == "call" and follower.operands and + follower.operands[0].type == capstone_x86.X86_OP_IMM): + has_workspace_handoff = True + if (instruction.mnemonic == "mov" and len(operands) == 2 and + operands[0].type == capstone_x86.X86_OP_REG and + operands[0].reg == capstone_x86.X86_REG_RCX and + operands[1].type == capstone_x86.X86_OP_MEM and + operands[1].mem.base == capstone_x86.X86_REG_RAX and + operands[1].mem.disp == 0): + for follower_index in range(index + 1, + min(index + 4, len(instructions))): + follower = instructions[follower_index] + if (follower.mnemonic == "mov" and + len(follower.operands) == 2 and + follower.operands[0].type == capstone_x86.X86_OP_REG and + follower.operands[0].reg == capstone_x86.X86_REG_RDI and + follower.operands[1].type == capstone_x86.X86_OP_REG and + follower.operands[1].reg == capstone_x86.X86_REG_RAX): + for caller in instructions[follower_index + + 1:follower_index + 4]: + if (caller.mnemonic == "call" and caller.operands and + caller.operands[0].type + == capstone_x86.X86_OP_MEM and + caller.operands[0].mem.base + == capstone_x86.X86_REG_RCX and + caller.operands[0].mem.disp > 0x100): + has_camera_slot_call = True + return (has_selector and has_scale and has_stride and + usercframe_loads >= 3 and has_workspace_handoff and + has_camera_slot_call and has_head_scale) + + +def extract_pointer_getter_anchors(instructions: Sequence[Any], + image: ElfImage) -> tuple[int, int]: + """Return (workspace getter RVA, camera vtable slot offset).""" + workspace_getter = None + camera_slot = None + for index, instruction in enumerate(instructions): + operands = instruction.operands + if (instruction.mnemonic == "mov" and len(operands) == 2 and + operands[0].type == capstone_x86.X86_OP_REG and + operands[0].reg == capstone_x86.X86_REG_RDI and + operands[1].type == capstone_x86.X86_OP_REG and + operands[1].reg == capstone_x86.X86_REG_RSI): + for follower in instructions[index + 1:index + 3]: + if (follower.mnemonic == "call" and follower.operands and + follower.operands[0].type == capstone_x86.X86_OP_IMM): + target = follower.operands[0].imm + try: + image.require_code_rva(target) + except AnalyzerError: + continue + workspace_getter = target + if (instruction.mnemonic == "mov" and len(operands) == 2 and + operands[0].type == capstone_x86.X86_OP_REG and + operands[0].reg == capstone_x86.X86_REG_RCX and + operands[1].type == capstone_x86.X86_OP_MEM and + operands[1].mem.base == capstone_x86.X86_REG_RAX and + operands[1].mem.disp == 0): + for follower_index in range(index + 1, + min(index + 4, len(instructions))): + follower = instructions[follower_index] + if (follower.mnemonic == "mov" and + len(follower.operands) == 2 and + follower.operands[0].type == capstone_x86.X86_OP_REG and + follower.operands[0].reg == capstone_x86.X86_REG_RDI and + follower.operands[1].type == capstone_x86.X86_OP_REG and + follower.operands[1].reg == capstone_x86.X86_REG_RAX): + for caller in instructions[follower_index + + 1:follower_index + 4]: + if (caller.mnemonic == "call" and caller.operands and + caller.operands[0].type + == capstone_x86.X86_OP_MEM and + caller.operands[0].mem.base + == capstone_x86.X86_REG_RCX and + caller.operands[0].mem.disp > 0x100): + camera_slot = caller.operands[0].mem.disp + if workspace_getter is None or camera_slot is None: + raise AnalyzerError("world-space pointer getter contract matched" + " but its workspace/camera" + " anchors could not be extracted") + return workspace_getter, camera_slot + + +def vtable_slots( + image: ElfImage, + relocations: dict[int, int], + vtable_rva: int, + table: "FunctionTable", +) -> dict[int, int]: + """Collect vtable slots whose targets are covered by the function table. + + Segment flags alone cannot distinguish .text from .rodata in these builds. + """ + slots: dict[int, int] = {} + misses = 0 + for slot in range(0, MAX_VTABLE_SCAN_BYTES, 8): + target = relocations.get(vtable_rva + slot) + valid = False + if target is not None: + try: + image.require_code_rva(target) + table.bounds(target) + valid = True + except AnalyzerError: + valid = False + if valid: + slots[slot] = target + misses = 0 + else: + misses += 1 + if misses >= 8: + break + if len(slots) < 16: + raise AnalyzerError( + f"vtable candidate at 0x{vtable_rva:x} does not look like a vtable") + return slots + + +def find_vr_offsets(elf_path: pathlib.Path) -> dict[str, Any]: + """Analyze VR entry points without executing the ELF. + + Args: + elf_path: Path to an x86-64 libroblox.so. + + Returns: + ELF identity, bridge offsets, evidence, and analysis warnings. + + Raises: + AnalyzerError: The ELF or a required machine-code contract is invalid. + """ + evidence: list[str] = [] + warnings: list[str] = [] + + with ElfImage(elf_path) as image: + relocations = image.relative_relocations() + table = FunctionTable(image) + disasm = Disassembler(image) + + # Itanium RTTI: mangled name -> typeinfo -> primary vtable. + rtti_name_rva = image.find_alloc_string(RTTI_TYPE_NAME) + name_slots = sorted(offset for offset, addend in relocations.items() + if addend == rtti_name_rva and offset % 8 == 0) + if not name_slots: + raise AnalyzerError("no APS2 relative relocation references" + " the DebugDeviceVR RTTI name") + vtable_result: tuple[int, dict[int, int]] | None = None + typeinfo_result = 0 + for name_slot in name_slots: + typeinfo = name_slot - 8 + for reference in sorted( + offset for offset, addend in relocations.items() + if addend == typeinfo and offset % 8 == 0): + vtable = reference + 8 + try: + image.require_relro_rva(vtable, 0xE0) + slots = vtable_slots(image, relocations, vtable, table) + except AnalyzerError: + continue + vtable_result = (vtable, slots) + typeinfo_result = typeinfo + break + if vtable_result is not None: + break + if vtable_result is None: + raise AnalyzerError( + "DebugDeviceVR vtable could not be resolved from its typeinfo") + vtable, slots = vtable_result + offset_to_top = image.bytes_at(image.rva_to_offset(vtable - 16, 8), 8, + "vtable header") + if offset_to_top != b"\0" * 8: + raise AnalyzerError( + f"vtable at 0x{vtable:x} is not a primary vtable (offset-to-top" + " is nonzero)") + evidence.append( + f"RTTI: mangled name {RTTI_TYPE_NAME.decode('ascii')!r} at" + f" 0x{rtti_name_rva:x} -> typeinfo 0x{typeinfo_result:x} -> vtable" + f" 0x{vtable:x} through APS2 relative relocations") + + state_matches = [ + (slot, target) + for slot, target in slots.items() + if is_state_getter(disasm.function(*table.bounds(target))) + ] + eye_matches = [(slot, target) + for slot, target in slots.items() + if is_eye_getter(disasm.function(*table.bounds(target)))] + eye_label_rva = image.find_alloc_string(EYE_RESOURCE_LABEL) + initializer_matches = [ + (slot, target) + for slot, target in slots.items() + if is_eye_initializer(disasm.function( + *table.bounds(target)), eye_label_rva) + ] + for label, matches in ( + ("state getter", state_matches), + ("eye getter", eye_matches), + ("eye initializer", initializer_matches), + ): + if not matches: + raise AnalyzerError( + f"no vtable slot satisfies the {label} contract") + if len(matches) > 1: + listed = ", ".join(f"vtable+0x{slot:x} -> 0x{target:x}" + for slot, target in matches) + raise AnalyzerError( + f"ambiguous {label} contract matches: {listed}") + state_slot, state_getter = state_matches[0] + eye_slot, eye_getter = eye_matches[0] + initializer_slot, eye_initializer = initializer_matches[0] + for label, slot, expected in ( + ("state getter", state_slot, EXPECTED_STATE_GETTER_SLOT), + ("eye getter", eye_slot, EXPECTED_EYE_GETTER_SLOT), + ("eye initializer", initializer_slot, + EXPECTED_EYE_INITIALIZER_SLOT), + ): + if slot != expected: + warnings.append(f"{label} moved from vtable+0x{expected:x}" + f" to vtable+0x{slot:x};" + " update the bridge slot constants") + evidence.append( + f"state getter: sret memcpy of 0x{STATE_COPY_SIZE:x} bytes from" + f" +0x{STATE_OFFSET:x} at vtable+0x{state_slot:x}" + f" -> 0x{state_getter:x}") + evidence.append("eye getter: movsxd/shl/mov" + f" [rdi+rax+0x{FRAMEBUFFER_SLOT_OFFSET:x}] at" + f" vtable+0x{eye_slot:x} -> 0x{eye_getter:x}") + evidence.append( + f"eye initializer: {EYE_RESOURCE_LABEL.decode('ascii')!r}" + " label, ready" + f" bytes {[f'0x{x:x}' for x in READY_BYTE_OFFSETS]}, slot arrays" + f" +0x{FRAMEBUFFER_SLOT_OFFSET:x}/+0x{TEXTURE_SLOT_OFFSET:x} at" + f" vtable+0x{initializer_slot:x} -> 0x{eye_initializer:x}") + + haptic_matches = [] + for slot, target in slots.items(): + start, end = table.bounds(target) + if end - start == 1: + body = image.bytes_at(image.rva_to_offset(target, 1), 1, + "haptic sink") + if body == b"\xc3": + haptic_matches.append((slot, target)) + if not haptic_matches: + raise AnalyzerError( + "no vtable slot points at a 1-byte ret haptic sink") + haptic_slot, haptic_sink = min(haptic_matches) + if haptic_slot != EXPECTED_HAPTIC_SLOT: + warnings.append( + f"haptic sink moved from vtable+0x{EXPECTED_HAPTIC_SLOT:x} to" + f" vtable+0x{haptic_slot:x}") + evidence.append( + f"haptic sink: 1-byte ret stub at vtable+0x{haptic_slot:x} ->" + f" 0x{haptic_sink:x}") + + device_name_rva = image.find_alloc_string(DEVICE_OBJECT_NAME) + vtable_refs = find_text_refs(image, table, disasm, {vtable}) + constructor_candidates: dict[int, int] = {} + for _target, refs in vtable_refs.items(): + for _address, start, end in refs: + instructions = disasm.function(start, end) + if is_constructor(instructions, vtable, device_name_rva): + constructor_candidates[start] = end + if not constructor_candidates: + raise AnalyzerError( + "no function satisfies the DebugDeviceVR constructor contract") + if len(constructor_candidates) > 1: + listed = ", ".join( + f"0x{start:x}" for start in constructor_candidates) + raise AnalyzerError( + f"ambiguous constructor contract matches: {listed}") + constructor = next(iter(constructor_candidates)) + if constructor in slots.values(): + raise AnalyzerError( + "constructor candidate is itself a vtable slot target" + " (destructor?)") + evidence.append( + f"constructor: vtable store, {DEVICE_OBJECT_NAME.decode('ascii')!r}" + f" name at +0x8, type {OBJECT_TYPE_DEBUG_DEVICE} at" + f" +0x{OBJECT_TYPE_OFFSET:x}," + f" memset 0x{CONSTRUCTOR_MEMSET_SIZE:x} of" + f" +0x{STATE_OFFSET:x} -> 0x{constructor:x}") + + initializer_instructions = disasm.function( + *table.bounds(eye_initializer)) + framebuffer_slot = extract_framebuffer_slot(initializer_instructions) + if framebuffer_slot is None: + raise AnalyzerError( + "eye initializer does not contain a unique device vtable" + " framebuffer call") + evidence.append( + f"device createFramebuffer: mov rax,[rax+0x{framebuffer_slot:x}];" + " call rax inside the eye initializer") + + emulator_name_rva = image.find_alloc_string(EMULATOR_FLAG_NAME) + flag_refs = find_text_refs(image, table, disasm, {emulator_name_rva}) + flag_functions: dict[int, int] = {} + for _target, refs in flag_refs.items(): + for _address, start, end in refs: + flag_functions[start] = end + registration_storage = None + for start, end in sorted(flag_functions.items()): + storage = extract_registration_storage(disasm.function(start, end), + emulator_name_rva) + if storage is not None: + if (registration_storage is not None and + storage != registration_storage): + raise AnalyzerError("ambiguous emulator flag storage:" + f" 0x{registration_storage:x}" + f" and 0x{storage:x}") + registration_storage = storage + if registration_storage is None: + raise AnalyzerError( + "DebugEnableVREmulator registration contract was not found") + image.require_writable_rva(registration_storage, 1) + zeroing_seen = any( + find_flag_zeroing(disasm.function(start, end), registration_storage) + for start, end in flag_functions.items()) + if zeroing_seen: + evidence.append( + "emulator flag: registration lea rsi/rdx contract plus init" + f" byte-store cross-check -> 0x{registration_storage:x}") + else: + warnings.append("emulator flag storage derived from" + " the registration contract only;" + " the zero-initializing writer was not found") + + hand_pitch_name_rva = image.find_alloc_string(HAND_PITCH_FLAG_NAME) + pitch_refs = find_text_refs(image, table, disasm, {hand_pitch_name_rva}) + pitch_functions: dict[int, int] = {} + for _target, refs in pitch_refs.items(): + for _address, start, end in refs: + pitch_functions[start] = end + hand_pitch = None + for start, end in sorted(pitch_functions.items()): + match = extract_hand_pitch(disasm.function(start, end), + hand_pitch_name_rva) + if match is not None: + if hand_pitch is not None and match != hand_pitch: + raise AnalyzerError( + "ambiguous NewVRSystemHandPitch storage:" + f" 0x{hand_pitch[0]:x}" + f" and 0x{match[0]:x}") + hand_pitch = match + if hand_pitch is None: + raise AnalyzerError( + "NewVRSystemHandPitch storage contract was not found") + hand_pitch_storage, hand_pitch_default = hand_pitch + image.require_writable_rva(hand_pitch_storage, 4) + evidence.append( + f"hand pitch: dword default {hand_pitch_default} degrees with name" + f" pointer at storage+8 -> 0x{hand_pitch_storage:x}") + + selector_hits = scan_text_bytes(image, POINTER_GETTER_SELECTOR_PATTERN) + movups_hits = scan_text_bytes(image, POINTER_GETTER_MOVUPS_PATTERN) + getter_candidates: set[int] = set() + for hit in selector_hits + movups_hits: + try: + start, _end = table.bounds(hit) + except AnalyzerError: + continue + getter_candidates.add(start) + getter_matches: list[tuple[int, tuple[Any, ...]]] = [] + for start in sorted(getter_candidates): + instructions = disasm.function(*table.bounds(start)) + if match_pointer_getter(instructions): + getter_matches.append((start, instructions)) + if not getter_matches: + raise AnalyzerError( + "no function satisfies the world-space pointer getter contract") + if len(getter_matches) > 1: + listed = ", ".join(f"0x{start:x}" for start, _ in getter_matches) + raise AnalyzerError(f"ambiguous pointer getter matches: {listed}") + pointer_getter, getter_instructions = getter_matches[0] + workspace_getter, camera_slot = extract_pointer_getter_anchors( + getter_instructions, image) + evidence.append("pointer getter: UserCFrame selector" + f" +0x{USERCFRAME_SELECTOR_OFFSET:x}," + f" base +0x{USERCFRAME_BASE_OFFSET:x}, stride" + f" 0x{USERCFRAME_STRIDE:x}," + f" HeadScale +0x{CAMERA_HEAD_SCALE_OFFSET:x}" + f" -> 0x{pointer_getter:x}") + evidence.append(f"workspace getter 0x{workspace_getter:x}" + " via mov rdi,rsi + direct call;" + f" camera via call [rcx+0x{camera_slot:x}]") + + return { + "schema_version": 1, + "tool": "find_roblox_vr_offsets", + "elf": { + "path": str(elf_path), + "size": image.file_size, + "build_id": image.build_id, + "sha256": image.sha256, + }, + "vr_debug_device_bridge": { + "vtable_rva": + f"0x{vtable:x}", + "constructor_rva": + f"0x{constructor:x}", + "state_getter_rva": + f"0x{state_getter:x}", + "eye_getter_rva": + f"0x{eye_getter:x}", + "eye_initializer_rva": + f"0x{eye_initializer:x}", + "emulator_flag_storage_rva": + f"0x{registration_storage:x}", + "device_create_framebuffer_vtable_offset": + f"0x{framebuffer_slot:x}", + }, + "vr_bridge_supplement": { + "state_getter_vtable_slot_offset": + f"0x{state_slot:x}", + "eye_getter_vtable_slot_offset": + f"0x{eye_slot:x}", + "eye_initializer_vtable_slot_offset": + f"0x{initializer_slot:x}", + "haptic_sink_rva": + f"0x{haptic_sink:x}", + "haptic_sink_vtable_slot_offset": + f"0x{haptic_slot:x}", + "world_pointer_getter_rva": + f"0x{pointer_getter:x}", + "workspace_getter_rva": + f"0x{workspace_getter:x}", + "workspace_camera_vtable_offset": + f"0x{camera_slot:x}", + "new_vr_system_hand_pitch_storage_rva": + f"0x{hand_pitch_storage:x}", + "new_vr_system_hand_pitch_default_degrees": + hand_pitch_default, + }, + "evidence": evidence, + "warnings": warnings, + } + + +def write_json_atomic(path: pathlib.Path, payload: dict[str, Any]) -> None: + encoded = json.dumps(payload, indent=2) + "\n" + if len(encoded.encode("utf-8")) > MAX_JSON_OUTPUT_BYTES: + raise AnalyzerError("analysis result exceeds the output size limit") + parent = path.parent + try: + handle = tempfile.NamedTemporaryFile("w", + encoding="utf-8", + dir=parent, + prefix=path.name, + delete=False) + with handle: + handle.write(encoded) + handle.flush() + os.fsync(handle.fileno()) + os.replace(handle.name, path) + except OSError as error: + raise AnalyzerError(f"cannot write output file: {path}") from error + + +def parse_arguments(argv: Sequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="find-roblox-vr-offsets", + description=( + "Extract and validate the native VR bridge offsets from an x86-64" + " Roblox libroblox.so payload."), + ) + parser.add_argument( + "--elf", + required=True, + type=pathlib.Path, + help="path to the libroblox.so ELF image to analyze", + ) + parser.add_argument( + "--output", + type=pathlib.Path, + default=None, + help="optional path for the JSON result (written atomically)", + ) + parser.add_argument( + "--quiet", + action="store_true", + help="do not echo the JSON result to stdout when --output is given", + ) + return parser.parse_args(list(argv)) + + +def main(argv: Sequence[str] | None = None) -> int: + arguments = parse_arguments(sys.argv[1:] if argv is None else argv) + try: + result = find_vr_offsets(arguments.elf) + if arguments.output is not None: + write_json_atomic(arguments.output, result) + if not arguments.quiet: + print(json.dumps(result, indent=2)) + else: + print(json.dumps(result, indent=2)) + except AnalyzerError as error: + print(f"find-roblox-vr-offsets: error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/audio/native_input_capture.h b/src/audio/native_input_capture.h index bfb04b5..4a916fa 100644 --- a/src/audio/native_input_capture.h +++ b/src/audio/native_input_capture.h @@ -15,12 +15,7 @@ #include "mocktail/audio/sdl_audio_capture.h" -// Port of main's native input capture (537828a "Fix vc") to Build 2998. -// Main pins the voice sink vtable, its PCM entry point and the shared_ptr -// destructor to 2.738 RVAs. Here the sink is dispatched through its own -// vtable, which is validated against the guest image first, and the -// shared_ptr is released through the libc++ control-block ABI, so no -// further 2998 RVAs are needed. +// Sink calls and shared ownership use the validated guest vtables. namespace mocktail::audio { class NativeInputCapture; diff --git a/src/audio/roblox_output_device_bridge.cc b/src/audio/roblox_output_device_bridge.cc index 8ae9e9e..3eef550 100644 --- a/src/audio/roblox_output_device_bridge.cc +++ b/src/audio/roblox_output_device_bridge.cc @@ -30,8 +30,6 @@ namespace { constexpr std::size_t kOutputCountVtableIndex = 5; constexpr std::size_t kOutputInfoVtableIndex = 6; -constexpr std::size_t kCurrentOutputVtableIndex = 7; -constexpr std::size_t kSelectOutputVtableIndex = 17; constexpr std::size_t kGuestStringSize = 24; constexpr std::size_t kGuestDeviceInfoSize = kGuestStringSize * 2 + 1; constexpr std::size_t kStringConstructorContractSize = 24; @@ -39,22 +37,15 @@ constexpr std::size_t kMaximumMenuDevices = 128; constexpr std::size_t kMaximumDeviceNameBytes = 512; constexpr std::size_t kFirstPatchedVtableIndex = 4; -// Microphone capture on Build 2998 (Roblox 2.736.1408 x86_64). Roblox 2.738 -// inserted device-list methods at slots 7 and 11; main verified its capture -// methods at {4, 13..17}, between the input current (12) and select (18) -// slots, so 2998 keeps them at {4, 11..15}, between input current (10) and -// select (16). The input slots, with main's input RVAs for this image, anchor -// that layout before anything is patched. -constexpr std::string_view kCaptureBuildId = - "ade08266c67aee88ec9c1d00902150e1684dad3a"; -constexpr std::uintptr_t kCaptureVtableRva = 0x6c58040; -// Format, latency, start, stop, poll, recording. -constexpr std::array kCaptureSlots{4, 11, 12, 13, 14, 15}; -constexpr std::array kInputAnchorSlots{8, 9, 10, 16}; -constexpr std::array kInputAnchorRvas{ - 0x32d12dc, 0x32d12ea, 0x32d13a6, 0x32d0a56}; -// On 2.738 the capture methods lie within 0x1400 bytes of the input methods. -constexpr std::uintptr_t kCaptureCodeWindow = 0x4000; +std::size_t CurrentOutputVtableIndex( + const compat::FmodOutputDeviceBridgeProfile& profile) { + return profile.vtable_layout_version == 2 ? 8 : 7; +} + +std::size_t SelectOutputVtableIndex( + const compat::FmodOutputDeviceBridgeProfile& profile) { + return profile.vtable_layout_version == 2 ? 19 : 17; +} std::atomic g_active_bridge{nullptr}; @@ -114,7 +105,8 @@ bool IsRelroImageRange(std::uintptr_t image_base, std::uintptr_t rva, return IsProgramHeaderRange(image_base, rva, size, PT_GNU_RELRO, 0); } -bool SetVtableWritable(std::uintptr_t* vtable, bool writable) { +bool SetVtableWritable(std::uintptr_t* vtable, std::size_t last_slot, + bool writable) { if (vtable == nullptr) { return false; } @@ -126,7 +118,7 @@ bool SetVtableWritable(std::uintptr_t* vtable, bool writable) { const std::uintptr_t first = reinterpret_cast(vtable + kFirstPatchedVtableIndex); const std::uintptr_t last = - reinterpret_cast(vtable + kSelectOutputVtableIndex + 1); + reinterpret_cast(vtable + last_slot + 1); if (last <= first) { return false; } @@ -150,6 +142,48 @@ std::uintptr_t FunctionAddress(Function function) { namespace internal { +const FmodInputCaptureProfile* FindFmodInputCaptureProfile( + const compat::BuildProfile& profile) { + static constexpr FmodInputCaptureProfile kProfiles[] = { + {"ade08266c67aee88ec9c1d00902150e1684dad3a", + 0x6c58040, + 1, + {4, 11, 12, 13, 14, 15}, + {0x32d0e9a, 0x32d0a34, 0x32d16a8, 0x32d1f84, 0x32d056c, 0x32d26ac}}, + {"5f0704edd9064f566ee3d6df2bd2fabbcc709f03", + 0x6cd3ce0, + 2, + {4, 13, 14, 15, 16, 17}, + {0x320c630, 0x320bcf6, 0x320d4b4, 0x320dd94, 0x320b844, 0x320e49a}}, + }; + if (!profile.allow_host_abi_bridges || !profile.fmod_output_device_bridge) { + return nullptr; + } + const auto& output = *profile.fmod_output_device_bridge; + for (const auto& capture : kProfiles) { + if (capture.build_id == profile.elf_build_id && + capture.vtable_rva == output.vtable_rva && + capture.vtable_layout_version == output.vtable_layout_version) { + return &capture; + } + } + return nullptr; +} + +bool HasExpectedFmodInputCaptureVtable(const std::uintptr_t* vtable, + std::uintptr_t image_base, + const FmodInputCaptureProfile& profile) { + if (vtable == nullptr || image_base == 0) return false; + for (std::size_t i = 0; i < profile.slots.size(); ++i) { + if (profile.method_rvas[i] > + std::numeric_limits::max() - image_base || + vtable[profile.slots[i]] != image_base + profile.method_rvas[i]) { + return false; + } + } + return true; +} + bool HasExpectedFmodStringConstructorContract(const std::uint8_t* code, std::size_t size) { constexpr std::array @@ -165,14 +199,16 @@ bool HasExpectedFmodStringConstructorContract(const std::uint8_t* code, bool HasExpectedFmodOutputDeviceVtable( const std::uintptr_t* vtable, std::uintptr_t image_base, const compat::FmodOutputDeviceBridgeProfile& profile) { - return vtable != nullptr && image_base != 0 && + return (profile.vtable_layout_version == 1 || + profile.vtable_layout_version == 2) && + vtable != nullptr && image_base != 0 && vtable[kOutputCountVtableIndex] == image_base + profile.count_method_rva && vtable[kOutputInfoVtableIndex] == image_base + profile.info_method_rva && - vtable[kCurrentOutputVtableIndex] == + vtable[CurrentOutputVtableIndex(profile)] == image_base + profile.current_method_rva && - vtable[kSelectOutputVtableIndex] == + vtable[SelectOutputVtableIndex(profile)] == image_base + profile.select_method_rva; } @@ -209,6 +245,10 @@ Status RobloxOutputDeviceBridge::Install(const compat::BuildProfile& profile) { if (!profile.fmod_output_device_bridge.has_value()) { return Status::Ok(); } + if (profile.fmod_output_device_bridge->vtable_layout_version != 1 && + profile.fmod_output_device_bridge->vtable_layout_version != 2) { + return FailedPrecondition("unsupported FMOD output-device vtable layout"); + } if (!profile.allow_host_abi_bridges) { return FailedPrecondition( "FMOD output-device profile requires host ABI bridges"); @@ -223,10 +263,10 @@ Status RobloxOutputDeviceBridge::Install(const compat::BuildProfile& profile) { } profile_ = *profile.fmod_output_device_bridge; const char* native_capture = std::getenv("MOCKTAIL_NATIVE_INPUT_CAPTURE"); - capture_profile_supported_ = - profile.elf_build_id == kCaptureBuildId && - profile_.vtable_rva == kCaptureVtableRva && - !(native_capture != nullptr && std::strcmp(native_capture, "0") == 0); + capture_profile_ = + native_capture != nullptr && std::strcmp(native_capture, "0") == 0 + ? nullptr + : internal::FindFmodInputCaptureProfile(profile); if (!linker::SetAndroidLibraryLoadObserver(&ObserveAndroidLibrary, this)) { g_active_bridge.store(nullptr, std::memory_order_release); profile_ = {}; @@ -264,6 +304,7 @@ void RobloxOutputDeviceBridge::Shutdown() { (void)g_active_bridge.compare_exchange_strong( expected, nullptr, std::memory_order_acq_rel, std::memory_order_acquire); profile_ = {}; + capture_profile_ = nullptr; installed_ = false; } @@ -372,13 +413,13 @@ Status RobloxOutputDeviceBridge::Activate(std::uintptr_t image_base) { Status RobloxOutputDeviceBridge::PatchVtableLocked() { if (profile_.vtable_rva > std::numeric_limits::max() - - (kSelectOutputVtableIndex + 1) * sizeof(std::uintptr_t)) { + (SelectOutputVtableIndex(profile_) + 1) * sizeof(std::uintptr_t)) { return FailedPrecondition("FMOD output-device vtable range overflows"); } const std::uintptr_t first_slot_rva = profile_.vtable_rva + kFirstPatchedVtableIndex * sizeof(std::uintptr_t); const std::size_t slot_range_size = - (kSelectOutputVtableIndex - kFirstPatchedVtableIndex + 1) * + (SelectOutputVtableIndex(profile_) - kFirstPatchedVtableIndex + 1) * sizeof(std::uintptr_t); if (!IsRelroImageRange(library_base_, first_slot_rva, slot_range_size) || !IsExecutableImageRange(library_base_, profile_.string_constructor_rva, @@ -403,7 +444,7 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() { "FmodAudioDevice string ABI contract validation failed"); } - if (capture_profile_supported_) { + if (capture_profile_ != nullptr) { // The output routes do not depend on capture; keep them if it is refused. const Status capture_status = PrepareCaptureLocked(); if (!capture_status.ok()) { @@ -415,10 +456,10 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() { original_methods_ = { vtable_[kOutputCountVtableIndex], vtable_[kOutputInfoVtableIndex], - vtable_[kCurrentOutputVtableIndex], - vtable_[kSelectOutputVtableIndex], + vtable_[CurrentOutputVtableIndex(profile_)], + vtable_[SelectOutputVtableIndex(profile_)], }; - if (!SetVtableWritable(vtable_, true)) { + if (!SetVtableWritable(vtable_, SelectOutputVtableIndex(profile_), true)) { capture_.reset(); return Status::Error(StatusCode::kPlatformError, "cannot make FmodAudioDevice vtable writable"); @@ -428,9 +469,9 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() { FunctionAddress(&GetOutputDeviceCount), __ATOMIC_RELEASE); __atomic_store_n(&vtable_[kOutputInfoVtableIndex], FunctionAddress(&GetOutputDeviceInfo), __ATOMIC_RELEASE); - __atomic_store_n(&vtable_[kCurrentOutputVtableIndex], + __atomic_store_n(&vtable_[CurrentOutputVtableIndex(profile_)], FunctionAddress(&GetCurrentOutputDevice), __ATOMIC_RELEASE); - __atomic_store_n(&vtable_[kSelectOutputVtableIndex], + __atomic_store_n(&vtable_[SelectOutputVtableIndex(profile_)], FunctionAddress(&SetCurrentOutputDevice), __ATOMIC_RELEASE); if (capture_) { g_native_capture.store(capture_.get(), std::memory_order_release); @@ -441,74 +482,55 @@ Status RobloxOutputDeviceBridge::PatchVtableLocked() { FunctionAddress(&NativeInputCapture::Stop), FunctionAddress(&NativeInputCapture::Poll), FunctionAddress(&NativeInputCapture::Recording)}; - for (std::size_t i = 0; i < kCaptureSlots.size(); ++i) - __atomic_store_n(&vtable_[kCaptureSlots[i]], methods[i], __ATOMIC_RELEASE); + for (std::size_t i = 0; i < capture_profile_->slots.size(); ++i) + __atomic_store_n(&vtable_[capture_profile_->slots[i]], methods[i], + __ATOMIC_RELEASE); } - if (!SetVtableWritable(vtable_, false)) { + if (!SetVtableWritable(vtable_, SelectOutputVtableIndex(profile_), false)) { __atomic_store_n(&vtable_[kOutputCountVtableIndex], original_methods_[0], __ATOMIC_RELEASE); __atomic_store_n(&vtable_[kOutputInfoVtableIndex], original_methods_[1], __ATOMIC_RELEASE); - __atomic_store_n(&vtable_[kCurrentOutputVtableIndex], original_methods_[2], - __ATOMIC_RELEASE); - __atomic_store_n(&vtable_[kSelectOutputVtableIndex], original_methods_[3], - __ATOMIC_RELEASE); + __atomic_store_n(&vtable_[CurrentOutputVtableIndex(profile_)], + original_methods_[2], __ATOMIC_RELEASE); + __atomic_store_n(&vtable_[SelectOutputVtableIndex(profile_)], + original_methods_[3], __ATOMIC_RELEASE); if (capture_) { - for (std::size_t i = 0; i < kCaptureSlots.size(); ++i) - __atomic_store_n(&vtable_[kCaptureSlots[i]], + for (std::size_t i = 0; i < capture_profile_->slots.size(); ++i) + __atomic_store_n(&vtable_[capture_profile_->slots[i]], original_capture_methods_[i], __ATOMIC_RELEASE); NativeInputCapture::Retire(std::move(capture_)); } - (void)SetVtableWritable(vtable_, false); + (void)SetVtableWritable(vtable_, SelectOutputVtableIndex(profile_), false); return Status::Error(StatusCode::kPlatformError, "cannot restore FmodAudioDevice RELRO protection"); } if (capture_) { std::fprintf(stderr, " [audio-input] native capture armed for Build ID %s " - "(slots 4/11-15 = %#" PRIxPTR "/%#" PRIxPTR "/%#" PRIxPTR - "/%#" PRIxPTR "/%#" PRIxPTR "/%#" PRIxPTR ")\n", - kCaptureBuildId.data(), - original_capture_methods_[0] - library_base_, - original_capture_methods_[1] - library_base_, - original_capture_methods_[2] - library_base_, - original_capture_methods_[3] - library_base_, - original_capture_methods_[4] - library_base_, - original_capture_methods_[5] - library_base_); + "(format/latency/start/stop/poll/recording slots " + "%zu/%zu/%zu/%zu/%zu/%zu)\n", + capture_profile_->build_id.data(), capture_profile_->slots[0], + capture_profile_->slots[1], capture_profile_->slots[2], + capture_profile_->slots[3], capture_profile_->slots[4], + capture_profile_->slots[5]); } return Status::Ok(); } Status RobloxOutputDeviceBridge::PrepareCaptureLocked() { - for (std::size_t i = 0; i < kInputAnchorSlots.size(); ++i) { - if (vtable_[kInputAnchorSlots[i]] != library_base_ + kInputAnchorRvas[i]) { - return FailedPrecondition( - "FmodAudioDevice input slots do not match Build 2998"); - } + if (!capture_profile_ || !internal::HasExpectedFmodInputCaptureVtable( + vtable_, library_base_, *capture_profile_)) { + return FailedPrecondition( + "FmodAudioDevice capture vtable does not match the Build ID"); } - const auto [lowest_input, highest_input] = - std::minmax_element(kInputAnchorRvas.begin(), kInputAnchorRvas.end()); - for (std::size_t i = 0; i < kCaptureSlots.size(); ++i) { - const std::uintptr_t method = vtable_[kCaptureSlots[i]]; - const std::uintptr_t rva = method - library_base_; - if (method < library_base_ || - !IsExecutableImageRange(library_base_, rva, 1) || - rva + kCaptureCodeWindow < *lowest_input || - rva > *highest_input + kCaptureCodeWindow) { + for (std::size_t i = 0; i < capture_profile_->slots.size(); ++i) { + if (!IsExecutableImageRange(library_base_, capture_profile_->method_rvas[i], + 1)) { return FailedPrecondition( - "FmodAudioDevice capture slot is outside the device code"); + "FmodAudioDevice capture method is outside executable code"); } - const bool repeated = - std::find(original_capture_methods_.begin(), - original_capture_methods_.begin() + i, - method) != original_capture_methods_.begin() + i || - std::find(kInputAnchorRvas.begin(), kInputAnchorRvas.end(), rva) != - kInputAnchorRvas.end(); - if (repeated) { - return FailedPrecondition( - "FmodAudioDevice capture slots are not distinct methods"); - } - original_capture_methods_[i] = method; + original_capture_methods_[i] = vtable_[capture_profile_->slots[i]]; } const char* input_device = std::getenv("MOCKTAIL_AUDIO_INPUT_DEVICE"); const bool enabled = @@ -523,23 +545,24 @@ Status RobloxOutputDeviceBridge::PrepareCaptureLocked() { } bool RobloxOutputDeviceBridge::RestoreVtableLocked() { - if (vtable_ == nullptr || !SetVtableWritable(vtable_, true)) { + if (vtable_ == nullptr || + !SetVtableWritable(vtable_, SelectOutputVtableIndex(profile_), true)) { return false; } __atomic_store_n(&vtable_[kOutputCountVtableIndex], original_methods_[0], __ATOMIC_RELEASE); __atomic_store_n(&vtable_[kOutputInfoVtableIndex], original_methods_[1], __ATOMIC_RELEASE); - __atomic_store_n(&vtable_[kCurrentOutputVtableIndex], original_methods_[2], - __ATOMIC_RELEASE); - __atomic_store_n(&vtable_[kSelectOutputVtableIndex], original_methods_[3], - __ATOMIC_RELEASE); + __atomic_store_n(&vtable_[CurrentOutputVtableIndex(profile_)], + original_methods_[2], __ATOMIC_RELEASE); + __atomic_store_n(&vtable_[SelectOutputVtableIndex(profile_)], + original_methods_[3], __ATOMIC_RELEASE); if (capture_) { - for (std::size_t i = 0; i < kCaptureSlots.size(); ++i) - __atomic_store_n(&vtable_[kCaptureSlots[i]], original_capture_methods_[i], - __ATOMIC_RELEASE); + for (std::size_t i = 0; i < capture_profile_->slots.size(); ++i) + __atomic_store_n(&vtable_[capture_profile_->slots[i]], + original_capture_methods_[i], __ATOMIC_RELEASE); } - return SetVtableWritable(vtable_, false); + return SetVtableWritable(vtable_, SelectOutputVtableIndex(profile_), false); } int RobloxOutputDeviceBridge::GetOutputDeviceCount(void* self) { diff --git a/src/audio/roblox_output_device_bridge_internal.h b/src/audio/roblox_output_device_bridge_internal.h index 1a28001..c28c132 100644 --- a/src/audio/roblox_output_device_bridge_internal.h +++ b/src/audio/roblox_output_device_bridge_internal.h @@ -1,6 +1,7 @@ #ifndef MOCKTAIL_AUDIO_ROBLOX_OUTPUT_DEVICE_BRIDGE_INTERNAL_H_ #define MOCKTAIL_AUDIO_ROBLOX_OUTPUT_DEVICE_BRIDGE_INTERNAL_H_ +#include #include #include #include @@ -10,6 +11,22 @@ namespace mocktail::audio::internal { +struct FmodInputCaptureProfile { + std::string_view build_id; + std::uintptr_t vtable_rva; + std::uint32_t vtable_layout_version; + // Format, latency, start, stop, poll, recording. + std::array slots; + std::array method_rvas; +}; + +const FmodInputCaptureProfile* FindFmodInputCaptureProfile( + const compat::BuildProfile& profile); + +bool HasExpectedFmodInputCaptureVtable(const std::uintptr_t* vtable, + std::uintptr_t image_base, + const FmodInputCaptureProfile& profile); + bool HasExpectedFmodStringConstructorContract(const std::uint8_t* code, std::size_t size); diff --git a/src/compat/build_profile.cc b/src/compat/build_profile.cc index ee5aaa8..1111747 100644 --- a/src/compat/build_profile.cc +++ b/src/compat/build_profile.cc @@ -119,9 +119,18 @@ bool ParseOptionalFmodOutputDeviceBridge( return false; } + std::uint32_t layout_version = 1; + const auto layout = field->find("vtable_layout_version"); + if (layout != field->end()) { + if (!layout->is_number_integer() || (*layout != 1 && *layout != 2)) { + return false; + } + layout_version = layout->get(); + } *bridge_profile = FmodOutputDeviceBridgeProfile{ *vtable_rva, *string_constructor_rva, *count_method_rva, *info_method_rva, *current_method_rva, *select_method_rva, + layout_version, }; return true; } diff --git a/src/compat/host_abi_profile.cc b/src/compat/host_abi_profile.cc index a769381..959fc39 100644 --- a/src/compat/host_abi_profile.cc +++ b/src/compat/host_abi_profile.cc @@ -6,6 +6,27 @@ namespace mocktail::compat { namespace { constexpr HostAbiProfile kProfiles[] = { + // Roblox 2.738.1397; promoted from the exact-build probation profile. + {"5f0704edd9064f566ee3d6df2bd2fabbcc709f03", + {{{0x1d73422, HostBridgeKind::kAllocate, "small-allocate"}, + {0x1d7667a, HostBridgeKind::kUsableSize, "usable-size"}, + {0x222f4c0, HostBridgeKind::kReallocate, "reallocate"}, + {0x1d731c2, HostBridgeKind::kAllocate, "allocate"}, + {0x1d8e7f9, HostBridgeKind::kAlignedAllocate, "aligned-allocate-direct"}, + {0x1d76c19, HostBridgeKind::kFree, "free"}}}, + 6, + {0x76e2da0, 0x77efb98, 0x7c4b780, 0x400, 0x1d73838, 0x1dffad7, 0x7b87830, + 0x7b878c0, 0x400000}, + {0x1d731c2, 0x1d76c19}, + 0x70e1618, + 3599, + {{{2, 3}, {5, 3599}}}, + 2, + {{{2, 3599}}}, + 1, + 2, + {0x22308d3, 0x7b8b998}, + HostAllocatorStrategy::kNativeMimalloc}, // Roblox 2.736.1408. Derived from the verified 2908 profile and validated // by two isolated no-recovery Vulkan canaries and real game sessions. {"ade08266c67aee88ec9c1d00902150e1684dad3a", @@ -13,12 +34,11 @@ constexpr HostAbiProfile kProfiles[] = { {0x1d2f43a, HostBridgeKind::kUsableSize, "usable-size"}, {0x21e68bf, HostBridgeKind::kReallocate, "reallocate"}, {0x1d2bf82, HostBridgeKind::kAllocate, "allocate"}, - {0x1d47a63, HostBridgeKind::kAlignedAllocate, - "aligned-allocate-direct"}, + {0x1d47a63, HostBridgeKind::kAlignedAllocate, "aligned-allocate-direct"}, {0x1d2f9d9, HostBridgeKind::kFree, "free"}}}, 6, - {0x7652e70, 0x775fd28, 0x7bb4340, 0x400, 0x1d2c5f8, 0x1db7b57, - 0x7af0430, 0x7af04c0, 0x400000}, + {0x7652e70, 0x775fd28, 0x7bb4340, 0x400, 0x1d2c5f8, 0x1db7b57, 0x7af0430, + 0x7af04c0, 0x400000}, {0x1d2bf82, 0x1d2f9d9}, 0x7057818, 3570, @@ -38,12 +58,11 @@ constexpr HostAbiProfile kProfiles[] = { {0x1cf9c3a, HostBridgeKind::kUsableSize, "usable-size"}, {0x21af33f, HostBridgeKind::kReallocate, "reallocate"}, {0x1cf677e, HostBridgeKind::kAllocate, "allocate"}, - {0x1d12197, HostBridgeKind::kAlignedAllocate, - "aligned-allocate-direct"}, + {0x1d12197, HostBridgeKind::kAlignedAllocate, "aligned-allocate-direct"}, {0x1cfa1d9, HostBridgeKind::kFree, "free"}}}, 6, - {0x7571570, 0x767e428, 0x7ad47e8, 0x400, 0x1cf6df8, 0x1d82be7, - 0x7a10730, 0x7a107c0, 0x400000}, + {0x7571570, 0x767e428, 0x7ad47e8, 0x400, 0x1cf6df8, 0x1d82be7, 0x7a10730, + 0x7a107c0, 0x400000}, {0x1cf677e, 0x1cfa1d9}, 0x6f78a18, 3556, @@ -64,12 +83,11 @@ constexpr HostAbiProfile kProfiles[] = { {0x1fe4c84, HostBridgeKind::kUsableSize, "usable-size"}, {0x203daae, HostBridgeKind::kReallocate, "reallocate"}, {0x1c39c3a, HostBridgeKind::kAllocate, "allocate"}, - {0x1c8dbf8, HostBridgeKind::kAlignedAllocate, - "aligned-allocate-direct"}, + {0x1c8dbf8, HostBridgeKind::kAlignedAllocate, "aligned-allocate-direct"}, {0x1c3eb72, HostBridgeKind::kFree, "free"}}}, 6, - {0x7030ae0, 0x703a7e8, 0x72a16d0, 0x400, 0x1c39f1d, 0x2040817, - 0x71e92f0, 0x71e9380, 0x400000}, + {0x7030ae0, 0x703a7e8, 0x72a16d0, 0x400, 0x1c39f1d, 0x2040817, 0x71e92f0, + 0x71e9380, 0x400000}, {0x1c39c3a, 0x1c3eb72}, 0x6c2c9b0, 0x6cc8 / 8, diff --git a/src/graphics/bionic_vulkan_loader_adapter.cc b/src/graphics/bionic_vulkan_loader_adapter.cc index a3a6dc4..ecef856 100644 --- a/src/graphics/bionic_vulkan_loader_adapter.cc +++ b/src/graphics/bionic_vulkan_loader_adapter.cc @@ -10,8 +10,11 @@ #include #include #include +#include #include +#include #include +#include #include #include @@ -22,8 +25,14 @@ extern "C" { VKAPI_ATTR VkResult VKAPI_CALL -vkCreateInstance(const VkInstanceCreateInfo* create_info, - const VkAllocationCallbacks* allocator, VkInstance* instance); +mocktail_vulkan_idle_synchronized(PFN_vkDeviceWaitIdle raw, VkDevice device); +VKAPI_ATTR VkResult VKAPI_CALL mocktail_vulkan_submit_synchronized( + PFN_vkQueueSubmit raw, VkDevice device, VkQueue queue, uint32_t count, + const VkSubmitInfo *submits, VkFence fence); + +VKAPI_ATTR VkResult VKAPI_CALL +vkCreateInstance(const VkInstanceCreateInfo *create_info, + const VkAllocationCallbacks *allocator, VkInstance *instance); VKAPI_ATTR void VKAPI_CALL vkDestroyInstance(VkInstance instance, const VkAllocationCallbacks* allocator); VKAPI_ATTR VkResult VKAPI_CALL vkCreateAndroidSurfaceKHR( @@ -62,13 +71,29 @@ VKAPI_ATTR VkResult VKAPI_CALL vkAcquireNextImageKHR( VkDevice device, VkSwapchainKHR swapchain, std::uint64_t timeout, VkSemaphore semaphore, VkFence fence, std::uint32_t* image_index); VKAPI_ATTR VkResult VKAPI_CALL vkAcquireNextImage2KHR( - VkDevice device, const VkAcquireNextImageInfoKHR* acquire_info, - std::uint32_t* image_index); -VKAPI_ATTR VkResult VKAPI_CALL vkWaitForFences( - VkDevice device, std::uint32_t fence_count, const VkFence* fences, - VkBool32 wait_all, std::uint64_t timeout); -VKAPI_ATTR VkResult VKAPI_CALL vkResetFences( - VkDevice device, std::uint32_t fence_count, const VkFence* fences); + VkDevice device, const VkAcquireNextImageInfoKHR *acquire_info, + std::uint32_t *image_index); +VKAPI_ATTR VkResult VKAPI_CALL vkWaitForFences(VkDevice device, + std::uint32_t fence_count, + const VkFence *fences, + VkBool32 wait_all, + std::uint64_t timeout); +VKAPI_ATTR VkResult VKAPI_CALL vkResetFences(VkDevice device, + std::uint32_t fence_count, + const VkFence *fences); +VKAPI_ATTR VkResult VKAPI_CALL vkCreateCommandPool( + VkDevice device, const VkCommandPoolCreateInfo *info, + const VkAllocationCallbacks *allocator, VkCommandPool *pool); +VKAPI_ATTR void VKAPI_CALL vkCmdPipelineBarrier( + VkCommandBuffer command, VkPipelineStageFlags src, VkPipelineStageFlags dst, + VkDependencyFlags flags, uint32_t memory_count, + const VkMemoryBarrier *memory, uint32_t buffer_count, + const VkBufferMemoryBarrier *buffers, uint32_t image_count, + const VkImageMemoryBarrier *images); +VKAPI_ATTR void VKAPI_CALL vkCmdPipelineBarrier2(VkCommandBuffer command, + const VkDependencyInfo *info); +VKAPI_ATTR void VKAPI_CALL +vkCmdPipelineBarrier2KHR(VkCommandBuffer command, const VkDependencyInfo *info); VKAPI_ATTR VkResult VKAPI_CALL vkResetCommandPool( VkDevice device, VkCommandPool command_pool, VkCommandPoolResetFlags flags); @@ -286,6 +311,46 @@ Function ResolveProcessFunction(const char* name) { return reinterpret_cast(dlsym(RTLD_DEFAULT, name)); } +// Queue calls share the gate; device idle is exclusive. +// Release it before fence waits and backend callbacks. +std::shared_ptr DeviceIdleGate(VkDevice device) { + static std::mutex registry_mutex; + static std::unordered_map> + registry; + std::lock_guard lock(registry_mutex); + auto &gate = registry[device]; + if (!gate) + gate = std::make_shared(); + return gate; +} +std::shared_ptr QueueCallMutex(VkQueue queue) { + static std::mutex registry_mutex; + static std::unordered_map> registry; + std::lock_guard lock(registry_mutex); + auto &mutex = registry[queue]; + if (!mutex) + mutex = std::make_shared(); + return mutex; +} + +void NoteVrSubmitted(VkQueue queue, uint32_t count, + const VkSubmitInfo *submits) { + static const auto note = ResolveProcessFunction("mocktail_vr_xr_submitted"); + if (note && submits) + for (uint32_t i = 0; i < count; ++i) + note(queue, submits[i].commandBufferCount, submits[i].pCommandBuffers); +} +void NoteVrSubmitted2(VkQueue queue, uint32_t count, + const VkSubmitInfo2 *submits) { + static const auto note = ResolveProcessFunction("mocktail_vr_xr_submitted"); + if (note && submits) + for (uint32_t i = 0; i < count; ++i) + for (uint32_t j = 0; j < submits[i].commandBufferInfoCount; ++j) + note(queue, 1, &submits[i].pCommandBufferInfos[j].commandBuffer); +} + bool EnsureInitialized() { AdapterState& state = State(); std::lock_guard lock(state.mutex); @@ -293,6 +358,18 @@ bool EnsureInitialized() { return true; } + const auto register_submit = + ResolveProcessFunction("mocktail_vr_xr_queue_submit_adapter"); + if (register_submit) + register_submit(mocktail_vulkan_submit_synchronized); + const auto register_idle = ResolveProcessFunction( + "mocktail_vr_xr_device_idle_adapter"); + if (register_idle) + register_idle(mocktail_vulkan_idle_synchronized); + const BackendWindowFn backend_window = ResolveProcessFunction("mocktail_window_backend_window"); const UsesDirectVulkanFn uses_direct_vulkan = @@ -564,6 +641,57 @@ const AdapterState::DeviceDispatch& HostDispatchForCommandBuffer( return kEmptyDeviceDispatch; } +VkResult VKAPI_CALL LockedHostSubmit(VkQueue queue, uint32_t count, + const VkSubmitInfo *submits, + VkFence fence) { + return mocktail_vulkan_submit_synchronized( + HostDispatchForQueue(queue).queue_submit, + HostDispatchForQueue(queue).device, queue, count, submits, fence); +} +VkResult VKAPI_CALL LockedHostSubmit2(VkQueue queue, uint32_t count, + const VkSubmitInfo2 *submits, + VkFence fence) { + const auto raw = HostDispatchForQueue(queue).queue_submit2; + const auto gate = DeviceIdleGate(HostDispatchForQueue(queue).device); + std::shared_lock device_gate(*gate); + const auto mutex = QueueCallMutex(queue); + std::lock_guard lock(*mutex); + return raw(queue, count, submits, fence); +} +VkResult VKAPI_CALL LockedHostSubmit2KHR(VkQueue queue, uint32_t count, + const VkSubmitInfo2 *submits, + VkFence fence) { + const auto raw = HostDispatchForQueue(queue).queue_submit2_khr; + const auto gate = DeviceIdleGate(HostDispatchForQueue(queue).device); + std::shared_lock device_gate(*gate); + const auto mutex = QueueCallMutex(queue); + std::lock_guard lock(*mutex); + return raw(queue, count, submits, fence); +} +VkResult VKAPI_CALL LockedHostBindSparse(VkQueue queue, uint32_t count, + const VkBindSparseInfo *binds, + VkFence fence) { + const auto raw = HostDispatchForQueue(queue).queue_bind_sparse; + const auto gate = DeviceIdleGate(HostDispatchForQueue(queue).device); + std::shared_lock device_gate(*gate); + const auto mutex = QueueCallMutex(queue); + std::lock_guard lock(*mutex); + return raw(queue, count, binds, fence); +} +VkResult VKAPI_CALL LockedHostQueueWaitIdle(VkQueue queue) { + const auto raw = HostDispatchForQueue(queue).queue_wait_idle; + const auto gate = DeviceIdleGate(HostDispatchForQueue(queue).device); + std::shared_lock device_gate(*gate); + const auto mutex = QueueCallMutex(queue); + std::lock_guard lock(*mutex); + return raw(queue); +} + +VkResult VKAPI_CALL LockedHostDeviceWaitIdle(VkDevice device) { + return mocktail_vulkan_idle_synchronized( + HostDispatchForDevice(device).device_wait_idle, device); +} + void RegisterHostDeviceDispatch(VkDevice device, VkPhysicalDevice physical_device, PFN_vkGetDeviceProcAddr get_device_proc_addr) { @@ -834,9 +962,9 @@ PFN_vkVoidFunction AdapterProc(const char* name) { if (name == nullptr) { return nullptr; } -#define MOCKTAIL_VK_PROC(function) \ - if (std::strcmp(name, #function) == 0) { \ - return reinterpret_cast(function); \ +#define MOCKTAIL_VK_PROC(function) \ + if (std::strcmp(name, #function) == 0) { \ + return reinterpret_cast(function); \ } MOCKTAIL_VK_PROC(vkCreateInstance) MOCKTAIL_VK_PROC(vkDestroyInstance) @@ -856,6 +984,10 @@ PFN_vkVoidFunction AdapterProc(const char* name) { MOCKTAIL_VK_PROC(vkAcquireNextImage2KHR) MOCKTAIL_VK_PROC(vkWaitForFences) MOCKTAIL_VK_PROC(vkResetFences) + MOCKTAIL_VK_PROC(vkCreateCommandPool) + MOCKTAIL_VK_PROC(vkCmdPipelineBarrier) + MOCKTAIL_VK_PROC(vkCmdPipelineBarrier2) + MOCKTAIL_VK_PROC(vkCmdPipelineBarrier2KHR) MOCKTAIL_VK_PROC(vkResetCommandPool) MOCKTAIL_VK_PROC(vkGetQueryPoolResults) MOCKTAIL_VK_PROC(vkAllocateCommandBuffers) @@ -886,43 +1018,47 @@ PFN_vkVoidFunction AdapterProc(const char* name) { return nullptr; } -bool IsDeviceAdapterProc(const char* name) { - return name != nullptr && (std::strcmp(name, "vkDestroyDevice") == 0 || - std::strcmp(name, "vkGetDeviceQueue") == 0 || - std::strcmp(name, "vkGetDeviceQueue2") == 0 || - std::strcmp(name, "vkCreateSwapchainKHR") == 0 || - std::strcmp(name, "vkDestroySwapchainKHR") == 0 || - std::strcmp(name, "vkAcquireNextImageKHR") == 0 || - std::strcmp(name, "vkAcquireNextImage2KHR") == 0 || - std::strcmp(name, "vkWaitForFences") == 0 || - std::strcmp(name, "vkResetFences") == 0 || - std::strcmp(name, "vkResetCommandPool") == 0 || - std::strcmp(name, "vkGetQueryPoolResults") == 0 || - std::strcmp(name, "vkAllocateCommandBuffers") == - 0 || - std::strcmp(name, "vkFreeCommandBuffers") == 0 || - std::strcmp(name, "vkDestroyCommandPool") == 0 || - std::strcmp(name, "vkBeginCommandBuffer") == 0 || - std::strcmp(name, "vkEndCommandBuffer") == 0 || - std::strcmp(name, "vkResetCommandBuffer") == 0 || - std::strcmp(name, "vkWaitSemaphores") == 0 || - std::strcmp(name, "vkWaitSemaphoresKHR") == 0 || - std::strcmp(name, "vkQueueSubmit") == 0 || - std::strcmp(name, "vkQueueSubmit2") == 0 || - std::strcmp(name, "vkQueueSubmit2KHR") == 0 || - std::strcmp(name, "vkQueueBindSparse") == 0 || - std::strcmp(name, "vkQueueWaitIdle") == 0 || - std::strcmp(name, "vkDeviceWaitIdle") == 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 IsDeviceAdapterProc(const char *name) { + return name != nullptr && + (std::strcmp(name, "vkDestroyDevice") == 0 || + std::strcmp(name, "vkGetDeviceQueue") == 0 || + std::strcmp(name, "vkGetDeviceQueue2") == 0 || + std::strcmp(name, "vkCreateSwapchainKHR") == 0 || + std::strcmp(name, "vkDestroySwapchainKHR") == 0 || + std::strcmp(name, "vkAcquireNextImageKHR") == 0 || + std::strcmp(name, "vkAcquireNextImage2KHR") == 0 || + std::strcmp(name, "vkWaitForFences") == 0 || + std::strcmp(name, "vkResetFences") == 0 || + std::strcmp(name, "vkCreateCommandPool") == 0 || + std::strcmp(name, "vkCmdPipelineBarrier") == 0 || + std::strcmp(name, "vkCmdPipelineBarrier2") == 0 || + std::strcmp(name, "vkCmdPipelineBarrier2KHR") == 0 || + std::strcmp(name, "vkResetCommandPool") == 0 || + std::strcmp(name, "vkGetQueryPoolResults") == 0 || + std::strcmp(name, "vkAllocateCommandBuffers") == 0 || + std::strcmp(name, "vkFreeCommandBuffers") == 0 || + std::strcmp(name, "vkDestroyCommandPool") == 0 || + std::strcmp(name, "vkBeginCommandBuffer") == 0 || + std::strcmp(name, "vkEndCommandBuffer") == 0 || + std::strcmp(name, "vkResetCommandBuffer") == 0 || + std::strcmp(name, "vkWaitSemaphores") == 0 || + std::strcmp(name, "vkWaitSemaphoresKHR") == 0 || + std::strcmp(name, "vkQueueSubmit") == 0 || + std::strcmp(name, "vkQueueSubmit2") == 0 || + std::strcmp(name, "vkQueueSubmit2KHR") == 0 || + std::strcmp(name, "vkQueueBindSparse") == 0 || + std::strcmp(name, "vkQueueWaitIdle") == 0 || + std::strcmp(name, "vkDeviceWaitIdle") == 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) { @@ -1201,12 +1337,35 @@ ObservedHostQueuePresent(VkQueue queue, const VkPresentInfoKHR* present_info) { } const VrXrNotePresentFn vr_note_present = state.vr_note_present.load(std::memory_order_acquire); + VkPresentInfoKHR synchronized{}; if (vr_note_present != nullptr) { - vr_note_present(queue, HostDispatchForQueue(queue).device); + static const auto prepare = ResolveProcessFunction( + "mocktail_vr_xr_prepare_present"); + // Mirror/overlay may have consumed waits; use the final dependency list. + const VkResult prepared = + prepare + ? prepare(queue, HostDispatchForQueue(queue).device, present_info) + : VK_NOT_READY; + if (prepared < 0) + return prepared; + if (prepared == VK_SUCCESS) { + vr_note_present(queue, HostDispatchForQueue(queue).device); + synchronized = *present_info; + synchronized.waitSemaphoreCount = 0; + synchronized.pWaitSemaphores = nullptr; + present_info = &synchronized; + } } const bool fps_trace = FpsTraceEnabled(); const std::uint64_t present_start_ns = fps_trace ? MonotonicNanos() : 0; - const VkResult result = host_present(queue, present_info); + const VkResult result = [&] { + const auto gate = DeviceIdleGate(HostDispatchForQueue(queue).device); + std::shared_lock device_gate(*gate); + const auto mutex = QueueCallMutex(queue); + std::lock_guard lock(*mutex); + return host_present(queue, present_info); + }(); if (fps_trace) { PresentWaitTrace().Record(present_start_ns); } @@ -1689,7 +1848,23 @@ VKAPI_ATTR VkResult VKAPI_CALL vkCreateSwapchainKHR( return VK_ERROR_INITIALIZATION_FAILED; } VkSwapchainCreateInfoKHR host_info = *create_info; - static const auto vr_desktop_enabled = ResolveProcessFunction("mocktail_vr_desktop_enabled"); + unsigned vr_families[256]{}; + bool converted_to_concurrent = false; + static const auto shared_families = + ResolveProcessFunction( + "mocktail_vr_desktop_queue_families"); + if (shared_families && + host_info.imageSharingMode == VK_SHARING_MODE_EXCLUSIVE) { + const unsigned count = shared_families(device, 256, vr_families); + if (count > 1) { + host_info.imageSharingMode = VK_SHARING_MODE_CONCURRENT; + host_info.queueFamilyIndexCount = count; + host_info.pQueueFamilyIndices = vr_families; + converted_to_concurrent = true; + } + } + static const auto vr_desktop_enabled = + ResolveProcessFunction("mocktail_vr_desktop_enabled"); if (vr_desktop_enabled && vr_desktop_enabled()) { PFN_vkGetPhysicalDeviceSurfaceCapabilitiesKHR caps; { std::lock_guard lock(State().mutex); caps = State().host_surface_capabilities; } @@ -1783,11 +1958,15 @@ VKAPI_ATTR VkResult VKAPI_CALL vkCreateSwapchainKHR( host_get_images(device, *swapchain, &image_count, images.data()); if (images_result == VK_SUCCESS || images_result == VK_INCOMPLETE) { images.resize(image_count); - static const auto record = ResolveProcessFunction("mocktail_vr_desktop_swapchain"); - if (record) record(device, *swapchain, &host_info, images.data(), image_count); - (void)State().text_overlay.RegisterSwapchain( - device, *swapchain, *create_info, images.data(), image_count); + static const auto record = ResolveProcessFunction( + "mocktail_vr_desktop_swapchain_shared"); + if (record) + record(device, *swapchain, &host_info, images.data(), image_count, + converted_to_concurrent); + (void)State().text_overlay.RegisterSwapchain(device, *swapchain, host_info, + images.data(), image_count); } return result; } @@ -1905,6 +2084,10 @@ VKAPI_ATTR VkResult VKAPI_CALL vkResetCommandPool( return VK_ERROR_INITIALIZATION_FAILED; } const VkResult result = host_reset(device, command_pool, flags); + static const auto note = ResolveProcessFunction( + "mocktail_vr_xr_reset_pool"); + if (result == VK_SUCCESS && note) + note(command_pool); observation.SetResult(result); return result; } @@ -1944,6 +2127,12 @@ VKAPI_ATTR VkResult VKAPI_CALL vkAllocateCommandBuffers( RegisterHostCommandBuffers(device, allocate_info->commandPool, allocate_info->commandBufferCount, command_buffers); + static const auto note = ResolveProcessFunction( + "mocktail_vr_xr_command_buffers"); + if (note) + note(device, allocate_info->commandPool, + allocate_info->commandBufferCount, command_buffers); } observation.SetResult(result); return result; @@ -1960,6 +2149,11 @@ VKAPI_ATTR void VKAPI_CALL vkFreeCommandBuffers( observation.SetResult(VK_ERROR_INITIALIZATION_FAILED); return; } + static const auto note = ResolveProcessFunction( + "mocktail_vr_xr_command_buffers"); + if (note) + note(device, VK_NULL_HANDLE, command_buffer_count, command_buffers); host_free(device, command_pool, command_buffer_count, command_buffers); RemoveHostCommandBuffers(device, command_pool, command_buffer_count, command_buffers); @@ -1976,6 +2170,11 @@ VKAPI_ATTR void VKAPI_CALL vkDestroyCommandPool( observation.SetResult(VK_ERROR_INITIALIZATION_FAILED); return; } + static const auto note = + ResolveProcessFunction( + "mocktail_vr_xr_command_pool"); + if (note) + note(device, command_pool, UINT32_MAX); host_destroy(device, command_pool, allocator); RemoveHostCommandPoolBindings(device, command_pool); observation.SetResult(VK_SUCCESS); @@ -1992,6 +2191,10 @@ VKAPI_ATTR VkResult VKAPI_CALL vkBeginCommandBuffer( return VK_ERROR_INITIALIZATION_FAILED; } const VkResult result = host_begin(command_buffer, begin_info); + static const auto note = ResolveProcessFunction( + "mocktail_vr_xr_reset_command"); + if (result == VK_SUCCESS && note) + note(command_buffer); observation.SetResult(result); return result; } @@ -2020,6 +2223,10 @@ VKAPI_ATTR VkResult VKAPI_CALL vkResetCommandBuffer( return VK_ERROR_INITIALIZATION_FAILED; } const VkResult result = host_reset(command_buffer, flags); + static const auto note = ResolveProcessFunction( + "mocktail_vr_xr_reset_command"); + if (result == VK_SUCCESS && note) + note(command_buffer); observation.SetResult(result); return result; } @@ -2056,7 +2263,9 @@ VKAPI_ATTR VkResult VKAPI_CALL vkQueueSubmit( return VK_ERROR_INITIALIZATION_FAILED; } const VkResult result = State().text_overlay.QueueSubmit( - queue, submit_count, submits, fence, host_submit); + queue, submit_count, submits, fence, LockedHostSubmit); + if (result == VK_SUCCESS) + NoteVrSubmitted(queue, submit_count, submits); observation.SetResult(result); return result; } @@ -2072,7 +2281,9 @@ VKAPI_ATTR VkResult VKAPI_CALL vkQueueSubmit2( return VK_ERROR_INITIALIZATION_FAILED; } const VkResult result = State().text_overlay.QueueSubmit2( - queue, submit_count, submits, fence, host_submit); + queue, submit_count, submits, fence, LockedHostSubmit2); + if (result == VK_SUCCESS) + NoteVrSubmitted2(queue, submit_count, submits); observation.SetResult(result); return result; } @@ -2088,8 +2299,9 @@ VKAPI_ATTR VkResult VKAPI_CALL vkQueueSubmit2KHR( return VK_ERROR_INITIALIZATION_FAILED; } const VkResult result = State().text_overlay.QueueSubmit2( - queue, submit_count, submits, fence, - reinterpret_cast(host_submit)); + queue, submit_count, submits, fence, LockedHostSubmit2KHR); + if (result == VK_SUCCESS) + NoteVrSubmitted2(queue, submit_count, submits); observation.SetResult(result); return result; } @@ -2105,7 +2317,7 @@ VKAPI_ATTR VkResult VKAPI_CALL vkQueueBindSparse( return VK_ERROR_INITIALIZATION_FAILED; } const VkResult result = State().text_overlay.QueueBindSparse( - queue, bind_info_count, bind_info, fence, host_bind); + queue, bind_info_count, bind_info, fence, LockedHostBindSparse); observation.SetResult(result); return result; } @@ -2121,7 +2333,7 @@ VKAPI_ATTR VkResult VKAPI_CALL vkQueueWaitIdle(VkQueue queue) { const bool fps_trace = FpsTraceEnabled(); const std::uint64_t idle_start_ns = fps_trace ? MonotonicNanos() : 0; const VkResult result = - State().text_overlay.QueueWaitIdle(queue, host_wait); + State().text_overlay.QueueWaitIdle(queue, LockedHostQueueWaitIdle); if (fps_trace) { QueueIdleWaitTrace().Record(idle_start_ns); } @@ -2138,7 +2350,7 @@ VKAPI_ATTR VkResult VKAPI_CALL vkDeviceWaitIdle(VkDevice device) { return VK_ERROR_INITIALIZATION_FAILED; } const VkResult result = - State().text_overlay.DeviceWaitIdle(device, host_wait); + State().text_overlay.DeviceWaitIdle(device, LockedHostDeviceWaitIdle); observation.SetResult(result); return result; } @@ -2434,4 +2646,100 @@ vkCmdBeginRenderPass(VkCommandBuffer command_buffer, } } -} // extern "C" \ No newline at end of file +VKAPI_ATTR VkResult VKAPI_CALL vkCreateCommandPool( + VkDevice device, const VkCommandPoolCreateInfo *info, + const VkAllocationCallbacks *allocator, VkCommandPool *pool) { + const auto create = reinterpret_cast( + HostDeviceProc(device, "vkCreateCommandPool")); + if (!create) + return VK_ERROR_INITIALIZATION_FAILED; + const VkResult result = create(device, info, allocator, pool); + static const auto note = + ResolveProcessFunction( + "mocktail_vr_xr_command_pool"); + if (result == VK_SUCCESS && note && info && pool) + note(device, *pool, info->queueFamilyIndex); + return result; +} + +VKAPI_ATTR void VKAPI_CALL vkCmdPipelineBarrier( + VkCommandBuffer command, VkPipelineStageFlags src, VkPipelineStageFlags dst, + VkDependencyFlags flags, uint32_t memory_count, + const VkMemoryBarrier *memory, uint32_t buffer_count, + const VkBufferMemoryBarrier *buffers, uint32_t image_count, + const VkImageMemoryBarrier *images) { + const auto barrier = reinterpret_cast( + HostDeviceProc(HostDispatchForCommandBuffer(command).device, + "vkCmdPipelineBarrier")); + if (!barrier) + return; + static const auto normalize = ResolveProcessFunction("mocktail_vr_xr_desktop_barrier"); + std::vector adjusted; + if (normalize && images && image_count) { + adjusted.assign(images, images + image_count); + for (auto &image : adjusted) + image.newLayout = normalize( + command, image.image, &image.srcQueueFamilyIndex, + &image.dstQueueFamilyIndex, &image.oldLayout, image.newLayout); + images = adjusted.data(); + } + barrier(command, src, dst, flags, memory_count, memory, buffer_count, buffers, + image_count, images); +} + +static void ForwardVrBarrier2(VkCommandBuffer command, + const VkDependencyInfo *info, const char *name) { + const auto barrier = reinterpret_cast( + HostDeviceProc(HostDispatchForCommandBuffer(command).device, name)); + if (!barrier || !info) + return; + static const auto normalize = ResolveProcessFunction("mocktail_vr_xr_desktop_barrier"); + VkDependencyInfo adjusted_info = *info; + std::vector adjusted; + if (normalize && info->pImageMemoryBarriers && + info->imageMemoryBarrierCount) { + adjusted.assign(info->pImageMemoryBarriers, + info->pImageMemoryBarriers + info->imageMemoryBarrierCount); + for (auto &image : adjusted) + image.newLayout = normalize( + command, image.image, &image.srcQueueFamilyIndex, + &image.dstQueueFamilyIndex, &image.oldLayout, image.newLayout); + adjusted_info.pImageMemoryBarriers = adjusted.data(); + } + barrier(command, &adjusted_info); +} +VKAPI_ATTR void VKAPI_CALL vkCmdPipelineBarrier2(VkCommandBuffer command, + const VkDependencyInfo *info) { + ForwardVrBarrier2(command, info, "vkCmdPipelineBarrier2"); +} +VKAPI_ATTR void VKAPI_CALL vkCmdPipelineBarrier2KHR( + VkCommandBuffer command, const VkDependencyInfo *info) { + ForwardVrBarrier2(command, info, "vkCmdPipelineBarrier2KHR"); +} + +VKAPI_ATTR VkResult VKAPI_CALL mocktail_vulkan_submit_synchronized( + PFN_vkQueueSubmit raw, VkDevice device, VkQueue queue, uint32_t count, + const VkSubmitInfo *submits, VkFence fence) { + if (!raw) + return VK_ERROR_INITIALIZATION_FAILED; + const auto gate = DeviceIdleGate(device); + std::shared_lock device_gate(*gate); + const auto mutex = QueueCallMutex(queue); + std::lock_guard lock(*mutex); + return raw(queue, count, submits, fence); +} + +VKAPI_ATTR VkResult VKAPI_CALL +mocktail_vulkan_idle_synchronized(PFN_vkDeviceWaitIdle raw, VkDevice device) { + if (!raw) + return VK_ERROR_INITIALIZATION_FAILED; + const auto gate = DeviceIdleGate(device); + std::unique_lock device_gate(*gate); + return raw(device); +} + +} // extern "C" diff --git a/src/legacy/legacy_runtime.cc b/src/legacy/legacy_runtime.cc index 6bfa5de..b3fe9ea 100644 --- a/src/legacy/legacy_runtime.cc +++ b/src/legacy/legacy_runtime.cc @@ -4507,17 +4507,11 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options, return EXIT_FAILURE; } - const mocktail::compat::BuildProfile& build_profile = - *profile_result.profile; - // The experimental VR ABI has only been observed on the active 2998 - // payload. Do not pass VR device parameters to unrelated builds, even when - // they have a general-purpose compatibility profile. Eye initialization and - // OpenXR projection are not yet implemented; this is a diagnostic launch. + const mocktail::compat::BuildProfile& build_profile = *profile_result.profile; if (IsEnabled("MOCKTAIL_VR_ENABLED") && - build_profile.elf_build_id != - "ade08266c67aee88ec9c1d00902150e1684dad3a") { - std::cerr << "[FATAL] Experimental Roblox VR requires exact Build ID " - "ade08266c67aee88ec9c1d00902150e1684dad3a; loaded " + mocktail::vr::FindRobloxVrDeviceProfile(build_profile.elf_build_id) == + nullptr) { + std::cerr << "[FATAL] Experimental Roblox VR has no verified profile for " << build_profile.elf_build_id << "\n"; return EXIT_FAILURE; } diff --git a/src/main.cc b/src/main.cc index 439117f..4eac0cc 100644 --- a/src/main.cc +++ b/src/main.cc @@ -742,6 +742,12 @@ int main(int argc, char* argv[]) { std::cerr << "[FATAL] " << compatibility.error << '\n'; return EXIT_FAILURE; } + if (!compatibility.profile.vr_debug_device_bridge.has_value()) { + if (const auto* vr_profile = + mocktail::vr::FindRobloxVrDeviceProfile(compatibility.build_id)) { + compatibility.profile.vr_debug_device_bridge = *vr_profile; + } + } vr_active = runtime_config.config.vr_enabled() && (vr_requested || compatibility.profile.vr_debug_device_bridge.has_value()); @@ -759,7 +765,8 @@ int main(int argc, char* argv[]) { 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)"; + "debug device (Build ID " + << compatibility.build_id << ")"; 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 or SteamVR/ALVR and " diff --git a/src/runtime/runtime_config_bootstrap.cc b/src/runtime/runtime_config_bootstrap.cc index 892ffd2..34abae5 100644 --- a/src/runtime/runtime_config_bootstrap.cc +++ b/src/runtime/runtime_config_bootstrap.cc @@ -69,12 +69,12 @@ graphics: # 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 + # Experimental VR for verified builds 2998 and 3092. 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 + # desktop testing). Controllers use the native VR bridge. Standalone OpenXR # test scene: mocktail-vr-probe --scene. Use --no-vr to launch normally. # enabled: false diff --git a/src/vr/diagnostics.cc b/src/vr/diagnostics.cc index 2d37435..2decdb7 100644 --- a/src/vr/diagnostics.cc +++ b/src/vr/diagnostics.cc @@ -67,7 +67,7 @@ std::string FormatProbeReport(const ProbeReport& report) { for (const auto& extension : report.extensions) output << " " << extension << '\n'; output << "Discovery: " << report.message << '\n' - << "Roblox VR rendering: not implemented yet.\n" + << "Roblox VR rendering: run mocktail --vr with a supported client.\n" << "This probe does not create an XR session or render frames.\n"; return output.str(); } diff --git a/src/vr/openxr_backend.cc b/src/vr/openxr_backend.cc index 66a67b5..488a174 100644 --- a/src/vr/openxr_backend.cc +++ b/src/vr/openxr_backend.cc @@ -35,8 +35,32 @@ namespace mocktail::vr { namespace { using Clock = std::chrono::steady_clock; +using SynchronizedQueueSubmit = VkResult(VKAPI_PTR *)(PFN_vkQueueSubmit, + VkDevice, VkQueue, + std::uint32_t, + const VkSubmitInfo *, + VkFence); +std::atomic g_queue_submit_adapter{nullptr}; +using SynchronizedDeviceIdle = VkResult(VKAPI_PTR *)(PFN_vkDeviceWaitIdle, + VkDevice); +std::atomic g_device_idle_adapter{nullptr}; -void Log(const char* format, ...) { +VkResult IdleHostDevice(PFN_vkDeviceWaitIdle raw, VkDevice device) { + const auto synchronized = + g_device_idle_adapter.load(std::memory_order_acquire); + return synchronized ? synchronized(raw, device) : raw(device); +} + +VkResult SubmitHostQueue(PFN_vkQueueSubmit raw, VkDevice device, VkQueue queue, + std::uint32_t count, const VkSubmitInfo *submits, + VkFence fence) { + const auto synchronized = + g_queue_submit_adapter.load(std::memory_order_acquire); + return synchronized ? synchronized(raw, device, queue, count, submits, fence) + : raw(queue, count, submits, fence); +} + +void Log(const char *format, ...) { va_list arguments; va_start(arguments, format); std::vfprintf(stderr, format, arguments); @@ -159,6 +183,7 @@ struct OpenXrBackend::VkProcs { PFN_vkResetFences ResetFences = nullptr; PFN_vkWaitForFences WaitForFences = nullptr; PFN_vkQueueSubmit QueueSubmit = nullptr; + PFN_vkGetDeviceQueue GetDeviceQueue = nullptr; PFN_vkDeviceWaitIdle DeviceWaitIdle = nullptr; PFN_vkGetPhysicalDeviceMemoryProperties GetPhysicalDeviceMemoryProperties = nullptr; @@ -288,16 +313,48 @@ Status OpenXrBackend::Arm(VrGraphicsApi graphics_api) { const char *vk_extensions[] = {XR_KHR_VULKAN_ENABLE2_EXTENSION_NAME}; const char *gl_extensions[] = {XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME, XR_MNDX_EGL_ENABLE_EXTENSION_NAME}; + std::vector extensions = + graphics_api_ == VrGraphicsApi::kVulkan + ? std::vector{vk_extensions[0]} + : std::vector{gl_extensions[0], gl_extensions[1]}; + std::uint32_t extension_count = 0; + CheckXr(XR_NULL_HANDLE, + xrEnumerateInstanceExtensionProperties(nullptr, 0, &extension_count, + nullptr), + "xrEnumerateInstanceExtensionProperties count"); + Require(extension_count <= 4096, "OpenXR extension list is too large"); + std::vector available( + extension_count, + XrInfo(XR_TYPE_EXTENSION_PROPERTIES)); + if (extension_count != 0) { + CheckXr(XR_NULL_HANDLE, + xrEnumerateInstanceExtensionProperties( + nullptr, extension_count, &extension_count, available.data()), + "xrEnumerateInstanceExtensionProperties"); + } + for (const char *optional : + {XR_FB_TOUCH_CONTROLLER_PRO_EXTENSION_NAME, + XR_META_TOUCH_CONTROLLER_PLUS_EXTENSION_NAME}) { + if (std::any_of(available.begin(), available.end(), + [&](const auto &entry) { + return std::strcmp(entry.extensionName, optional) == 0; + })) { + extensions.push_back(optional); + } + } 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 = - graphics_api_ == VrGraphicsApi::kVulkan ? 1 : 2; - info.enabledExtensionNames = - graphics_api_ == VrGraphicsApi::kVulkan ? vk_extensions : gl_extensions; + info.applicationInfo.apiVersion = XR_MAKE_VERSION(1, 1, 0); + info.enabledExtensionCount = static_cast(extensions.size()); + info.enabledExtensionNames = extensions.data(); XrInstance instance = XR_NULL_HANDLE; - const XrResult instance_result = xrCreateInstance(&info, &instance); + XrResult instance_result = xrCreateInstance(&info, &instance); + if (instance_result == XR_ERROR_API_VERSION_UNSUPPORTED) { + info.applicationInfo.apiVersion = XR_MAKE_VERSION(1, 0, 0); + instance = XR_NULL_HANDLE; + instance_result = xrCreateInstance(&info, &instance); + } if (instance_result == XR_ERROR_RUNTIME_UNAVAILABLE) { throw std::runtime_error("xrCreateInstance: " + VrRuntimeUnavailableHint(!user_manifest.empty(), manifest)); @@ -517,7 +574,8 @@ bool OpenXrBackend::CreateVulkanDevice(VkPhysicalDevice physical_device, 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"); + Log(" [vr-backend] refusing a guest GPU different from the OpenXR " + "GPU\n"); return true; } @@ -563,60 +621,60 @@ bool OpenXrBackend::CreateVulkanDevice(VkPhysicalDevice physical_device, 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(CmdClearColorImage) - 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) + 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(CmdClearColorImage) + 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(GetDeviceQueue) + 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) - MOCKTAIL_BACKEND_VK_INSTANCE_FN(GetPhysicalDeviceFormatProperties) +#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) + MOCKTAIL_BACKEND_VK_INSTANCE_FN(GetPhysicalDeviceFormatProperties) #undef MOCKTAIL_BACKEND_VK_INSTANCE_FN VkPhysicalDeviceProperties device_properties{}; @@ -630,9 +688,6 @@ void OpenXrBackend::SetupDeviceAfterCreationLocked( 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(), @@ -659,7 +714,7 @@ bool OpenXrBackend::InitializeSessionLocked( const VkDeviceQueueCreateInfo& queue_info = create_info->pQueueCreateInfos[index]; if (queue_info.queueFamilyIndex >= family_count || - queue_info.queueCount == 0) { + queue_info.queueCount == 0 || queue_info.flags != 0) { continue; } if (families[queue_info.queueFamilyIndex].queueFlags & @@ -671,6 +726,21 @@ bool OpenXrBackend::InitializeSessionLocked( 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; + if (create_info) { + device_queue_families_.clear(); + for (std::uint32_t i = 0; i < create_info->queueCreateInfoCount; ++i) { + const auto &requested = create_info->pQueueCreateInfos[i]; + if (requested.queueCount && requested.flags == 0 && + std::find( + device_queue_families_.begin(), device_queue_families_.end(), + requested.queueFamilyIndex) == device_queue_families_.end()) + device_queue_families_.push_back(requested.queueFamilyIndex); + } + } + vk_->GetDeviceQueue(device, chosen_family, 0, &vk_graphics_queue_); + Require(vk_graphics_queue_ != VK_NULL_HANDLE, + "missing guest graphics queue"); + queue_families_[vk_graphics_queue_] = chosen_family; auto binding = XrInfo( XR_TYPE_GRAPHICS_BINDING_VULKAN2_KHR); @@ -840,6 +910,12 @@ bool OpenXrBackend::InitializeSessionResourcesLocked(std::string *error) { xrCreateReferenceSpace(session, &space_info, &view_space), "xrCreateReferenceSpace VIEW"); xr_view_space_ = view_space; + space_info.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_STAGE; + XrSpace stage_space = XR_NULL_HANDLE; + if (XR_SUCCEEDED( + xrCreateReferenceSpace(session, &space_info, &stage_space))) { + xr_stage_space_ = stage_space; + } auto system_properties = XrInfo(XR_TYPE_SYSTEM_PROPERTIES); @@ -1072,7 +1148,7 @@ void OpenXrBackend::TeardownSessionLocked(const char* reason, bool preserve_devi // Destruction also ends a running session; xrEndSession is legal only in // STOPPING (handled by PollSessionEvents). if (vk_ && vk_device_) { - (void)vk_->DeviceWaitIdle(vk_device_); + (void)IdleHostDevice(vk_->DeviceWaitIdle, vk_device_); // Mirror staging lives on this device; free it before the device goes. mirror_.DestroyStaging(MirrorProcs()); } @@ -1081,7 +1157,14 @@ void OpenXrBackend::TeardownSessionLocked(const char* reason, bool preserve_devi DestroySwapchains(); // Action pose spaces belong to this session; detach before the session and // its spaces go away so a recreated session attaches cleanly. - { std::lock_guard lock(controller_mutex_); actions_.Detach(); } + { + std::lock_guard lock(controller_mutex_); + actions_.Detach(); + } + if (xr_stage_space_ != nullptr) { + (void)xrDestroySpace(static_cast(xr_stage_space_)); + xr_stage_space_ = nullptr; + } if (xr_view_space_ != nullptr) { (void)xrDestroySpace(static_cast(xr_view_space_)); xr_view_space_ = nullptr; @@ -1100,7 +1183,7 @@ void OpenXrBackend::TeardownSessionLocked(const char* reason, bool preserve_devi frame_open_ = false; frame_views_valid_ = false; visibility_lost_ = false; - canted_rejection_logged_ = false; + projection_rejection_logged_ = false; eyes_[0] = {}; eyes_[1] = {}; if (gles_) @@ -1125,6 +1208,12 @@ void OpenXrBackend::TeardownSessionLocked(const char* reason, bool preserve_devi vk_device_ = VK_NULL_HANDLE; vk_physical_device_ = VK_NULL_HANDLE; queue_families_.clear(); + vk_graphics_queue_ = VK_NULL_HANDLE; + device_queue_families_.clear(); + command_pool_families_.clear(); + command_records_.clear(); + eye_render_queues_.clear(); + eye_render_frames_.clear(); delete vk_; vk_ = nullptr; } @@ -1155,6 +1244,137 @@ void OpenXrBackend::NoteQueue(VkDevice device, VkQueue queue, } } +std::vector +OpenXrBackend::DesktopQueueFamilies(VkDevice device) const { + std::lock_guard lock(mutex_); + return device == vk_device_ ? device_queue_families_ + : std::vector{}; +} + +void OpenXrBackend::RecordCommandPool(VkDevice device, VkCommandPool pool, + std::uint32_t family) { + std::lock_guard lock(mutex_); + if (device == vk_device_) + command_pool_families_[pool] = family; +} + +void OpenXrBackend::RecordCommandBuffers(VkDevice device, VkCommandPool pool, + std::uint32_t count, + const VkCommandBuffer *buffers) { + std::lock_guard lock(mutex_); + const auto found = command_pool_families_.find(pool); + if (device != vk_device_ || found == command_pool_families_.end() || !buffers) + return; + for (std::uint32_t i = 0; i < count; ++i) + command_records_[buffers[i]] = {pool, found->second, {}}; +} + +void OpenXrBackend::ForgetCommandBuffers(std::uint32_t count, + const VkCommandBuffer *buffers) { + if (buffers) + for (std::uint32_t i = 0; i < count; ++i) + ResetCommandBuffer(buffers[i]); + std::lock_guard lock(mutex_); + if (buffers) + for (std::uint32_t i = 0; i < count; ++i) + command_records_.erase(buffers[i]); +} + +void OpenXrBackend::ForgetCommandPool(VkCommandPool pool) { + ResetCommandPool(pool); + std::lock_guard lock(mutex_); + command_pool_families_.erase(pool); + for (auto it = command_records_.begin(); it != command_records_.end();) { + if (it->second.pool == pool) + it = command_records_.erase(it); + else + ++it; + } +} + +void OpenXrBackend::ResetCommandBuffer(VkCommandBuffer buffer) { + std::lock_guard lock(mutex_); + const auto found = command_records_.find(buffer); + if (found != command_records_.end()) { + for (const auto &image : found->second.eye_images) { + const auto frame = eye_render_frames_.find(image.first); + if (frame != eye_render_frames_.end() && frame->second == image.second) { + eye_render_frames_.erase(frame); + eye_render_queues_.erase(image.first); + } + } + found->second.eye_images.clear(); + } +} + +void OpenXrBackend::ResetCommandPool(VkCommandPool pool) { + std::lock_guard lock(mutex_); + for (auto &entry : command_records_) { + if (entry.second.pool != pool) + continue; + for (const auto &image : entry.second.eye_images) { + const auto frame = eye_render_frames_.find(image.first); + if (frame != eye_render_frames_.end() && frame->second == image.second) { + eye_render_frames_.erase(frame); + eye_render_queues_.erase(image.first); + } + } + entry.second.eye_images.clear(); + } +} + +void OpenXrBackend::NoteCommandBuffersSubmitted( + VkQueue queue, std::uint32_t count, const VkCommandBuffer *buffers) { + std::lock_guard lock(mutex_); + const auto family = queue_families_.find(queue); + if (family == queue_families_.end() || !buffers) + return; + for (std::uint32_t i = 0; i < count; ++i) { + const auto command = command_records_.find(buffers[i]); + if (command == command_records_.end() || + command->second.family != family->second) + continue; + for (const auto &image : command->second.eye_images) { + eye_render_queues_[image.first] = queue; + eye_render_frames_[image.first] = image.second; + } + } +} + +VkImageLayout OpenXrBackend::NormalizeDesktopBarrier(VkCommandBuffer command, + VkImage image, + std::uint32_t *source, + std::uint32_t *destination, + VkImageLayout *old_layout, + VkImageLayout new_layout) { + std::lock_guard lock(mutex_); + const auto found = command_records_.find(command); + if (found == command_records_.end() || *source == *destination || + *source >= VK_QUEUE_FAMILY_FOREIGN_EXT || + *destination >= VK_QUEUE_FAMILY_FOREIGN_EXT) + return new_layout; + for (const auto &entry : desktop_swapchains_) { + const auto &swapchain = entry.second; + if (!swapchain.converted_to_concurrent || + std::find(swapchain.images.begin(), swapchain.images.end(), image) == + swapchain.images.end()) + continue; + // Concurrent sharing: graphics->present transitions on release; + // PRESENT/UNDEFINED->graphics transitions on acquire. + if (found->second.family == *destination && + *old_layout != VK_IMAGE_LAYOUT_PRESENT_SRC_KHR && + *old_layout != VK_IMAGE_LAYOUT_UNDEFINED) + *old_layout = new_layout; + // Keep PRESENT until graphics acquire, even with an explicit release. + if (found->second.family == *source && + *old_layout == VK_IMAGE_LAYOUT_PRESENT_SRC_KHR) + new_layout = *old_layout; + *source = *destination = VK_QUEUE_FAMILY_IGNORED; + return new_layout; + } + return new_layout; +} + void OpenXrBackend::RecordImage(VkDevice device, VkImage image, const VkImageCreateInfo* info) { if (!recording_.load(std::memory_order_acquire) || info == nullptr) { @@ -1172,6 +1392,7 @@ void OpenXrBackend::RecordImage(VkDevice device, VkImage image, record.height = info->extent.height; record.format = info->format; record.usage = info->usage; + record.samples = info->samples; record.owner = owner; record.order = ++image_order_; images_[image] = record; @@ -1220,6 +1441,16 @@ void OpenXrBackend::NoteDestroyImage(VkImage image) { } std::lock_guard lock(mutex_); images_.erase(image); + eye_render_queues_.erase(image); + eye_render_frames_.erase(image); + for (auto &command : command_records_) { + auto &recorded = command.second.eye_images; + recorded.erase(std::remove_if(recorded.begin(), recorded.end(), + [image](const auto &value) { + return value.first == image; + }), + recorded.end()); + } 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); @@ -1283,6 +1514,7 @@ void OpenXrBackend::NoteRenderPassBegin(VkCommandBuffer command_buffer, 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.samples != VK_SAMPLE_COUNT_1_BIT || (image->second.usage & VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT) == 0 || (image->second.usage & VK_IMAGE_USAGE_TRANSFER_SRC_BIT) == 0) { continue; @@ -1320,13 +1552,23 @@ void OpenXrBackend::NoteRenderPassBegin(VkCommandBuffer command_buffer, 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)); + const auto command = command_records_.find(command_buffer); + if (command != command_records_.end()) { + const auto recorded = std::make_pair(target->image, binding.frame); + if (std::find(command->second.eye_images.begin(), + command->second.eye_images.end(), + recorded) == command->second.eye_images.end()) + command->second.eye_images.push_back(recorded); + } + 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 && @@ -1463,10 +1705,13 @@ void OpenXrBackend::PollSessionEvents() { 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", + change.referenceSpaceType == XR_REFERENCE_SPACE_TYPE_VIEW || + change.referenceSpaceType == XR_REFERENCE_SPACE_TYPE_STAGE); + // 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); @@ -1501,81 +1746,80 @@ void OpenXrBackend::HandleSessionState(int state) { std::fill(std::begin(published_pose_.controller_channels), std::end(published_pose_.controller_channels), 0.f); } 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; + 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) { @@ -1623,17 +1867,106 @@ bool OpenXrBackend::RecoverSessionLocked(std::uint64_t now_ns) { return false; } session_recovery_pending_ = false; - Log(" [vr-backend] XR session recreated on the existing guest device; awaiting READY\n"); + Log(" [vr-backend] XR session recreated on the existing guest device; " + "awaiting READY\n"); return true; } +VkResult internal::WaitForPresentDependencies(VkDevice device, VkQueue queue, + const VkPresentInfoKHR &present, + VkFence fence, + PFN_vkResetFences reset, + PFN_vkQueueSubmit submit, + PFN_vkWaitForFences wait) { + if (present.waitSemaphoreCount == 0) + return VK_SUCCESS; + if (!present.pWaitSemaphores) + return VK_ERROR_INITIALIZATION_FAILED; + std::vector stages(present.waitSemaphoreCount, + VK_PIPELINE_STAGE_ALL_COMMANDS_BIT); + VkSubmitInfo info{}; + info.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + info.waitSemaphoreCount = present.waitSemaphoreCount; + info.pWaitSemaphores = present.pWaitSemaphores; + info.pWaitDstStageMask = stages.data(); + VkResult result = reset(device, 1, &fence); + if (result != VK_SUCCESS) + return result; + result = SubmitHostQueue(submit, device, queue, 1, &info, fence); + if (result != VK_SUCCESS) + return result; + // The completed fence permits presentation without repeating these waits. + result = wait(device, 1, &fence, VK_TRUE, UINT64_MAX); + // A consumed binary semaphore cannot be retried after a timeout. + return result == VK_SUCCESS || result < 0 ? result : VK_ERROR_DEVICE_LOST; +} + +bool OpenXrBackend::SupportsPresentQueueLocked(VkQueue queue) const { + return GraphicsQueueForPresentLocked(queue) != VK_NULL_HANDLE; +} + +VkQueue OpenXrBackend::GraphicsQueueForPresentLocked(VkQueue present) const { + if (queue_families_.find(present) == queue_families_.end()) + return VK_NULL_HANDLE; + VkQueue graphics = vk_graphics_queue_; + for (const auto &eye : eyes_) { + if (!eye.valid) + continue; + const auto rendered = eye_render_queues_.find(eye.image); + // An eye attachment is not evidence that its command buffer was submitted. + if (rendered == eye_render_queues_.end()) + return VK_NULL_HANDLE; + const auto frame = eye_render_frames_.find(eye.image); + if (frame == eye_render_frames_.end() || frame->second != eye.frame) + return VK_NULL_HANDLE; + const auto family = queue_families_.find(rendered->second); + if (family == queue_families_.end() || family->second != vk_queue_family_) + return VK_NULL_HANDLE; + graphics = rendered->second; + } + return graphics; +} + +VkResult OpenXrBackend::PrepareHostPresent(VkQueue queue, VkDevice device, + const VkPresentInfoKHR *info) { + std::lock_guard lock(mutex_); + if (!armed_ || graphics_api_ != VrGraphicsApi::kVulkan || !vk_ || + device != vk_device_ || !info) + return VK_NOT_READY; + if (!SupportsPresentQueueLocked(queue)) { + if (!unsupported_present_queue_logged_) { + unsupported_present_queue_logged_ = true; + Log(" [vr-backend] waiting for verified eye-render submissions on " + "the OpenXR graphics family before preparing presentation\n"); + } + return VK_NOT_READY; + } + if (info->waitSemaphoreCount == 0) + return VK_SUCCESS; + std::string error; + if (!EnsureCopyResources(device, vk_queue_family_, &error)) { + Log(" [vr-backend] cannot synchronize present dependencies: %s\n", + error.c_str()); + return VK_ERROR_INITIALIZATION_FAILED; + } + return internal::WaitForPresentDependencies( + device, GraphicsQueueForPresentLocked(queue), *info, copy_fence_, + vk_->ResetFences, vk_->QueueSubmit, vk_->WaitForFences); +} + void OpenXrBackend::NoteHostPresent(VkQueue queue, VkDevice device) { std::lock_guard lock(mutex_); if (!armed_.load(std::memory_order_acquire) || device != vk_device_) { return; } + if (graphics_api_ == VrGraphicsApi::kVulkan && + !SupportsPresentQueueLocked(queue)) + return; + if (graphics_api_ == VrGraphicsApi::kVulkan) + queue = GraphicsQueueForPresentLocked(queue); try { - if (xr_session_ == nullptr && !RecoverSessionLocked(perf::NowNs())) return; + if (xr_session_ == nullptr && !RecoverSessionLocked(perf::NowNs())) + return; PollSessionEvents(); if (!session_running_.load(std::memory_order_acquire)) { return; @@ -1642,25 +1975,29 @@ void OpenXrBackend::NoteHostPresent(VkQueue queue, VkDevice device) { 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); + 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"); + 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) { + } catch (const XrCallError &error) { if (error.result == XR_ERROR_SESSION_LOST) { - Log(" [vr-backend] XR session lost; scheduling recovery on the existing device\n"); + 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", + 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) { + } catch (const std::exception &error) { Log(" [vr-backend] frame cycle failed: %s; XR output stopped, native " "stereo continues\n", error.what()); @@ -1684,18 +2021,17 @@ void OpenXrBackend::RunFrameCycle(VkQueue queue, VkDevice device) { XrInfo( XR_TYPE_COMPOSITION_LAYER_PROJECTION); std::array views{}; - const XrCompositionLayerBaseHeader* layers[1] = { - reinterpret_cast(&projection)}; + 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_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; + 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_) { @@ -1734,7 +2070,8 @@ void OpenXrBackend::RunFrameCycle(VkQueue queue, VkDevice device) { const auto deadline = Clock::now() + std::chrono::seconds(5); while (true) { const XrResult wait_result = xrWaitSwapchainImage( - static_cast(eye_swapchains_[eye].swapchain), &wait); + static_cast(eye_swapchains_[eye].swapchain), + &wait); if (wait_result != XR_TIMEOUT_EXPIRED) { CheckXr(instance, wait_result, "xrWaitSwapchainImage"); break; @@ -1779,10 +2116,9 @@ void OpenXrBackend::RunFrameCycle(VkQueue queue, VkDevice device) { 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].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}; @@ -1804,8 +2140,11 @@ void OpenXrBackend::RunFrameCycle(VkQueue queue, VkDevice device) { 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)) { + published_pose_.frame % + std::max( + 1, + EnvUnsigned("MOCKTAIL_VR_XR_EVIDENCE_INTERVAL", 20)) == + 0)) { if (graphics_api_ == VrGraphicsApi::kOpenGles) { Log(" [vr-backend][evidence] projection submitted=%llu frame=%llu " "graphics=GLES eye0_texture=%u eye1_texture=%u\n", @@ -1873,16 +2212,15 @@ void OpenXrBackend::RunFrameCycle(VkQueue queue, VkDevice device) { } 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) { + 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); + 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; @@ -1895,11 +2233,11 @@ void OpenXrBackend::RunFrameCycle(VkQueue queue, VkDevice device) { 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]}}; + 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_; @@ -1916,6 +2254,24 @@ void OpenXrBackend::RunFrameCycle(VkQueue queue, VkDevice device) { sample.valid = head_valid; published_pose_ = sample; } + if (head_valid && xr_stage_space_ != nullptr) { + auto floor = XrInfo(XR_TYPE_SPACE_LOCATION); + const auto result = + xrLocateSpace(static_cast(xr_stage_space_), + static_cast(xr_local_space_), + predicted_display_time, &floor); + if (XR_SUCCEEDED(result) && + (floor.locationFlags & required_location) == required_location) { + published_pose_.floor_position[0] = floor.pose.position.x; + published_pose_.floor_position[1] = floor.pose.position.y; + published_pose_.floor_position[2] = floor.pose.position.z; + published_pose_.floor_orientation[0] = floor.pose.orientation.x; + published_pose_.floor_orientation[1] = floor.pose.orientation.y; + published_pose_.floor_orientation[2] = floor.pose.orientation.z; + published_pose_.floor_orientation[3] = floor.pose.orientation.w; + published_pose_.floor_valid = true; + } + } 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 @@ -1938,32 +2294,23 @@ void OpenXrBackend::RunFrameCycle(VkQueue queue, VkDevice device) { 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. + pose::EyeFov rendered_fov{ + xr_views[eye].fov.angleLeft, xr_views[eye].fov.angleRight, + xr_views[eye].fov.angleUp, xr_views[eye].fov.angleDown}; if (head_valid && - !pose::EyeOrientationIsParallel(relative.orientation)) { + !pose::ParallelEyeFov(relative.orientation, rendered_fov, + &rendered_fov)) { 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); + if (!projection_rejection_logged_) { + projection_rejection_logged_ = true; + Log(" [vr-backend] eye%d projection exceeds the guest camera " + "FOV; " + "XR submission paused\n", + eye); } } - const Pose final_eye = PoseMultiply(head_pose, relative); + const Pose final_eye = + PoseMultiply(head_pose, Pose{relative.position, {}}); 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; @@ -1978,10 +2325,10 @@ void OpenXrBackend::RunFrameCycle(VkQueue queue, VkDevice device) { 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; + frame_views_[eye].fov[0] = rendered_fov.angle_left; + frame_views_[eye].fov[1] = rendered_fov.angle_right; + frame_views_[eye].fov[2] = rendered_fov.angle_up; + frame_views_[eye].fov[3] = rendered_fov.angle_down; std::copy_n(frame_views_[eye].fov, 4, published_pose_.eye_fov[eye]); frame_views_[eye].valid = true; } @@ -2115,11 +2462,10 @@ void OpenXrBackend::DestroyCopyResources() { 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)) { + const auto family = queue_families_.find(queue); + if (family == queue_families_.end() || family->second != vk_queue_family_) + return false; + if (!EnsureCopyResources(vk_device_, vk_queue_family_, &error)) { Log(" [vr-backend] copy resources unavailable: %s\n", error.c_str()); return false; } @@ -2267,14 +2613,13 @@ bool OpenXrBackend::CopyEyesIntoSwapchains(VkQueue queue) { 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. + // Present dependencies are complete; this fence protects reuse of the + // copy command buffer and mirror staging buffers. VkResult submit_result = VK_SUCCESS; { const ScopedStageTimer submit_timer(perf::Stage::kCopySubmit); - submit_result = vk_->QueueSubmit(queue, 1, &submit, copy_fence_); + submit_result = SubmitHostQueue(vk_->QueueSubmit, vk_device_, queue, 1, + &submit, copy_fence_); } perf::ProcessCollector().Count(perf::Counter::kSubmits); if (submit_result != VK_SUCCESS) { @@ -2288,7 +2633,7 @@ bool OpenXrBackend::CopyEyesIntoSwapchains(VkQueue queue) { } perf::ProcessCollector().Count(perf::Counter::kFenceWaits); if (fence_result != VK_SUCCESS) { - (void)vk_->DeviceWaitIdle(vk_device_); + (void)IdleHostDevice(vk_->DeviceWaitIdle, vk_device_); throw std::runtime_error("XR copy fence wait failed"); } if (mirror_.enabled()) { @@ -2313,8 +2658,9 @@ void OpenXrBackend::EnsureMirrorForExtent(std::uint32_t width, 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); + 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_, @@ -2345,45 +2691,84 @@ MirrorVkProcs OpenXrBackend::MirrorProcs() const { } // DebugDeviceVR renders offscreen eyes but does not mirror them to the host -// window. Keep this copy on the guest GPU; no readback or image files in normal use. -void OpenXrBackend::RecordDesktopSwapchain(VkDevice device, VkSwapchainKHR swapchain, - const VkSwapchainCreateInfoKHR* info, const VkImage* images, unsigned count) { +// window. Keep this copy on the guest GPU; no readback or image files in normal +// use. +void OpenXrBackend::RecordDesktopSwapchain(VkDevice device, + VkSwapchainKHR swapchain, + const VkSwapchainCreateInfoKHR *info, + const VkImage *images, + unsigned count, + bool converted_to_concurrent) { std::lock_guard lock(mutex_); desktop_swapchains_.erase(swapchain); if (!device || device != vk_device_ || !info || !images || !count || !(info->imageUsage & VK_IMAGE_USAGE_TRANSFER_DST_BIT) || - info->imageArrayLayers != 1 || info->imageSharingMode != VK_SHARING_MODE_EXCLUSIVE) + info->imageArrayLayers != 1) return; - desktop_swapchains_[swapchain] = {info->imageExtent, info->imageFormat, - {images, images + count}}; + DesktopSwapchain record{}; + record.extent = info->imageExtent; + record.format = info->imageFormat; + record.images.assign(images, images + count); + record.sharing_mode = info->imageSharingMode; + record.converted_to_concurrent = converted_to_concurrent; + if (info->imageSharingMode == VK_SHARING_MODE_CONCURRENT) { + if (!info->pQueueFamilyIndices || info->queueFamilyIndexCount < 2) + return; + record.families.assign(info->pQueueFamilyIndices, + info->pQueueFamilyIndices + + info->queueFamilyIndexCount); + } + desktop_swapchains_[swapchain] = std::move(record); } -VkResult OpenXrBackend::MirrorDesktop(VkQueue queue, const VkPresentInfoKHR* info) { +VkResult OpenXrBackend::MirrorDesktop(VkQueue queue, + const VkPresentInfoKHR *info) { std::lock_guard lock(mutex_); - if (EnvString("MOCKTAIL_VR_DESKTOP_MIRROR", "1") == "0" || - !vk_ || !info || info->swapchainCount != 1 || - !info->pSwapchains || !info->pImageIndices || !eyes_[0].valid || - !eyes_[0].image || (info->waitSemaphoreCount && !info->pWaitSemaphores) || - !queue_families_.count(queue) || queue_families_[queue] != vk_queue_family_) + if (EnvString("MOCKTAIL_VR_DESKTOP_MIRROR", "1") == "0" || !vk_ || !info || + info->swapchainCount != 1 || !info->pSwapchains || !info->pImageIndices || + !eyes_[0].valid || !eyes_[0].image || + (info->waitSemaphoreCount && !info->pWaitSemaphores) || + !SupportsPresentQueueLocked(queue)) return VK_NOT_READY; const auto found = desktop_swapchains_.find(info->pSwapchains[0]); if (found == desktop_swapchains_.end() || - info->pImageIndices[0] >= found->second.images.size()) return VK_NOT_READY; - const auto& target = found->second; + info->pImageIndices[0] >= found->second.images.size()) + return VK_NOT_READY; + const auto &target = found->second; + const auto present_family = queue_families_.at(queue); + if (target.sharing_mode == VK_SHARING_MODE_CONCURRENT) { + if (std::find(target.families.begin(), target.families.end(), + vk_queue_family_) == target.families.end() || + std::find(target.families.begin(), target.families.end(), + present_family) == target.families.end()) + return VK_NOT_READY; + } else if (present_family != vk_queue_family_) { + // A swapchain created before VR activation has no sharing adaptation. + return VK_NOT_READY; + } + queue = GraphicsQueueForPresentLocked(queue); VkFormatProperties source_properties{}, target_properties{}; - vk_->GetPhysicalDeviceFormatProperties(vk_physical_device_, eyes_[0].format, &source_properties); - vk_->GetPhysicalDeviceFormatProperties(vk_physical_device_, target.format, &target_properties); - if (!(source_properties.optimalTilingFeatures & VK_FORMAT_FEATURE_BLIT_SRC_BIT) || - !(target_properties.optimalTilingFeatures & VK_FORMAT_FEATURE_BLIT_DST_BIT)) return VK_NOT_READY; + vk_->GetPhysicalDeviceFormatProperties(vk_physical_device_, eyes_[0].format, + &source_properties); + vk_->GetPhysicalDeviceFormatProperties(vk_physical_device_, target.format, + &target_properties); + if (!(source_properties.optimalTilingFeatures & + VK_FORMAT_FEATURE_BLIT_SRC_BIT) || + !(target_properties.optimalTilingFeatures & + VK_FORMAT_FEATURE_BLIT_DST_BIT)) + return VK_NOT_READY; std::string error; - if (!EnsureCopyResources(vk_device_, vk_queue_family_, &error)) return VK_NOT_READY; - if (vk_->ResetCommandBuffer(copy_command_, 0) != VK_SUCCESS) return VK_NOT_READY; + if (!EnsureCopyResources(vk_device_, vk_queue_family_, &error)) + return VK_NOT_READY; + if (vk_->ResetCommandBuffer(copy_command_, 0) != VK_SUCCESS) + return VK_NOT_READY; 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 VK_NOT_READY; + if (vk_->BeginCommandBuffer(copy_command_, &begin) != VK_SUCCESS) + return VK_NOT_READY; VkImageMemoryBarrier barriers[2]{}; - for (auto& b : barriers) { + for (auto &b : barriers) { b.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; b.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; @@ -2398,15 +2783,19 @@ VkResult OpenXrBackend::MirrorDesktop(VkQueue queue, const VkPresentInfoKHR* inf barriers[1].newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; barriers[1].dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; vk_->CmdPipelineBarrier(copy_command_, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, - VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 2, barriers); + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, + nullptr, 2, barriers); const VkClearColorValue black{{0.f, 0.f, 0.f, 1.f}}; - vk_->CmdClearColorImage(copy_command_, barriers[1].image, barriers[1].newLayout, - &black, 1, &barriers[1].subresourceRange); + vk_->CmdClearColorImage(copy_command_, barriers[1].image, + barriers[1].newLayout, &black, 1, + &barriers[1].subresourceRange); VkMemoryBarrier clear_barrier{}; clear_barrier.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER; - clear_barrier.srcAccessMask = clear_barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + clear_barrier.srcAccessMask = clear_barrier.dstAccessMask = + VK_ACCESS_TRANSFER_WRITE_BIT; vk_->CmdPipelineBarrier(copy_command_, VK_PIPELINE_STAGE_TRANSFER_BIT, - VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 1, &clear_barrier, 0, nullptr, 0, nullptr); + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 1, &clear_barrier, + 0, nullptr, 0, nullptr); const double scale = std::min(double(target.extent.width) / eyes_[0].width, double(target.extent.height) / eyes_[0].height); const int width = std::max(1, int(eyes_[0].width * scale)); @@ -2414,22 +2803,28 @@ VkResult OpenXrBackend::MirrorDesktop(VkQueue queue, const VkPresentInfoKHR* inf const int x = (int(target.extent.width) - width) / 2; const int y = (int(target.extent.height) - height) / 2; VkImageBlit region{}; - region.srcSubresource = region.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; - region.srcOffsets[1] = {static_cast(eyes_[0].width), static_cast(eyes_[0].height), 1}; + region.srcSubresource = + region.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + region.srcOffsets[1] = {static_cast(eyes_[0].width), + static_cast(eyes_[0].height), 1}; region.dstOffsets[0] = {x, y, 0}; region.dstOffsets[1] = {x + width, y + height, 1}; vk_->CmdBlitImage(copy_command_, barriers[0].image, barriers[0].newLayout, - barriers[1].image, barriers[1].newLayout, 1, ®ion, VK_FILTER_NEAREST); - for (auto& b : barriers) { + barriers[1].image, barriers[1].newLayout, 1, ®ion, + VK_FILTER_NEAREST); + for (auto &b : barriers) { std::swap(b.oldLayout, b.newLayout); b.srcAccessMask = b.dstAccessMask; b.dstAccessMask = VK_ACCESS_MEMORY_READ_BIT; } vk_->CmdPipelineBarrier(copy_command_, VK_PIPELINE_STAGE_TRANSFER_BIT, - VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, 0, nullptr, 0, nullptr, 2, barriers); + VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, 0, nullptr, 0, + nullptr, 2, barriers); if (vk_->EndCommandBuffer(copy_command_) != VK_SUCCESS || - vk_->ResetFences(vk_device_, 1, ©_fence_) != VK_SUCCESS) return VK_NOT_READY; - std::vector stages(info->waitSemaphoreCount, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT); + vk_->ResetFences(vk_device_, 1, ©_fence_) != VK_SUCCESS) + return VK_NOT_READY; + std::vector stages(info->waitSemaphoreCount, + VK_PIPELINE_STAGE_ALL_COMMANDS_BIT); VkSubmitInfo submit{}; submit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; submit.waitSemaphoreCount = info->waitSemaphoreCount; @@ -2437,8 +2832,10 @@ VkResult OpenXrBackend::MirrorDesktop(VkQueue queue, const VkPresentInfoKHR* inf submit.pWaitDstStageMask = stages.data(); submit.commandBufferCount = 1; submit.pCommandBuffers = ©_command_; - VkResult result = vk_->QueueSubmit(queue, 1, &submit, copy_fence_); - if (result != VK_SUCCESS) return result; + VkResult result = SubmitHostQueue(vk_->QueueSubmit, vk_device_, queue, 1, + &submit, copy_fence_); + if (result != VK_SUCCESS) + return result; // Once submitted, never fall back to waiting on the consumed semaphores. result = vk_->WaitForFences(vk_device_, 1, ©_fence_, VK_TRUE, UINT64_MAX); return result; @@ -2448,60 +2845,74 @@ VkResult OpenXrBackend::MirrorDesktop(VkQueue queue, const VkPresentInfoKHR* inf // 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; + 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_); + if (pending) + IdleHostDevice(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); + 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 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}; + 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, + 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, + 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; + 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; } + (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, + 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, + 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"); + 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"); + 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; @@ -2509,50 +2920,64 @@ bool OpenXrBackend::CaptureEyeEvidence(VkQueue queue, int eye) { 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.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); + 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); + 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; + 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); + 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"); + 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"); + Require(SubmitHostQueue(vk_->QueueSubmit, vk_device_, queue, 1, &submit, + copy_fence_) == VK_SUCCESS, + "evidence submit failed"); pending = true; - Require(vk_->WaitForFences(vk_device_, 1, ©_fence_, VK_TRUE, 2000000000ULL) == VK_SUCCESS, + 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)); + ("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, + 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 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)]; + 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); + 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"); @@ -2578,6 +3003,14 @@ bool OpenXrBackend::CaptureEyeEvidence(VkQueue queue, int eye) { // inert while no backend is armed; the adapter falls back to its plain host // loader path whenever a hook returns false. extern "C" { +void mocktail_vr_xr_device_idle_adapter( + mocktail::vr::SynchronizedDeviceIdle idle) { + mocktail::vr::g_device_idle_adapter.store(idle, std::memory_order_release); +} +void mocktail_vr_xr_queue_submit_adapter( + mocktail::vr::SynchronizedQueueSubmit submit) { + mocktail::vr::g_queue_submit_adapter.store(submit, std::memory_order_release); +} bool mocktail_vr_xr_create_vulkan_instance( const VkInstanceCreateInfo* create_info, @@ -2695,7 +3128,70 @@ void mocktail_vr_desktop_swapchain(VkDevice device, VkSwapchainKHR swapchain, std::fprintf(stderr, " [vr-backend] desktop swapchain registration failed: %s\n", error.what()); } } -VkResult mocktail_vr_desktop_present(VkQueue queue, const VkPresentInfoKHR* info) { +void mocktail_vr_desktop_swapchain_shared(VkDevice device, + VkSwapchainKHR swapchain, + const VkSwapchainCreateInfoKHR *info, + const VkImage *images, unsigned count, + bool converted) { + if (auto *backend = mocktail::vr::ActiveVrBackend()) + backend->RecordDesktopSwapchain(device, swapchain, info, images, count, + converted); +} +unsigned mocktail_vr_desktop_queue_families(VkDevice device, unsigned capacity, + unsigned *families) { + if (auto *backend = mocktail::vr::ActiveVrBackend()) { + const auto values = backend->DesktopQueueFamilies(device); + if (capacity < values.size() || !families) + return 0; + std::copy(values.begin(), values.end(), families); + return values.size(); + } + return 0; +} +void mocktail_vr_xr_command_pool(VkDevice device, VkCommandPool pool, + unsigned family) { + if (auto *backend = mocktail::vr::ActiveVrBackend()) { + if (family == UINT32_MAX) + backend->ForgetCommandPool(pool); + else + backend->RecordCommandPool(device, pool, family); + } +} +void mocktail_vr_xr_command_buffers(VkDevice device, VkCommandPool pool, + unsigned count, + const VkCommandBuffer *buffers) { + if (auto *backend = mocktail::vr::ActiveVrBackend()) { + if (pool == VK_NULL_HANDLE) + backend->ForgetCommandBuffers(count, buffers); + else + backend->RecordCommandBuffers(device, pool, count, buffers); + } +} +void mocktail_vr_xr_reset_command(VkCommandBuffer buffer) { + if (auto *backend = mocktail::vr::ActiveVrBackend()) + backend->ResetCommandBuffer(buffer); +} +void mocktail_vr_xr_reset_pool(VkCommandPool pool) { + if (auto *backend = mocktail::vr::ActiveVrBackend()) + backend->ResetCommandPool(pool); +} +void mocktail_vr_xr_submitted(VkQueue queue, unsigned count, + const VkCommandBuffer *buffers) { + if (auto *backend = mocktail::vr::ActiveVrBackend()) + backend->NoteCommandBuffersSubmitted(queue, count, buffers); +} +VkImageLayout mocktail_vr_xr_desktop_barrier(VkCommandBuffer command, + VkImage image, unsigned *source, + unsigned *destination, + VkImageLayout *old_layout, + VkImageLayout new_layout) { + if (auto *backend = mocktail::vr::ActiveVrBackend()) + return backend->NormalizeDesktopBarrier(command, image, source, destination, + old_layout, new_layout); + return new_layout; +} +VkResult mocktail_vr_desktop_present(VkQueue queue, + const VkPresentInfoKHR *info) { try { if (auto* backend = mocktail::vr::ActiveVrBackend()) return backend->MirrorDesktop(queue, info); return VK_NOT_READY; @@ -2712,7 +3208,20 @@ void mocktail_vr_xr_note_present(VkQueue queue, VkDevice device) { } } -} // extern "C" +VkResult mocktail_vr_xr_prepare_present(VkQueue queue, VkDevice device, + const VkPresentInfoKHR *info) { + try { + if (auto *backend = mocktail::vr::ActiveVrBackend()) + return backend->PrepareHostPresent(queue, device, info); + return VK_NOT_READY; + } catch (const std::exception &error) { + std::fprintf(stderr, " [vr-backend] present synchronization failed: %s\n", + error.what()); + return VK_ERROR_OUT_OF_HOST_MEMORY; + } +} + +} // extern "C" // Controller delivery ABI consumed by the input runtime through dlsym. These // are defined only in the VR build; a non-VR build omits them and the consumer diff --git a/src/vr/roblox_vr_device_bridge.cc b/src/vr/roblox_vr_device_bridge.cc index 06e4471..0dfb2db 100644 --- a/src/vr/roblox_vr_device_bridge.cc +++ b/src/vr/roblox_vr_device_bridge.cc @@ -43,6 +43,14 @@ using internal::kStateGetterVtableSlot; using internal::kTextureSlotOffset; using internal::kWidthOffset; +std::uintptr_t RelativeTarget(const std::uint8_t* displacement, + std::uintptr_t next_instruction_rva) { + std::int32_t relative; + std::memcpy(&relative, displacement, sizeof(relative)); + return static_cast( + static_cast(next_instruction_rva) + relative); +} + 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. @@ -55,8 +63,7 @@ std::mutex g_native_pose_mutex; ScriptedPoseSample g_native_pose; using PointerFrameFn = void *(*)(void *, void *); std::atomic g_pointer_original{nullptr}; -constexpr std::uintptr_t kPointerFrameRva = 0x4d573ee; -// Whole instructions, no RIP-relative operands. 2998's sret prologue. +// Whole instructions, no RIP-relative operands; shared sret prologue. constexpr std::array kPointerPrologue = { 0x55, 0x48, 0x89, 0xe5, 0x41, 0x57, 0x41, 0x56, 0x41, 0x54, 0x53, 0x48, 0x83, 0xec, 0x70}; @@ -75,50 +82,7 @@ extern "C" void *mocktail_vr_pointer_frame(void *result, void *service) { original(result, service); auto *bridge = g_active_bridge.load(std::memory_order_acquire); if (!bridge || !bridge->active()) return result; - ScriptedPoseSample native_pose; - { - std::lock_guard lock(g_native_pose_mutex); - native_pose = g_native_pose; - } - if (!native_pose.valid) return result; - auto *backend = ActiveVrBackend(); - if (!backend) - return result; - const auto current = backend->PublishedHeadPose(); - if (!current.valid) - return result; - int index = 0; - std::memcpy(&index, static_cast(service) + 0x118, 4); - if (index != 1 && index != 2) - return result; - if (!current.hands[index - 1].aim_valid) - return result; - const auto &hand = native_pose.hands[index - 1]; - if (!hand.grip_valid || !hand.aim_valid) - return result; - const auto base = bridge->image_base(); - // Same camera lookup as the original getter (see pointer-world-2998.asm). - auto get_workspace = reinterpret_cast(base + 0x263778c); - void *workspace = get_workspace(service); - if (!workspace) - return result; - auto *vtable = *static_cast(workspace); - void *camera = - reinterpret_cast(vtable[0x3a8 / 8])(workspace); - if (!camera) - return result; - float scale = 0; - int degrees = 0; - std::memcpy(&scale, static_cast(camera) + 0x140, 4); - std::memcpy(°rees, reinterpret_cast(base + 0x73677a8), 4); - if (internal::ApplyAimToWorldFrame(static_cast(result), hand, scale, - -static_cast(degrees))) { - static std::atomic logged[2]{}; - if (!logged[index - 1].exchange(true)) - std::fprintf( - stderr, " [vr-input] native menu pointer uses OpenXR aim: hand=%d\n", - index - 1); - } + bridge->OnPointerFrameCall(result, service); return result; } @@ -133,10 +97,10 @@ bool SetCodeWritable(std::uintptr_t target, bool writable) { PROT_READ | PROT_EXEC | (writable ? PROT_WRITE : 0)) == 0; } -bool InstallPointerHook(std::uintptr_t base) { +bool InstallPointerHook(std::uintptr_t base, std::uintptr_t pointer_rva) { if (g_pointer_target) return false; - auto *target = reinterpret_cast(base + kPointerFrameRva); + auto* target = reinterpret_cast(base + pointer_rva); if (std::memcmp(target, kPointerPrologue.data(), kPointerPrologue.size()) != 0) return false; @@ -148,9 +112,9 @@ bool InstallPointerHook(std::uintptr_t base) { return false; std::memcpy(code, target, kPointerPrologue.size()); AbsoluteJump(code + kPointerPrologue.size(), - base + kPointerFrameRva + kPointerPrologue.size()); + base + pointer_rva + kPointerPrologue.size()); if (mprotect(code, page_size, PROT_READ | PROT_EXEC) != 0 || - !SetCodeWritable(base + kPointerFrameRva, true)) { + !SetCodeWritable(base + pointer_rva, true)) { munmap(code, page_size); return false; } @@ -159,14 +123,14 @@ bool InstallPointerHook(std::uintptr_t base) { AbsoluteJump(target, reinterpret_cast(&mocktail_vr_pointer_frame)); target[14] = 0x90; - if (!SetCodeWritable(base + kPointerFrameRva, false)) { + if (!SetCodeWritable(base + pointer_rva, false)) { std::memcpy(target, kPointerPrologue.data(), kPointerPrologue.size()); - (void)SetCodeWritable(base + kPointerFrameRva, false); + (void)SetCodeWritable(base + pointer_rva, false); return false; } // Retain this single RX page for process lifetime: an in-flight guest call // may still be returning through it when the entrypoint is restored. - g_pointer_target = base + kPointerFrameRva; + g_pointer_target = base + pointer_rva; return true; } @@ -324,6 +288,48 @@ void Log(const char *format, ...) { namespace internal { +const VrBuildContract* FindVrBuildContract(std::string_view build_id) { + static constexpr VrBuildContract kContracts[] = { + {"ade08266c67aee88ec9c1d00902150e1684dad3a", + {0x6c87b38, 0x3810614, 0x38109f6, 0x38109e6, 0x3810a18, 0x7369920, + 0x180}, + 0x6b7bfe0, + 0x4d573ee, + 0x263778c, + 0x3a8, + 0x73677a8, + 0x1d48b70}, + {"5f0704edd9064f566ee3d6df2bd2fabbcc709f03", + {0x6d08108, 0x37f9fe4, 0x37fa3c6, 0x37fa3b6, 0x37fa3e8, 0x73f4690, + 0x188}, + 0x6bfb180, + 0x4d97b98, + 0x265cebc, + 0x3a0, + 0x73f24c8, + 0x1d8f8c0}, + }; + for (const auto& contract : kContracts) { + if (contract.build_id == build_id) return &contract; + } + return nullptr; +} + +bool HasExpectedVrPointerContract(const std::uint8_t* code, std::size_t size, + const VrBuildContract& contract) { + if (code == nullptr || size < 0x1b2 || + std::memcmp(code, kPointerPrologue.data(), kPointerPrologue.size()) != + 0 || + code[0x59] != 0xe8 || code[0x6d] != 0xff || code[0x6e] != 0x91) { + return false; + } + std::uint32_t camera_slot; + std::memcpy(&camera_slot, code + 0x6f, sizeof(camera_slot)); + return RelativeTarget(code + 0x5a, contract.pointer_frame_rva + 0x5e) == + contract.workspace_getter_rva && + camera_slot == contract.camera_vtable_offset; +} + EyeResourceState InspectEyeResources(const void *object) { if (object == nullptr) return EyeResourceState::kInvalid; @@ -348,8 +354,9 @@ EyeResourceState InspectEyeResources(const void *object) { return EyeResourceState::kInvalid; } -bool HasExpectedVrStateGetterContract(const std::uint8_t *code, - std::size_t size) { +bool HasExpectedVrStateGetterContract(const std::uint8_t* code, + std::size_t size, + const VrBuildContract& contract) { // 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 @@ -358,7 +365,10 @@ bool HasExpectedVrStateGetterContract(const std::uint8_t *code, 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; + std::memcmp(code, kExpected.data(), 19) == 0 && + std::memcmp(code + 23, kExpected.data() + 23, 10) == 0 && + RelativeTarget(code + 19, contract.device.state_getter_rva + 23) == + contract.state_memcpy_rva; } bool HasExpectedVrEyeGetterContract(const std::uint8_t *code, @@ -371,15 +381,16 @@ bool HasExpectedVrEyeGetterContract(const std::uint8_t *code, std::memcmp(code, kExpected.data(), kExpected.size()) == 0; } -bool HasExpectedVrConstructorContract(const std::uint8_t *code, - std::size_t size) { +bool HasExpectedVrConstructorContract( + 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, 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 kVtableStore = {0x48, 0x8d, + 0x05}; static constexpr std::array kVptrWrite = {0x48, 0x89, 0x07}; // mov dword ptr [rdi+0x10], 6 static constexpr std::array kTypeStore = { @@ -398,7 +409,10 @@ bool HasExpectedVrConstructorContract(const std::uint8_t *code, return false; } return std::memcmp(code, kPrologue.data(), kPrologue.size()) == 0 && - ContainsBytes(code, size, kVtableStore.data(), kVtableStore.size()) && + std::memcmp(code + 43, kVtableStore.data(), kVtableStore.size()) == + 0 && + RelativeTarget(code + 46, profile.constructor_rva + 50) == + profile.vtable_rva && ContainsBytes(code, size, kVptrWrite.data(), kVptrWrite.size()) && ContainsBytes(code, size, kTypeStore.data(), kTypeStore.size()) && ContainsBytes(code, size, kStateMemset.data(), kStateMemset.size()) && @@ -529,6 +543,12 @@ extern "C" void mocktail_vr_note_host_present_begin() { } } +const compat::VrDebugDeviceBridgeProfile* FindRobloxVrDeviceProfile( + std::string_view build_id) { + const auto* contract = internal::FindVrBuildContract(build_id); + return contract == nullptr ? nullptr : &contract->device; +} + RobloxVrDeviceBridge::~RobloxVrDeviceBridge() { Shutdown(); RobloxVrDeviceBridge *expected = this; @@ -536,6 +556,50 @@ RobloxVrDeviceBridge::~RobloxVrDeviceBridge() { expected, nullptr, std::memory_order_acq_rel, std::memory_order_acquire); } +void RobloxVrDeviceBridge::OnPointerFrameCall(void* result, void* service) { + if (!active() || contract_ == nullptr) return; + ScriptedPoseSample native_pose; + { + std::lock_guard lock(g_native_pose_mutex); + native_pose = g_native_pose; + } + if (!native_pose.valid) return; + auto* backend = ActiveVrBackend(); + if (!backend) return; + const auto current = backend->PublishedHeadPose(); + if (!current.valid) return; + int index = 0; + std::memcpy(&index, static_cast(service) + 0x118, 4); + if (index != 1 && index != 2) return; + if (!current.hands[index - 1].aim_valid) return; + const auto& hand = native_pose.hands[index - 1]; + if (!hand.grip_valid || !hand.aim_valid) return; + const auto base = image_base(); + // Follow the same workspace/camera path as the guest pointer getter. + auto get_workspace = reinterpret_cast( + base + contract_->workspace_getter_rva); + void* workspace = get_workspace(service); + if (!workspace) return; + auto* vtable = *static_cast(workspace); + void* camera = reinterpret_cast( + vtable[contract_->camera_vtable_offset / sizeof(void*)])(workspace); + if (!camera) return; + float scale = 0; + int degrees = 0; + std::memcpy(&scale, static_cast(camera) + 0x140, 4); + std::memcpy(°rees, + reinterpret_cast(base + contract_->hand_pitch_storage_rva), + 4); + if (internal::ApplyAimToWorldFrame(static_cast(result), hand, scale, + -static_cast(degrees))) { + static std::atomic logged[2]{}; + if (!logged[index - 1].exchange(true)) + std::fprintf( + stderr, " [vr-input] native menu pointer uses OpenXR aim: hand=%d\n", + index - 1); + } +} + Status RobloxVrDeviceBridge::Install(const compat::BuildProfile &profile) { if (installed_) { return FailedPrecondition("Roblox VR device bridge is already armed"); @@ -545,8 +609,25 @@ Status RobloxVrDeviceBridge::Install(const compat::BuildProfile &profile) { "experimental Roblox VR requires vr_debug_device_bridge metadata in " "the exact-build compatibility profile"); } - if (profile.elf_build_id != "ade08266c67aee88ec9c1d00902150e1684dad3a") - return FailedPrecondition("VR controller/pointer/haptic contracts are verified only for Build 2998"); + const auto* contract = internal::FindVrBuildContract(profile.elf_build_id); + if (contract == nullptr) { + return FailedPrecondition( + "VR contracts are not verified for this Build ID"); + } + const auto& actual = *profile.vr_debug_device_bridge; + const auto& expected_profile = contract->device; + if (actual.vtable_rva != expected_profile.vtable_rva || + actual.constructor_rva != expected_profile.constructor_rva || + actual.state_getter_rva != expected_profile.state_getter_rva || + actual.eye_getter_rva != expected_profile.eye_getter_rva || + actual.eye_initializer_rva != expected_profile.eye_initializer_rva || + actual.emulator_flag_storage_rva != + expected_profile.emulator_flag_storage_rva || + actual.device_create_framebuffer_vtable_offset != + expected_profile.device_create_framebuffer_vtable_offset) { + return FailedPrecondition( + "VR metadata does not match the verified Build ID"); + } if (!profile.allow_host_abi_bridges) { return FailedPrecondition( "Roblox VR device bridge profile requires host ABI bridges"); @@ -560,6 +641,7 @@ Status RobloxVrDeviceBridge::Install(const compat::BuildProfile &profile) { "process"); } profile_ = *profile.vr_debug_device_bridge; + contract_ = contract; installed_ = true; std::fprintf(stderr, " [vr-device] native stereo bridge armed for Build ID %s " @@ -588,7 +670,11 @@ Status RobloxVrDeviceBridge::Activate(std::uintptr_t image_base) { profile_.state_getter_rva + kStateGetterContractSize, profile_.eye_getter_rva + kEyeGetterContractSize, profile_.eye_initializer_rva + kInitializerContractSize, - profile_.emulator_flag_storage_rva + 1}); + profile_.emulator_flag_storage_rva + 1, contract_->state_memcpy_rva + 1, + contract_->pointer_frame_rva + 0x1b2, + contract_->workspace_getter_rva + 1, + contract_->hand_pitch_storage_rva + sizeof(int), + contract_->haptic_sink_rva + 1}); if (image_base > std::numeric_limits::max() - largest_rva) { return InvalidArgument("VR device profile address overflows"); } @@ -606,12 +692,16 @@ Status RobloxVrDeviceBridge::Activate(std::uintptr_t image_base) { !IsExecutableImageRange(image_base, profile_.eye_getter_rva, kEyeGetterContractSize) || !IsExecutableImageRange(image_base, profile_.eye_initializer_rva, - kInitializerContractSize)) { + kInitializerContractSize) || + !IsExecutableImageRange(image_base, contract_->state_memcpy_rva, 1) || + !IsExecutableImageRange(image_base, contract_->workspace_getter_rva, 1)) { return FailedPrecondition( "VR device profile functions are not in executable image ranges"); } if (!IsWritableDataImageRange(image_base, profile_.emulator_flag_storage_rva, - 1)) { + 1) || + !IsWritableDataImageRange(image_base, contract_->hand_pitch_storage_rva, + sizeof(int))) { return FailedPrecondition( "VR emulator flag storage is not in a writable data range"); } @@ -630,15 +720,15 @@ Status RobloxVrDeviceBridge::Activate(std::uintptr_t image_base) { image_base + profile_.constructor_rva); const auto *initializer_code = reinterpret_cast( image_base + profile_.eye_initializer_rva); - if (!internal::HasExpectedVrStateGetterContract(state_getter_code, - kStateGetterContractSize) || + if (!internal::HasExpectedVrStateGetterContract( + state_getter_code, kStateGetterContractSize, *contract_) || !internal::HasExpectedVrEyeGetterContract(eye_getter_code, kEyeGetterContractSize)) { return FailedPrecondition( "DebugDeviceVR getter machine contracts do not match the profile"); } - if (!internal::HasExpectedVrConstructorContract(constructor_code, - kConstructorContractSize) || + if (!internal::HasExpectedVrConstructorContract( + constructor_code, kConstructorContractSize, profile_) || !internal::HasExpectedVrEyeInitializerContract( initializer_code, kInitializerContractSize, profile_)) { return FailedPrecondition( @@ -646,20 +736,23 @@ Status RobloxVrDeviceBridge::Activate(std::uintptr_t image_base) { "match the profile"); } - if (!IsExecutableImageRange(image_base, kPointerFrameRva, 0x1b2) || - std::memcmp(reinterpret_cast(image_base + kPointerFrameRva), - kPointerPrologue.data(), kPointerPrologue.size()) != 0) - return FailedPrecondition("2998 VR pointer function contract mismatch"); + if (!IsExecutableImageRange(image_base, contract_->pointer_frame_rva, + 0x1b2) || + !internal::HasExpectedVrPointerContract( + reinterpret_cast(image_base + + contract_->pointer_frame_rva), + 0x1b2, *contract_)) { + return FailedPrecondition("VR pointer function contract mismatch"); + } + if (vtable[3] != image_base + contract_->haptic_sink_rva || + !IsExecutableImageRange(image_base, contract_->haptic_sink_rva, 1) || + *reinterpret_cast( + image_base + contract_->haptic_sink_rva) != 0xc3) { + return FailedPrecondition("VR haptic vtable contract mismatch"); + } 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); - // 2998's haptic sink is a verified no-op (ret at 0x1d48b70). - // The caller at 0x28a767e passes self, hand 0/1 and float amplitude. - if (vtable_[3] != image_base + 0x1d48b70 || - !IsExecutableImageRange(image_base, 0x1d48b70, 1) || - *reinterpret_cast(image_base + 0x1d48b70) != 0xc3) - { vtable_ = nullptr; return FailedPrecondition("2998 VR haptic vtable contract mismatch"); } + vtable_ = reinterpret_cast(image_base + profile_.vtable_rva); original_haptic_slot_ = vtable_[3]; original_state_slot_ = vtable_[kStateGetterVtableSlot / sizeof(void *)]; original_eye_slot_ = vtable_[kEyeGetterVtableSlot / sizeof(void *)]; @@ -699,7 +792,7 @@ Status RobloxVrDeviceBridge::Activate(std::uintptr_t image_base) { "protection"); } - if (!InstallPointerHook(image_base)) { + if (!InstallPointerHook(image_base, contract_->pointer_frame_rva)) { (void)RestoreVtableLocked(); return FailedPrecondition("could not install verified VR aim pointer hook"); } @@ -891,8 +984,7 @@ bool internal::ApplyXrPose(void *state, const ScriptedPoseSample &pose) { std::memset(bytes + 0x88, 0, 28 * sizeof(float)); // Hand records are cleared unless a tracked grip pose is supplied below. // Clearing the valid byte (never the whole record) makes the guest report - // the hand as untracked instead of holding a stale pose. The extra index-3 - // record is unused by this backend and stays invalid. + // the hand as untracked instead of holding a stale pose. Index 3 is Floor. bytes[kExtraRecordOffset + kRecordValidOffset] = 0; bytes[kLeftHandRecordOffset + kRecordValidOffset] = 0; bytes[kRightHandRecordOffset + kRecordValidOffset] = 0; @@ -914,7 +1006,7 @@ bool internal::ApplyXrPose(void *state, const ScriptedPoseSample &pose) { if (!std::isfinite(v)) return false; for (float v : pose.eye_fov[eye]) - if (!std::isfinite(v) || std::abs(v) >= 1.56f) + if (!std::isfinite(v) || std::abs(v) >= pose::kMaxEyeFovAngle) return false; } bytes[0x80] = pose.valid && pose.controllers_connected; @@ -928,9 +1020,25 @@ bool internal::ApplyXrPose(void *state, const ScriptedPoseSample &pose) { } bytes[0x132] = pose.valid && pose.controllers_connected; - // 2998's 0x37eacb8 converts metres to studs itself (factor 10/3). + // The verified guest pose helper applies metres-to-studs conversion. std::memcpy(bytes + 4, pose.position, sizeof(pose.position)); std::memcpy(bytes + 0x10, pose.orientation, sizeof(pose.orientation)); + if (pose.floor_valid) { + bool finite = true; + float floor_norm = 0.f; + for (float value : pose.floor_position) finite &= std::isfinite(value); + for (float value : pose.floor_orientation) { + finite &= std::isfinite(value); + floor_norm += value * value; + } + if (finite && floor_norm >= .99f && floor_norm <= 1.01f) { + std::memcpy(bytes + kExtraRecordOffset + kRecordPositionOffset, + pose.floor_position, sizeof(pose.floor_position)); + std::memcpy(bytes + kExtraRecordOffset + kRecordOrientationOffset, + pose.floor_orientation, sizeof(pose.floor_orientation)); + bytes[kExtraRecordOffset + kRecordValidOffset] = 1; + } + } // Tracked hands go into the same state copy the head uses, so the guest's // own conversion (10/3 studs) and -20 deg hand pitch offset apply once, // exactly as they do for the head. Grip is the held-object pose; a hand diff --git a/src/vr/roblox_vr_device_bridge_internal.h b/src/vr/roblox_vr_device_bridge_internal.h index fe42469..9e98e1c 100644 --- a/src/vr/roblox_vr_device_bridge_internal.h +++ b/src/vr/roblox_vr_device_bridge_internal.h @@ -3,6 +3,7 @@ #include #include +#include #include "compat/build_profile.h" #include "mocktail/vr/xr_controller.h" @@ -10,6 +11,19 @@ namespace mocktail::vr { struct ScriptedPoseSample; } namespace mocktail::vr::internal { +struct VrBuildContract { + std::string_view build_id; + compat::VrDebugDeviceBridgeProfile device; + std::uintptr_t state_memcpy_rva; + std::uintptr_t pointer_frame_rva; + std::uintptr_t workspace_getter_rva; + std::uintptr_t camera_vtable_offset; + std::uintptr_t hand_pitch_storage_rva; + std::uintptr_t haptic_sink_rva; +}; + +const VrBuildContract* FindVrBuildContract(std::string_view build_id); + // Writes only a caller-owned state copy; preserves all resource readiness. bool ApplyXrPose(void* state, const ScriptedPoseSample& pose); // Adjust a world-space grip CoordinateFrame to OpenXR aim without changing @@ -78,16 +92,21 @@ 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); +bool HasExpectedVrStateGetterContract(const std::uint8_t* code, + std::size_t size, + const VrBuildContract& contract); // 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); +bool HasExpectedVrConstructorContract( + const std::uint8_t* code, std::size_t size, + const compat::VrDebugDeviceBridgeProfile& profile); + +bool HasExpectedVrPointerContract(const std::uint8_t* code, std::size_t size, + const VrBuildContract& contract); // Eye-resource initializer: raises ready byte self+0x147 plus +0x94/+0x144 // and creates the per-eye resources through the graphics-device vtable slot diff --git a/src/vr/xr_actions.cc b/src/vr/xr_actions.cc index 10c7e00..016252d 100644 --- a/src/vr/xr_actions.cc +++ b/src/vr/xr_actions.cc @@ -54,6 +54,14 @@ constexpr const char *kProfileTouch = "/interaction_profiles/oculus/touch_controller"; constexpr const char *kProfileQuest2 = "/interaction_profiles/meta/touch_controller_quest_2"; +constexpr const char* kTouchProfiles[] = { + kProfileTouch, + kProfileQuest2, + "/interaction_profiles/facebook/touch_controller_pro", + "/interaction_profiles/meta/touch_controller_plus", + "/interaction_profiles/meta/touch_pro_controller", + "/interaction_profiles/meta/touch_plus_controller", +}; constexpr const char *kProfileIndex = "/interaction_profiles/valve/index_controller"; constexpr const char *kProfileVive = @@ -191,8 +199,9 @@ Status XrActions::Create(void *xr_instance) { binds.emplace_back(impl_->touches[static_cast(kind)], path(component)); }; - const bool is_touch = std::strcmp(profile, kProfileTouch) == 0 || - std::strcmp(profile, kProfileQuest2) == 0; + const bool is_touch = std::any_of( + std::begin(kTouchProfiles), std::end(kTouchProfiles), + [&](const char* name) { return std::strcmp(profile, name) == 0; }); const bool is_index = std::strcmp(profile, kProfileIndex) == 0; if (is_touch || is_index) { touch(ControllerTouch::kTrigger, "/user/hand/left/input/trigger/touch"); @@ -209,7 +218,7 @@ Status XrActions::Create(void *xr_instance) { touch(ControllerTouch::kY, is_touch ? "/user/hand/left/input/y/touch" : "/user/hand/left/input/b/touch"); } - if (is_touch) { + if (is_touch && std::strcmp(profile, kProfileQuest2) != 0) { touch(ControllerTouch::kThumbrest, "/user/hand/left/input/thumbrest/touch"); touch(ControllerTouch::kThumbrest, @@ -244,7 +253,7 @@ Status XrActions::Create(void *xr_instance) { const XrPath l_haptic = path("/user/hand/left/output/haptic"); const XrPath r_haptic = path("/user/hand/right/output/haptic"); - for (const char *profile : {kProfileTouch, kProfileQuest2}) { + for (const char* profile : kTouchProfiles) { suggest( profile, { @@ -337,7 +346,8 @@ Status XrActions::Create(void *xr_instance) { created_ = true; Log(" [vr-input] action set created: grip+aim poses, trigger/squeeze " "analog, thumbstick, face/menu buttons, haptic output; bindings " - "suggested for Touch/Quest2/Index/Vive/Simple (system button never " + "suggested for Touch/Quest2/Touch Pro/Touch Plus/Index/Vive/Simple " + "(system button never " "bound)\n"); return Status::Ok(); } catch (const std::exception &error) { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index aa51072..6f8329d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -60,6 +60,10 @@ if(MOCKTAIL_ENABLE_VR) 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_executable(vr_queue_submission_test vr/queue_submission_test.cc) + target_link_libraries(vr_queue_submission_test PRIVATE + Mocktail::VR stub_vulkan GTest::gtest_main) + gtest_discover_tests(vr_queue_submission_test) add_executable(gles_vr_transport_test vr/gles_transport_test.cc) target_include_directories(gles_vr_transport_test PRIVATE "${PROJECT_SOURCE_DIR}/src" "${PROJECT_SOURCE_DIR}/third_party/OpenXR-SDK/include" ${MOCKTAIL_GLES3_INCLUDE_DIR}) target_link_libraries(gles_vr_transport_test PRIVATE Mocktail::VR SDL3::SDL3 GTest::gtest_main) @@ -640,6 +644,14 @@ target_include_directories(roblox_output_device_bridge_test PRIVATE ) mocktail_apply_compile_options(roblox_output_device_bridge_test) +add_executable(native_input_capture_test native_input_capture_test.cc) +target_include_directories(native_input_capture_test PRIVATE + "${PROJECT_SOURCE_DIR}/src") +target_link_libraries(native_input_capture_test PRIVATE + mocktail_audio_sdl GTest::gtest_main) +mocktail_apply_compile_options(native_input_capture_test) +gtest_discover_tests(native_input_capture_test) + add_executable(window_text_input_owner_test window_text_input_owner_test.cc ) diff --git a/tests/build_profile_test.cc b/tests/build_profile_test.cc index e081eeb..198a329 100644 --- a/tests/build_profile_test.cc +++ b/tests/build_profile_test.cc @@ -14,6 +14,23 @@ namespace { const std::string kManifestPath = std::string(MOCKTAIL_TEST_SOURCE_DIR) + "/config/roblox_compatibility.json"; +TEST(BuildProfileTest, Payload3092IncludesVrAndSecondAudioLayout) { + const auto result = FindBuildProfile( + kManifestPath, "5f0704edd9064f566ee3d6df2bd2fabbcc709f03"); + ASSERT_TRUE(result) << result.error; + ASSERT_TRUE(result.profile.has_value()); + EXPECT_EQ(result.profile->version_name, "2.738.1397"); + EXPECT_EQ(result.profile->version_code, 3092); + EXPECT_TRUE(result.profile->default_allowed); + EXPECT_TRUE(result.profile->allow_host_abi_bridges); + EXPECT_FALSE(result.profile->allow_legacy_binary_patches); + ASSERT_TRUE(result.profile->vr_debug_device_bridge.has_value()); + EXPECT_EQ(result.profile->vr_debug_device_bridge->vtable_rva, 0x6d08108u); + ASSERT_TRUE(result.profile->fmod_output_device_bridge.has_value()); + EXPECT_EQ(result.profile->fmod_output_device_bridge->vtable_layout_version, + 2u); +} + TEST(BuildProfileTest, FindsCurrentPayloadAsSupported) { const ProfileLookupResult result = FindBuildProfile( kManifestPath, "d0cb1fa0deb3d9161b4cd77530cbcd2e50de3a21"); diff --git a/tests/host_abi_profile_test.cc b/tests/host_abi_profile_test.cc index e132de6..67355ca 100644 --- a/tests/host_abi_profile_test.cc +++ b/tests/host_abi_profile_test.cc @@ -28,6 +28,28 @@ namespace mocktail::compat { namespace { +TEST(HostAbiProfileTest, Payload3092KeepsVerifiedConstructorBoundaries) { + const auto* profile = + FindHostAbiProfile("5f0704edd9064f566ee3d6df2bd2fabbcc709f03"); + ASSERT_NE(profile, nullptr); + EXPECT_EQ(profile->bridge_entry_count, 6u); + EXPECT_EQ(profile->init_array_offset, 0x70e1618u); + EXPECT_EQ(profile->init_array_count, 3599u); + EXPECT_TRUE(profile->HasValidConstructorRanges()); + EXPECT_TRUE(profile->AllowsConstructor(2)); + EXPECT_FALSE(profile->AllowsConstructor(3)); + EXPECT_FALSE(profile->AllowsConstructor(4)); + EXPECT_TRUE(profile->AllowsConstructor(5)); + EXPECT_TRUE(profile->AllowsConstructor(3598)); + EXPECT_FALSE(profile->AllowsConstructor(3599)); + EXPECT_TRUE(profile->HasValidNativeMimallocConstructorRanges()); + EXPECT_FALSE(profile->AllowsNativeMimallocConstructor(1)); + EXPECT_TRUE(profile->AllowsNativeMimallocConstructor(2)); + EXPECT_TRUE(profile->AllowsNativeMimallocConstructor(3598)); + EXPECT_FALSE(profile->AllowsNativeMimallocConstructor(3599)); + EXPECT_TRUE(profile->HasValidNativePreJniBootstrap()); +} + TEST(HostAbiProfileTest, Supported2908HasExactDerivedBoundaries) { const HostAbiProfile* profile = FindHostAbiProfile("63c5109637b7d7b2bdb8ed8f858023ff5ef49326"); diff --git a/tests/native_input_capture_test.cc b/tests/native_input_capture_test.cc new file mode 100644 index 0000000..dd7319c --- /dev/null +++ b/tests/native_input_capture_test.cc @@ -0,0 +1,186 @@ +#include "audio/native_input_capture.h" + +#include +#include +#include + +#include +#include + +#include "mocktail/audio/sdl_audio_sink.h" + +namespace mocktail::audio { +namespace { + +struct TestSink { + const std::uintptr_t* vtable; + std::thread::id poll_thread = std::this_thread::get_id(); + int frames = 0; + bool nonzero = false; + bool invalid = false; +}; + +struct TestOwner { + const std::uintptr_t* vtable; + long shared_owners = 0; + long weak_owners = 0; + int destroyed = 0; + int released = 0; +}; + +std::size_t ReceivePcm(void* self, const void* pcm, std::size_t samples, + std::uint64_t delay, const NativePcmFormat* format) { + auto& sink = *static_cast(self); + sink.invalid |= samples != 480 || format->rate != 48000 || + format->channels != 1 || format->bytes_per_sample != 2 || + delay < 10000000 || delay > 200000000 || + std::this_thread::get_id() != sink.poll_thread; + const auto* data = static_cast(pcm); + sink.nonzero |= std::any_of(data, data + samples, + [](auto sample) { return sample != 0; }); + ++sink.frames; + return samples; +} + +void DestroySink(void* self) { ++static_cast(self)->destroyed; } +void ReleaseOwner(void* self) { ++static_cast(self)->released; } +void OriginalLatency(void*, std::uint32_t* playback, std::uint32_t* recording) { + if (playback) *playback = 42; + if (recording) *recording = 99; +} + +const std::array kSinkVtable{ + 0, 0, reinterpret_cast(&ReceivePcm)}; +const std::array kOwnerVtable{ + 0, 0, reinterpret_cast(&DestroySink), 0, + reinterpret_cast(&ReleaseOwner)}; + +bool TestRelro(std::uintptr_t base, std::uintptr_t rva, std::size_t size) { + return (base + rva == reinterpret_cast(kSinkVtable.data()) && + size <= sizeof(kSinkVtable)) || + (base + rva == reinterpret_cast(kOwnerVtable.data()) && + size <= sizeof(kOwnerVtable)); +} + +bool TestCode(std::uintptr_t base, std::uintptr_t rva, std::size_t) { + return base + rva == kSinkVtable[2] || base + rva == kOwnerVtable[2] || + base + rva == kOwnerVtable[4]; +} + +class NativeInputCaptureTest : public testing::Test { + protected: + void SetUp() override { + const int fd = mkstemp(pcm_path_); + ASSERT_GE(fd, 0); + FILE* file = fdopen(fd, "wb"); + if (!file) close(fd); + ASSERT_NE(file, nullptr); + const std::vector samples(1024 * 1024, 0x3f); + const auto written = std::fwrite(samples.data(), 1, samples.size(), file); + const int closed = std::fclose(file); + ASSERT_EQ(written, samples.size()); + ASSERT_EQ(closed, 0); + ASSERT_TRUE(SDL_SetHintWithPriority(SDL_HINT_AUDIO_DRIVER, "disk", + SDL_HINT_OVERRIDE)); + ASSERT_TRUE(SDL_SetHintWithPriority(SDL_HINT_AUDIO_DISK_INPUT_FILE, + pcm_path_, SDL_HINT_OVERRIDE)); + ASSERT_TRUE(SDL_SetHintWithPriority(SDL_HINT_AUDIO_DISK_OUTPUT_FILE, + "/dev/null", SDL_HINT_OVERRIDE)); + ASSERT_TRUE(InitializeSdlAudioSubsystem().ok()); + ASSERT_STREQ(SDL_GetCurrentAudioDriver(), "disk"); + } + + void TearDown() override { + g_native_capture.store(nullptr); + if (capture_) capture_->Shutdown(); + EXPECT_TRUE(ShutdownSdlAudioSubsystem().ok()); + SDL_ResetHint(SDL_HINT_AUDIO_DRIVER); + SDL_ResetHint(SDL_HINT_AUDIO_DISK_INPUT_FILE); + SDL_ResetHint(SDL_HINT_AUDIO_DISK_OUTPUT_FILE); + std::remove(pcm_path_); + } + + void Publish(bool enabled) { + capture_ = std::make_unique( + NativeInputCapture::SinkAbi{1, &TestRelro, &TestCode, &OriginalLatency}, + enabled); + g_native_capture.store(capture_.get()); + } + + char pcm_path_[64] = "/tmp/mocktail-native-capture-XXXXXX"; + std::unique_ptr capture_; + TestSink sink_{kSinkVtable.data()}; + TestOwner owner_{kOwnerVtable.data()}; + int device_ = 0; +}; + +TEST_F(NativeInputCaptureTest, DeliversPcmAndReopensAfterStop) { + Publish(true); + const auto format = NativeInputCapture::Format(&device_); + EXPECT_EQ(format.present, 1u); + EXPECT_EQ(format.format.rate, 48000u); + EXPECT_EQ(format.format.channels, 1u); + for (int pass = 0; pass < 2; ++pass) { + owner_ = {kOwnerVtable.data()}; + sink_ = {kSinkVtable.data()}; + NativeSharedSink parameter{&sink_, &owner_}; + ASSERT_TRUE(NativeInputCapture::Start(&device_, ¶meter)); + EXPECT_EQ(parameter.object, nullptr); + EXPECT_EQ(parameter.owner, nullptr); + EXPECT_TRUE(NativeInputCapture::Recording(&device_)); + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::seconds(2); + while (sink_.frames < 3 && std::chrono::steady_clock::now() < deadline) { + NativeInputCapture::Poll(&device_); + SDL_Delay(10); + } + EXPECT_GE(sink_.frames, 3); + EXPECT_TRUE(sink_.nonzero); + EXPECT_FALSE(sink_.invalid); + std::uint32_t playback = 0, recording = 0; + NativeInputCapture::Latency(&device_, &playback, &recording); + EXPECT_EQ(playback, 42u); + EXPECT_GE(recording, 10u); + NativeSharedSink stop{&sink_, &owner_}; + NativeInputCapture::Stop(&device_, &stop); + EXPECT_FALSE(NativeInputCapture::Recording(&device_)); + EXPECT_EQ(owner_.destroyed, 1); + EXPECT_EQ(owner_.released, 1); + const int frames = sink_.frames; + NativeInputCapture::Poll(&device_); + NativeInputCapture::Stop(&device_, &stop); + EXPECT_EQ(sink_.frames, frames); + EXPECT_EQ(owner_.released, 1); + } +} + +TEST_F(NativeInputCaptureTest, RejectsDisabledAndClosedCapture) { + Publish(false); + NativeSharedSink parameter{&sink_, &owner_}; + EXPECT_EQ(NativeInputCapture::Format(&device_).present, 0u); + EXPECT_FALSE(NativeInputCapture::Start(&device_, ¶meter)); + EXPECT_EQ(parameter.object, &sink_); + Publish(true); + ASSERT_TRUE(NativeInputCapture::Start(&device_, ¶meter)); + capture_->Shutdown(); + EXPECT_EQ(NativeInputCapture::Format(&device_).present, 0u); + EXPECT_FALSE(NativeInputCapture::Recording(&device_)); + parameter = {&sink_, &owner_}; + EXPECT_FALSE(NativeInputCapture::Start(&device_, ¶meter)); + EXPECT_EQ(owner_.released, 1); +} + +TEST_F(NativeInputCaptureTest, RejectsUnverifiedSinkWithoutTakingOwnership) { + Publish(true); + const std::array unknown_vtable{}; + TestSink unknown{unknown_vtable.data()}; + NativeSharedSink parameter{&unknown, &owner_}; + EXPECT_FALSE(NativeInputCapture::Start(&device_, ¶meter)); + EXPECT_EQ(parameter.object, &unknown); + EXPECT_EQ(parameter.owner, &owner_); + EXPECT_FALSE(NativeInputCapture::Recording(&device_)); + EXPECT_EQ(owner_.released, 0); +} + +} // namespace +} // namespace mocktail::audio diff --git a/tests/roblox_output_device_bridge_test.cc b/tests/roblox_output_device_bridge_test.cc index ff58cf2..ab26643 100644 --- a/tests/roblox_output_device_bridge_test.cc +++ b/tests/roblox_output_device_bridge_test.cc @@ -48,6 +48,86 @@ TEST(RobloxOutputDeviceBridgeTest, ValidatesEveryInterposedVtableSlot) { vtable.data(), kImageBase, profile)); } +TEST(RobloxOutputDeviceBridgeTest, Validates3092LayoutWithoutUsingListSlots) { + constexpr std::uintptr_t kBase = 0x10000000; + auto profile = TestProfile(); + profile.vtable_layout_version = 2; + std::array vtable{}; + vtable[5] = kBase + profile.count_method_rva; + vtable[6] = kBase + profile.info_method_rva; + vtable[7] = kBase + 0x7000; + vtable[8] = kBase + profile.current_method_rva; + vtable[17] = kBase + 0x8000; + vtable[19] = kBase + profile.select_method_rva; + EXPECT_TRUE(internal::HasExpectedFmodOutputDeviceVtable(vtable.data(), kBase, + profile)); + profile.vtable_layout_version = 1; + EXPECT_FALSE(internal::HasExpectedFmodOutputDeviceVtable(vtable.data(), kBase, + profile)); + profile.vtable_layout_version = 3; + EXPECT_FALSE(internal::HasExpectedFmodOutputDeviceVtable(vtable.data(), kBase, + profile)); +} + +TEST(RobloxOutputDeviceBridgeTest, PinsCaptureToBuildIdVtableAndLayout) { + compat::BuildProfile profile; + profile.allow_host_abi_bridges = true; + profile.elf_build_id = "5f0704edd9064f566ee3d6df2bd2fabbcc709f03"; + EXPECT_EQ(internal::FindFmodInputCaptureProfile(profile), nullptr); + profile.fmod_output_device_bridge = TestProfile(); + EXPECT_EQ(internal::FindFmodInputCaptureProfile(profile), nullptr); + profile.fmod_output_device_bridge->vtable_rva = 0x6cd3ce0; + EXPECT_EQ(internal::FindFmodInputCaptureProfile(profile), nullptr); + profile.fmod_output_device_bridge->vtable_layout_version = 2; + ASSERT_NE(internal::FindFmodInputCaptureProfile(profile), nullptr); + profile.allow_host_abi_bridges = false; + EXPECT_EQ(internal::FindFmodInputCaptureProfile(profile), nullptr); + profile.allow_host_abi_bridges = true; + profile.elf_build_id = "unknown"; + EXPECT_EQ(internal::FindFmodInputCaptureProfile(profile), nullptr); +} + +TEST(RobloxOutputDeviceBridgeTest, ValidatesEveryCaptureSlotOnBothBuilds) { + constexpr std::uintptr_t kBase = 0x10000000; + for (int version : {2998, 3092}) { + compat::BuildProfile profile; + profile.allow_host_abi_bridges = true; + profile.elf_build_id = version == 2998 + ? "ade08266c67aee88ec9c1d00902150e1684dad3a" + : "5f0704edd9064f566ee3d6df2bd2fabbcc709f03"; + profile.fmod_output_device_bridge = TestProfile(); + profile.fmod_output_device_bridge->vtable_rva = + version == 2998 ? 0x6c58040 : 0x6cd3ce0; + profile.fmod_output_device_bridge->vtable_layout_version = + version == 2998 ? 1 : 2; + const auto* capture = internal::FindFmodInputCaptureProfile(profile); + ASSERT_NE(capture, nullptr); + const std::array expected_slots = + version == 2998 ? std::array{4, 11, 12, 13, 14, 15} + : std::array{4, 13, 14, 15, 16, 17}; + EXPECT_EQ(capture->slots, expected_slots); + std::array vtable{}; + for (std::size_t i = 0; i < capture->slots.size(); ++i) { + vtable[capture->slots[i]] = kBase + capture->method_rvas[i]; + } + EXPECT_TRUE(internal::HasExpectedFmodInputCaptureVtable(vtable.data(), + kBase, *capture)); + for (const auto slot : capture->slots) { + ++vtable[slot]; + EXPECT_FALSE(internal::HasExpectedFmodInputCaptureVtable(vtable.data(), + kBase, *capture)) + << slot; + --vtable[slot]; + } + EXPECT_FALSE( + internal::HasExpectedFmodInputCaptureVtable(nullptr, kBase, *capture)); + EXPECT_FALSE(internal::HasExpectedFmodInputCaptureVtable(vtable.data(), 0, + *capture)); + EXPECT_FALSE(internal::HasExpectedFmodInputCaptureVtable( + vtable.data(), UINTPTR_MAX, *capture)); + } +} + TEST(RobloxOutputDeviceBridgeTest, BuildsStableDistinctHostGuids) { const std::string first = internal::MakeOutputDeviceGuid(17, "USB Headset"); EXPECT_EQ(first, internal::MakeOutputDeviceGuid(17, "USB Headset")); diff --git a/tests/vr/openxr_backend_test.cc b/tests/vr/openxr_backend_test.cc index d3843f8..d9da14a 100644 --- a/tests/vr/openxr_backend_test.cc +++ b/tests/vr/openxr_backend_test.cc @@ -20,6 +20,18 @@ struct OpenXrBackendTestAccess { backend.published_pose_.valid = true; } static VkInstance Instance(const OpenXrBackend& b) { return b.vk_instance_; } + static void GraphicsFamily(OpenXrBackend& b, unsigned family) { + b.vk_queue_family_ = family; + b.vk_graphics_queue_ = Handle(30); + b.device_queue_families_ = {family, family + 1}; + } + static VkQueue CopyQueue(const OpenXrBackend& b, VkQueue present) { + return b.GraphicsQueueForPresentLocked(present); + } + static void NextFrame(OpenXrBackend& b) { ++b.published_pose_.frame; } + static bool SupportsQueue(const OpenXrBackend& b, VkQueue queue) { + return b.SupportsPresentQueueLocked(queue); + } static void Attachment(OpenXrBackend& b, int id, void* owner, VkDevice device) { auto image = Handle(id); auto& r = b.images_[image]; @@ -29,16 +41,36 @@ struct OpenXrBackendTestAccess { 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) { + static void Pass( + OpenXrBackend& b, RobloxVrDeviceBridge& bridge, int eye, int id, + void* owner, + VkImageLayout layout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VkCommandBuffer command = VK_NULL_HANDLE) { 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); + b.NoteRenderPassBegin(command, &info); } static VkImage Image(const OpenXrBackend& b, int eye) { return b.eyes_[eye].image; } + static void AddMsaaAttachment(OpenXrBackend& b, int framebuffer, int msaa) { + b.images_[Handle(msaa)].samples = VK_SAMPLE_COUNT_4_BIT; + b.framebuffer_views_[Handle(framebuffer)].insert( + b.framebuffer_views_[Handle(framebuffer)].begin(), + Handle(msaa)); + } + static void ResolvedPass(OpenXrBackend& b, RobloxVrDeviceBridge& bridge, + void* owner, int framebuffer) { + bridge.OnEyeGetterCall(owner, 0, Handle(framebuffer)); + VkRenderPassBeginInfo info{}; + info.framebuffer = Handle(framebuffer); + info.renderPass = Handle(99); + b.render_pass_final_layouts_[info.renderPass] = { + VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL}; + b.NoteRenderPassBegin(VK_NULL_HANDLE, &info); + } static VkImageLayout Layout(const OpenXrBackend& b, int eye) { return b.eyes_[eye].final_layout; } static void Invalidate(OpenXrBackend& b) { b.InvalidateInFlightPose("test"); @@ -62,8 +94,12 @@ struct OpenXrBackendTestAccess { 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_; } + static void LatchProjectionRejection(OpenXrBackend& b) { + b.projection_rejection_logged_ = true; + } + static bool ProjectionRejectionLogged(const OpenXrBackend& b) { + return b.projection_rejection_logged_; + } }; using Access = OpenXrBackendTestAccess; TEST(OpenXrBackendLifecycle, DeviceDestructionPreservesLiveInstance) { @@ -181,13 +217,13 @@ TEST(OpenXrBackendLifecycle, VisibilityLostStateSurvivesUntilSessionReset) { EXPECT_FALSE(Access::VisibilityLost(b)); } -TEST(OpenXrBackendLifecycle, TeardownClearsCantedRejectionLatch) { +TEST(OpenXrBackendLifecycle, TeardownClearsProjectionRejectionLatch) { OpenXrBackend b; Access::Configure(b); - Access::LatchCanted(b); - ASSERT_TRUE(Access::CantedLogged(b)); + Access::LatchProjectionRejection(b); + ASSERT_TRUE(Access::ProjectionRejectionLogged(b)); b.NoteDeviceDestroyed(Access::Handle(2)); - EXPECT_FALSE(Access::CantedLogged(b)); + EXPECT_FALSE(Access::ProjectionRejectionLogged(b)); } } // namespace mocktail::vr @@ -214,3 +250,311 @@ TEST(OpenXrDesktopMirror, RequiresTransferUsageAndDropsDestroyedSwapchain) { EXPECT_EQ(b.MirrorDesktop(VK_NULL_HANDLE, nullptr), VK_NOT_READY); } } + +namespace mocktail::vr { +namespace { +VkResult reset_result, submit_result, wait_result; +int resets, submits, waits; +VkQueue expected_queue; +VkSemaphore expected_semaphores[2]; +VKAPI_ATTR VkResult VKAPI_CALL ResetPresentFence(VkDevice, uint32_t count, + const VkFence*) { + EXPECT_EQ(count, 1u); + ++resets; + return reset_result; +} +VKAPI_ATTR VkResult VKAPI_CALL SubmitPresentWait(VkQueue queue, uint32_t count, + const VkSubmitInfo* info, + VkFence fence) { + ++submits; + EXPECT_EQ(queue, expected_queue); + EXPECT_EQ(count, 1u); + EXPECT_NE(fence, VK_NULL_HANDLE); + EXPECT_EQ(info->commandBufferCount, 0u); + EXPECT_EQ(info->waitSemaphoreCount, 2u); + EXPECT_EQ(info->signalSemaphoreCount, 0u); + for (unsigned i = 0; i < 2; ++i) { + EXPECT_EQ(info->pWaitSemaphores[i], expected_semaphores[i]); + EXPECT_EQ(info->pWaitDstStageMask[i], VK_PIPELINE_STAGE_ALL_COMMANDS_BIT); + } + return submit_result; +} +VKAPI_ATTR VkResult VKAPI_CALL WaitPresentFence(VkDevice, uint32_t count, + const VkFence*, VkBool32 all, + uint64_t timeout) { + ++waits; + EXPECT_EQ(count, 1u); + EXPECT_EQ(all, VK_TRUE); + EXPECT_EQ(timeout, UINT64_MAX); + return wait_result; +} +class OpenXrPresentDependencies : public testing::Test { + protected: + VkPresentInfoKHR info{}; + void SetUp() override { + reset_result = submit_result = wait_result = VK_SUCCESS; + resets = submits = waits = 0; + expected_queue = Access::Handle(30); + expected_semaphores[0] = Access::Handle(31); + expected_semaphores[1] = Access::Handle(32); + info.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; + info.waitSemaphoreCount = 2; + info.pWaitSemaphores = expected_semaphores; + } + VkResult Run() { + return internal::WaitForPresentDependencies( + Access::Handle(2), expected_queue, info, + Access::Handle(33), ResetPresentFence, SubmitPresentWait, + WaitPresentFence); + } +}; +TEST_F(OpenXrPresentDependencies, ConsumesDependenciesBeforePermittingCopies) { + EXPECT_EQ(Run(), VK_SUCCESS); + EXPECT_EQ(resets, 1); + EXPECT_EQ(submits, 1); + EXPECT_EQ(waits, 1); + EXPECT_EQ(info.waitSemaphoreCount, 2u); +} +TEST_F(OpenXrPresentDependencies, MirrorConsumedWaitsNeedNoSecondSubmission) { + info.waitSemaphoreCount = 0; + info.pWaitSemaphores = nullptr; + EXPECT_EQ(Run(), VK_SUCCESS); + EXPECT_EQ(resets + submits + waits, 0); +} +TEST_F(OpenXrPresentDependencies, SubmitFailureDoesNotWaitForUnsignalledFence) { + submit_result = VK_ERROR_DEVICE_LOST; + EXPECT_EQ(Run(), VK_ERROR_DEVICE_LOST); + EXPECT_EQ(submits, 1); + EXPECT_EQ(waits, 0); +} +TEST_F(OpenXrPresentDependencies, + ConsumedWaitsNeverAuthorizeFallbackOnTimeout) { + wait_result = VK_TIMEOUT; + EXPECT_LT(Run(), VK_SUCCESS); + EXPECT_EQ(submits, 1); + EXPECT_EQ(waits, 1); +} +TEST_F(OpenXrPresentDependencies, InvalidDependenciesDoNotSubmit) { + info.pWaitSemaphores = nullptr; + EXPECT_LT(Run(), VK_SUCCESS); + EXPECT_EQ(resets + submits + waits, 0); +} +TEST(OpenXrPresentQueue, RoutesSameAndSplitFamilyPresentationToGraphicsQueue) { + OpenXrBackend backend; + Access::Configure(backend); + Access::GraphicsFamily(backend, 3); + const auto device = Access::Handle(2); + const auto graphics = Access::Handle(30); + const auto same_family = Access::Handle(31); + const auto split_family = Access::Handle(32); + backend.NoteQueue(device, graphics, 3, 0); + backend.NoteQueue(device, same_family, 3, 1); + backend.NoteQueue(device, split_family, 4, 0); + EXPECT_TRUE(Access::SupportsQueue(backend, graphics)); + EXPECT_TRUE(Access::SupportsQueue(backend, same_family)); + EXPECT_TRUE(Access::SupportsQueue(backend, split_family)); + EXPECT_EQ(Access::CopyQueue(backend, split_family), graphics); + EXPECT_FALSE(Access::SupportsQueue(backend, Access::Handle(99))); +} +} // namespace +} // namespace mocktail::vr + +namespace mocktail::vr { +TEST(OpenXrBackendBinding, + SelectsSingleSampleResolveAlongsideMultisampleColor) { + OpenXrBackend backend; + RobloxVrDeviceBridge bridge; + internal::VrBridgeTestAccess::Enable(bridge); + Access::Configure(backend); + auto* owner = Access::Handle(100); + const auto device = Access::Handle(2); + Access::Attachment(backend, 10, owner, device); + Access::Attachment(backend, 11, owner, device); + Access::AddMsaaAttachment(backend, 10, 11); + Access::ResolvedPass(backend, bridge, owner, 10); + EXPECT_EQ(Access::Image(backend, 0), Access::Handle(10)); + EXPECT_EQ(Access::Layout(backend, 0), + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); +} +TEST(OpenXrBackendBinding, RejectsMultisampleImageWithoutResolve) { + OpenXrBackend backend; + RobloxVrDeviceBridge bridge; + internal::VrBridgeTestAccess::Enable(bridge); + Access::Configure(backend); + auto* owner = Access::Handle(100); + Access::Attachment(backend, 10, owner, Access::Handle(2)); + Access::AddMsaaAttachment(backend, 10, 10); + Access::ResolvedPass(backend, bridge, owner, 10); + EXPECT_EQ(Access::Image(backend, 0), VK_NULL_HANDLE); +} +} // namespace mocktail::vr + +namespace mocktail::vr { +namespace { +class OpenXrSplitPresentation : public testing::Test { + protected: + OpenXrBackend backend; + RobloxVrDeviceBridge bridge; + const VkDevice device = Access::Handle(2); + const VkQueue graphics = Access::Handle(30); + const VkQueue present = Access::Handle(31); + const VkQueue graphics_second = Access::Handle(32); + const VkCommandPool graphics_pool = Access::Handle(40); + const VkCommandPool present_pool = Access::Handle(41); + const VkCommandBuffer draw = Access::Handle(50); + const VkCommandBuffer acquire = Access::Handle(51); + const VkSwapchainKHR chain = Access::Handle(60); + const VkImage desktop = Access::Handle(61); + void* const owner = Access::Handle(100); + void SetUp() override { + Access::Configure(backend); + Access::GraphicsFamily(backend, 3); + internal::VrBridgeTestAccess::Enable(bridge); + backend.NoteQueue(device, graphics, 3, 0); + backend.NoteQueue(device, present, 4, 0); + backend.NoteQueue(device, graphics_second, 3, 1); + backend.RecordCommandPool(device, graphics_pool, 3); + backend.RecordCommandPool(device, present_pool, 4); + backend.RecordCommandBuffers(device, graphics_pool, 1, &draw); + backend.RecordCommandBuffers(device, present_pool, 1, &acquire); + RegisterDesktop(true); + } + void RegisterDesktop(bool converted) { + const unsigned families[] = {3, 4}; + VkSwapchainCreateInfoKHR info{}; + info.imageArrayLayers = 1; + info.imageExtent = {800, 600}; + info.imageFormat = VK_FORMAT_B8G8R8A8_UNORM; + info.imageUsage = VK_IMAGE_USAGE_TRANSFER_DST_BIT; + info.imageSharingMode = VK_SHARING_MODE_CONCURRENT; + info.queueFamilyIndexCount = 2; + info.pQueueFamilyIndices = families; + backend.RecordDesktopSwapchain(device, chain, &info, &desktop, 1, + converted); + } + void Draw() { + Access::Attachment(backend, 10, owner, device); + Access::Pass(backend, bridge, 0, 10, owner, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, draw); + } +}; +TEST_F(OpenXrSplitPresentation, ActualSubmittedGraphicsQueueOwnsTheEyeCopy) { + Draw(); + EXPECT_EQ(Access::CopyQueue(backend, present), VK_NULL_HANDLE); + backend.NoteCommandBuffersSubmitted(present, 1, &draw); // Wrong pool family. + EXPECT_EQ(Access::CopyQueue(backend, present), VK_NULL_HANDLE); + backend.NoteCommandBuffersSubmitted(graphics_second, 1, &draw); + EXPECT_EQ(Access::CopyQueue(backend, present), graphics_second); + Access::NextFrame(backend); + backend.ResetCommandBuffer(draw); + Draw(); + EXPECT_EQ(Access::CopyQueue(backend, present), VK_NULL_HANDLE); + backend.NoteCommandBuffersSubmitted(graphics, 1, &draw); + EXPECT_EQ(Access::CopyQueue(backend, present), graphics); +} +TEST_F(OpenXrSplitPresentation, + ResetAndFreeDoNotResurrectUnsubmittedEyeCommands) { + Draw(); + backend.ResetCommandBuffer(draw); + backend.NoteCommandBuffersSubmitted(graphics, 1, &draw); + EXPECT_EQ(Access::CopyQueue(backend, present), VK_NULL_HANDLE); + Draw(); + backend.ForgetCommandPool(graphics_pool); + backend.NoteCommandBuffersSubmitted(graphics, 1, &draw); + EXPECT_EQ(Access::CopyQueue(backend, present), VK_NULL_HANDLE); +} +TEST_F(OpenXrSplitPresentation, ConcurrentSwapchainTracksBothFamilies) { + EXPECT_EQ(Access::DesktopCount(backend), 1u); + EXPECT_EQ(backend.DesktopQueueFamilies(device), + (std::vector{3, 4})); + EXPECT_TRUE( + backend.DesktopQueueFamilies(Access::Handle(9)).empty()); +} +TEST_F(OpenXrSplitPresentation, PoolResetAndReusedImageRequireNewSubmission) { + Draw(); + backend.NoteCommandBuffersSubmitted(graphics, 1, &draw); + ASSERT_EQ(Access::CopyQueue(backend, present), graphics); + backend.ResetCommandPool(graphics_pool); + EXPECT_EQ(Access::CopyQueue(backend, present), VK_NULL_HANDLE); + Draw(); + backend.NoteCommandBuffersSubmitted(graphics, 1, &draw); + ASSERT_EQ(Access::CopyQueue(backend, present), graphics); + backend.NoteDestroyImage(Access::Handle(10)); + Draw(); + EXPECT_EQ(Access::CopyQueue(backend, present), VK_NULL_HANDLE); + backend.NoteCommandBuffersSubmitted(graphics_second, 1, &draw); + EXPECT_EQ(Access::CopyQueue(backend, present), graphics_second); +} +TEST_F(OpenXrSplitPresentation, + ReleaseTransitionsOnceAndMatchingAcquireDoesNotRepeat) { + unsigned source = 3, destination = 4; + VkImageLayout old_layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + backend.NormalizeDesktopBarrier(draw, desktop, &source, &destination, + &old_layout, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR); + EXPECT_EQ(source, VK_QUEUE_FAMILY_IGNORED); + EXPECT_EQ(destination, VK_QUEUE_FAMILY_IGNORED); + EXPECT_EQ(old_layout, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); + source = 3; + destination = 4; + backend.NormalizeDesktopBarrier(acquire, desktop, &source, &destination, + &old_layout, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR); + EXPECT_EQ(source, VK_QUEUE_FAMILY_IGNORED); + EXPECT_EQ(destination, VK_QUEUE_FAMILY_IGNORED); + EXPECT_EQ(old_layout, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR); +} +TEST_F(OpenXrSplitPresentation, WsiAcquirePreservesPresentToColorTransition) { + unsigned source = 4, destination = 3; + VkImageLayout old_layout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + backend.NormalizeDesktopBarrier(draw, desktop, &source, &destination, + &old_layout, + VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); + EXPECT_EQ(source, VK_QUEUE_FAMILY_IGNORED); + EXPECT_EQ(destination, VK_QUEUE_FAMILY_IGNORED); + EXPECT_EQ(old_layout, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR); + source = 4; + destination = 3; + old_layout = VK_IMAGE_LAYOUT_UNDEFINED; + backend.NormalizeDesktopBarrier(draw, desktop, &source, &destination, + &old_layout, + VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); + EXPECT_EQ(old_layout, VK_IMAGE_LAYOUT_UNDEFINED); +} +TEST_F(OpenXrSplitPresentation, + ExplicitPresentReleaseDefersLayoutTransitionToAcquire) { + unsigned source = 4, destination = 3; + VkImageLayout old_layout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + const auto released_layout = backend.NormalizeDesktopBarrier( + acquire, desktop, &source, &destination, &old_layout, + VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); + EXPECT_EQ(old_layout, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR); + EXPECT_EQ(released_layout, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR); + EXPECT_EQ(source, VK_QUEUE_FAMILY_IGNORED); + source = 4; + destination = 3; + const auto acquired_layout = backend.NormalizeDesktopBarrier( + draw, desktop, &source, &destination, &old_layout, + VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); + EXPECT_EQ(old_layout, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR); + EXPECT_EQ(acquired_layout, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL); +} +TEST_F(OpenXrSplitPresentation, + OriginalConcurrentAndUnrelatedImagesRemainUnchanged) { + RegisterDesktop(false); + unsigned source = 3, destination = 4; + VkImageLayout old_layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + backend.NormalizeDesktopBarrier(acquire, desktop, &source, &destination, + &old_layout, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR); + EXPECT_EQ(source, 3u); + EXPECT_EQ(destination, 4u); + RegisterDesktop(true); + backend.NormalizeDesktopBarrier(acquire, Access::Handle(99), &source, + &destination, &old_layout, + VK_IMAGE_LAYOUT_PRESENT_SRC_KHR); + EXPECT_EQ(source, 3u); + backend.RecordDesktopSwapchain(device, chain, nullptr, nullptr, 0); + backend.NormalizeDesktopBarrier(acquire, desktop, &source, &destination, + &old_layout, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR); + EXPECT_EQ(source, 3u); +} +} // namespace +} // namespace mocktail::vr diff --git a/tests/vr/probe_contract_test.py b/tests/vr/probe_contract_test.py index 9cff3d7..c93f58a 100644 --- a/tests/vr/probe_contract_test.py +++ b/tests/vr/probe_contract_test.py @@ -65,7 +65,7 @@ with tempfile.TemporaryDirectory(prefix="mocktail-xr-probe-") as directory: assert result.returncode == code, (scenario, result.returncode, result.stdout, result.stderr) assert message in result.stdout, (scenario, result.stdout, result.stderr) assert "SDK: 1.1.63" in result.stdout, result.stdout - assert "Roblox VR rendering: not implemented yet" in result.stdout + assert "Roblox VR rendering: run mocktail --vr" in result.stdout events = trace.read_text().splitlines() if trace.exists() else [] assert events == ([] if scenario == "create_failure" else ["create", "destroy"]), (scenario, events) if code == 0: diff --git a/tests/vr/queue_submission_test.cc b/tests/vr/queue_submission_test.cc new file mode 100644 index 0000000..fe4b545 --- /dev/null +++ b/tests/vr/queue_submission_test.cc @@ -0,0 +1,214 @@ +#include + +#include +#include +#include +#include +#include + +#include "mocktail/vr/openxr_backend.h" + +using SubmitAdapter = VkResult(VKAPI_PTR *)(PFN_vkQueueSubmit, VkDevice, + VkQueue, uint32_t, + const VkSubmitInfo *, VkFence); +extern "C" VKAPI_ATTR VkResult VKAPI_CALL mocktail_vulkan_submit_synchronized( + PFN_vkQueueSubmit raw, VkDevice device, VkQueue queue, uint32_t count, + const VkSubmitInfo *submits, VkFence fence); +extern "C" void mocktail_vr_xr_queue_submit_adapter(SubmitAdapter); +extern "C" VKAPI_ATTR VkResult VKAPI_CALL +mocktail_vulkan_idle_synchronized(PFN_vkDeviceWaitIdle raw, VkDevice device); + +namespace { +std::mutex state_mutex; +std::condition_variable state_changed; +bool waiting, signal_submitted; +bool idle_release; +std::atomic inside_driver{0}, max_inside{0}; +VKAPI_ATTR VkResult VKAPI_CALL Reset(VkDevice, uint32_t, const VkFence *) { + return VK_SUCCESS; +} +VKAPI_ATTR VkResult VKAPI_CALL Submit(VkQueue, uint32_t, + const VkSubmitInfo *info, VkFence) { + if (info && info->signalSemaphoreCount) { + std::lock_guard lock(state_mutex); + signal_submitted = true; + state_changed.notify_all(); + } + return VK_SUCCESS; +} +VKAPI_ATTR VkResult VKAPI_CALL Wait(VkDevice, uint32_t, const VkFence *, + VkBool32, uint64_t) { + std::unique_lock lock(state_mutex); + waiting = true; + state_changed.notify_all(); + return state_changed.wait_for(lock, std::chrono::seconds(2), + [] { return signal_submitted; }) + ? VK_SUCCESS + : VK_TIMEOUT; +} +VKAPI_ATTR VkResult VKAPI_CALL SlowSubmit(VkQueue, uint32_t, + const VkSubmitInfo *, VkFence) { + const int active = ++inside_driver; + int previous = max_inside.load(); + while (active > previous && + !max_inside.compare_exchange_weak(previous, active)) { + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + --inside_driver; + return VK_SUCCESS; +} +VKAPI_ATTR VkResult VKAPI_CALL BlockingIdle(VkDevice) { + std::unique_lock lock(state_mutex); + waiting = true; + state_changed.notify_all(); + return state_changed.wait_for(lock, std::chrono::seconds(2), + [] { return idle_release; }) + ? VK_SUCCESS + : VK_TIMEOUT; +} +TEST(VrQueueSubmission, DeviceIdleExcludesPrivateSubmitsUntilDriverReturns) { + waiting = signal_submitted = idle_release = false; + VkResult idle_result = VK_NOT_READY; + std::thread idler([&] { + idle_result = mocktail_vulkan_idle_synchronized( + BlockingIdle, reinterpret_cast(2)); + }); + bool entered = false; + { + std::unique_lock lock(state_mutex); + entered = state_changed.wait_for(lock, std::chrono::seconds(1), + [] { return waiting; }); + } + const auto semaphore = reinterpret_cast(32); + VkSubmitInfo signal{}; + signal.signalSemaphoreCount = 1; + signal.pSignalSemaphores = &semaphore; + std::thread submitter([&] { + EXPECT_EQ(mocktail_vulkan_submit_synchronized( + Submit, reinterpret_cast(2), + reinterpret_cast(30), 1, &signal, VK_NULL_HANDLE), + VK_SUCCESS); + }); + { + std::unique_lock lock(state_mutex); + EXPECT_FALSE(state_changed.wait_for(lock, std::chrono::milliseconds(50), + [] { return signal_submitted; })); + idle_release = true; + state_changed.notify_all(); + } + idler.join(); + submitter.join(); + EXPECT_TRUE(entered); + EXPECT_EQ(idle_result, VK_SUCCESS); + EXPECT_TRUE(signal_submitted); +} +TEST(VrQueueSubmission, OtherDeviceCanSignalWhileDeviceIdleIsPending) { + waiting = signal_submitted = idle_release = false; + VkResult idle_result = VK_NOT_READY; + // Wait emulates device A waiting for an imported semaphore signal from B. + auto idle = [](VkDevice) -> VkResult { + return Wait(VK_NULL_HANDLE, 0, nullptr, VK_TRUE, UINT64_MAX); + }; + std::thread idler([&] { + idle_result = + mocktail_vulkan_idle_synchronized(idle, reinterpret_cast(2)); + }); + bool entered = false; + { + std::unique_lock lock(state_mutex); + entered = state_changed.wait_for(lock, std::chrono::seconds(1), + [] { return waiting; }); + } + const auto semaphore = reinterpret_cast(32); + VkSubmitInfo signal{}; + signal.signalSemaphoreCount = 1; + signal.pSignalSemaphores = &semaphore; + EXPECT_EQ(mocktail_vulkan_submit_synchronized( + Submit, reinterpret_cast(3), + reinterpret_cast(31), 1, &signal, VK_NULL_HANDLE), + VK_SUCCESS); + idler.join(); + EXPECT_TRUE(entered); + EXPECT_EQ(idle_result, VK_SUCCESS); +} +TEST(VrQueueSubmission, AnotherQueueCanSignalWhilePresentWaitsForFence) { + waiting = signal_submitted = false; + mocktail_vr_xr_queue_submit_adapter(mocktail_vulkan_submit_synchronized); + const auto graphics = reinterpret_cast(30); + const auto signal_queue = reinterpret_cast(31); + const auto semaphore = reinterpret_cast(32); + VkPresentInfoKHR present{}; + present.waitSemaphoreCount = 1; + present.pWaitSemaphores = &semaphore; + VkResult prepared = VK_NOT_READY; + std::thread presenter([&] { + prepared = mocktail::vr::internal::WaitForPresentDependencies( + reinterpret_cast(2), graphics, present, + reinterpret_cast(33), Reset, Submit, Wait); + }); + bool entered = false; + { + std::unique_lock lock(state_mutex); + entered = state_changed.wait_for(lock, std::chrono::seconds(1), + [] { return waiting; }); + } + VkSubmitInfo signal{}; + signal.signalSemaphoreCount = 1; + signal.pSignalSemaphores = &semaphore; + EXPECT_EQ(mocktail_vulkan_submit_synchronized( + Submit, reinterpret_cast(2), signal_queue, 1, &signal, + VK_NULL_HANDLE), + VK_SUCCESS); + presenter.join(); + mocktail_vr_xr_queue_submit_adapter(nullptr); + EXPECT_TRUE(entered); + EXPECT_EQ(prepared, VK_SUCCESS); +} +TEST(VrQueueSubmission, PrivateAndGuestCallsCannotEnterOneQueueConcurrently) { + inside_driver = max_inside = 0; + const auto queue = reinterpret_cast(30); + auto call = [&] { + EXPECT_EQ(mocktail_vulkan_submit_synchronized( + SlowSubmit, reinterpret_cast(2), queue, 0, nullptr, + VK_NULL_HANDLE), + VK_SUCCESS); + }; + std::thread first(call), second(call); + first.join(); + second.join(); + EXPECT_EQ(max_inside, 1); +} +TEST(VrQueueSubmission, FenceWaitDoesNotKeepItsOwnQueueHostLocked) { + waiting = signal_submitted = false; + mocktail_vr_xr_queue_submit_adapter(mocktail_vulkan_submit_synchronized); + const auto queue = reinterpret_cast(30); + const auto semaphore = reinterpret_cast(32); + VkPresentInfoKHR present{}; + present.waitSemaphoreCount = 1; + present.pWaitSemaphores = &semaphore; + VkResult result = VK_NOT_READY; + std::thread presenter([&] { + result = mocktail::vr::internal::WaitForPresentDependencies( + reinterpret_cast(2), queue, present, + reinterpret_cast(33), Reset, Submit, Wait); + }); + { + std::unique_lock lock(state_mutex); + state_changed.wait_for(lock, std::chrono::seconds(1), + [] { return waiting; }); + } + // An unrelated already-ready batch can still be enqueued to this queue. + EXPECT_EQ( + mocktail_vulkan_submit_synchronized(Submit, reinterpret_cast(2), + queue, 0, nullptr, VK_NULL_HANDLE), + VK_SUCCESS); + { + std::lock_guard lock(state_mutex); + signal_submitted = true; + state_changed.notify_all(); + } + presenter.join(); + mocktail_vr_xr_queue_submit_adapter(nullptr); + EXPECT_EQ(result, VK_SUCCESS); +} +} // namespace diff --git a/tests/vr/roblox_vr_device_bridge_test.cc b/tests/vr/roblox_vr_device_bridge_test.cc index 3e190d6..3f32318 100644 --- a/tests/vr/roblox_vr_device_bridge_test.cc +++ b/tests/vr/roblox_vr_device_bridge_test.cc @@ -54,6 +54,11 @@ const std::vector kEyeGetter = {0x48, 0x63, 0xc6, 0x48, 0xc1, 0xe0, 0x04, 0x48, 0x8b, 0x84, 0x07, 0x50, 0x01, 0x00, 0x00, 0xc3}; +const internal::VrBuildContract& TestContract() { + return *internal::FindVrBuildContract( + "ade08266c67aee88ec9c1d00902150e1684dad3a"); +} + std::vector ValidConstructor() { std::vector code(kConstructorContractSize, 0x90); const std::vector prologue = { @@ -70,6 +75,7 @@ std::vector ValidConstructor() { 0x00, 0x00}, {0x4c, 0x89, 0xbb, 0x98, 0x01, 0x00, 0x00}, }; + std::memcpy(code.data() + 43, markers.front().data(), markers.front().size()); size_t offset = 64; for (const auto &marker : markers) { std::memcpy(code.data() + offset, marker.data(), marker.size()); @@ -240,19 +246,100 @@ TEST(VrResourceReadiness, TEST(VrStateGetterContract, AcceptsExact2998Bytes) { ASSERT_EQ(kStateGetter.size(), kStateGetterContractSize); - EXPECT_TRUE(internal::HasExpectedVrStateGetterContract(kStateGetter.data(), - kStateGetter.size())); + EXPECT_TRUE(internal::HasExpectedVrStateGetterContract( + kStateGetter.data(), kStateGetter.size(), TestContract())); } TEST(VrStateGetterContract, RejectsCorruptionAndShortInput) { - EXPECT_FALSE(internal::HasExpectedVrStateGetterContract(nullptr, 0)); + EXPECT_FALSE( + internal::HasExpectedVrStateGetterContract(nullptr, 0, TestContract())); EXPECT_FALSE(internal::HasExpectedVrStateGetterContract( - kStateGetter.data(), kStateGetter.size() - 1)); + kStateGetter.data(), kStateGetter.size() - 1, TestContract())); 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())); + EXPECT_FALSE(internal::HasExpectedVrStateGetterContract( + corrupted.data(), corrupted.size(), TestContract())); +} + +TEST(VrBuildContracts, RejectsUnknownBuildsAndMismatchedMetadata) { + EXPECT_EQ(FindRobloxVrDeviceProfile("unknown"), nullptr); + auto profile = MakeBuildProfile(true, true); + profile.elf_build_id = "5f0704edd9064f566ee3d6df2bd2fabbcc709f03"; + RobloxVrDeviceBridge bridge; + EXPECT_FALSE(bridge.Install(profile).ok()); + profile.vr_debug_device_bridge = + *FindRobloxVrDeviceProfile(profile.elf_build_id); + EXPECT_TRUE(bridge.Install(profile).ok()); + EXPECT_FALSE(bridge.active()); +} + +TEST(VrBuildContracts, RestoresVrForApprovedProfileWithoutVrMetadata) { + auto profile = MakeBuildProfile(false, true); + profile.elf_build_id = "5f0704edd9064f566ee3d6df2bd2fabbcc709f03"; + const auto* vr = FindRobloxVrDeviceProfile(profile.elf_build_id); + ASSERT_NE(vr, nullptr); + EXPECT_EQ(vr->device_create_framebuffer_vtable_offset, 0x188u); + profile.vr_debug_device_bridge = *vr; + RobloxVrDeviceBridge bridge; + EXPECT_TRUE(bridge.Install(profile).ok()); +} + +TEST(VrStateGetterContract, Verifies3092CopyTargetAndRejectsWrongBuild) { + const auto& latest = *internal::FindVrBuildContract( + "5f0704edd9064f566ee3d6df2bd2fabbcc709f03"); + auto code = kStateGetter; + const std::uint8_t displacement[] = {0xa3, 0x0d, 0x40, 0x03}; + std::memcpy(code.data() + 19, displacement, sizeof(displacement)); + EXPECT_TRUE(internal::HasExpectedVrStateGetterContract(code.data(), + code.size(), latest)); + EXPECT_FALSE(internal::HasExpectedVrStateGetterContract( + code.data(), code.size(), TestContract())); + code[19] ^= 1; + EXPECT_FALSE(internal::HasExpectedVrStateGetterContract(code.data(), + code.size(), latest)); +} + +TEST(VrConstructorContract, VerifiesRelocatedVtableFor3092) { + const auto& latest = + *FindRobloxVrDeviceProfile("5f0704edd9064f566ee3d6df2bd2fabbcc709f03"); + auto code = ValidConstructor(); + const std::uint8_t displacement[] = {0xf2, 0xe0, 0x50, 0x03}; + std::memcpy(code.data() + 46, displacement, sizeof(displacement)); + EXPECT_TRUE(internal::HasExpectedVrConstructorContract(code.data(), + code.size(), latest)); + EXPECT_FALSE(internal::HasExpectedVrConstructorContract( + code.data(), code.size(), TestProfile())); +} + +TEST(VrPointerContract, PinsWorkspaceAndCameraSlotForEachBuild) { + for (const auto* id : {"ade08266c67aee88ec9c1d00902150e1684dad3a", + "5f0704edd9064f566ee3d6df2bd2fabbcc709f03"}) { + const auto& contract = *internal::FindVrBuildContract(id); + std::array code{}; + const std::uint8_t prologue[] = {0x55, 0x48, 0x89, 0xe5, 0x41, + 0x57, 0x41, 0x56, 0x41, 0x54, + 0x53, 0x48, 0x83, 0xec, 0x70}; + std::memcpy(code.data(), prologue, sizeof(prologue)); + code[0x59] = 0xe8; + const std::int32_t displacement = + static_cast(contract.workspace_getter_rva) - + (contract.pointer_frame_rva + 0x5e); + std::memcpy(code.data() + 0x5a, &displacement, sizeof(displacement)); + code[0x6d] = 0xff; + code[0x6e] = 0x91; + const std::uint32_t slot = contract.camera_vtable_offset; + std::memcpy(code.data() + 0x6f, &slot, sizeof(slot)); + EXPECT_TRUE(internal::HasExpectedVrPointerContract(code.data(), code.size(), + contract)); + code[0x6f] ^= 8; + EXPECT_FALSE(internal::HasExpectedVrPointerContract(code.data(), + code.size(), contract)); + code[0x6f] ^= 8; + code[0x5a] ^= 1; + EXPECT_FALSE(internal::HasExpectedVrPointerContract(code.data(), + code.size(), contract)); + } } TEST(VrEyeGetterContract, AcceptsExact2998Bytes) { @@ -271,10 +358,10 @@ TEST(VrEyeGetterContract, RejectsDifferentEyeSlotOffset) { 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)); + EXPECT_TRUE(internal::HasExpectedVrConstructorContract( + valid.data(), valid.size(), TestProfile())); + EXPECT_FALSE(internal::HasExpectedVrConstructorContract( + valid.data(), valid.size() - 1, TestProfile())); // 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) { @@ -285,7 +372,7 @@ TEST(VrConstructorContract, AcceptsMarkersAndRejectsEachMissingOne) { } } EXPECT_FALSE(internal::HasExpectedVrConstructorContract( - no_device_store.data(), no_device_store.size())); + no_device_store.data(), no_device_store.size(), TestProfile())); } TEST(VrInitializerContract, PinsDeviceVtableSlotFromProfile) { @@ -484,7 +571,8 @@ TEST(VrProfileParsing, Issue153VrBridgeFailsWithoutMetadataForArmedProfile) { "count_method_rva": "0x32d1000", "info_method_rva": "0x32d10a0", "current_method_rva": "0x32d1050", - "select_method_rva": "0x32d0d04" + "select_method_rva": "0x32d0d04", + "vtable_layout_version": 2 }, "reason": "issue 153 reproduction profile: armed without vr metadata" }] @@ -500,6 +588,8 @@ TEST(VrProfileParsing, Issue153VrBridgeFailsWithoutMetadataForArmedProfile) { ASSERT_TRUE(result); ASSERT_TRUE(result.profile.has_value()); ASSERT_TRUE(result.profile->fmod_output_device_bridge.has_value()); + EXPECT_EQ(result.profile->fmod_output_device_bridge->vtable_layout_version, + 2u); ASSERT_TRUE(result.profile->user_game_settings_fullscreen_setter_rva .has_value()); // But it carries no VR debug-device bridge metadata. @@ -607,6 +697,30 @@ TEST(VrHandPoseBridge, ValidGripPosesFillBothHandRecordsInMetres) { EXPECT_FLOAT_EQ(right_pos[0], 0.25f); } +TEST(VrFloorPoseBridge, PublishesStagePoseInMetresAndClearsTrackingLoss) { + std::array state{}; + state[internal::kStateReadyOffsetInCopy] = 1; + ScriptedPoseSample pose; + pose.valid = pose.floor_valid = true; + pose.floor_position[1] = -1.7f; + pose.floor_orientation[1] = std::sin(.2f); + pose.floor_orientation[3] = std::cos(.2f); + ASSERT_TRUE(internal::ApplyXrPose(state.data(), pose)); + EXPECT_EQ(state[internal::kExtraRecordOffset], 1); + float height = 0; + std::memcpy(&height, state.data() + internal::kExtraRecordOffset + 8, 4); + EXPECT_FLOAT_EQ(height, -1.7f); + EXPECT_EQ(state[internal::kStateReadyOffsetInCopy], 1); + pose.floor_valid = false; + ASSERT_TRUE(internal::ApplyXrPose(state.data(), pose)); + EXPECT_EQ(state[internal::kExtraRecordOffset], 0); + EXPECT_EQ(state[internal::kHeadRecordOffset], 1); + pose.floor_valid = true; + pose.floor_position[1] = std::numeric_limits::quiet_NaN(); + ASSERT_TRUE(internal::ApplyXrPose(state.data(), pose)); + EXPECT_EQ(state[internal::kExtraRecordOffset], 0); +} + TEST(VrHandPoseBridge, UntrackedOrNonFiniteHandsStayInvalidWithoutTouchingHead) { std::array state; state.fill(0); diff --git a/tests/vr/vr_pose_math_test.cc b/tests/vr/vr_pose_math_test.cc index 4c931b1..53098d5 100644 --- a/tests/vr/vr_pose_math_test.cc +++ b/tests/vr/vr_pose_math_test.cc @@ -151,21 +151,64 @@ TEST(VrPoseMath, FovToTangentsPinsUpDownLeftRightOrderAndSigns) { EXPECT_NE(tangents.up, tangents.down); } -// ---- canted-view rejection ---- +TEST(VrPoseMath, ParallelProjectionKeepsUnrotatedFovExact) { + const EyeFov display{-.7f, .8f, .9f, -.6f}; + for (float sign : {1.f, -1.f}) { + EyeFov rendered; + ASSERT_TRUE(ParallelEyeFov({0, 0, 0, sign}, display, &rendered)); + EXPECT_EQ(rendered.angle_left, display.angle_left); + EXPECT_EQ(rendered.angle_right, display.angle_right); + EXPECT_EQ(rendered.angle_up, display.angle_up); + EXPECT_EQ(rendered.angle_down, display.angle_down); + } +} -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})); +TEST(VrPoseMath, ParallelProjectionContainsEveryCantedDisplayRay) { + const EyeFov display{-.7f, .8f, .9f, -.6f}; + for (const Vec3 axis : {Vec3{1, 0, 0}, Vec3{0, 1, 0}, Vec3{0, 0, 1}}) { + for (float angle : {-.2f, .2f}) { + const Quat rotation = QuatFromAxisAngle(axis, angle); + EyeFov rendered; + ASSERT_TRUE(ParallelEyeFov(rotation, display, &rendered)); + for (int x = 0; x <= 4; ++x) { + for (int y = 0; y <= 4; ++y) { + const Vec3 ray = + QuatRotate(rotation, {std::tan(display.angle_left) + + x * .25f * + (std::tan(display.angle_right) - + std::tan(display.angle_left)), + std::tan(display.angle_down) + + y * .25f * + (std::tan(display.angle_up) - + std::tan(display.angle_down)), + -1}); + const float horizontal = std::atan2(ray.x, -ray.z); + const float vertical = std::atan2(ray.y, -ray.z); + EXPECT_GE(horizontal, rendered.angle_left - 1e-6f); + EXPECT_LE(horizontal, rendered.angle_right + 1e-6f); + EXPECT_GE(vertical, rendered.angle_down - 1e-6f); + EXPECT_LE(vertical, rendered.angle_up + 1e-6f); + } + } + const Pose head{{1, 2, 3}, QuatFromYawPitch(.4f, -.1f)}; + const Pose parallel_eye{{-.03f, .01f, .02f}, {}}; + const Pose submitted = PoseMultiply(head, parallel_eye); + EXPECT_TRUE( + QuatSameRotation(submitted.orientation, head.orientation, 1e-6f)); + } + } +} + +TEST(VrPoseMath, UnrepresentableProjectionDoesNotModifyOutput) { + const EyeFov display{-.7f, .8f, .9f, -.6f}; + EyeFov rendered{1, 2, 3, 4}; + EXPECT_FALSE(ParallelEyeFov({0, 0, 0, 0}, display, &rendered)); + EXPECT_FALSE(ParallelEyeFov({}, {.8f, -.7f, .9f, -.6f}, &rendered)); + EXPECT_FALSE( + ParallelEyeFov(QuatFromAxisAngle({0, 1, 0}, 1.5f), display, &rendered)); + EXPECT_FALSE(ParallelEyeFov({}, display, nullptr)); + EXPECT_EQ(rendered.angle_left, 1); + EXPECT_EQ(rendered.angle_down, 4); } // ---- units: the 0.10 m translation check through the native conversion ---- diff --git a/tests/vr/xr_actions_test.cc b/tests/vr/xr_actions_test.cc index cd2c653..1978e8b 100644 --- a/tests/vr/xr_actions_test.cc +++ b/tests/vr/xr_actions_test.cc @@ -1,11 +1,15 @@ #include "mocktail/vr/xr_actions.h" + +#include +#include + +#include +#include +#include +#include + #include "runtime/roblox_input_router.h" #include "runtime/roblox_xr_controller_input.h" -#include -#include -#include -#include -#include namespace { using namespace mocktail::vr; @@ -23,6 +27,7 @@ float last_amplitude; XrDuration last_duration; XrPath last_haptic_hand; int simple_haptic_bindings; +std::map> suggested_paths; } // namespace extern "C" { XRAPI_ATTR XrResult XRAPI_CALL @@ -69,6 +74,10 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateAction(XrActionSet, XRAPI_ATTR XrResult XRAPI_CALL xrDestroyAction(XrAction) { return XR_SUCCESS; } XRAPI_ATTR XrResult XRAPI_CALL xrSuggestInteractionProfileBindings( XrInstance, const XrInteractionProfileSuggestedBinding *info) { + for (std::uint32_t i = 0; i < info->countSuggestedBindings; ++i) { + suggested_paths[info->interactionProfile].push_back( + info->suggestedBindings[i].binding); + } if (info->interactionProfile == paths["/interaction_profiles/khr/simple_controller"]) for (std::uint32_t i = 0; i < info->countSuggestedBindings; ++i) @@ -165,6 +174,7 @@ protected: void SetUp() override { names.clear(); paths.clear(); + suggested_paths.clear(); next_handle = 100; focused = input_active = true; simple = click = touching = false; @@ -188,6 +198,33 @@ protected: } }; +TEST_F(XrActionsProduction, BindsTouchProAndPlusAndOmitsQuest2Thumbrest) { + for (const char* name : + {"/interaction_profiles/facebook/touch_controller_pro", + "/interaction_profiles/meta/touch_controller_plus", + "/interaction_profiles/meta/touch_pro_controller", + "/interaction_profiles/meta/touch_plus_controller"}) { + const auto& bindings = suggested_paths.at(paths.at(name)); + for (const char* component : + {"/user/hand/left/input/grip/pose", "/user/hand/right/input/aim/pose", + "/user/hand/left/input/trigger/value", + "/user/hand/right/input/squeeze/value", + "/user/hand/left/output/haptic", "/user/hand/right/output/haptic"}) { + EXPECT_NE( + std::find(bindings.begin(), bindings.end(), paths.at(component)), + bindings.end()) + << name << " " << component; + } + } + const auto& quest = suggested_paths.at( + paths.at("/interaction_profiles/meta/touch_controller_quest_2")); + for (const char* component : {"/user/hand/left/input/thumbrest/touch", + "/user/hand/right/input/thumbrest/touch"}) { + EXPECT_EQ(std::find(quest.begin(), quest.end(), paths.at(component)), + quest.end()); + } +} + TEST_F(XrActionsProduction, HapticSamplesKeepHandLeaseAndClampAmplitude) { EXPECT_EQ(simple_haptic_bindings, 2); actions.RequestHaptics(1, 1.5f, 50000000, 0);