add roblox 3092 support

Also fix openxr shit
This commit is contained in:
komaruworld committed 2026-10-03 13:40:24 +03:00
1 parent ecb80b3ee1
commit 9a1ccbb5ce
34 files changed
+4407 -766

No files matched your search

+23 -119
View File
@@ -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*'
```
+3 -3
View File
@@ -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
+30
View File
@@ -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"
}
}
]
}
+3 -4
View File
@@ -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;
@@ -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<NativeInputCapture> capture_;
bool capture_profile_supported_ = false;
const internal::FmodInputCaptureProfile* capture_profile_ = nullptr;
std::array<std::uintptr_t, 6> original_capture_methods_{};
mutable std::mutex mutex_;
compat::FmodOutputDeviceBridgeProfile profile_;
+59 -3
View File
@@ -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<std::uint32_t> 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<std::uint32_t> 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<VkImage> images;
VkSharingMode sharing_mode = VK_SHARING_MODE_EXCLUSIVE;
std::vector<std::uint32_t> families;
bool converted_to_concurrent = false;
};
std::unordered_map<VkSwapchainKHR, DesktopSwapchain> desktop_swapchains_;
std::unordered_map<VkImageView, VkImage> image_views_;
@@ -326,6 +373,15 @@ class OpenXrBackend final {
std::unordered_map<VkFramebuffer, VkRenderPass> framebuffer_render_pass_;
std::unordered_map<VkRenderPass, std::vector<VkImageLayout>> render_pass_final_layouts_;
std::unordered_map<VkQueue, std::uint32_t> queue_families_;
struct CommandRecord {
VkCommandPool pool = VK_NULL_HANDLE;
std::uint32_t family = UINT32_MAX;
std::vector<std::pair<VkImage, std::uint64_t>> eye_images;
};
std::unordered_map<VkCommandPool, std::uint32_t> command_pool_families_;
std::unordered_map<VkCommandBuffer, CommandRecord> command_records_;
std::unordered_map<VkImage, VkQueue> eye_render_queues_;
std::unordered_map<VkImage, std::uint64_t> 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<void*, std::uint64_t> applied_poses_;
@@ -4,6 +4,7 @@
#include <atomic>
#include <cstdint>
#include <mutex>
#include <string_view>
#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<bool> active_{false};
std::atomic<std::uintptr_t> image_base_{0};
+55 -9
View File
@@ -1,6 +1,7 @@
#ifndef MOCKTAIL_VR_POSE_MATH_H_
#define MOCKTAIL_VR_POSE_MATH_H_
#include <algorithm>
#include <cmath>
#include <cstdint>
@@ -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
+1510
View File
File diff suppressed because it is too large. Load diff
+1 -6
View File
@@ -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;
+112 -89
View File
@@ -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<std::size_t, 6> kCaptureSlots{4, 11, 12, 13, 14, 15};
constexpr std::array<std::size_t, 4> kInputAnchorSlots{8, 9, 10, 16};
constexpr std::array<std::uintptr_t, 4> 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<RobloxOutputDeviceBridge*> 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<std::uintptr_t>(vtable + kFirstPatchedVtableIndex);
const std::uintptr_t last =
reinterpret_cast<std::uintptr_t>(vtable + kSelectOutputVtableIndex + 1);
reinterpret_cast<std::uintptr_t>(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<std::uintptr_t>::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<std::uint8_t, kStringConstructorContractSize>
@@ -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<std::uintptr_t>::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) {
@@ -1,6 +1,7 @@
#ifndef MOCKTAIL_AUDIO_ROBLOX_OUTPUT_DEVICE_BRIDGE_INTERNAL_H_
#define MOCKTAIL_AUDIO_ROBLOX_OUTPUT_DEVICE_BRIDGE_INTERNAL_H_
#include <array>
#include <cstddef>
#include <cstdint>
#include <string>
@@ -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<std::size_t, 6> slots;
std::array<std::uintptr_t, 6> 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);
+9
View File
@@ -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<std::uint32_t>();
}
*bridge_profile = FmodOutputDeviceBridgeProfile{
*vtable_rva, *string_constructor_rva, *count_method_rva,
*info_method_rva, *current_method_rva, *select_method_rva,
layout_version,
};
return true;
}
+30 -12
View File
@@ -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,
+373 -65
View File
@@ -10,8 +10,11 @@
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <mutex>
#include <shared_mutex>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
@@ -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<Function>(dlsym(RTLD_DEFAULT, name));
}
// Queue calls share the gate; device idle is exclusive.
// Release it before fence waits and backend callbacks.
std::shared_ptr<std::shared_mutex> DeviceIdleGate(VkDevice device) {
static std::mutex registry_mutex;
static std::unordered_map<VkDevice, std::shared_ptr<std::shared_mutex>>
registry;
std::lock_guard<std::mutex> lock(registry_mutex);
auto &gate = registry[device];
if (!gate)
gate = std::make_shared<std::shared_mutex>();
return gate;
}
std::shared_ptr<std::mutex> QueueCallMutex(VkQueue queue) {
static std::mutex registry_mutex;
static std::unordered_map<VkQueue, std::shared_ptr<std::mutex>> registry;
std::lock_guard<std::mutex> lock(registry_mutex);
auto &mutex = registry[queue];
if (!mutex)
mutex = std::make_shared<std::mutex>();
return mutex;
}
void NoteVrSubmitted(VkQueue queue, uint32_t count,
const VkSubmitInfo *submits) {
static const auto note = ResolveProcessFunction<void (*)(
VkQueue, unsigned, const VkCommandBuffer *)>("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<void (*)(
VkQueue, unsigned, const VkCommandBuffer *)>("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<std::mutex> lock(state.mutex);
@@ -293,6 +358,18 @@ bool EnsureInitialized() {
return true;
}
const auto register_submit =
ResolveProcessFunction<void (*)(VkResult(VKAPI_PTR *)(
PFN_vkQueueSubmit, VkDevice, VkQueue, uint32_t, const VkSubmitInfo *,
VkFence))>("mocktail_vr_xr_queue_submit_adapter");
if (register_submit)
register_submit(mocktail_vulkan_submit_synchronized);
const auto register_idle = ResolveProcessFunction<void (*)(
VkResult(VKAPI_PTR *)(PFN_vkDeviceWaitIdle, VkDevice))>(
"mocktail_vr_xr_device_idle_adapter");
if (register_idle)
register_idle(mocktail_vulkan_idle_synchronized);
const BackendWindowFn backend_window =
ResolveProcessFunction<BackendWindowFn>("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<std::shared_mutex> device_gate(*gate);
const auto mutex = QueueCallMutex(queue);
std::lock_guard<std::mutex> 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<std::shared_mutex> device_gate(*gate);
const auto mutex = QueueCallMutex(queue);
std::lock_guard<std::mutex> 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<std::shared_mutex> device_gate(*gate);
const auto mutex = QueueCallMutex(queue);
std::lock_guard<std::mutex> 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<std::shared_mutex> device_gate(*gate);
const auto mutex = QueueCallMutex(queue);
std::lock_guard<std::mutex> 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<PFN_vkVoidFunction>(function); \
#define MOCKTAIL_VK_PROC(function) \
if (std::strcmp(name, #function) == 0) { \
return reinterpret_cast<PFN_vkVoidFunction>(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<VkResult (*)(
VkQueue, VkDevice, const VkPresentInfoKHR *)>(
"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<std::shared_mutex> device_gate(*gate);
const auto mutex = QueueCallMutex(queue);
std::lock_guard<std::mutex> 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<bool (*)()>("mocktail_vr_desktop_enabled");
unsigned vr_families[256]{};
bool converted_to_concurrent = false;
static const auto shared_families =
ResolveProcessFunction<unsigned (*)(VkDevice, unsigned, unsigned *)>(
"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<bool (*)()>("mocktail_vr_desktop_enabled");
if (vr_desktop_enabled && vr_desktop_enabled()) {
PFN_vkGetPhysicalDeviceSurfaceCapabilitiesKHR caps;
{ std::lock_guard<std::mutex> 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<void (*)(VkDevice, VkSwapchainKHR,
const VkSwapchainCreateInfoKHR*, const VkImage*, unsigned)>("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<void (*)(
VkDevice, VkSwapchainKHR, const VkSwapchainCreateInfoKHR *,
const VkImage *, unsigned, bool)>(
"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<void (*)(VkCommandPool)>(
"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<void (*)(
VkDevice, VkCommandPool, unsigned, const VkCommandBuffer *)>(
"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<void (*)(
VkDevice, VkCommandPool, unsigned, const VkCommandBuffer *)>(
"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<void (*)(VkDevice, VkCommandPool, unsigned)>(
"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<void (*)(VkCommandBuffer)>(
"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<void (*)(VkCommandBuffer)>(
"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<PFN_vkQueueSubmit2>(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"
VKAPI_ATTR VkResult VKAPI_CALL vkCreateCommandPool(
VkDevice device, const VkCommandPoolCreateInfo *info,
const VkAllocationCallbacks *allocator, VkCommandPool *pool) {
const auto create = reinterpret_cast<PFN_vkCreateCommandPool>(
HostDeviceProc(device, "vkCreateCommandPool"));
if (!create)
return VK_ERROR_INITIALIZATION_FAILED;
const VkResult result = create(device, info, allocator, pool);
static const auto note =
ResolveProcessFunction<void (*)(VkDevice, VkCommandPool, unsigned)>(
"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<PFN_vkCmdPipelineBarrier>(
HostDeviceProc(HostDispatchForCommandBuffer(command).device,
"vkCmdPipelineBarrier"));
if (!barrier)
return;
static const auto normalize = ResolveProcessFunction<VkImageLayout (*)(
VkCommandBuffer, VkImage, unsigned *, unsigned *, VkImageLayout *,
VkImageLayout)>("mocktail_vr_xr_desktop_barrier");
std::vector<VkImageMemoryBarrier> 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<PFN_vkCmdPipelineBarrier2>(
HostDeviceProc(HostDispatchForCommandBuffer(command).device, name));
if (!barrier || !info)
return;
static const auto normalize = ResolveProcessFunction<VkImageLayout (*)(
VkCommandBuffer, VkImage, unsigned *, unsigned *, VkImageLayout *,
VkImageLayout)>("mocktail_vr_xr_desktop_barrier");
VkDependencyInfo adjusted_info = *info;
std::vector<VkImageMemoryBarrier2> 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<std::shared_mutex> device_gate(*gate);
const auto mutex = QueueCallMutex(queue);
std::lock_guard<std::mutex> 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<std::shared_mutex> device_gate(*gate);
return raw(device);
}
} // extern "C"
+4 -10
View File
@@ -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;
}
+8 -1
View File
@@ -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 "
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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();
}
+810 -301
View File
File diff suppressed because it is too large. Load diff
+195 -87
View File
@@ -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<std::uintptr_t>(
static_cast<std::int64_t>(next_instruction_rva) + relative);
}
std::atomic<RobloxVrDeviceBridge *> 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<PointerFrameFn> 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<unsigned char, 15> 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<std::mutex> 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<unsigned char *>(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<void *(*)(void *)>(base + 0x263778c);
void *workspace = get_workspace(service);
if (!workspace)
return result;
auto *vtable = *static_cast<std::uintptr_t **>(workspace);
void *camera =
reinterpret_cast<void *(*)(void *)>(vtable[0x3a8 / 8])(workspace);
if (!camera)
return result;
float scale = 0;
int degrees = 0;
std::memcpy(&scale, static_cast<unsigned char *>(camera) + 0x140, 4);
std::memcpy(&degrees, reinterpret_cast<void *>(base + 0x73677a8), 4);
if (internal::ApplyAimToWorldFrame(static_cast<float *>(result), hand, scale,
-static_cast<float>(degrees))) {
static std::atomic<bool> 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<unsigned char *>(base + kPointerFrameRva);
auto* target = reinterpret_cast<unsigned char*>(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<std::uintptr_t>(&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<std::uint8_t, kStateGetterContractSize>
@@ -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<std::uint8_t, 29> 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<std::uint8_t, 7> kVtableStore = {
0x48, 0x8d, 0x05, 0xf2, 0x74, 0x47, 0x03};
static constexpr std::array<std::uint8_t, 3> kVtableStore = {0x48, 0x8d,
0x05};
static constexpr std::array<std::uint8_t, 3> kVptrWrite = {0x48, 0x89, 0x07};
// mov dword ptr [rdi+0x10], 6
static constexpr std::array<std::uint8_t, 7> 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<std::mutex> 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<unsigned char*>(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<void* (*)(void*)>(
base + contract_->workspace_getter_rva);
void* workspace = get_workspace(service);
if (!workspace) return;
auto* vtable = *static_cast<std::uintptr_t**>(workspace);
void* camera = reinterpret_cast<void* (*)(void*)>(
vtable[contract_->camera_vtable_offset / sizeof(void*)])(workspace);
if (!camera) return;
float scale = 0;
int degrees = 0;
std::memcpy(&scale, static_cast<unsigned char*>(camera) + 0x140, 4);
std::memcpy(&degrees,
reinterpret_cast<void*>(base + contract_->hand_pitch_storage_rva),
4);
if (internal::ApplyAimToWorldFrame(static_cast<float*>(result), hand, scale,
-static_cast<float>(degrees))) {
static std::atomic<bool> 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<std::uintptr_t>::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<const std::uint8_t *>(
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<void*>(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<const std::uint8_t*>(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<const unsigned char*>(
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<std::uintptr_t *>(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<const unsigned char*>(image_base + 0x1d48b70) != 0xc3)
{ vtable_ = nullptr; return FailedPrecondition("2998 VR haptic vtable contract mismatch"); }
vtable_ = reinterpret_cast<std::uintptr_t*>(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
+23 -4
View File
@@ -3,6 +3,7 @@
#include <cstddef>
#include <cstdint>
#include <string_view>
#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
+15 -5
View File
@@ -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<std::size_t>(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) {
+12
View File
@@ -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
)
+17
View File
@@ -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");
+22
View File
@@ -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");
+186
View File
@@ -0,0 +1,186 @@
#include "audio/native_input_capture.h"
#include <SDL3/SDL.h>
#include <gtest/gtest.h>
#include <unistd.h>
#include <chrono>
#include <thread>
#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<TestSink*>(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<const std::int16_t*>(pcm);
sink.nonzero |= std::any_of(data, data + samples,
[](auto sample) { return sample != 0; });
++sink.frames;
return samples;
}
void DestroySink(void* self) { ++static_cast<TestOwner*>(self)->destroyed; }
void ReleaseOwner(void* self) { ++static_cast<TestOwner*>(self)->released; }
void OriginalLatency(void*, std::uint32_t* playback, std::uint32_t* recording) {
if (playback) *playback = 42;
if (recording) *recording = 99;
}
const std::array<std::uintptr_t, 3> kSinkVtable{
0, 0, reinterpret_cast<std::uintptr_t>(&ReceivePcm)};
const std::array<std::uintptr_t, 5> kOwnerVtable{
0, 0, reinterpret_cast<std::uintptr_t>(&DestroySink), 0,
reinterpret_cast<std::uintptr_t>(&ReleaseOwner)};
bool TestRelro(std::uintptr_t base, std::uintptr_t rva, std::size_t size) {
return (base + rva == reinterpret_cast<std::uintptr_t>(kSinkVtable.data()) &&
size <= sizeof(kSinkVtable)) ||
(base + rva == reinterpret_cast<std::uintptr_t>(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<unsigned char> 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>(
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<NativeInputCapture> 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_, &parameter));
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_, &parameter));
EXPECT_EQ(parameter.object, &sink_);
Publish(true);
ASSERT_TRUE(NativeInputCapture::Start(&device_, &parameter));
capture_->Shutdown();
EXPECT_EQ(NativeInputCapture::Format(&device_).present, 0u);
EXPECT_FALSE(NativeInputCapture::Recording(&device_));
parameter = {&sink_, &owner_};
EXPECT_FALSE(NativeInputCapture::Start(&device_, &parameter));
EXPECT_EQ(owner_.released, 1);
}
TEST_F(NativeInputCaptureTest, RejectsUnverifiedSinkWithoutTakingOwnership) {
Publish(true);
const std::array<std::uintptr_t, 3> unknown_vtable{};
TestSink unknown{unknown_vtable.data()};
NativeSharedSink parameter{&unknown, &owner_};
EXPECT_FALSE(NativeInputCapture::Start(&device_, &parameter));
EXPECT_EQ(parameter.object, &unknown);
EXPECT_EQ(parameter.owner, &owner_);
EXPECT_FALSE(NativeInputCapture::Recording(&device_));
EXPECT_EQ(owner_.released, 0);
}
} // namespace
} // namespace mocktail::audio
+80
View File
@@ -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<std::uintptr_t, 20> 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<std::size_t, 6> expected_slots =
version == 2998 ? std::array<std::size_t, 6>{4, 11, 12, 13, 14, 15}
: std::array<std::size_t, 6>{4, 13, 14, 15, 16, 17};
EXPECT_EQ(capture->slots, expected_slots);
std::array<std::uintptr_t, 20> 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"));
+353 -9
View File
@@ -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<VkQueue>(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<VkImage>(id);
auto& r = b.images_[image];
@@ -29,16 +41,36 @@ struct OpenXrBackendTestAccess {
b.image_views_[Handle<VkImageView>(id)] = image;
b.framebuffer_views_[Handle<VkFramebuffer>(id)] = {Handle<VkImageView>(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<void*>(id));
VkRenderPassBeginInfo info{};
info.framebuffer = Handle<VkFramebuffer>(id);
info.renderPass = Handle<VkRenderPass>(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<VkImage>(msaa)].samples = VK_SAMPLE_COUNT_4_BIT;
b.framebuffer_views_[Handle<VkFramebuffer>(framebuffer)].insert(
b.framebuffer_views_[Handle<VkFramebuffer>(framebuffer)].begin(),
Handle<VkImageView>(msaa));
}
static void ResolvedPass(OpenXrBackend& b, RobloxVrDeviceBridge& bridge,
void* owner, int framebuffer) {
bridge.OnEyeGetterCall(owner, 0, Handle<void*>(framebuffer));
VkRenderPassBeginInfo info{};
info.framebuffer = Handle<VkFramebuffer>(framebuffer);
info.renderPass = Handle<VkRenderPass>(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<VkImage>(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<VkDevice>(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<VkQueue>(30);
expected_semaphores[0] = Access::Handle<VkSemaphore>(31);
expected_semaphores[1] = Access::Handle<VkSemaphore>(32);
info.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
info.waitSemaphoreCount = 2;
info.pWaitSemaphores = expected_semaphores;
}
VkResult Run() {
return internal::WaitForPresentDependencies(
Access::Handle<VkDevice>(2), expected_queue, info,
Access::Handle<VkFence>(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<VkDevice>(2);
const auto graphics = Access::Handle<VkQueue>(30);
const auto same_family = Access::Handle<VkQueue>(31);
const auto split_family = Access::Handle<VkQueue>(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<VkQueue>(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<void*>(100);
const auto device = Access::Handle<VkDevice>(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<VkImage>(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<void*>(100);
Access::Attachment(backend, 10, owner, Access::Handle<VkDevice>(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<VkDevice>(2);
const VkQueue graphics = Access::Handle<VkQueue>(30);
const VkQueue present = Access::Handle<VkQueue>(31);
const VkQueue graphics_second = Access::Handle<VkQueue>(32);
const VkCommandPool graphics_pool = Access::Handle<VkCommandPool>(40);
const VkCommandPool present_pool = Access::Handle<VkCommandPool>(41);
const VkCommandBuffer draw = Access::Handle<VkCommandBuffer>(50);
const VkCommandBuffer acquire = Access::Handle<VkCommandBuffer>(51);
const VkSwapchainKHR chain = Access::Handle<VkSwapchainKHR>(60);
const VkImage desktop = Access::Handle<VkImage>(61);
void* const owner = Access::Handle<void*>(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<unsigned>{3, 4}));
EXPECT_TRUE(
backend.DesktopQueueFamilies(Access::Handle<VkDevice>(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<VkImage>(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<VkImage>(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
+1 -1
View File
@@ -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:
+214
View File
@@ -0,0 +1,214 @@
#include <gtest/gtest.h>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <mutex>
#include <thread>
#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<int> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<VkDevice>(2));
});
bool entered = false;
{
std::unique_lock<std::mutex> lock(state_mutex);
entered = state_changed.wait_for(lock, std::chrono::seconds(1),
[] { return waiting; });
}
const auto semaphore = reinterpret_cast<VkSemaphore>(32);
VkSubmitInfo signal{};
signal.signalSemaphoreCount = 1;
signal.pSignalSemaphores = &semaphore;
std::thread submitter([&] {
EXPECT_EQ(mocktail_vulkan_submit_synchronized(
Submit, reinterpret_cast<VkDevice>(2),
reinterpret_cast<VkQueue>(30), 1, &signal, VK_NULL_HANDLE),
VK_SUCCESS);
});
{
std::unique_lock<std::mutex> 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<VkDevice>(2));
});
bool entered = false;
{
std::unique_lock<std::mutex> lock(state_mutex);
entered = state_changed.wait_for(lock, std::chrono::seconds(1),
[] { return waiting; });
}
const auto semaphore = reinterpret_cast<VkSemaphore>(32);
VkSubmitInfo signal{};
signal.signalSemaphoreCount = 1;
signal.pSignalSemaphores = &semaphore;
EXPECT_EQ(mocktail_vulkan_submit_synchronized(
Submit, reinterpret_cast<VkDevice>(3),
reinterpret_cast<VkQueue>(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<VkQueue>(30);
const auto signal_queue = reinterpret_cast<VkQueue>(31);
const auto semaphore = reinterpret_cast<VkSemaphore>(32);
VkPresentInfoKHR present{};
present.waitSemaphoreCount = 1;
present.pWaitSemaphores = &semaphore;
VkResult prepared = VK_NOT_READY;
std::thread presenter([&] {
prepared = mocktail::vr::internal::WaitForPresentDependencies(
reinterpret_cast<VkDevice>(2), graphics, present,
reinterpret_cast<VkFence>(33), Reset, Submit, Wait);
});
bool entered = false;
{
std::unique_lock<std::mutex> 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<VkDevice>(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<VkQueue>(30);
auto call = [&] {
EXPECT_EQ(mocktail_vulkan_submit_synchronized(
SlowSubmit, reinterpret_cast<VkDevice>(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<VkQueue>(30);
const auto semaphore = reinterpret_cast<VkSemaphore>(32);
VkPresentInfoKHR present{};
present.waitSemaphoreCount = 1;
present.pWaitSemaphores = &semaphore;
VkResult result = VK_NOT_READY;
std::thread presenter([&] {
result = mocktail::vr::internal::WaitForPresentDependencies(
reinterpret_cast<VkDevice>(2), queue, present,
reinterpret_cast<VkFence>(33), Reset, Submit, Wait);
});
{
std::unique_lock<std::mutex> 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<VkDevice>(2),
queue, 0, nullptr, VK_NULL_HANDLE),
VK_SUCCESS);
{
std::lock_guard<std::mutex> 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
+126 -12
View File
@@ -54,6 +54,11 @@ const std::vector<uint8_t> 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<uint8_t> ValidConstructor() {
std::vector<uint8_t> code(kConstructorContractSize, 0x90);
const std::vector<uint8_t> prologue = {
@@ -70,6 +75,7 @@ std::vector<uint8_t> 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<uint8_t> 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<std::uint8_t, 0x1b2> 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<std::int64_t>(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<uint8_t> 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<uint8_t> 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<std::uint8_t, internal::kStateCopySize> 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<float>::quiet_NaN();
ASSERT_TRUE(internal::ApplyXrPose(state.data(), pose));
EXPECT_EQ(state[internal::kExtraRecordOffset], 0);
}
TEST(VrHandPoseBridge, UntrackedOrNonFiniteHandsStayInvalidWithoutTouchingHead) {
std::array<std::uint8_t, internal::kStateCopySize> state;
state.fill(0);
+57 -14
View File
@@ -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 ----
+42 -5
View File
@@ -1,11 +1,15 @@
#include "mocktail/vr/xr_actions.h"
#include <gtest/gtest.h>
#include <openxr/openxr.h>
#include <algorithm>
#include <cstring>
#include <map>
#include <string>
#include "runtime/roblox_input_router.h"
#include "runtime/roblox_xr_controller_input.h"
#include <cstring>
#include <gtest/gtest.h>
#include <map>
#include <openxr/openxr.h>
#include <string>
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<XrPath, std::vector<XrPath>> 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);