Full support camera

NOT TESTED
This commit is contained in:
komaruworld committed 2026-09-13 18:20:58 +03:00
1 parent dd23f06a8b
commit b187efbedf
45 files changed
+7476 -333

No files matched your search

+3
View File
@@ -305,6 +305,9 @@ target_include_directories(mocktail_runtime PUBLIC include)
target_include_directories(mocktail_runtime PUBLIC
${MOCKTAIL_JNI_INCLUDE_DIRS}
)
target_compile_definitions(mocktail_runtime PUBLIC
MOCKTAIL_VR_DEFAULT_ENABLED=$<BOOL:${MOCKTAIL_ENABLE_VR}>
)
target_compile_definitions(mocktail_runtime PRIVATE
"MOCKTAIL_DISCORD_APPLICATION_ID=\"${MOCKTAIL_DISCORD_APPLICATION_ID}\""
)
+20 -15
View File
@@ -3,11 +3,23 @@
include_guard(GLOBAL)
option(MOCKTAIL_ENABLE_VR "Build experimental native OpenXR support" OFF)
option(MOCKTAIL_ENABLE_VR "Build experimental native OpenXR support (VR branch default)" ON)
add_library(mocktail_vr STATIC src/vr/diagnostics.cc)
add_library(mocktail_vr STATIC
src/vr/diagnostics.cc
src/vr/openxr_backend_mode.cc
src/vr/vr_perf.cc
src/vr/vr_mirror.cc
src/vr/roblox_vr_device_bridge.cc)
add_library(Mocktail::VR ALIAS mocktail_vr)
target_include_directories(mocktail_vr PUBLIC "${CMAKE_SOURCE_DIR}/include")
target_include_directories(mocktail_vr PRIVATE "${CMAKE_SOURCE_DIR}/src")
target_link_libraries(mocktail_vr PUBLIC Vulkan::Headers)
# shm_open for the desktop eye mirror lives in librt on glibc.
find_library(MOCKTAIL_RT_LIBRARY rt)
if(MOCKTAIL_RT_LIBRARY)
target_link_libraries(mocktail_vr PUBLIC ${MOCKTAIL_RT_LIBRARY})
endif()
mocktail_apply_compile_options(mocktail_vr)
if(MOCKTAIL_ENABLE_VR)
@@ -34,11 +46,12 @@ if(MOCKTAIL_ENABLE_VR)
"${CMAKE_BINARY_DIR}/third_party/OpenXR-SDK" EXCLUDE_FROM_ALL)
endfunction()
mocktail_add_openxr_sdk()
target_sources(mocktail_vr PRIVATE src/vr/openxr_probe.cc src/vr/openxr_preview.cc)
# Error unwinding is confined to this native session implementation; its
# public entry point catches errors before returning to the guest runtime.
set_source_files_properties(src/vr/openxr_preview.cc PROPERTIES
COMPILE_OPTIONS -fexceptions)
target_sources(mocktail_vr PRIVATE src/vr/openxr_probe.cc src/vr/openxr_preview.cc
src/vr/openxr_backend.cc)
# Error unwinding is confined to these native session implementations; their
# public entry points catch errors before returning to the guest runtime.
set_source_files_properties(src/vr/openxr_preview.cc src/vr/openxr_backend.cc
PROPERTIES COMPILE_OPTIONS -fexceptions)
target_compile_definitions(mocktail_vr PRIVATE XR_USE_GRAPHICS_API_VULKAN)
target_link_libraries(mocktail_vr PRIVATE OpenXR::openxr_loader Vulkan::Headers ${CMAKE_DL_LIBS})
else()
@@ -50,11 +63,3 @@ set_target_properties(mocktail_vr_probe PROPERTIES OUTPUT_NAME mocktail-vr-probe
target_link_libraries(mocktail_vr_probe PRIVATE Mocktail::VR)
mocktail_apply_compile_options(mocktail_vr_probe)
install(TARGETS mocktail_vr_probe RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}")
# Development launcher for an independently built Monado runtime. --prepare
# keeps its shared library and a relative manifest under this build directory.
configure_file("${CMAKE_SOURCE_DIR}/scripts/vr_simulated.py"
"${CMAKE_BINARY_DIR}/mocktail-vr-simulated" COPYONLY)
file(CHMOD "${CMAKE_BINARY_DIR}/mocktail-vr-simulated"
PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE GROUP_READ GROUP_EXECUTE
WORLD_READ WORLD_EXECUTE)
+10 -6
View File
@@ -49,12 +49,16 @@ graphics:
# Optional presentation synchronization override: auto, on, or off.
# vsync: off
vr:
# Boolean (default: false): launch the Roblox experimental VR path.
# Only Build ID 2998 is currently accepted; eye resources and OpenXR output
# are NOT yet connected. Standalone scene: mocktail-vr-probe --scene.
# Use --no-vr to launch Roblox normally.
enabled: false
# vr:
# Boolean (default: enabled in VR builds, disabled in non-VR builds).
# Experimental VR for the exact Build ID 2998
# payload. Mocktail initializes the guest DebugDeviceVR eye resources (two
# distinct 500x500 eye framebuffers), the Roblox renderer draws both eyes
# every frame, and the frames are submitted to the active OpenXR runtime as
# a stereo projection layer with head pose (runtime-tracked, or scripted for
# desktop testing). Controllers are NOT connected yet. Standalone OpenXR
# test scene: mocktail-vr-probe --scene. Use --no-vr to launch normally.
# enabled: false
performance:
# Boolean (default: false): size Roblox scheduler/render queues from every
+10 -1
View File
@@ -81,7 +81,16 @@
"current_method_rva": "0x32d0f30",
"select_method_rva": "0x32d0cb4"
},
"reason": "Verified 2026-09-06: exact Build-ID profile passed two isolated no-recovery Tier C Vulkan canaries and real game sessions, including fullscreen state synchronization and host FMOD output-device interposition; legacy executable patches and signal recovery remain disabled."
"vr_debug_device_bridge": {
"vtable_rva": "0x6c87b38",
"constructor_rva": "0x3810614",
"state_getter_rva": "0x38109f6",
"eye_getter_rva": "0x38109e6",
"eye_initializer_rva": "0x3810a18",
"emulator_flag_storage_rva": "0x7369920",
"device_create_framebuffer_vtable_offset": "0x180"
},
"reason": "Verified 2026-09-06: exact Build-ID profile passed two isolated no-recovery Tier C Vulkan canaries and real game sessions, including fullscreen state synchronization and host FMOD output-device interposition; legacy executable patches and signal recovery remain disabled. Experimental vr_debug_device_bridge metadata (DebugDeviceVR constructor/state/eye/initializer RVAs) is machine-validated at activation and gated behind --vr; native stereo output is not yet verified end-to-end."
}
]
}
+17
View File
@@ -24,6 +24,21 @@ struct FmodOutputDeviceBridgeProfile {
std::uintptr_t select_method_rva = 0;
};
// Exact-build metadata for the experimental native VR bridge. The bridge
// interposes only DebugDeviceVR virtual slots (state getter and eye
// framebuffer getter) and invokes the guest eye-resource initializer through
// its own vtable; executable guest code is never modified. All RVAs are
// validated against machine-code contracts before the first use.
struct VrDebugDeviceBridgeProfile {
std::uintptr_t vtable_rva = 0;
std::uintptr_t constructor_rva = 0;
std::uintptr_t state_getter_rva = 0;
std::uintptr_t eye_getter_rva = 0;
std::uintptr_t eye_initializer_rva = 0;
std::uintptr_t emulator_flag_storage_rva = 0;
std::uintptr_t device_create_framebuffer_vtable_offset = 0;
};
struct BuildProfile {
std::string version_name;
int version_code = 0;
@@ -41,6 +56,8 @@ struct BuildProfile {
// output-device virtual slots and restores them during controlled teardown;
// executable guest code is never modified.
std::optional<FmodOutputDeviceBridgeProfile> fmod_output_device_bridge;
// Optional exact-build experimental VR capability; see the struct above.
std::optional<VrDebugDeviceBridgeProfile> vr_debug_device_bridge;
std::string reason;
};
+286
View File
@@ -0,0 +1,286 @@
#ifndef MOCKTAIL_VR_OPENXR_BACKEND_H_
#define MOCKTAIL_VR_OPENXR_BACKEND_H_
#include <vulkan/vulkan.h>
#include <atomic>
#include <cstdint>
#include <mutex>
#include <string>
#include <unordered_map>
#include <vector>
#include "mocktail/status.h"
#include "mocktail/vr/vr_mirror.h"
namespace mocktail::vr {
// Resolved --vr output mode. kXrOutput drives the real Roblox stereo images
// into an OpenXR projection layer; kNativeOnly keeps the diagnostic native
// stereo prototype without any runtime requirement. Selected through
// MOCKTAIL_VR_BACKEND ("xr" default, "native" diagnostic).
enum class VrBackendMode {
kDisabled,
kNativeOnly,
kXrOutput,
};
// Explicit XR_RUNTIME_JSON wins. Otherwise prefer a native WiVRn manifest,
// then let the OpenXR loader use the registered runtime (including Flatpak WiVRn).
std::string SelectVrRuntimeManifest(const std::string& explicit_manifest,
const std::vector<std::string>& candidates);
std::string VrRuntimeUnavailableHint(bool user_selected, const std::string& manifest);
VrBackendMode ResolveVrBackendMode(bool vr_enabled);
const char* VrBackendModeName(VrBackendMode mode);
// One deterministic head pose sample. Position is metres, orientation is a
// quaternion (x, y, z, w) in the OpenXR LOCAL reference space.
struct ScriptedPoseSample {
float position[3] = {0.f, 0.f, 0.f};
float orientation[4] = {0.f, 0.f, 0.f, 1.f};
float eye_offset[2][3] = {}; // metres in head space
float eye_fov[2][4] = {}; // left, right, up, down angles
std::uint64_t frame = 0;
bool valid = false;
};
// Production OpenXR backend for the native stereo prototype.
//
// Lifecycle: main arms the backend before Roblox startup (xrCreateInstance
// and xrGetSystem fail fast with a clear message when no runtime exists).
// The Vulkan adapter delegates the guest's vkCreateInstance/vkCreateDevice
// through xrCreateVulkanInstanceKHR/xrCreateVulkanDeviceKHR so the runtime's
// extension and GPU requirements are satisfied by the very device Roblox
// renders with. The session is created on that device; per-frame XR work runs
// on the render thread from the adapter's host-present notification:
// xrEndFrame(N) with the two eye images of frame N copied into the
// projection swapchains, then xrWaitFrame/xrBeginFrame/xrLocateViews for
// frame N+1 and publishing the scripted/runtime head pose that the device
// bridge injects into Roblox before its eye passes run.
//
// Eye image provenance is recorded in the adapter during the guest eye
// initializer (owner = DebugDeviceVR object) and bound to an eye index only
// through the real per-frame eye-getter request plus the actual render-pass
// attachment, mirroring the validated evidence-layer semantics. Creation
// order or size alone never selects an eye.
//
// All public entry points are exception-free at their boundary; the
// implementation is compiled with exceptions like openxr_preview.cc and
// translates failures into Status/log outcomes.
class OpenXrBackend final {
public:
OpenXrBackend() = default;
~OpenXrBackend();
OpenXrBackend(const OpenXrBackend&) = delete;
OpenXrBackend& operator=(const OpenXrBackend&) = delete;
// Claims the process-wide backend slot and creates XrInstance/system.
Status Arm();
// Idempotent full teardown (XR resources first, then the instance).
void Disarm();
bool armed() const { return armed_.load(std::memory_order_acquire); }
// Adapter delegation. Return false when the backend does not own creation
// (caller falls back to the plain host loader path).
bool CreateVulkanInstance(const VkInstanceCreateInfo* create_info,
const VkAllocationCallbacks* allocator,
VkInstance* instance, VkResult* result);
bool CreateVulkanDevice(VkPhysicalDevice physical_device,
const VkDeviceCreateInfo* create_info,
const VkAllocationCallbacks* allocator,
VkDevice* device, VkResult* result);
void NoteDeviceDestroyed(VkDevice device);
void NoteInstanceDestroyed(VkInstance instance);
void NoteQueue(VkDevice device, VkQueue queue, std::uint32_t family,
std::uint32_t index);
// Resource provenance recording (adapter wrappers).
bool WantsResourceRecords() const {
return recording_.load(std::memory_order_acquire);
}
void RecordImage(VkDevice device, VkImage image,
const VkImageCreateInfo* info);
void RecordImageView(VkImageView view, VkImage image);
void RecordFramebuffer(VkFramebuffer framebuffer,
const VkFramebufferCreateInfo* info);
void RecordRenderPass(VkRenderPass render_pass,
const VkRenderPassCreateInfo* info);
void NoteDestroyImage(VkImage image);
void NoteDestroyImageView(VkImageView view);
void NoteDestroyFramebuffer(VkFramebuffer framebuffer);
void NoteRenderPassBegin(VkCommandBuffer command_buffer,
const VkRenderPassBeginInfo* info);
bool EyeBindingComplete() const {
return eyes_bound_.load(std::memory_order_acquire);
}
// Render-thread frame cycle at host present begin.
void NoteHostPresent(VkQueue queue, VkDevice device);
// Pose published for the frame that is about to render. The device bridge
// reads it once per frame on the render thread and injects it into the
// guest. valid=false until the XR session located a usable head pose.
ScriptedPoseSample PublishedHeadPose() const;
void NotePoseApplied(void* owner, std::uint64_t frame);
// Diagnostics.
std::uint64_t submitted_frames() const {
return submitted_frames_.load(std::memory_order_relaxed);
}
std::uint64_t skipped_frames() const {
return skipped_frames_.load(std::memory_order_relaxed);
}
bool session_running() const {
return session_running_.load(std::memory_order_relaxed);
}
std::string runtime_name() const;
private:
friend struct OpenXrBackendTestAccess;
struct ImageRecord {
VkImage image = VK_NULL_HANDLE;
VkDevice device = VK_NULL_HANDLE;
std::uint32_t width = 0;
std::uint32_t height = 0;
VkFormat format = VK_FORMAT_UNDEFINED;
VkImageUsageFlags usage = 0;
void* owner = nullptr;
std::uint64_t order = 0;
};
struct EyeBinding {
bool valid = false;
std::uint64_t frame = 0;
VkImage image = VK_NULL_HANDLE;
VkFramebuffer framebuffer = VK_NULL_HANDLE;
VkImageLayout final_layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
void* owner = nullptr;
std::uint32_t width = 0;
std::uint32_t height = 0;
VkFormat format = VK_FORMAT_UNDEFINED;
};
struct EyeSwapchain {
void* swapchain = nullptr; // XrSwapchain (opaque to keep the header XR-free)
std::vector<VkImage> images;
std::uint32_t acquired_index = 0;
bool image_acquired = false;
std::uint32_t width = 0;
std::uint32_t height = 0;
VkFormat format = VK_FORMAT_UNDEFINED;
};
bool InitializeSessionLocked(VkPhysicalDevice physical_device,
VkDevice device,
const VkDeviceCreateInfo* create_info,
std::string* error);
void DisarmLocked();
void* ResolveFunction(const char* name) const;
void SetupDeviceAfterCreationLocked(VkPhysicalDevice physical_device,
VkDevice device,
const VkDeviceCreateInfo* create_info);
void TeardownSessionLocked(const char* reason, bool preserve_device = false);
void HandleSessionState(int state);
void HandleReferenceSpaceChange(std::int64_t change_time);
bool RecoverSessionLocked(std::uint64_t now_ns);
void PollSessionEvents();
// Drops the pose/images of the frame currently in flight without touching
// guest ownership, so the next xrEndFrame submits no layer instead of an
// image paired with a pose that no longer describes the same origin.
void InvalidateInFlightPose(const char* reason);
void RunFrameCycle(VkQueue queue, VkDevice device);
bool CreateSwapchains(std::string* error);
void DestroySwapchains();
bool EnsureCopyResources(VkDevice device, std::uint32_t queue_family,
std::string* error);
void DestroyCopyResources();
// Records both eye copies (and, when the mirror is enabled, both staging
// readbacks) into one command buffer, submits once and waits on one fence.
// Replaces the previous per-eye submit+fence pair.
bool CopyEyesIntoSwapchains(VkQueue queue);
bool CaptureEyeEvidence(VkQueue queue, int eye);
ScriptedPoseSample ComputeScriptedPose(std::uint64_t frame) const;
void EnsureMirrorForExtent(std::uint32_t width, std::uint32_t height);
MirrorVkProcs MirrorProcs() const;
mutable std::mutex mutex_;
std::atomic<bool> armed_{false};
std::atomic<bool> recording_{false};
std::atomic<bool> eyes_bound_{false};
std::atomic<bool> session_running_{false};
std::atomic<std::uint64_t> submitted_frames_{0};
std::atomic<std::uint64_t> skipped_frames_{0};
// XR handles are stored as opaque pointers in the header; the implementation
// casts them back. This keeps vulkan-only consumers free of OpenXR headers.
void* xr_instance_ = nullptr;
std::uint64_t xr_system_ = 0;
void* xr_session_ = nullptr;
void* xr_local_space_ = nullptr;
void* xr_view_space_ = nullptr;
EyeSwapchain eye_swapchains_[2];
int xr_blend_mode_ = 0;
// Runtime name captured at xrCreateInstance so the bind-time diagnostics line
// can report it without re-entering runtime_name() under the held mutex.
std::string runtime_name_;
VkInstance vk_instance_ = VK_NULL_HANDLE;
VkDevice vk_device_ = VK_NULL_HANDLE;
VkPhysicalDevice vk_physical_device_ = VK_NULL_HANDLE;
std::uint32_t vk_queue_family_ = 0;
void* vk_loader_ = nullptr;
struct VkProcs;
VkProcs* vk_ = nullptr;
VkCommandPool copy_pool_ = VK_NULL_HANDLE;
VkCommandBuffer copy_command_ = VK_NULL_HANDLE;
VkFence copy_fence_ = VK_NULL_HANDLE;
// Optional desktop two-eye mirror (MOCKTAIL_VR_MIRROR_SHM). Publishes the
// latest complete pair through shared memory; inert when unset.
VrMirror mirror_;
std::unordered_map<VkImage, ImageRecord> images_;
std::unordered_map<VkImageView, VkImage> image_views_;
std::unordered_map<VkFramebuffer, std::vector<VkImageView>> framebuffer_views_;
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_;
std::uint64_t image_order_ = 0;
EyeBinding eyes_[2];
// Frame cycle state (render thread only).
bool frame_open_ = false;
bool frame_should_render_ = false;
// A running session is hidden in SYNCHRONIZED; shouldRender still governs rendering.
bool visibility_lost_ = false;
bool session_recovery_pending_ = false;
bool test_session_loss_injected_ = false;
std::uint64_t next_session_retry_ns_ = 0;
std::uint64_t frame_display_time_ = 0;
struct ViewState {
float position[3];
float orientation[4];
float fov[4]; // angleLeft, angleRight, angleUp, angleDown (radians)
bool valid;
};
ViewState frame_views_[2] = {};
struct PoseStorage {
float position[3] = {0.f, 0.f, 0.f};
float orientation[4] = {0.f, 0.f, 0.f, 1.f};
};
PoseStorage frame_view_poses_[2];
bool frame_views_valid_ = false;
// One-shot guard so a canted-view runtime reports the rejection reason once
// instead of once per frame per eye.
bool canted_rejection_logged_ = false;
mutable ScriptedPoseSample published_pose_;
std::uint64_t xr_frame_counter_ = 0;
std::unordered_map<void*, std::uint64_t> applied_poses_;
};
// Process-wide single backend owned by main. Adapter hooks resolve these
// exported C symbols; they are inert while no backend is armed.
OpenXrBackend* ActiveVrBackend();
} // namespace mocktail::vr
#endif // MOCKTAIL_VR_OPENXR_BACKEND_H_
+4
View File
@@ -43,6 +43,10 @@ ProbeReport ProbeOpenXr();
std::string FormatProbeReport(const ProbeReport& report);
int ProbeExitCode(ProbeStatus status);
// False in builds configured without MOCKTAIL_ENABLE_VR; --vr fails closed
// early in that case instead of starting Roblox without VR support.
bool IsOpenXrSupportCompiled();
} // namespace mocktail::vr
#endif // MOCKTAIL_VR_OPENXR_PROBE_H_
@@ -0,0 +1,113 @@
#ifndef MOCKTAIL_VR_ROBLOX_VR_DEVICE_BRIDGE_H_
#define MOCKTAIL_VR_ROBLOX_VR_DEVICE_BRIDGE_H_
#include <atomic>
#include <cstdint>
#include <mutex>
#include "compat/build_profile.h"
#include "mocktail/status.h"
namespace mocktail::vr {
namespace internal {
struct VrBridgeTestAccess;
}
// Experimental native stereo bring-up for the exact Build-ID-scoped
// RBX::Graphics::DebugDeviceVR object of the guest client.
//
// The bridge interposes the state and eye getters of the exact guest build.
// Resource readiness is checked on the live object during each state call;
// initialization never dereferences cached guest object/device pointers. The
// guest owns resource destruction. Recreated objects and reused addresses
// therefore need no cache invalidation. Initialization happens only on a thread
// observed presenting. OpenXR image submission and poses are not connected yet.
class RobloxVrDeviceBridge final {
public:
using StateGetterFn = void *(*)(void *result_buffer, void *device_object);
using EyeGetterFn = void *(*)(void *device_object, int eye_index);
using EyeInitializerFn = void (*)(void *device_object, void *graphics_device);
RobloxVrDeviceBridge() = default;
~RobloxVrDeviceBridge();
RobloxVrDeviceBridge(const RobloxVrDeviceBridge &) = delete;
RobloxVrDeviceBridge &operator=(const RobloxVrDeviceBridge &) = delete;
// Arms the bridge for an exact-build profile that carries
// vr_debug_device_bridge metadata. Claims the process-wide bridge slot.
Status Install(const compat::BuildProfile &profile);
// Validates machine contracts at the loaded image base and interposes the
// two DebugDeviceVR vtable slots. Called once, after libroblox relocations
// and before any guest startup code runs.
Status Activate(std::uintptr_t image_base);
// Restores the interposed vtable slots and disarms. Safe to call twice.
void Shutdown();
bool installed() const { return installed_; }
bool active() const { return active_.load(std::memory_order_acquire); }
// Render-thread notification from the Vulkan adapter's host present path.
void NoteHostPresent();
// Guest-side hooks invoked by the interposed vtable stubs.
void OnStateGetterCall(void *result_buffer, void *device_object);
void OnEyeGetterCall(void *device_object, int eye_index, void *framebuffer);
StateGetterFn original_state_getter() const {
return original_state_getter_.load(std::memory_order_acquire);
}
EyeGetterFn original_eye_getter() const {
return original_eye_getter_.load(std::memory_order_acquire);
}
// Cumulative initialization diagnostics (not a count of live devices).
std::uintptr_t image_base() const {
return image_base_.load(std::memory_order_acquire);
}
int captured_device_count() const;
int initialized_device_count() const;
std::uint64_t present_count() const {
return present_count_.load(std::memory_order_relaxed);
}
std::uint64_t eye_getter_call_count(int eye_index) const;
private:
friend struct internal::VrBridgeTestAccess;
static constexpr std::uint64_t kEyeGetterSummaryInterval = 600;
bool ValidateObjectHeader(void *self) const;
void *ValidatedGraphicsDevice(void *self) const;
void TryInitializeEyes(void *self);
bool RestoreVtableLocked();
mutable std::mutex mutex_;
compat::VrDebugDeviceBridgeProfile profile_{};
bool installed_ = false;
std::atomic<bool> active_{false};
std::atomic<std::uintptr_t> image_base_{0};
std::atomic<std::uintptr_t> image_size_{0};
std::uintptr_t *vtable_ = nullptr;
std::uintptr_t original_state_slot_ = 0;
std::uintptr_t original_eye_slot_ = 0;
std::atomic<StateGetterFn> original_state_getter_{nullptr};
std::atomic<EyeGetterFn> original_eye_getter_{nullptr};
std::atomic<std::uint64_t> present_thread_{0};
std::atomic<std::uint64_t> present_count_{0};
std::atomic<bool> flag_warning_logged_{false};
std::atomic<int> captured_count_{0};
std::atomic<int> initialized_count_{0};
mutable std::atomic<std::uint64_t> eye_call_totals_[2] = {};
};
// Process-wide single active bridge, mirroring the audio output-device
// bridge ownership model. Install claims the slot; the destructor releases
// it. legacy_runtime forwards the loaded libroblox image base through
// NotifyRobloxVrImageLoaded; it is a successful no-op while no bridge is
// armed, and fail-closed once one is.
Status NotifyRobloxVrImageLoaded(std::uintptr_t image_base);
} // namespace mocktail::vr
#endif // MOCKTAIL_VR_ROBLOX_VR_DEVICE_BRIDGE_H_
+105
View File
@@ -0,0 +1,105 @@
#ifndef MOCKTAIL_VR_MIRROR_H_
#define MOCKTAIL_VR_MIRROR_H_
#include <vulkan/vulkan.h>
#include <cstdint>
#include <string>
#include "mocktail/vr/vr_mirror_shm.h"
namespace mocktail::vr {
// The subset of device/instance procs the mirror needs, supplied by the XR
// backend from its already-resolved guest-device table. Keeping this explicit
// avoids a second proc table and keeps the mirror independently testable.
struct MirrorVkProcs {
PFN_vkCreateBuffer CreateBuffer = nullptr;
PFN_vkDestroyBuffer DestroyBuffer = nullptr;
PFN_vkGetBufferMemoryRequirements GetBufferMemoryRequirements = nullptr;
PFN_vkAllocateMemory AllocateMemory = nullptr;
PFN_vkFreeMemory FreeMemory = nullptr;
PFN_vkBindBufferMemory BindBufferMemory = nullptr;
PFN_vkMapMemory MapMemory = nullptr;
PFN_vkUnmapMemory UnmapMemory = nullptr;
PFN_vkCmdCopyImageToBuffer CmdCopyImageToBuffer = nullptr;
PFN_vkCmdPipelineBarrier CmdPipelineBarrier = nullptr;
PFN_vkGetPhysicalDeviceMemoryProperties GetPhysicalDeviceMemoryProperties =
nullptr;
};
// Producer side of the desktop two-eye mirror. Maps a POSIX shared-memory
// segment (created with the name from MOCKTAIL_VR_MIRROR_SHM) and publishes
// the latest complete eye pair under a seqlock. It owns two persistent,
// bounded host-visible staging buffers reused across frames; the GPU->staging
// copies are recorded into the caller's single batched command buffer so the
// mirror adds no extra queue submission or fence wait.
//
// The mirror never touches the filesystem on the frame path and keeps only the
// newest pair (one latest slot), so a slow consumer drops rather than queues
// old frames.
class VrMirror {
public:
VrMirror() = default;
~VrMirror();
VrMirror(const VrMirror&) = delete;
VrMirror& operator=(const VrMirror&) = delete;
bool enabled() const { return mapping_ != nullptr; }
// Opens the shared segment. Returns false (and stays disabled) when the name
// is empty or mapping fails; the XR path then runs without a mirror.
bool Open(const std::string& shm_name, std::uint32_t width,
std::uint32_t height, std::string* error);
void Close();
// Ensures the two staging buffers match width*height*4 bytes on this device.
// Allocates once; a size/device change recreates them. Existing contents are
// discarded on resize.
bool EnsureStaging(const MirrorVkProcs& vk, VkDevice device,
VkPhysicalDevice physical, std::uint32_t width,
std::uint32_t height, std::string* error);
void DestroyStaging(const MirrorVkProcs& vk);
// Records a source-eye -> staging-buffer copy for one eye into cmd, adding
// the layout barriers around it. The source is returned to final_layout so
// the next guest render pass sees it unchanged.
void RecordEyeCopy(const MirrorVkProcs& vk, VkCommandBuffer cmd, int eye,
VkImage source, VkImageLayout final_layout,
std::uint32_t width, std::uint32_t height);
// After the batched fence wait completed, copies both staging buffers into
// the shared segment and bumps the seqlock with the frame id and a monotonic
// timestamp. No-op when staging does not cover width*height.
void Publish(std::uint64_t frame, VkFormat format, std::uint32_t width,
std::uint32_t height, std::uint64_t now_ns);
std::uint64_t published_pairs() const { return published_pairs_; }
std::uint32_t staging_width() const { return staging_width_; }
std::uint32_t staging_height() const { return staging_height_; }
const void* mapping() const { return mapping_; }
// Test seam: publish a pair from caller-supplied staging bytes without any
// Vulkan resources, exercising the same seqlock/segment write as the GPU
// path. Both buffers must hold width*height*4 bytes.
void PublishFromBytesForTest(std::uint64_t frame, VkFormat format,
std::uint32_t width, std::uint32_t height,
std::uint64_t now_ns, const void* eye0,
const void* eye1);
private:
void* mapping_ = nullptr;
std::uint64_t mapping_bytes_ = 0;
std::string shm_name_;
VkBuffer staging_[2] = {VK_NULL_HANDLE, VK_NULL_HANDLE};
VkDeviceMemory staging_memory_[2] = {VK_NULL_HANDLE, VK_NULL_HANDLE};
void* staging_map_[2] = {nullptr, nullptr};
std::uint32_t staging_width_ = 0;
std::uint32_t staging_height_ = 0;
VkDevice staging_device_ = VK_NULL_HANDLE;
std::uint64_t published_pairs_ = 0;
};
} // namespace mocktail::vr
#endif // MOCKTAIL_VR_MIRROR_H_
+42
View File
@@ -0,0 +1,42 @@
#ifndef MOCKTAIL_VR_MIRROR_SHM_H_
#define MOCKTAIL_VR_MIRROR_SHM_H_
#include <cstdint>
namespace mocktail::vr {
// Shared-memory transport for the desktop two-eye mirror. The producer (XR
// backend, inside the client process) publishes the latest complete eye pair
// under a seqlock; the consumer (desktop viewer) reads the newest pair without
// any per-frame filesystem transport, image encode/decode, or allocation.
//
// Layout is fixed and little-endian.
// sizeof(VrMirrorHeader) == kVrMirrorHeaderBytes.
struct VrMirrorHeader {
std::uint32_t magic; // kVrMirrorMagic
std::uint32_t header_bytes; // kVrMirrorHeaderBytes (pixel area offset)
std::uint64_t seq; // seqlock: odd while writing, even when stable
std::uint64_t frame; // XR frame id the pair belongs to
std::uint32_t width; // eye width in pixels
std::uint32_t height; // eye height in pixels
std::uint32_t format; // VkFormat of the source eye images
std::uint32_t flags; // bit0: a complete, valid pair is present
std::uint64_t producer_ns; // CLOCK_MONOTONIC publish timestamp
std::uint64_t published_pairs;
std::uint64_t reserved;
};
inline constexpr std::uint32_t kVrMirrorMagic = 0x3152564D; // 'MVR1'
inline constexpr std::uint32_t kVrMirrorHeaderBytes = 64;
inline constexpr std::uint32_t kVrMirrorFlagValid = 1u << 0;
// Total mapped bytes for a given eye extent (header + two RGBA/BGRA images).
inline std::uint64_t VrMirrorBytes(std::uint32_t width, std::uint32_t height) {
return static_cast<std::uint64_t>(kVrMirrorHeaderBytes) +
2ull * static_cast<std::uint64_t>(width) *
static_cast<std::uint64_t>(height) * 4ull;
}
} // namespace mocktail::vr
#endif // MOCKTAIL_VR_MIRROR_SHM_H_
+97
View File
@@ -0,0 +1,97 @@
#ifndef MOCKTAIL_VR_PERF_H_
#define MOCKTAIL_VR_PERF_H_
#include <cstdint>
#include <string>
#include <vector>
namespace mocktail::vr::perf {
// Opt-in, low-overhead stage timing and counter instrumentation. Collection is
// compiled out at runtime unless MOCKTAIL_VR_PERF is set, so the steady-state
// path only pays a single cached boolean test per record.
bool Enabled();
enum class Stage {
kFrameCycle,
kXrWaitFrame,
kXrBeginFrame,
kXrLocateViews,
kSwapchainAcquire,
kSwapchainWait,
kCopyRecord,
kCopySubmit,
kCopyFenceWait,
kMirrorPublish,
kEvidence,
kStageCount,
};
enum class Counter {
kPresents,
kFramesBegun,
kFramesSubmitted,
kFramesSkipped,
kSkipShouldRender,
kSkipPoseInvalid,
kSkipEyesUnbound,
kSkipEyeFrameMismatch,
kSubmits,
kFenceWaits,
kMirrorPairs,
kMirrorDrops,
kSkipVisibilityLost,
kReferenceSpaceChanges,
kVisibilityLost,
kVisibilityGained,
kFramesInvalidated,
kCounterCount,
};
const char* StageName(Stage stage);
const char* CounterName(Counter counter);
// Bounded reservoir per stage: the most recent N samples are kept and ranked
// only when a report is emitted (once per interval), never on the frame path.
class Collector {
public:
static constexpr std::size_t kReservoir = 4096;
static constexpr std::uint64_t kIntervalNs = 1'000'000'000ULL;
void Record(Stage stage, std::uint64_t nanos);
void Count(Counter counter, std::uint64_t delta = 1);
// Emits one distribution line per active stage plus a counter line when at
// least kIntervalNs elapsed since the previous emission. Cheap no-op
// otherwise. now_ns is a CLOCK_MONOTONIC timestamp supplied by the caller so
// the collector stays clock-agnostic and testable.
void EmitIfDue(std::uint64_t now_ns, const char* context);
// Test/inspection accessors.
std::uint64_t counter(Counter counter_id) const;
std::vector<std::uint64_t> Percentiles(Stage stage) const; // p50,p95,p99,max
std::size_t Samples(Stage stage) const;
void Reset();
private:
struct StageStat {
std::vector<std::uint64_t> samples; // ring, bounded by kReservoir
std::size_t head = 0;
std::size_t count = 0;
std::uint64_t total_ns = 0;
};
StageStat stages_[static_cast<std::size_t>(Stage::kStageCount)]{};
std::uint64_t counters_[static_cast<std::size_t>(Counter::kCounterCount)]{};
std::uint64_t last_emit_ns_ = 0;
std::uint64_t window_start_ns_ = 0;
};
// Process-wide collector used by the XR backend frame cycle.
Collector& ProcessCollector();
// Monotonic nanoseconds (CLOCK_MONOTONIC), 0 on failure.
std::uint64_t NowNs();
} // namespace mocktail::vr::perf
#endif // MOCKTAIL_VR_PERF_H_
+171
View File
@@ -0,0 +1,171 @@
#ifndef MOCKTAIL_VR_POSE_MATH_H_
#define MOCKTAIL_VR_POSE_MATH_H_
#include <cmath>
#include <cstdint>
namespace mocktail::vr::pose {
// Coordinate spaces and units used across the whole camera path, documented so
// every stage is auditable:
//
// OpenXR head/views metres, right-handed, +Y up, -Z forward, LOCAL space
// -> published pose metres, quaternion (x, y, z, w), unchanged units
// -> guest state metres; the exact-build native helper converts metres
// to studs itself (factor 10/3). This layer must never
// pre-apply that scale.
// -> camera engine composes
// out.rotation = base.rotation * head.rotation
// out.position = base.position
// + base.rotation * head.position * HeadScale
// where HeadScale is an engine value distinct from the
// metres-to-studs conversion factor.
// -> render one pass per eye using the per-eye offset/FOV
// -> XR submission metres again, the very pose used to render the pair.
struct Vec3 {
float x = 0.f, y = 0.f, z = 0.f;
};
struct Quat {
float x = 0.f, y = 0.f, z = 0.f, w = 1.f;
};
struct Pose {
Vec3 position{};
Quat orientation{};
};
inline constexpr float kPi = 3.14159265358979323846f;
inline Quat QuatMultiply(const Quat& a, const Quat& b) {
return Quat{
a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z,
};
}
inline Quat QuatConjugate(const Quat& q) { return Quat{-q.x, -q.y, -q.z, q.w}; }
inline float QuatNorm(const Quat& q) {
return std::sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w);
}
// Returns false for a non-finite or near-zero quaternion; the output is left
// untouched in that case so callers never publish a fabricated rotation.
inline bool QuatNormalize(const Quat& in, Quat* out) {
if (out == nullptr) {
return false;
}
if (!std::isfinite(in.x) || !std::isfinite(in.y) || !std::isfinite(in.z) ||
!std::isfinite(in.w)) {
return false;
}
const float norm = QuatNorm(in);
if (!(norm > 1e-6f) || !std::isfinite(norm)) {
return false;
}
const float inverse = 1.f / norm;
*out = Quat{in.x * inverse, in.y * inverse, in.z * inverse, in.w * inverse};
return true;
}
// q and -q denote the same rotation. Used to prove the path is sign-invariant.
inline bool QuatSameRotation(const Quat& a, const Quat& b, float tolerance) {
const float dot = a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w;
return std::abs(std::abs(dot) - 1.f) <= tolerance;
}
inline Vec3 QuatRotate(const Quat& q, const Vec3& v) {
// t = 2 * cross(q.xyz, v); v' = v + q.w * t + cross(q.xyz, t)
const Vec3 u{q.x, q.y, q.z};
const Vec3 cross1{u.y * v.z - u.z * v.y, u.z * v.x - u.x * v.z,
u.x * v.y - u.y * v.x};
const Vec3 t{2.f * cross1.x, 2.f * cross1.y, 2.f * cross1.z};
const Vec3 cross2{u.y * t.z - u.z * t.y, u.z * t.x - u.x * t.z,
u.x * t.y - u.y * t.x};
return Vec3{v.x + q.w * t.x + cross2.x, v.y + q.w * t.y + cross2.y,
v.z + q.w * t.z + cross2.z};
}
// result = a * b in SE(3): apply b first, then a.
inline Pose PoseMultiply(const Pose& a, const Pose& b) {
Pose result;
result.orientation = QuatMultiply(a.orientation, b.orientation);
const Vec3 rotated = QuatRotate(a.orientation, b.position);
result.position = Vec3{a.position.x + rotated.x, a.position.y + rotated.y,
a.position.z + rotated.z};
return result;
}
inline Pose PoseInverse(const Pose& p) {
Pose result;
result.orientation = QuatConjugate(p.orientation);
const Vec3 neg{-p.position.x, -p.position.y, -p.position.z};
result.position = QuatRotate(result.orientation, neg);
return result;
}
// Rotation for a head turned by yaw about +Y then pitched about +X, matching
// the scripted diagnostic motion. Quaternion order is (x, y, z, w).
inline Quat QuatFromYawPitch(float yaw, float pitch) {
const float cy = std::cos(yaw * 0.5f), sy = std::sin(yaw * 0.5f);
const float cp = std::cos(pitch * 0.5f), sp = std::sin(pitch * 0.5f);
return Quat{cy * sp, sy * cp, -sy * sp, cy * cp};
}
inline Quat QuatFromAxisAngle(const Vec3& axis, float angle) {
const float half = angle * 0.5f;
const float s = std::sin(half);
return Quat{axis.x * s, axis.y * s, axis.z * s, std::cos(half)};
}
// Per-eye FOV as the runtime reports it: angleLeft, angleRight, angleUp,
// angleDown in radians, each signed relative to the eye's forward axis.
struct EyeFov {
float angle_left = 0.f;
float angle_right = 0.f;
float angle_up = 0.f;
float angle_down = 0.f;
};
// The guest state stores projection tangents, not angles. The 2998 layout is
// up, down, left, right in that order; down/left are negated because the
// guest's tangent space measures them in the opposite direction. Getting this
// order or a sign wrong flips or shears the image, so it is pinned by test.
struct ProjectionTangents {
float up = 0.f;
float down = 0.f;
float left = 0.f;
float right = 0.f;
};
inline ProjectionTangents FovToTangents(const EyeFov& fov) {
return ProjectionTangents{std::tan(fov.angle_up), std::tan(-fov.angle_down),
std::tan(-fov.angle_left), std::tan(fov.angle_right)};
}
// The pinned guest state can only represent two parallel eye orientations.
// A canted (rotated) relative eye pose needs a different camera ABI, so it is
// rejected explicitly rather than silently submitted with a projection that
// does not match how the image was rendered.
inline constexpr float kCantedTolerance = 0.001f;
inline bool EyeOrientationIsParallel(const Quat& relative_orientation) {
return std::abs(relative_orientation.x) <= kCantedTolerance &&
std::abs(relative_orientation.y) <= kCantedTolerance &&
std::abs(relative_orientation.z) <= kCantedTolerance;
}
// Metres-to-studs factor the exact-build native helper applies itself. Exposed
// only so tests can prove this layer does NOT pre-apply it.
inline constexpr float kMetresToStuds = 10.f / 3.f;
// Expected stud value for a metre offset, for numerical unit checks.
inline float MetresToStuds(float metres) { return metres * kMetresToStuds; }
} // namespace mocktail::vr::pose
#endif // MOCKTAIL_VR_POSE_MATH_H_
+5
View File
@@ -62,6 +62,11 @@ bool MergeRuntimeClientSettingsOverrides(const FrameRatePolicy& frame_rate,
std::string* merged_json,
std::string* error);
// The resolved VR mode owns this flag in both client-settings channels.
bool MergeVrClientSettingsOverrides(bool enabled, std::string_view base_json,
std::string* merged_json,
std::string* error);
// Keeps the legacy test bypass disabled. The host PermissionsProtocol now
// answers microphone authorization from capture configuration. No permission
// query opens a recording device.
+6 -1
View File
@@ -15,6 +15,11 @@
namespace mocktail {
namespace runtime {
#ifndef MOCKTAIL_VR_DEFAULT_ENABLED
#define MOCKTAIL_VR_DEFAULT_ENABLED 0
#endif
inline constexpr bool kDefaultVrEnabled = MOCKTAIL_VR_DEFAULT_ENABLED != 0;
enum class GraphicsBackend {
kAuto,
kSystem,
@@ -137,7 +142,7 @@ class RuntimeConfig {
private:
bool headless_ = false;
bool vr_enabled_ = false;
bool vr_enabled_ = kDefaultVrEnabled;
bool vr_valid_ = true;
std::filesystem::path roblox_library_path_ = "rbx_bin/libroblox.so";
GraphicsBackend graphics_backend_ = GraphicsBackend::kVulkan;
-187
View File
@@ -1,187 +0,0 @@
#!/usr/bin/env python3
"""Launch the VR preview with a separately built Monado simulated runtime."""
import argparse
import json
import os
from pathlib import Path
import select
import shutil
import signal
import subprocess
import sys
import tempfile
def positive_integer(value):
number = int(value)
if not 0 < number <= 0xFFFFFFFF:
raise argparse.ArgumentTypeError("must be a positive 32-bit integer")
return number
def prepare_runtime(source, destination):
"""Keep the development runtime beside the build, independent of /tmp."""
source = source.resolve()
document = json.loads(source.read_text())
library = Path(document["runtime"]["library_path"])
if not library.is_absolute():
library = source.parent / library
if not library.is_file():
raise ValueError(f"Runtime library does not exist: {library}")
destination.mkdir(parents=True, exist_ok=True)
output_library = destination / "libopenxr_monado.so"
if library.resolve() != output_library.resolve():
shutil.copy2(library, output_library)
output_manifest = destination / "runtime.json"
output_manifest.write_text(json.dumps({
"file_format_version": "1.0.0",
"runtime": {"name": "Monado (Mocktail simulator)",
"library_path": "./libopenxr_monado.so"},
}, indent=2) + "\n")
print(f"Prepared simulator: {output_manifest}")
def simulator_environment(parent, manifest, display, wayland, software):
environment = parent.copy()
# These overrides belong to this child process; no active_runtime.json is
# written and the user's WiVRn/system runtime selection remains intact.
environment.update(
XR_RUNTIME_JSON=str(manifest.resolve()),
SIMULATED_ENABLE="1",
XRT_COMPOSITOR_NULL="0",
XRT_COMPOSITOR_FORCE_XCB="1" if display else "0",
XRT_COMPOSITOR_FORCE_WAYLAND="1" if not display and wayland else "0",
XRT_COMPOSITOR_FORCE_NVIDIA="0",
XRT_COMPOSITOR_FORCE_RANDR="0",
XRT_COMPOSITOR_FORCE_WAYLAND_DIRECT="0",
XRT_COMPOSITOR_FORCE_VK_DISPLAY="-1",
XRT_COMPOSITOR_XCB_FULLSCREEN="0",
)
environment.setdefault("XRT_COMPOSITOR_DEFAULT_FRAMERATE", "30")
if display:
environment["DISPLAY"] = display
environment.pop("WAYLAND_DISPLAY", None)
if software:
candidates = [directory / "lvp_icd.json" for directory in
(Path("/usr/share/vulkan/icd.d"), Path("/usr/local/share/vulkan/icd.d"))]
manifest_path = next((path for path in candidates if path.is_file()), None)
if manifest_path is None:
raise ValueError("Mesa Lavapipe is not installed; omit --software to use your GPU")
environment["VK_DRIVER_FILES"] = str(manifest_path)
environment.pop("VK_ICD_FILENAMES", None)
environment["XRT_COMPOSITOR_FORCE_GPU_INDEX"] = "0"
environment["XRT_COMPOSITOR_FORCE_CLIENT_GPU_INDEX"] = "0"
return environment
def main():
location = Path(__file__).resolve().parent
default_build = location.parent / "build" if location.name == "scripts" else location
parser = argparse.ArgumentParser(
description="Show the Mocktail VR test scene on the desktop using a simulated Monado headset.")
parser.add_argument("--build-dir", type=Path, default=default_build,
help="Mocktail build directory (defaults to the directory of this launcher)")
parser.add_argument("--runtime", type=Path,
help="Manifest of an in-process Monado build with the simulated driver enabled")
parser.add_argument("--prepare", type=Path, metavar="MONADO_MANIFEST",
help="Copy an already built Monado runtime next to this build, then exit")
parser.add_argument("--frames", type=positive_integer,
help="Request exit after this many frames; otherwise run until Ctrl+C")
parser.add_argument("--software", action="store_true", help="Use Mesa Lavapipe instead of a physical GPU")
parser.add_argument("--headless", action="store_true",
help="Use a private Xvfb display for automated checks (defaults to 180 frames)")
args = parser.parse_args()
build = args.build_dir.resolve()
if args.prepare:
prepare_runtime(args.prepare, build / "vr-simulator")
return 0
manifest = args.runtime or build / "vr-simulator/runtime.json"
if not manifest.is_file():
parser.error("No simulated Monado runtime is prepared. Use --runtime /path/to/openxr_monado-dev.json "
"or --prepare /path/to/openxr_monado-dev.json with a separately built Monado runtime.")
probe = build / "mocktail-vr-probe"
if not probe.is_file():
parser.error(f"Build mocktail_vr_probe with MOCKTAIL_ENABLE_VR=ON first: {probe}")
display = os.environ.get("DISPLAY")
wayland = os.environ.get("WAYLAND_DISPLAY")
if not args.headless and not (display or wayland):
parser.error("Run from a graphical desktop terminal to see the scene, or use --headless for an automated check.")
xvfb = None
try:
if args.headless:
if not shutil.which("Xvfb"):
parser.error("--headless requires Xvfb")
read_fd, write_fd = os.pipe()
try:
xvfb = subprocess.Popen(
["Xvfb", "-displayfd", str(write_fd), "-screen", "0", "1280x720x24", "-nolisten", "tcp"],
pass_fds=(write_fd,), stdout=subprocess.DEVNULL, start_new_session=True)
except OSError:
os.close(read_fd)
raise
finally:
os.close(write_fd)
with os.fdopen(read_fd) as number:
if not select.select([number], [], [], 10)[0]:
raise ValueError("Xvfb did not start within 10 seconds")
display_number = number.readline().strip()
if not display_number.isdigit():
raise ValueError("Xvfb failed to allocate a display")
display = ":" + display_number
wayland = None
env = simulator_environment(os.environ, manifest, display, wayland, args.software)
# Keep simulator settings and runtime files private and temporary. The
# desktop's XDG_RUNTIME_DIR is retained for Wayland authentication.
with tempfile.TemporaryDirectory(prefix="mocktail-vr-simulated-") as directory:
root = Path(directory)
env["XDG_CONFIG_HOME"] = str(root / "config")
env["XDG_DATA_HOME"] = str(root / "data")
if not env.get("XDG_RUNTIME_DIR"):
env["XDG_RUNTIME_DIR"] = str(root)
command = [str(probe), "--scene"]
frames = args.frames or (180 if args.headless else None)
if frames:
command += ["--frames", str(frames)]
print(f"Simulated Monado headset: {manifest.resolve()}", flush=True)
print("The test scene will appear in a desktop window. Ctrl+C exits." if not args.headless
else "Checking the test scene on a private Xvfb display.", flush=True)
child = None
previous = {}
def forward_signal(signum, _frame):
if child is not None and child.poll() is None:
child.send_signal(signum)
try:
child = subprocess.Popen(command, env=env, start_new_session=True)
for signum in (signal.SIGINT, signal.SIGTERM):
previous[signum] = signal.signal(signum, forward_signal)
return child.wait()
finally:
if child is not None and child.poll() is None:
child.terminate()
try:
child.wait(timeout=10)
except subprocess.TimeoutExpired:
child.kill()
child.wait()
for signum, handler in previous.items():
signal.signal(signum, handler)
finally:
if xvfb is not None:
xvfb.terminate()
try:
xvfb.wait(timeout=5)
except subprocess.TimeoutExpired:
xvfb.kill()
xvfb.wait()
if __name__ == "__main__":
try:
sys.exit(main())
except (OSError, ValueError, KeyError) as error:
print(f"VR simulator: {error}", file=sys.stderr)
sys.exit(1)
+55 -1
View File
@@ -126,6 +126,57 @@ bool ParseOptionalFmodOutputDeviceBridge(
return true;
}
bool ParseOptionalVrDebugDeviceBridge(
const Json& object,
std::optional<VrDebugDeviceBridgeProfile>* bridge_profile) {
if (bridge_profile == nullptr) {
return false;
}
bridge_profile->reset();
const auto field = object.find("vr_debug_device_bridge");
if (field == object.end() || field->is_null()) {
return true;
}
if (!field->is_object()) {
return false;
}
std::optional<std::uintptr_t> vtable_rva;
std::optional<std::uintptr_t> constructor_rva;
std::optional<std::uintptr_t> state_getter_rva;
std::optional<std::uintptr_t> eye_getter_rva;
std::optional<std::uintptr_t> eye_initializer_rva;
std::optional<std::uintptr_t> emulator_flag_storage_rva;
std::optional<std::uintptr_t> device_create_framebuffer_vtable_offset;
if (!ParseOptionalRva(*field, "vtable_rva", &vtable_rva) ||
!ParseOptionalRva(*field, "constructor_rva", &constructor_rva) ||
!ParseOptionalRva(*field, "state_getter_rva", &state_getter_rva) ||
!ParseOptionalRva(*field, "eye_getter_rva", &eye_getter_rva) ||
!ParseOptionalRva(*field, "eye_initializer_rva", &eye_initializer_rva) ||
!ParseOptionalRva(*field, "emulator_flag_storage_rva",
&emulator_flag_storage_rva) ||
!ParseOptionalRva(*field, "device_create_framebuffer_vtable_offset",
&device_create_framebuffer_vtable_offset) ||
!vtable_rva.has_value() || !constructor_rva.has_value() ||
!state_getter_rva.has_value() || !eye_getter_rva.has_value() ||
!eye_initializer_rva.has_value() ||
!emulator_flag_storage_rva.has_value() ||
!device_create_framebuffer_vtable_offset.has_value()) {
return false;
}
*bridge_profile = VrDebugDeviceBridgeProfile{
*vtable_rva,
*constructor_rva,
*state_getter_rva,
*eye_getter_rva,
*eye_initializer_rva,
*emulator_flag_storage_rva,
*device_create_framebuffer_vtable_offset,
};
return true;
}
} // namespace
std::string_view BuildStatusName(BuildStatus status) noexcept {
@@ -189,6 +240,7 @@ ProfileLookupResult FindBuildProfile(const std::string& manifest_path,
const Json* reason = RequiredField(entry, "reason");
std::optional<std::uintptr_t> fullscreen_setter_rva;
std::optional<FmodOutputDeviceBridgeProfile> fmod_output_device_bridge;
std::optional<VrDebugDeviceBridgeProfile> vr_debug_device_bridge;
if (version_name == nullptr || !version_name->is_string() ||
version_code == nullptr || !version_code->is_number_integer() ||
profile_build_id == nullptr || !profile_build_id->is_string() ||
@@ -205,7 +257,8 @@ ProfileLookupResult FindBuildProfile(const std::string& manifest_path,
"user_game_settings_fullscreen_setter_rva",
&fullscreen_setter_rva) ||
!ParseOptionalFmodOutputDeviceBridge(
entry, &fmod_output_device_bridge)) {
entry, &fmod_output_device_bridge) ||
!ParseOptionalVrDebugDeviceBridge(entry, &vr_debug_device_bridge)) {
return Failure("compatibility profile has missing or invalid fields");
}
@@ -240,6 +293,7 @@ ProfileLookupResult FindBuildProfile(const std::string& manifest_path,
allow_host_constructor_replay->get<bool>(),
fullscreen_setter_rva,
fmod_output_device_bridge,
vr_debug_device_bridge,
reason->get_ref<const std::string&>(),
};
}
+387 -7
View File
@@ -121,6 +121,33 @@ VKAPI_ATTR VkResult VKAPI_CALL vkGetPhysicalDeviceSurfaceCapabilitiesKHR(
VKAPI_ATTR VkResult VKAPI_CALL vkGetPhysicalDeviceSurfacePresentModesKHR(
VkPhysicalDevice physical_device, VkSurfaceKHR surface,
std::uint32_t* present_mode_count, VkPresentModeKHR* present_modes);
VKAPI_ATTR VkResult VKAPI_CALL vkCreateImage(
VkDevice device, const VkImageCreateInfo* create_info,
const VkAllocationCallbacks* allocator, VkImage* image);
VKAPI_ATTR void VKAPI_CALL vkDestroyImage(VkDevice device, VkImage image,
const VkAllocationCallbacks* allocator);
VKAPI_ATTR VkResult VKAPI_CALL vkCreateImageView(
VkDevice device, const VkImageViewCreateInfo* create_info,
const VkAllocationCallbacks* allocator, VkImageView* view);
VKAPI_ATTR void VKAPI_CALL
vkDestroyImageView(VkDevice device, VkImageView image_view,
const VkAllocationCallbacks* allocator);
VKAPI_ATTR VkResult VKAPI_CALL vkCreateFramebuffer(
VkDevice device, const VkFramebufferCreateInfo* create_info,
const VkAllocationCallbacks* allocator, VkFramebuffer* framebuffer);
VKAPI_ATTR void VKAPI_CALL
vkDestroyFramebuffer(VkDevice device, VkFramebuffer framebuffer,
const VkAllocationCallbacks* allocator);
VKAPI_ATTR VkResult VKAPI_CALL vkCreateRenderPass(
VkDevice device, const VkRenderPassCreateInfo* create_info,
const VkAllocationCallbacks* allocator, VkRenderPass* render_pass);
VKAPI_ATTR void VKAPI_CALL
vkDestroyRenderPass(VkDevice device, VkRenderPass render_pass,
const VkAllocationCallbacks* allocator);
VKAPI_ATTR void VKAPI_CALL
vkCmdBeginRenderPass(VkCommandBuffer command_buffer,
const VkRenderPassBeginInfo* begin_info,
VkSubpassContents contents);
}
namespace {
@@ -130,6 +157,30 @@ using UsesDirectVulkanFn = bool (*)();
using NotePresentFn = void (*)();
using NoteHostPresentBeginFn = void (*)();
using NoteHostPresentEndFn = void (*)(std::int32_t);
using NoteVrPresentBeginFn = void (*)();
using VrXrCreateInstanceFn = bool (*)(const VkInstanceCreateInfo*,
const VkAllocationCallbacks*,
VkInstance*, VkResult*);
using VrXrCreateDeviceFn = bool (*)(VkPhysicalDevice,
const VkDeviceCreateInfo*,
const VkAllocationCallbacks*, VkDevice*,
VkResult*);
using VrXrNoteDeviceDestroyedFn = void (*)(VkDevice);
using VrXrNoteQueueFn = void (*)(VkDevice, VkQueue, std::uint32_t,
std::uint32_t);
using VrXrRecordImageFn = void (*)(VkDevice, VkImage,
const VkImageCreateInfo*);
using VrXrRecordImageViewFn = void (*)(VkImageView, VkImage);
using VrXrRecordFramebufferFn = void (*)(VkFramebuffer,
const VkFramebufferCreateInfo*);
using VrXrRecordRenderPassFn = void (*)(VkRenderPass,
const VkRenderPassCreateInfo*);
using VrXrNoteDestroyImageFn = void (*)(VkImage);
using VrXrNoteDestroyImageViewFn = void (*)(VkImageView);
using VrXrNoteDestroyFramebufferFn = void (*)(VkFramebuffer);
using VrXrNoteRenderPassBeginFn = void (*)(VkCommandBuffer,
const VkRenderPassBeginInfo*);
using VrXrNotePresentFn = void (*)(VkQueue, VkDevice);
using NoteVulkanCallBeginFn = std::uint64_t (*)(const char*);
using NoteVulkanCallEndFn = void (*)(std::uint64_t, std::int32_t);
using NoteSurfaceOutOfDateFn = void (*)();
@@ -160,6 +211,15 @@ struct AdapterState {
PFN_vkBeginCommandBuffer begin_command_buffer = nullptr;
PFN_vkEndCommandBuffer end_command_buffer = nullptr;
PFN_vkResetCommandBuffer reset_command_buffer = nullptr;
PFN_vkCreateImage create_image = nullptr;
PFN_vkDestroyImage destroy_image = nullptr;
PFN_vkCreateImageView create_image_view = nullptr;
PFN_vkDestroyImageView destroy_image_view = nullptr;
PFN_vkCreateFramebuffer create_framebuffer = nullptr;
PFN_vkDestroyFramebuffer destroy_framebuffer = nullptr;
PFN_vkCreateRenderPass create_render_pass = nullptr;
PFN_vkDestroyRenderPass destroy_render_pass = nullptr;
PFN_vkCmdBeginRenderPass cmd_begin_render_pass = nullptr;
};
struct QueueBinding {
@@ -191,6 +251,21 @@ struct AdapterState {
std::atomic<NotePresentFn> note_present{nullptr};
std::atomic<NoteHostPresentBeginFn> note_host_present_begin{nullptr};
std::atomic<NoteHostPresentEndFn> note_host_present_end{nullptr};
std::atomic<NoteVrPresentBeginFn> note_vr_present_begin{nullptr};
std::atomic<VrXrCreateInstanceFn> vr_create_instance{nullptr};
std::atomic<VrXrCreateDeviceFn> vr_create_device{nullptr};
std::atomic<VrXrNoteDeviceDestroyedFn> vr_note_device_destroyed{nullptr};
std::atomic<VrXrNoteQueueFn> vr_note_queue{nullptr};
std::atomic<VrXrRecordImageFn> vr_record_image{nullptr};
std::atomic<VrXrRecordImageViewFn> vr_record_image_view{nullptr};
std::atomic<VrXrRecordFramebufferFn> vr_record_framebuffer{nullptr};
std::atomic<VrXrRecordRenderPassFn> vr_record_render_pass{nullptr};
std::atomic<VrXrNoteDestroyImageFn> vr_note_destroy_image{nullptr};
std::atomic<VrXrNoteDestroyImageViewFn> vr_note_destroy_image_view{nullptr};
std::atomic<VrXrNoteDestroyFramebufferFn>
vr_note_destroy_framebuffer{nullptr};
std::atomic<VrXrNoteRenderPassBeginFn> vr_note_render_pass_begin{nullptr};
std::atomic<VrXrNotePresentFn> vr_note_present{nullptr};
std::atomic<NoteVulkanCallBeginFn> note_vulkan_call_begin{nullptr};
std::atomic<NoteVulkanCallEndFn> note_vulkan_call_end{nullptr};
std::atomic<NoteSurfaceOutOfDateFn> note_surface_out_of_date{nullptr};
@@ -234,6 +309,59 @@ bool EnsureInitialized() {
ResolveProcessFunction<NoteHostPresentEndFn>(
"mocktail_window_note_vulkan_host_present_end"),
std::memory_order_release);
state.note_vr_present_begin.store(
ResolveProcessFunction<NoteVrPresentBeginFn>(
"mocktail_vr_note_host_present_begin"),
std::memory_order_release);
state.vr_create_instance.store(
ResolveProcessFunction<VrXrCreateInstanceFn>(
"mocktail_vr_xr_create_vulkan_instance"),
std::memory_order_release);
state.vr_create_device.store(
ResolveProcessFunction<VrXrCreateDeviceFn>(
"mocktail_vr_xr_create_vulkan_device"),
std::memory_order_release);
state.vr_note_device_destroyed.store(
ResolveProcessFunction<VrXrNoteDeviceDestroyedFn>(
"mocktail_vr_xr_note_device_destroyed"),
std::memory_order_release);
state.vr_note_queue.store(
ResolveProcessFunction<VrXrNoteQueueFn>("mocktail_vr_xr_note_queue"),
std::memory_order_release);
state.vr_record_image.store(
ResolveProcessFunction<VrXrRecordImageFn>("mocktail_vr_xr_record_image"),
std::memory_order_release);
state.vr_record_image_view.store(
ResolveProcessFunction<VrXrRecordImageViewFn>(
"mocktail_vr_xr_record_image_view"),
std::memory_order_release);
state.vr_record_framebuffer.store(
ResolveProcessFunction<VrXrRecordFramebufferFn>(
"mocktail_vr_xr_record_framebuffer"),
std::memory_order_release);
state.vr_record_render_pass.store(
ResolveProcessFunction<VrXrRecordRenderPassFn>(
"mocktail_vr_xr_record_render_pass"),
std::memory_order_release);
state.vr_note_destroy_image.store(
ResolveProcessFunction<VrXrNoteDestroyImageFn>(
"mocktail_vr_xr_note_destroy_image"),
std::memory_order_release);
state.vr_note_destroy_image_view.store(
ResolveProcessFunction<VrXrNoteDestroyImageViewFn>(
"mocktail_vr_xr_note_destroy_image_view"),
std::memory_order_release);
state.vr_note_destroy_framebuffer.store(
ResolveProcessFunction<VrXrNoteDestroyFramebufferFn>(
"mocktail_vr_xr_note_destroy_framebuffer"),
std::memory_order_release);
state.vr_note_render_pass_begin.store(
ResolveProcessFunction<VrXrNoteRenderPassBeginFn>(
"mocktail_vr_xr_note_render_pass_begin"),
std::memory_order_release);
state.vr_note_present.store(
ResolveProcessFunction<VrXrNotePresentFn>("mocktail_vr_xr_note_present"),
std::memory_order_release);
const auto call_begin = ResolveProcessFunction<NoteVulkanCallBeginFn>(
"mocktail_window_note_vulkan_call_begin");
const auto call_end = ResolveProcessFunction<NoteVulkanCallEndFn>(
@@ -489,6 +617,24 @@ void RegisterHostDeviceDispatch(VkDevice device,
get_device_proc_addr(device, "vkEndCommandBuffer"));
dispatch.reset_command_buffer = reinterpret_cast<PFN_vkResetCommandBuffer>(
get_device_proc_addr(device, "vkResetCommandBuffer"));
dispatch.create_image = reinterpret_cast<PFN_vkCreateImage>(
get_device_proc_addr(device, "vkCreateImage"));
dispatch.destroy_image = reinterpret_cast<PFN_vkDestroyImage>(
get_device_proc_addr(device, "vkDestroyImage"));
dispatch.create_image_view = reinterpret_cast<PFN_vkCreateImageView>(
get_device_proc_addr(device, "vkCreateImageView"));
dispatch.destroy_image_view = reinterpret_cast<PFN_vkDestroyImageView>(
get_device_proc_addr(device, "vkDestroyImageView"));
dispatch.create_framebuffer = reinterpret_cast<PFN_vkCreateFramebuffer>(
get_device_proc_addr(device, "vkCreateFramebuffer"));
dispatch.destroy_framebuffer = reinterpret_cast<PFN_vkDestroyFramebuffer>(
get_device_proc_addr(device, "vkDestroyFramebuffer"));
dispatch.create_render_pass = reinterpret_cast<PFN_vkCreateRenderPass>(
get_device_proc_addr(device, "vkCreateRenderPass"));
dispatch.destroy_render_pass = reinterpret_cast<PFN_vkDestroyRenderPass>(
get_device_proc_addr(device, "vkDestroyRenderPass"));
dispatch.cmd_begin_render_pass = reinterpret_cast<PFN_vkCmdBeginRenderPass>(
get_device_proc_addr(device, "vkCmdBeginRenderPass"));
AdapterState& state = State();
std::lock_guard<std::mutex> lock(state.mutex);
@@ -727,6 +873,15 @@ PFN_vkVoidFunction AdapterProc(const char* name) {
MOCKTAIL_VK_PROC(vkQueueWaitIdle)
MOCKTAIL_VK_PROC(vkDeviceWaitIdle)
MOCKTAIL_VK_PROC(vkQueuePresentKHR)
MOCKTAIL_VK_PROC(vkCreateImage)
MOCKTAIL_VK_PROC(vkDestroyImage)
MOCKTAIL_VK_PROC(vkCreateImageView)
MOCKTAIL_VK_PROC(vkDestroyImageView)
MOCKTAIL_VK_PROC(vkCreateFramebuffer)
MOCKTAIL_VK_PROC(vkDestroyFramebuffer)
MOCKTAIL_VK_PROC(vkCreateRenderPass)
MOCKTAIL_VK_PROC(vkDestroyRenderPass)
MOCKTAIL_VK_PROC(vkCmdBeginRenderPass)
#undef MOCKTAIL_VK_PROC
return nullptr;
}
@@ -758,7 +913,16 @@ bool IsDeviceAdapterProc(const char* name) {
std::strcmp(name, "vkQueueBindSparse") == 0 ||
std::strcmp(name, "vkQueueWaitIdle") == 0 ||
std::strcmp(name, "vkDeviceWaitIdle") == 0 ||
std::strcmp(name, "vkQueuePresentKHR") == 0);
std::strcmp(name, "vkQueuePresentKHR") == 0 ||
std::strcmp(name, "vkCreateImage") == 0 ||
std::strcmp(name, "vkDestroyImage") == 0 ||
std::strcmp(name, "vkCreateImageView") == 0 ||
std::strcmp(name, "vkDestroyImageView") == 0 ||
std::strcmp(name, "vkCreateFramebuffer") == 0 ||
std::strcmp(name, "vkDestroyFramebuffer") == 0 ||
std::strcmp(name, "vkCreateRenderPass") == 0 ||
std::strcmp(name, "vkDestroyRenderPass") == 0 ||
std::strcmp(name, "vkCmdBeginRenderPass") == 0);
}
bool IsGlobalAdapterProc(const char* name) {
@@ -988,10 +1152,12 @@ struct FpsWaitTrace {
if (count == 0) {
return;
}
std::fprintf(stderr, " [fps] %s n=%llu avg=%llu us max=%llu us\n", name,
std::fprintf(stderr, " [fps] %s n=%llu avg=%llu us max=%llu us start_ns=%llu end_ns=%llu\n", name,
static_cast<unsigned long long>(count),
static_cast<unsigned long long>(total / count / 1000ULL),
static_cast<unsigned long long>(peak / 1000ULL));
static_cast<unsigned long long>(peak / 1000ULL),
static_cast<unsigned long long>(window),
static_cast<unsigned long long>(start_ns));
}
};
@@ -1028,6 +1194,16 @@ ObservedHostQueuePresent(VkQueue queue, const VkPresentInfoKHR* present_info) {
if (note_begin != nullptr) {
note_begin();
}
const NoteVrPresentBeginFn vr_note_begin =
state.note_vr_present_begin.load(std::memory_order_acquire);
if (vr_note_begin != nullptr) {
vr_note_begin();
}
const VrXrNotePresentFn vr_note_present =
state.vr_note_present.load(std::memory_order_acquire);
if (vr_note_present != nullptr) {
vr_note_present(queue, HostDispatchForQueue(queue).device);
}
const bool fps_trace = FpsTraceEnabled();
const std::uint64_t present_start_ns = fps_trace ? MonotonicNanos() : 0;
const VkResult result = host_present(queue, present_info);
@@ -1102,7 +1278,20 @@ vkCreateInstance(const VkInstanceCreateInfo* create_info,
if (host_create == nullptr) {
return VK_ERROR_INITIALIZATION_FAILED;
}
const VkResult result = host_create(&host_info, allocator, instance);
VkResult result = VK_ERROR_INITIALIZATION_FAILED;
bool vr_handled = false;
const auto vr_create_instance =
State().vr_create_instance.load(std::memory_order_acquire);
if (vr_create_instance != nullptr) {
// The XR backend creates the instance through xrCreateVulkanInstanceKHR
// so the runtime's required instance extensions are enabled on the very
// instance Roblox renders with.
vr_handled =
vr_create_instance(&host_info, allocator, instance, &result);
}
if (!vr_handled) {
result = host_create(&host_info, allocator, instance);
}
if (result == VK_SUCCESS) {
AdapterState& state = State();
std::lock_guard<std::mutex> lock(state.mutex);
@@ -1124,6 +1313,10 @@ vkCreateInstance(const VkInstanceCreateInfo* create_info,
VKAPI_ATTR void VKAPI_CALL
vkDestroyInstance(VkInstance instance, const VkAllocationCallbacks* allocator) {
const auto note = ResolveProcessFunction<void (*)(VkInstance)>(
"mocktail_vr_xr_note_instance_destroyed");
if (note != nullptr) note(instance);
const auto host_destroy = reinterpret_cast<PFN_vkDestroyInstance>(
HostInstanceProc(instance, "vkDestroyInstance"));
if (host_destroy != nullptr) {
@@ -1149,6 +1342,21 @@ vkDestroyInstance(VkInstance instance, const VkAllocationCallbacks* allocator) {
state.note_present.store(nullptr, std::memory_order_release);
state.note_host_present_begin.store(nullptr, std::memory_order_release);
state.note_host_present_end.store(nullptr, std::memory_order_release);
state.note_vr_present_begin.store(nullptr, std::memory_order_release);
state.vr_create_instance.store(nullptr, std::memory_order_release);
state.vr_create_device.store(nullptr, std::memory_order_release);
state.vr_note_device_destroyed.store(nullptr, std::memory_order_release);
state.vr_note_queue.store(nullptr, std::memory_order_release);
state.vr_record_image.store(nullptr, std::memory_order_release);
state.vr_record_image_view.store(nullptr, std::memory_order_release);
state.vr_record_framebuffer.store(nullptr, std::memory_order_release);
state.vr_record_render_pass.store(nullptr, std::memory_order_release);
state.vr_note_destroy_image.store(nullptr, std::memory_order_release);
state.vr_note_destroy_image_view.store(nullptr, std::memory_order_release);
state.vr_note_destroy_framebuffer.store(nullptr,
std::memory_order_release);
state.vr_note_render_pass_begin.store(nullptr, std::memory_order_release);
state.vr_note_present.store(nullptr, std::memory_order_release);
state.note_vulkan_call_begin.store(nullptr, std::memory_order_release);
state.note_vulkan_call_end.store(nullptr, std::memory_order_release);
g_vulkan_call_observation_active.store(false, std::memory_order_release);
@@ -1339,8 +1547,20 @@ VKAPI_ATTR VkResult VKAPI_CALL vkCreateDevice(
}
}
const VkResult result =
host_create(physical_device, &host_info, allocator, device);
VkResult result = VK_ERROR_INITIALIZATION_FAILED;
bool vr_handled = false;
const auto vr_create_device =
State().vr_create_device.load(std::memory_order_acquire);
if (vr_create_device != nullptr) {
// The XR backend creates the device through xrCreateVulkanDeviceKHR and
// binds the OpenXR session to it; falling back afterwards would create a
// second, XR-incompatible device, so the hook result is final.
vr_handled = vr_create_device(physical_device, &host_info, allocator,
device, &result);
}
if (!vr_handled) {
result = host_create(physical_device, &host_info, allocator, device);
}
if (result != VK_SUCCESS) {
return result;
}
@@ -1402,6 +1622,13 @@ VKAPI_ATTR void VKAPI_CALL
vkDestroyDevice(VkDevice device, const VkAllocationCallbacks* allocator) {
const auto host_destroy = reinterpret_cast<PFN_vkDestroyDevice>(
HostDeviceProc(device, "vkDestroyDevice"));
const auto vr_note_device_destroyed =
State().vr_note_device_destroyed.load(std::memory_order_acquire);
if (vr_note_device_destroyed != nullptr) {
// Runs while the device is still valid so the XR backend can destroy
// its session and swapchain resources first.
vr_note_device_destroyed(device);
}
State().text_overlay.DestroyDevice(device);
if (host_destroy != nullptr) {
host_destroy(device, allocator);
@@ -1423,6 +1650,11 @@ VKAPI_ATTR void VKAPI_CALL vkGetDeviceQueue(VkDevice device,
RegisterHostQueueBinding(device, *queue);
(void)State().text_overlay.RegisterQueue(device, *queue, queue_family_index,
queue_index);
const auto vr_note_queue =
State().vr_note_queue.load(std::memory_order_acquire);
if (vr_note_queue != nullptr) {
vr_note_queue(device, *queue, queue_family_index, queue_index);
}
}
}
@@ -1438,6 +1670,12 @@ VKAPI_ATTR void VKAPI_CALL vkGetDeviceQueue2(
RegisterHostQueueBinding(device, *queue);
(void)State().text_overlay.RegisterQueue(
device, *queue, queue_info->queueFamilyIndex, queue_info->queueIndex);
const auto vr_note_queue =
State().vr_note_queue.load(std::memory_order_acquire);
if (vr_note_queue != nullptr) {
vr_note_queue(device, *queue, queue_info->queueFamilyIndex,
queue_info->queueIndex);
}
}
}
@@ -2025,4 +2263,146 @@ vkQueuePresentKHR(VkQueue queue, const VkPresentInfoKHR* present_info) {
return normalized_result;
}
} // extern "C"
// ---- VR backend resource provenance wrappers -------------------------------
// Thin pass-throughs to the host dispatch with optional notifications for the
// experimental OpenXR backend. Creation/destruction calls are rare; the only
// per-frame entry (vkCmdBeginRenderPass) costs one atomic load and stops
// doing work once both eye bindings are established.
VKAPI_ATTR VkResult VKAPI_CALL vkCreateImage(
VkDevice device, const VkImageCreateInfo* create_info,
const VkAllocationCallbacks* allocator, VkImage* image) {
const PFN_vkCreateImage host_create =
HostDispatchForDevice(device).create_image;
if (host_create == nullptr) {
return VK_ERROR_INITIALIZATION_FAILED;
}
const VkResult result = host_create(device, create_info, allocator, image);
const auto record = State().vr_record_image.load(std::memory_order_acquire);
if (result == VK_SUCCESS && record != nullptr) {
record(device, *image, create_info);
}
return result;
}
VKAPI_ATTR void VKAPI_CALL vkDestroyImage(VkDevice device, VkImage image,
const VkAllocationCallbacks* allocator) {
const auto note = State().vr_note_destroy_image.load(std::memory_order_acquire);
if (note != nullptr && image != VK_NULL_HANDLE) {
note(image);
}
const PFN_vkDestroyImage host_destroy =
HostDispatchForDevice(device).destroy_image;
if (host_destroy != nullptr) {
host_destroy(device, image, allocator);
}
}
VKAPI_ATTR VkResult VKAPI_CALL vkCreateImageView(
VkDevice device, const VkImageViewCreateInfo* create_info,
const VkAllocationCallbacks* allocator, VkImageView* view) {
const PFN_vkCreateImageView host_create =
HostDispatchForDevice(device).create_image_view;
if (host_create == nullptr) {
return VK_ERROR_INITIALIZATION_FAILED;
}
const VkResult result = host_create(device, create_info, allocator, view);
const auto record =
State().vr_record_image_view.load(std::memory_order_acquire);
if (result == VK_SUCCESS && record != nullptr && create_info != nullptr) {
record(*view, create_info->image);
}
return result;
}
VKAPI_ATTR void VKAPI_CALL
vkDestroyImageView(VkDevice device, VkImageView image_view,
const VkAllocationCallbacks* allocator) {
const auto note =
State().vr_note_destroy_image_view.load(std::memory_order_acquire);
if (note != nullptr && image_view != VK_NULL_HANDLE) {
note(image_view);
}
const PFN_vkDestroyImageView host_destroy =
HostDispatchForDevice(device).destroy_image_view;
if (host_destroy != nullptr) {
host_destroy(device, image_view, allocator);
}
}
VKAPI_ATTR VkResult VKAPI_CALL vkCreateFramebuffer(
VkDevice device, const VkFramebufferCreateInfo* create_info,
const VkAllocationCallbacks* allocator, VkFramebuffer* framebuffer) {
const PFN_vkCreateFramebuffer host_create =
HostDispatchForDevice(device).create_framebuffer;
if (host_create == nullptr) {
return VK_ERROR_INITIALIZATION_FAILED;
}
const VkResult result = host_create(device, create_info, allocator, framebuffer);
const auto record =
State().vr_record_framebuffer.load(std::memory_order_acquire);
if (result == VK_SUCCESS && record != nullptr) {
record(*framebuffer, create_info);
}
return result;
}
VKAPI_ATTR void VKAPI_CALL
vkDestroyFramebuffer(VkDevice device, VkFramebuffer framebuffer,
const VkAllocationCallbacks* allocator) {
const auto note =
State().vr_note_destroy_framebuffer.load(std::memory_order_acquire);
if (note != nullptr && framebuffer != VK_NULL_HANDLE) {
note(framebuffer);
}
const PFN_vkDestroyFramebuffer host_destroy =
HostDispatchForDevice(device).destroy_framebuffer;
if (host_destroy != nullptr) {
host_destroy(device, framebuffer, allocator);
}
}
VKAPI_ATTR VkResult VKAPI_CALL vkCreateRenderPass(
VkDevice device, const VkRenderPassCreateInfo* create_info,
const VkAllocationCallbacks* allocator, VkRenderPass* render_pass) {
const PFN_vkCreateRenderPass host_create =
HostDispatchForDevice(device).create_render_pass;
if (host_create == nullptr) {
return VK_ERROR_INITIALIZATION_FAILED;
}
const VkResult result = host_create(device, create_info, allocator, render_pass);
const auto record =
State().vr_record_render_pass.load(std::memory_order_acquire);
if (result == VK_SUCCESS && record != nullptr) {
record(*render_pass, create_info);
}
return result;
}
VKAPI_ATTR void VKAPI_CALL
vkDestroyRenderPass(VkDevice device, VkRenderPass render_pass,
const VkAllocationCallbacks* allocator) {
const PFN_vkDestroyRenderPass host_destroy =
HostDispatchForDevice(device).destroy_render_pass;
if (host_destroy != nullptr) {
host_destroy(device, render_pass, allocator);
}
}
VKAPI_ATTR void VKAPI_CALL
vkCmdBeginRenderPass(VkCommandBuffer command_buffer,
const VkRenderPassBeginInfo* begin_info,
VkSubpassContents contents) {
const auto note =
State().vr_note_render_pass_begin.load(std::memory_order_acquire);
if (note != nullptr) {
note(command_buffer, begin_info);
}
const PFN_vkCmdBeginRenderPass host_begin =
HostDispatchForCommandBuffer(command_buffer).cmd_begin_render_pass;
if (host_begin != nullptr) {
host_begin(command_buffer, begin_info, contents);
}
}
} // extern "C"
+15
View File
@@ -56,6 +56,7 @@
#include "libc_shim/libc_shim.h"
#include "linker/linker.h"
#include "mocktail/graphics/bionic_egl_bridge.h"
#include "mocktail/vr/roblox_vr_device_bridge.h"
#include "runtime/environment.h"
#include "runtime/discord_rpc.h"
#include "runtime/jnivm_platform_web_callbacks.h"
@@ -5610,6 +5611,20 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
std::cout << " [linker] Bionic unwind metadata validated for libroblox\n"
<< std::flush;
// The experimental VR bridge is armed only for --vr on an exact supported
// Build ID. It validates its machine contracts and interposes the
// DebugDeviceVR vtable slots after relocations and before guest startup
// can create the object. A no-op success while no bridge is armed.
if (const mocktail::Status vr_activation =
mocktail::vr::NotifyRobloxVrImageLoaded(
linker::FindLoadedAndroidLibraryBase("libroblox"));
!vr_activation.ok()) {
std::cerr << "\n[FATAL] Experimental Roblox VR bridge activation "
"failed: "
<< vr_activation.message() << '\n';
return EXIT_FAILURE;
}
// The pre-constructor hook owns installation; repeating arena setup here is
// not idempotent.
if (HostAbiExperimentRequested() &&
+65 -4
View File
@@ -23,6 +23,9 @@
#include "mocktail/audio/fmod_jni_audio_bridge.h"
#include "mocktail/audio/webrtc_jni_audio_bridge.h"
#include "mocktail/audio/roblox_output_device_bridge.h"
#include "mocktail/vr/openxr_backend.h"
#include "mocktail/vr/openxr_probe.h"
#include "mocktail/vr/roblox_vr_device_bridge.h"
#include "runtime/auth_runtime_composition.h"
#include "runtime/command_line.h"
#include "runtime/crash_report_policy.h"
@@ -332,11 +335,28 @@ int main(int argc, char* argv[]) {
return EXIT_FAILURE;
}
// The standalone OpenXR scene belongs to mocktail-vr-probe --scene.
// Continue through Roblox startup to inspect its native VR device.
// --vr continues through Roblox startup as a native stereo prototype.
if (runtime_config.config.vr_enabled()) {
std::cerr << " [vr] Roblox VR initialization requested (experimental); "
"stereo eye resources and OpenXR output are not yet connected. "
"Standalone scene: mocktail-vr-probe --scene\n";
if (!mocktail::vr::IsOpenXrSupportCompiled()) {
std::cerr << " [vr] This build does not include OpenXR support. "
"Use a VR-enabled build of Mocktail; refusing to start "
"Roblox with --vr in a non-VR build.\n";
return EXIT_FAILURE;
}
const mocktail::vr::VrBackendMode vr_backend_mode =
mocktail::vr::ResolveVrBackendMode(true);
std::cerr << " [vr] experimental backend="
<< mocktail::vr::VrBackendModeName(vr_backend_mode)
<< ": Roblox renders both eyes through its DebugDeviceVR "
"debug device (exact Build ID 2998 only)";
if (vr_backend_mode == mocktail::vr::VrBackendMode::kXrOutput) {
std::cerr << "; eye images are submitted to the active OpenXR runtime "
"as a projection layer. Start WiVRn and connect the headset "
"before launching.\n";
} else {
std::cerr << "; OpenXR output is disabled in this diagnostic mode "
"(MOCKTAIL_VR_BACKEND=native).\n";
}
}
mocktail::runtime::SessionLog session_log;
mocktail::runtime::FailureSupportBundleGuard support_bundle_guard(
@@ -755,6 +775,12 @@ int main(int argc, char* argv[]) {
<< command_line_error << '\n';
return EXIT_FAILURE;
}
if (!mocktail::runtime::MergeVrClientSettingsOverrides(
runtime_config.config.vr_enabled(), audio_capture_overrides,
&audio_capture_overrides, &command_line_error)) {
std::cerr << "[FATAL] Cannot apply VR policy: " << command_line_error << '\n';
return EXIT_FAILURE;
}
if (setenv("MOCKTAIL_CLIENT_SETTINGS_OVERRIDES_JSON",
audio_capture_overrides.c_str(), 1) != 0) {
std::cerr << "[FATAL] Cannot export runtime client-settings policy\n";
@@ -770,6 +796,12 @@ int main(int argc, char* argv[]) {
<< command_line_error << '\n';
return EXIT_FAILURE;
}
if (!mocktail::runtime::MergeVrClientSettingsOverrides(
runtime_config.config.vr_enabled(), fast_flags_overrides,
&fast_flags_overrides, &command_line_error)) {
std::cerr << "[FATAL] Cannot apply VR policy: " << command_line_error << '\n';
return EXIT_FAILURE;
}
if (setenv("MOCKTAIL_FAST_FLAGS_JSON", fast_flags_overrides.c_str(), 1) !=
0) {
std::cerr << "[FATAL] Cannot export crash-report fast-flags policy\n";
@@ -828,6 +860,8 @@ int main(int argc, char* argv[]) {
mocktail::runtime::RobloxFullscreenRuntimeBridge fullscreen_bridge;
mocktail::audio::RobloxOutputDeviceBridge output_device_bridge;
mocktail::vr::RobloxVrDeviceBridge vr_device_bridge;
mocktail::vr::OpenXrBackend xr_backend;
if (command_line.options.mode == mocktail::runtime::CommandMode::kRun) {
const std::string compatibility_manifest =
environment.GetOr("MOCKTAIL_COMPATIBILITY_MANIFEST",
@@ -855,6 +889,31 @@ int main(int argc, char* argv[]) {
<< output_device_status.message() << '\n';
return EXIT_FAILURE;
}
if (runtime_config.config.vr_enabled()) {
if (mocktail::vr::ResolveVrBackendMode(true) ==
mocktail::vr::VrBackendMode::kXrOutput) {
// Fail closed and loudly when no OpenXR runtime is reachable; the
// native-stereo diagnostic mode must be requested explicitly.
const mocktail::Status xr_status = xr_backend.Arm();
if (!xr_status.ok()) {
std::cerr << "[FATAL] Cannot arm the OpenXR backend for --vr: "
<< xr_status.message() << '\n'
<< " Start WiVRn, connect your headset, then run mocktail -vr again.\n"
<< " For a custom runtime set XR_RUNTIME_JSON to its manifest.\n"
<< " Set MOCKTAIL_VR_BACKEND=native for the "
"native-stereo diagnostic mode without a runtime.\n";
return EXIT_FAILURE;
}
}
const mocktail::Status vr_device_status =
vr_device_bridge.Install(compatibility.profile);
if (!vr_device_status.ok()) {
std::cerr << "[FATAL] Cannot install experimental Roblox VR device "
"bridge: "
<< vr_device_status.message() << '\n';
return EXIT_FAILURE;
}
}
}
mocktail::legacy::RuntimeDependencies dependencies;
@@ -1046,6 +1105,8 @@ int main(int argc, char* argv[]) {
std::to_string(runtime_status) + ".");
}
support_bundle_guard.SetExitCode(runtime_status);
xr_backend.Disarm();
vr_device_bridge.Shutdown();
output_device_bridge.Shutdown();
fullscreen_bridge.Shutdown();
memory_limit_watchdog.Stop();
+4 -3
View File
@@ -74,8 +74,8 @@ CommandLineParseResult ParseCommandLine(int argc, const char* const argv[]) {
&result.options.graphics_backend, &result.error)) {
return result;
}
} else if (argument == "--vr" || argument == "--no-vr") {
result.options.vr_enabled = argument == "--vr";
} else if (argument == "--vr" || argument == "-vr" || argument == "--no-vr") {
result.options.vr_enabled = argument != "--no-vr";
} else if (argument == "--allow-unverified-build") {
result.options.allow_unverified_build = true;
} else if (argument == "--force-run-latest") {
@@ -256,8 +256,9 @@ std::string CommandLineUsage(const std::string& program_name) {
"x86_64 libroblox.so\n"
<< " --headless Run without creating an SDL window\n"
<< " --windowed Force windowed startup (default)\n"
<< " --vr / --no-vr Launch Roblox in experimental VR mode or "
<< " -vr / --vr / --no-vr Launch Roblox via OpenXR (WiVRn) or "
"disable vr.enabled\n"
<< " VR builds enable VR by default; use --no-vr to opt out.\n"
<< " --graphics <backend> direct-vulkan | opengl | system | "
"angle-vulkan (default: direct-vulkan)\n"
<< " --allow-unverified-build Run a known but unverified Build-ID "
+22
View File
@@ -367,6 +367,28 @@ bool MergeRuntimeClientSettingsOverrides(const FrameRatePolicy& frame_rate,
merged_json, error);
}
bool MergeVrClientSettingsOverrides(bool enabled, std::string_view base_json,
std::string* merged_json,
std::string* error) {
if (merged_json == nullptr) {
if (error != nullptr) {
*error = "VR client-settings output is required";
}
return false;
}
auto overrides = nlohmann::json::parse(base_json.empty() ? "{}" : base_json,
nullptr, false, true);
if (overrides.is_discarded() || !overrides.is_object()) {
if (error != nullptr) {
*error = "VR client-settings input must be a JSON object";
}
return false;
}
overrides["FFlagDebugEnableVREmulator"] = enabled ? "True" : "False";
*merged_json = overrides.dump();
return true;
}
bool MergeAudioCaptureClientSettingsOverrides(bool microphone_enabled,
std::string_view base_json,
std::string* merged_json,
+1 -1
View File
@@ -171,7 +171,7 @@ RuntimeConfig RuntimeConfig::FromEnvironment(const Environment& environment) {
RuntimeConfig config;
config.headless_ = LegacyEnabled(environment, "MOCKTAIL_HEADLESS");
config.vr_enabled_ =
ReadBoolean(environment, "MOCKTAIL_VR_ENABLED", false, &config.vr_valid_);
ReadBoolean(environment, "MOCKTAIL_VR_ENABLED", kDefaultVrEnabled, &config.vr_valid_);
config.roblox_library_path_ = environment.GetOr(
"ROBLOX_LIB_PATH", config.roblox_library_path_.string());
config.graphics_backend_name_ = environment.GetOr(
+10 -6
View File
@@ -67,12 +67,16 @@ graphics:
# Optional presentation synchronization override: auto, on, or off.
# vsync: off
vr:
# Boolean (default: false): launch the Roblox experimental VR path.
# Only Build ID 2998 is currently accepted; eye resources and OpenXR output
# are NOT yet connected. Standalone scene: mocktail-vr-probe --scene.
# Use --no-vr to launch Roblox normally.
enabled: false
# vr:
# Boolean (default: enabled in VR builds, disabled in non-VR builds).
# Experimental VR for the exact Build ID 2998
# payload. Mocktail initializes the guest DebugDeviceVR eye resources (two
# distinct 500x500 eye framebuffers), the Roblox renderer draws both eyes
# every frame, and the frames are submitted to the active OpenXR runtime as
# a stereo projection layer with head pose (runtime-tracked, or scripted for
# desktop testing). Controllers are NOT connected yet. Standalone OpenXR
# test scene: mocktail-vr-probe --scene. Use --no-vr to launch normally.
# enabled: false
performance:
# Boolean (default: false): size Roblox scheduler/render queues from every
File diff suppressed because it is too large. Load diff
+53
View File
@@ -0,0 +1,53 @@
#include "mocktail/vr/openxr_backend.h"
#include <cstdlib>
#include <filesystem>
namespace mocktail::vr {
std::string SelectVrRuntimeManifest(const std::string& explicit_manifest,
const std::vector<std::string>& candidates) {
if (!explicit_manifest.empty()) return explicit_manifest;
for (const auto& path : candidates) {
std::error_code error;
if (std::filesystem::is_regular_file(path, error)) return path;
}
return {};
}
std::string VrRuntimeUnavailableHint(bool user_selected, const std::string& manifest) {
if (user_selected) {
return "User-selected XR_RUNTIME_JSON '" + manifest +
"' is unavailable. Check the manifest/library and that its runtime server "
"is running with a connected headset; this error alone does not distinguish them.";
}
if (!manifest.empty()) {
return "Auto-selected WiVRn runtime is unavailable. Start WiVRn and connect "
"the headset, then retry. If it remains unavailable, check the runtime installation.";
}
return "The registered OpenXR runtime is unavailable. Install/start WiVRn and "
"connect the headset, or explicitly select an installed runtime with XR_RUNTIME_JSON.";
}
VrBackendMode ResolveVrBackendMode(bool vr_enabled) {
if (!vr_enabled) {
return VrBackendMode::kDisabled;
}
const char* value = std::getenv("MOCKTAIL_VR_BACKEND");
const std::string mode = value ? value : "xr";
if (mode == "native" || mode == "native-stereo") {
return VrBackendMode::kNativeOnly;
}
return VrBackendMode::kXrOutput;
}
const char* VrBackendModeName(VrBackendMode mode) {
switch (mode) {
case VrBackendMode::kDisabled:
return "disabled";
case VrBackendMode::kNativeOnly:
return "native-stereo-diagnostic";
case VrBackendMode::kXrOutput:
return "openxr-output";
}
return "disabled";
}
}
+12
View File
@@ -1,7 +1,17 @@
#include "mocktail/vr/openxr_probe.h"
#include "mocktail/vr/openxr_backend.h"
#include "mocktail/vr/openxr_preview.h"
namespace mocktail::vr {
OpenXrBackend::~OpenXrBackend() = default;
Status OpenXrBackend::Arm() {
return Status::Error(StatusCode::kUnavailable, "OpenXR support is disabled in this build");
}
void OpenXrBackend::Disarm() {}
OpenXrBackend* ActiveVrBackend() { return nullptr; }
void OpenXrBackend::NotePoseApplied(void*, std::uint64_t) {}
ScriptedPoseSample OpenXrBackend::PublishedHeadPose() const { return {}; }
PreviewResult RunOpenXrPreview(const PreviewOptions&) {
PreviewResult result;
@@ -11,6 +21,8 @@ PreviewResult RunOpenXrPreview(const PreviewOptions&) {
return result;
}
bool IsOpenXrSupportCompiled() { return false; }
ProbeReport ProbeOpenXr() {
ProbeReport report;
report.status = ProbeStatus::kBuildDisabled;
+1 -2
View File
@@ -301,8 +301,7 @@ class Preview {
" failed: " + (text[0] ? std::string(text) : std::to_string(result));
if (result == XR_ERROR_RUNTIME_UNAVAILABLE)
message += ". No active OpenXR runtime was found. Start WiVRn for a real "
"headset. Without a headset, run mocktail-vr-simulated from "
"this build directory.";
"headset, or select an installed runtime using XR_RUNTIME_JSON.";
else if (result == XR_ERROR_FORM_FACTOR_UNAVAILABLE)
message += ". Connect a headset or select a simulated runtime.";
else if (result == XR_ERROR_EXTENSION_NOT_PRESENT)
+2
View File
@@ -73,6 +73,8 @@ XrResult EnumerateProperties(XrStructureType type, Enumerate enumerate,
} // namespace
bool IsOpenXrSupportCompiled() { return true; }
ProbeReport ProbeOpenXr() {
ProbeReport report;
report.sdk_version = Version(XR_CURRENT_API_VERSION);
+885
View File
@@ -0,0 +1,885 @@
#include "mocktail/vr/roblox_vr_device_bridge.h"
#include <elf.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <cinttypes>
#include <cmath>
#include "mocktail/vr/openxr_backend.h"
#include <cstdarg>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <limits>
#include <string>
#include "vr/roblox_vr_device_bridge_internal.h"
namespace mocktail::vr {
namespace {
using internal::kConstructorContractSize;
using internal::kExpectedObjectType;
using internal::kEyeGetterContractSize;
using internal::kEyeGetterVtableSlot;
using internal::kEyeInitializerVtableSlot;
using internal::kFramebufferSlotOffset;
using internal::kGraphicsDeviceOffset;
using internal::kHeightOffset;
using internal::kInitializerContractSize;
using internal::kObjectNameOffset;
using internal::kObjectTypeOffset;
using internal::kReadyByteOffset;
using internal::kStateGetterContractSize;
using internal::kStateGetterVtableSlot;
using internal::kTextureSlotOffset;
using internal::kWidthOffset;
std::atomic<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.
thread_local void *g_initializing_object = nullptr;
thread_local void *g_eye_object = nullptr;
thread_local void *g_eye_framebuffer = nullptr;
thread_local int g_eye_index = -1;
thread_local bool g_thread_presented = false;
Status InvalidArgument(std::string message) {
return Status::Error(StatusCode::kInvalidArgument, std::move(message));
}
Status FailedPrecondition(std::string message) {
return Status::Error(StatusCode::kFailedPrecondition, std::move(message));
}
std::uint64_t CurrentThreadId() {
return static_cast<std::uint64_t>(syscall(SYS_gettid));
}
bool IsValidElfImage(std::uintptr_t image_base) {
if (image_base == 0) {
return false;
}
const auto *header = reinterpret_cast<const Elf64_Ehdr *>(image_base);
return std::memcmp(header->e_ident, ELFMAG, SELFMAG) == 0 &&
header->e_ident[EI_CLASS] == ELFCLASS64 && header->e_phoff != 0 &&
header->e_phnum != 0;
}
bool IsProgramHeaderRange(std::uintptr_t image_base, std::uintptr_t rva,
std::size_t size, std::uint32_t type,
std::uint32_t required_flags) {
if (!IsValidElfImage(image_base) || size == 0 ||
rva > std::numeric_limits<std::uintptr_t>::max() - size) {
return false;
}
const auto *header = reinterpret_cast<const Elf64_Ehdr *>(image_base);
const auto *program_headers = reinterpret_cast<const Elf64_Phdr *>(
image_base + static_cast<std::uintptr_t>(header->e_phoff));
const std::uintptr_t range_end = rva + size;
for (std::size_t index = 0; index < header->e_phnum; ++index) {
const Elf64_Phdr &segment = program_headers[index];
if (segment.p_type != type ||
(segment.p_flags & required_flags) != required_flags ||
segment.p_vaddr >
std::numeric_limits<std::uintptr_t>::max() - segment.p_memsz) {
continue;
}
const std::uintptr_t segment_end =
static_cast<std::uintptr_t>(segment.p_vaddr + segment.p_memsz);
if (rva >= segment.p_vaddr && range_end <= segment_end) {
return true;
}
}
return false;
}
bool IsExecutableImageRange(std::uintptr_t image_base, std::uintptr_t rva,
std::size_t size) {
return IsProgramHeaderRange(image_base, rva, size, PT_LOAD, PF_R | PF_X);
}
bool IsWritableDataImageRange(std::uintptr_t image_base, std::uintptr_t rva,
std::size_t size) {
return IsProgramHeaderRange(image_base, rva, size, PT_LOAD, PF_R | PF_W);
}
bool IsRelroImageRange(std::uintptr_t image_base, std::uintptr_t rva,
std::size_t size) {
return IsProgramHeaderRange(image_base, rva, size, PT_GNU_RELRO, 0);
}
std::uintptr_t ImageSize(std::uintptr_t image_base) {
const auto *header = reinterpret_cast<const Elf64_Ehdr *>(image_base);
const auto *program_headers = reinterpret_cast<const Elf64_Phdr *>(
image_base + static_cast<std::uintptr_t>(header->e_phoff));
std::uintptr_t highest = 0;
for (std::size_t index = 0; index < header->e_phnum; ++index) {
const Elf64_Phdr &segment = program_headers[index];
if (segment.p_type != PT_LOAD ||
segment.p_vaddr >
std::numeric_limits<std::uintptr_t>::max() - segment.p_memsz) {
continue;
}
highest = std::max(highest, static_cast<std::uintptr_t>(segment.p_vaddr +
segment.p_memsz));
}
return highest;
}
bool IsCodePointerInImage(std::uintptr_t image_base, std::uintptr_t image_size,
std::uintptr_t address) {
return address > image_base && image_size != 0 &&
address - image_base < image_size;
}
bool SetVtableRangeWritable(std::uintptr_t *first_slot,
std::uintptr_t *last_slot_exclusive,
bool writable) {
if (first_slot == nullptr || last_slot_exclusive <= first_slot) {
return false;
}
const long page_size_value = sysconf(_SC_PAGESIZE);
if (page_size_value <= 0) {
return false;
}
const std::uintptr_t page_size = static_cast<std::uintptr_t>(page_size_value);
const std::uintptr_t first = reinterpret_cast<std::uintptr_t>(first_slot);
const std::uintptr_t last =
reinterpret_cast<std::uintptr_t>(last_slot_exclusive);
const std::uintptr_t page_begin = first - first % page_size;
const std::uintptr_t page_end =
((last + page_size - 1) / page_size) * page_size;
const int protection = writable ? PROT_READ | PROT_WRITE : PROT_READ;
return mprotect(reinterpret_cast<void *>(page_begin), page_end - page_begin,
protection) == 0;
}
bool ContainsBytes(const std::uint8_t *code, std::size_t size,
const std::uint8_t *expected, std::size_t expected_size) {
if (code == nullptr || expected == nullptr || expected_size == 0 ||
expected_size > size) {
return false;
}
for (std::size_t index = 0; index + expected_size <= size; ++index) {
if (std::memcmp(code + index, expected, expected_size) == 0) {
return true;
}
}
return false;
}
template <typename Function> std::uintptr_t FunctionAddress(Function function) {
return reinterpret_cast<std::uintptr_t>(function);
}
void Log(const char *format, ...) {
std::va_list arguments;
va_start(arguments, format);
std::vfprintf(stderr, format, arguments);
va_end(arguments);
std::fflush(stderr);
}
} // namespace
namespace internal {
EyeResourceState InspectEyeResources(const void *object) {
if (object == nullptr)
return EyeResourceState::kInvalid;
const auto *bytes = static_cast<const std::uint8_t *>(object);
std::uintptr_t fb[2], tex[2];
std::memcpy(&fb[0], bytes + kFramebufferSlotOffset, sizeof(fb[0]));
std::memcpy(&fb[1], bytes + kFramebufferSlotOffset + kEyeSlotStride,
sizeof(fb[1]));
std::memcpy(&tex[0], bytes + kTextureSlotOffset, sizeof(tex[0]));
std::memcpy(&tex[1], bytes + kTextureSlotOffset + kEyeSlotStride,
sizeof(tex[1]));
std::uint32_t width, height;
std::memcpy(&width, bytes + kWidthOffset, sizeof(width));
std::memcpy(&height, bytes + kHeightOffset, sizeof(height));
if (width == 0 || height == 0 || width > 16384 || height > 16384)
return EyeResourceState::kInvalid;
if (bytes[kReadyByteOffset] == 0 && !fb[0] && !fb[1] && !tex[0] && !tex[1])
return EyeResourceState::kEmpty;
if (bytes[kReadyByteOffset] == 1 && fb[0] && fb[1] && tex[0] && tex[1] &&
fb[0] != fb[1] && tex[0] != tex[1])
return EyeResourceState::kReady;
return EyeResourceState::kInvalid;
}
bool HasExpectedVrStateGetterContract(const std::uint8_t *code,
std::size_t size) {
// push rbp; mov rbp,rsp; push rbx; push rax; mov rbx,rdi; add rsi,0x14;
// mov edx,0x138; call memcpy; mov rax,rbx; add rsp,8; pop rbx; pop rbp; ret
static constexpr std::array<std::uint8_t, kStateGetterContractSize>
kExpected = {0x55, 0x48, 0x89, 0xe5, 0x53, 0x50, 0x48, 0x89, 0xfb,
0x48, 0x83, 0xc6, 0x14, 0xba, 0x38, 0x01, 0x00, 0x00,
0xe8, 0xd3, 0xb5, 0x36, 0x03, 0x48, 0x89, 0xd8, 0x48,
0x83, 0xc4, 0x08, 0x5b, 0x5d, 0xc3};
return code != nullptr && size >= kExpected.size() &&
std::memcmp(code, kExpected.data(), kExpected.size()) == 0;
}
bool HasExpectedVrEyeGetterContract(const std::uint8_t *code,
std::size_t size) {
// movsxd rax,esi; shl rax,4; mov rax,[rdi+rax+0x150]; ret
static constexpr std::array<std::uint8_t, kEyeGetterContractSize> kExpected =
{0x48, 0x63, 0xc6, 0x48, 0xc1, 0xe0, 0x04, 0x48,
0x8b, 0x84, 0x07, 0x50, 0x01, 0x00, 0x00, 0xc3};
return code != nullptr && size >= kExpected.size() &&
std::memcmp(code, kExpected.data(), kExpected.size()) == 0;
}
bool HasExpectedVrConstructorContract(const std::uint8_t *code,
std::size_t size) {
static constexpr std::array<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> kVptrWrite = {0x48, 0x89, 0x07};
// mov dword ptr [rdi+0x10], 6
static constexpr std::array<std::uint8_t, 7> kTypeStore = {
0xc7, 0x47, 0x10, 0x06, 0x00, 0x00, 0x00};
// lea r14,[rdi+0x14]; mov edx,0x134 (state block memset)
static constexpr std::array<std::uint8_t, 9> kStateMemset = {
0x4c, 0x8d, 0x77, 0x14, 0xba, 0x34, 0x01, 0x00, 0x00};
// mov [rbx+0x190],r13d ; mov [rbx+0x194],r12d
static constexpr std::array<std::uint8_t, 14> kWidthHeightStore = {
0x44, 0x89, 0xab, 0x90, 0x01, 0x00, 0x00,
0x44, 0x89, 0xa3, 0x94, 0x01, 0x00, 0x00};
// mov [rbx+0x198],r15 (graphics device captured from rcx)
static constexpr std::array<std::uint8_t, 7> kDeviceStore = {
0x4c, 0x89, 0xbb, 0x98, 0x01, 0x00, 0x00};
if (code == nullptr || size < kConstructorContractSize) {
return false;
}
return std::memcmp(code, kPrologue.data(), kPrologue.size()) == 0 &&
ContainsBytes(code, size, kVtableStore.data(), kVtableStore.size()) &&
ContainsBytes(code, size, kVptrWrite.data(), kVptrWrite.size()) &&
ContainsBytes(code, size, kTypeStore.data(), kTypeStore.size()) &&
ContainsBytes(code, size, kStateMemset.data(), kStateMemset.size()) &&
ContainsBytes(code, size, kWidthHeightStore.data(),
kWidthHeightStore.size()) &&
ContainsBytes(code, size, kDeviceStore.data(), kDeviceStore.size());
}
bool HasExpectedVrEyeInitializerContract(
const std::uint8_t *code, std::size_t size,
const compat::VrDebugDeviceBridgeProfile &profile) {
static constexpr std::array<std::uint8_t, 24> kPrologue = {
0x55, 0x48, 0x89, 0xe5, 0x41, 0x57, 0x41, 0x56, 0x41, 0x55, 0x41, 0x54,
0x53, 0x48, 0x81, 0xec, 0xa8, 0x00, 0x00, 0x00, 0x48, 0x89, 0xb5, 0x50};
// mov cl,1; mov [rdi+0x147],cl; mov [rdi+0x94],cl; mov [rdi+0x144],cl
static constexpr std::array<std::uint8_t, 20> kReadyBytes = {
0xb1, 0x01, 0x88, 0x8f, 0x47, 0x01, 0x00, 0x00, 0x88, 0x8f,
0x94, 0x00, 0x00, 0x00, 0x88, 0x8f, 0x44, 0x01, 0x00, 0x00};
// lea rax,[rdi+0x170] (texture slots)
static constexpr std::array<std::uint8_t, 7> kTextureSlotBase = {
0x48, 0x8d, 0x87, 0x70, 0x01, 0x00, 0x00};
// lea rax,[rdi+0x150] (framebuffer slots)
static constexpr std::array<std::uint8_t, 7> kFramebufferSlotBase = {
0x48, 0x8d, 0x87, 0x50, 0x01, 0x00, 0x00};
// mov rsi,[rbp-0xb0]; mov rax,[rsi]; mov rax,[rax+createSlot]; call rax
std::array<std::uint8_t, 13> device_call = {0x48, 0x8b, 0x06, 0x48, 0x8b,
0x80, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00};
const std::uint32_t create_slot = static_cast<std::uint32_t>(
profile.device_create_framebuffer_vtable_offset);
device_call[6] = static_cast<std::uint8_t>(create_slot);
device_call[7] = static_cast<std::uint8_t>(create_slot >> 8);
device_call[8] = static_cast<std::uint8_t>(create_slot >> 16);
device_call[9] = static_cast<std::uint8_t>(create_slot >> 24);
// Two-iteration eye loop: mov r15d,1; test [rbp-0xa4],1; mov ecx,0; jne back
static constexpr std::array<std::uint8_t, 15> kEyeLoopTail = {
0x41, 0xbf, 0x01, 0x00, 0x00, 0x00, 0xf6, 0x85,
0x5c, 0xff, 0xff, 0xff, 0x01, 0xb9, 0x00};
if (code == nullptr || size < kInitializerContractSize) {
return false;
}
return std::memcmp(code, kPrologue.data(), kPrologue.size()) == 0 &&
ContainsBytes(code, size, kReadyBytes.data(), kReadyBytes.size()) &&
ContainsBytes(code, size, kTextureSlotBase.data(),
kTextureSlotBase.size()) &&
ContainsBytes(code, size, kFramebufferSlotBase.data(),
kFramebufferSlotBase.size()) &&
ContainsBytes(code, size, device_call.data(), 10) &&
ContainsBytes(code, size, kEyeLoopTail.data(), kEyeLoopTail.size());
}
bool HasExpectedVrDebugDeviceVtable(
const std::uintptr_t *vtable, std::uintptr_t image_base,
const compat::VrDebugDeviceBridgeProfile &profile) {
return vtable != nullptr && image_base != 0 &&
vtable[kStateGetterVtableSlot / sizeof(std::uintptr_t)] ==
image_base + profile.state_getter_rva &&
vtable[kEyeGetterVtableSlot / sizeof(std::uintptr_t)] ==
image_base + profile.eye_getter_rva &&
vtable[kEyeInitializerVtableSlot / sizeof(std::uintptr_t)] ==
image_base + profile.eye_initializer_rva;
}
} // namespace internal
namespace {
// Guest-callable interposed vtable entries. The guest Android ABI for these
// methods is SysV amd64: the state getter is sret (rdi = hidden 0x138-byte
// result buffer, rsi = device object, returns the buffer) and the eye getter
// takes (rdi = device object, esi = eye index) and returns the framebuffer
// object pointer.
extern "C" void *mocktail_vr_debug_device_state_getter(void *result_buffer,
void *device_object) {
RobloxVrDeviceBridge *bridge =
g_active_bridge.load(std::memory_order_acquire);
if (bridge != nullptr) {
bridge->OnStateGetterCall(result_buffer, device_object);
}
return result_buffer;
}
extern "C" void *mocktail_vr_debug_device_eye_getter(void *device_object,
int eye_index) {
RobloxVrDeviceBridge *bridge =
g_active_bridge.load(std::memory_order_acquire);
const auto original =
bridge != nullptr ? bridge->original_eye_getter() : nullptr;
void *framebuffer =
original != nullptr ? original(device_object, eye_index) : nullptr;
if (bridge != nullptr) {
bridge->OnEyeGetterCall(device_object, eye_index, framebuffer);
}
return framebuffer;
}
} // namespace
extern "C" void *mocktail_vr_initializing_object() {
return g_initializing_object;
}
extern "C" bool mocktail_vr_current_eye(void **self, int *eye,
void **framebuffer) {
*self = g_eye_object;
*eye = g_eye_index;
*framebuffer = g_eye_framebuffer;
return g_eye_object != nullptr && g_eye_index >= 0 &&
g_eye_framebuffer != nullptr;
}
extern "C" void mocktail_vr_consume_eye() {
g_eye_object = nullptr;
g_eye_framebuffer = nullptr;
g_eye_index = -1;
}
extern "C" void mocktail_vr_note_host_present_begin() {
RobloxVrDeviceBridge *bridge =
g_active_bridge.load(std::memory_order_acquire);
if (bridge != nullptr) {
bridge->NoteHostPresent();
}
}
RobloxVrDeviceBridge::~RobloxVrDeviceBridge() {
Shutdown();
RobloxVrDeviceBridge *expected = this;
(void)g_active_bridge.compare_exchange_strong(
expected, nullptr, std::memory_order_acq_rel, std::memory_order_acquire);
}
Status RobloxVrDeviceBridge::Install(const compat::BuildProfile &profile) {
if (installed_) {
return FailedPrecondition("Roblox VR device bridge is already armed");
}
if (!profile.vr_debug_device_bridge.has_value()) {
return FailedPrecondition(
"experimental Roblox VR requires vr_debug_device_bridge metadata in "
"the exact-build compatibility profile");
}
if (!profile.allow_host_abi_bridges) {
return FailedPrecondition(
"Roblox VR device bridge profile requires host ABI bridges");
}
RobloxVrDeviceBridge *expected = nullptr;
if (!g_active_bridge.compare_exchange_strong(expected, this,
std::memory_order_acq_rel,
std::memory_order_acquire)) {
return FailedPrecondition("another Roblox VR device bridge owns the "
"process");
}
profile_ = *profile.vr_debug_device_bridge;
installed_ = true;
std::fprintf(stderr,
" [vr-device] native stereo bridge armed for Build ID %s "
"(vtable +0x28/+0x48 interposition; init phase: %s)\n",
profile.elf_build_id.c_str(), "live render-thread state getter");
return Status::Ok();
}
Status RobloxVrDeviceBridge::Activate(std::uintptr_t image_base) {
std::lock_guard<std::mutex> lock(mutex_);
if (!installed_) {
return FailedPrecondition("Roblox VR device bridge is not armed");
}
if (active_.load(std::memory_order_acquire)) {
return image_base == image_base_.load(std::memory_order_relaxed)
? Status::Ok()
: FailedPrecondition("Roblox VR device bridge saw a second "
"image");
}
if (!IsValidElfImage(image_base)) {
return InvalidArgument("libroblox image base is not a valid ELF64 image");
}
const std::uintptr_t largest_rva = std::max(
{profile_.vtable_rva + kEyeInitializerVtableSlot + sizeof(std::uintptr_t),
profile_.constructor_rva + kConstructorContractSize,
profile_.state_getter_rva + kStateGetterContractSize,
profile_.eye_getter_rva + kEyeGetterContractSize,
profile_.eye_initializer_rva + kInitializerContractSize,
profile_.emulator_flag_storage_rva + 1});
if (image_base > std::numeric_limits<std::uintptr_t>::max() - largest_rva) {
return InvalidArgument("VR device profile address overflows");
}
const std::uintptr_t vtable_rva_size =
kEyeInitializerVtableSlot + sizeof(std::uintptr_t);
if (!IsRelroImageRange(image_base, profile_.vtable_rva, vtable_rva_size)) {
return FailedPrecondition(
"DebugDeviceVR vtable is not inside the verified RELRO range");
}
if (!IsExecutableImageRange(image_base, profile_.constructor_rva,
kConstructorContractSize) ||
!IsExecutableImageRange(image_base, profile_.state_getter_rva,
kStateGetterContractSize) ||
!IsExecutableImageRange(image_base, profile_.eye_getter_rva,
kEyeGetterContractSize) ||
!IsExecutableImageRange(image_base, profile_.eye_initializer_rva,
kInitializerContractSize)) {
return FailedPrecondition(
"VR device profile functions are not in executable image ranges");
}
if (!IsWritableDataImageRange(image_base, profile_.emulator_flag_storage_rva,
1)) {
return FailedPrecondition(
"VR emulator flag storage is not in a writable data range");
}
const auto *vtable = reinterpret_cast<const std::uintptr_t *>(
image_base + profile_.vtable_rva);
if (!internal::HasExpectedVrDebugDeviceVtable(vtable, image_base, profile_)) {
return FailedPrecondition(
"DebugDeviceVR vtable does not match the Build-ID profile");
}
const auto *state_getter_code = reinterpret_cast<const std::uint8_t *>(
image_base + profile_.state_getter_rva);
const auto *eye_getter_code = reinterpret_cast<const std::uint8_t *>(
image_base + profile_.eye_getter_rva);
const auto *constructor_code = reinterpret_cast<const std::uint8_t *>(
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) ||
!internal::HasExpectedVrEyeGetterContract(eye_getter_code,
kEyeGetterContractSize)) {
return FailedPrecondition(
"DebugDeviceVR getter machine contracts do not match the profile");
}
if (!internal::HasExpectedVrConstructorContract(constructor_code,
kConstructorContractSize) ||
!internal::HasExpectedVrEyeInitializerContract(
initializer_code, kInitializerContractSize, profile_)) {
return FailedPrecondition(
"DebugDeviceVR constructor/initializer machine contracts do not "
"match the profile");
}
image_base_.store(image_base, std::memory_order_relaxed);
image_size_.store(ImageSize(image_base), std::memory_order_relaxed);
vtable_ =
reinterpret_cast<std::uintptr_t *>(image_base + profile_.vtable_rva);
original_state_slot_ = vtable_[kStateGetterVtableSlot / sizeof(void *)];
original_eye_slot_ = vtable_[kEyeGetterVtableSlot / sizeof(void *)];
std::uintptr_t *first_slot = vtable_ + kStateGetterVtableSlot / 8;
std::uintptr_t *last_slot_exclusive =
vtable_ + kEyeInitializerVtableSlot / 8 + 1;
if (!SetVtableRangeWritable(first_slot, last_slot_exclusive, true)) {
vtable_ = nullptr;
return Status::Error(StatusCode::kPlatformError,
"cannot make DebugDeviceVR vtable writable");
}
original_state_getter_.store(
reinterpret_cast<StateGetterFn>(original_state_slot_),
std::memory_order_release);
original_eye_getter_.store(reinterpret_cast<EyeGetterFn>(original_eye_slot_),
std::memory_order_release);
__atomic_store_n(&vtable_[kStateGetterVtableSlot / 8],
FunctionAddress(&mocktail_vr_debug_device_state_getter),
__ATOMIC_RELEASE);
__atomic_store_n(&vtable_[kEyeGetterVtableSlot / 8],
FunctionAddress(&mocktail_vr_debug_device_eye_getter),
__ATOMIC_RELEASE);
if (!SetVtableRangeWritable(first_slot, last_slot_exclusive, false)) {
__atomic_store_n(&vtable_[kStateGetterVtableSlot / 8], original_state_slot_,
__ATOMIC_RELEASE);
__atomic_store_n(&vtable_[kEyeGetterVtableSlot / 8], original_eye_slot_,
__ATOMIC_RELEASE);
(void)SetVtableRangeWritable(first_slot, last_slot_exclusive, false);
original_state_getter_.store(nullptr, std::memory_order_release);
original_eye_getter_.store(nullptr, std::memory_order_release);
vtable_ = nullptr;
return Status::Error(StatusCode::kPlatformError,
"cannot restore DebugDeviceVR vtable RELRO "
"protection");
}
active_.store(true, std::memory_order_release);
Log(" [vr-device] activated: DebugDeviceVR vtable slots +0x28/+0x48 "
"interposed at image base 0x%" PRIxPTR "\n",
image_base);
return Status::Ok();
}
void RobloxVrDeviceBridge::Shutdown() {
if (!installed_) {
return;
}
bool restored = true;
{
std::lock_guard<std::mutex> lock(mutex_);
if (active_.load(std::memory_order_acquire)) {
active_.store(false, std::memory_order_release);
restored = RestoreVtableLocked();
Log(" [vr-device] shutdown: captured=%d initialized=%d "
"eye0_calls=%" PRIu64 " eye1_calls=%" PRIu64 " presents=%" PRIu64
" vtable_restored=%d\n",
captured_device_count(), initialized_device_count(),
eye_getter_call_count(0), eye_getter_call_count(1),
present_count_.load(std::memory_order_relaxed), restored ? 1 : 0);
vtable_ = nullptr;
image_base_.store(0, std::memory_order_relaxed);
image_size_.store(0, std::memory_order_relaxed);
}
}
if (!restored) {
std::fprintf(stderr, " [vr-device] failed to restore DebugDeviceVR vtable "
"protection\n");
}
installed_ = false;
}
bool RobloxVrDeviceBridge::RestoreVtableLocked() {
if (vtable_ == nullptr) {
return false;
}
std::uintptr_t *first_slot = vtable_ + kStateGetterVtableSlot / 8;
std::uintptr_t *last_slot_exclusive =
vtable_ + kEyeInitializerVtableSlot / 8 + 1;
if (!SetVtableRangeWritable(first_slot, last_slot_exclusive, true)) {
return false;
}
__atomic_store_n(&vtable_[kStateGetterVtableSlot / 8], original_state_slot_,
__ATOMIC_RELEASE);
__atomic_store_n(&vtable_[kEyeGetterVtableSlot / 8], original_eye_slot_,
__ATOMIC_RELEASE);
return SetVtableRangeWritable(first_slot, last_slot_exclusive, false);
}
void RobloxVrDeviceBridge::NoteHostPresent() {
if (!active_.load(std::memory_order_acquire)) {
return;
}
const std::uint64_t thread_id = CurrentThreadId();
std::uint64_t expected_thread = 0;
if (present_thread_.compare_exchange_strong(expected_thread, thread_id,
std::memory_order_acq_rel)) {
Log(" [vr-device] render thread observed: tid=%" PRIu64 "\n", thread_id);
}
const std::uint64_t presents =
present_count_.fetch_add(1, std::memory_order_relaxed) + 1;
if (presents == 1) {
Log(" [vr-device] first host present completed on tid=%" PRIu64 "\n",
thread_id);
}
g_thread_presented = true;
mocktail_vr_consume_eye();
}
bool RobloxVrDeviceBridge::ValidateObjectHeader(void *self) const {
const std::uintptr_t base = image_base_.load(std::memory_order_acquire);
if (self == nullptr || base == 0) {
return false;
}
auto *bytes = static_cast<std::uint8_t *>(self);
const std::uintptr_t vptr = *reinterpret_cast<const std::uintptr_t *>(bytes);
if (vptr != base + profile_.vtable_rva) {
return false;
}
const std::uint32_t type =
*reinterpret_cast<const std::uint32_t *>(bytes + kObjectTypeOffset);
if (type != kExpectedObjectType) {
return false;
}
const char *name =
*reinterpret_cast<const char *const *>(bytes + kObjectNameOffset);
return name != nullptr && std::strcmp(name, "DebugDevice") == 0;
}
void *RobloxVrDeviceBridge::ValidatedGraphicsDevice(void *self) const {
auto *bytes = static_cast<std::uint8_t *>(self);
void *device =
*reinterpret_cast<void *const *>(bytes + kGraphicsDeviceOffset);
if (device == nullptr) {
return nullptr;
}
const std::uintptr_t base = image_base_.load(std::memory_order_acquire);
const std::uintptr_t size = image_size_.load(std::memory_order_acquire);
const std::uintptr_t device_vtable =
*reinterpret_cast<const std::uintptr_t *>(device);
if (!IsCodePointerInImage(base, size, device_vtable)) {
return nullptr;
}
const std::uintptr_t create_framebuffer =
*reinterpret_cast<const std::uintptr_t *>(
device_vtable + profile_.device_create_framebuffer_vtable_offset);
if (!IsCodePointerInImage(base, size, create_framebuffer)) {
return nullptr;
}
return device;
}
bool internal::ApplyXrPose(void* state, const ScriptedPoseSample& pose) {
if (!state) return false;
auto* bytes = static_cast<std::uint8_t*>(state);
bytes[0] = 0;
// No tracked controllers are supplied by this backend.
bytes[0x20] = bytes[0x40] = bytes[0x60] = 0;
if (!pose.valid) return false;
float norm = 0;
for (float v : pose.orientation) { if (!std::isfinite(v)) return false; norm += v*v; }
if (norm < 0.99f || norm > 1.01f) return false;
for (float v : pose.position) if (!std::isfinite(v)) return false;
for (int eye = 0; eye < 2; ++eye) {
for (float v : pose.eye_offset[eye]) if (!std::isfinite(v)) return false;
for (float v : pose.eye_fov[eye])
if (!std::isfinite(v) || std::abs(v) >= 1.56f) return false;
}
// 2998's 0x37eacb8 converts metres to studs itself (factor 10/3).
std::memcpy(bytes + 4, pose.position, sizeof(pose.position));
std::memcpy(bytes + 0x10, pose.orientation, sizeof(pose.orientation));
for (int eye = 0; eye < 2; ++eye) {
std::memcpy(bytes + 0xf8 + eye*12, pose.eye_offset[eye], 12);
const auto* fov = pose.eye_fov[eye];
const float tangents[4] = {std::tan(fov[2]), -std::tan(fov[3]),
-std::tan(fov[0]), std::tan(fov[1])};
std::memcpy(bytes + 0x110 + eye*16, tangents, sizeof(tangents));
}
bytes[0] = 1;
return true;
}
void RobloxVrDeviceBridge::OnStateGetterCall(void *result_buffer,
void *device_object) {
// Serialize initialization with all copies of readiness: the initializer
// raises the guest ready byte before its resources have been allocated.
std::lock_guard<std::mutex> lock(mutex_);
if (active_.load(std::memory_order_acquire) && g_thread_presented &&
ValidateObjectHeader(device_object) &&
internal::InspectEyeResources(device_object) ==
internal::EyeResourceState::kEmpty) {
TryInitializeEyes(device_object);
}
const auto original = original_state_getter();
if (original != nullptr)
original(result_buffer, device_object);
if (active_.load(std::memory_order_acquire) && result_buffer && original) {
if (auto* backend = ActiveVrBackend()) {
const auto pose = backend->PublishedHeadPose();
if (internal::ApplyXrPose(result_buffer, pose)) {
backend->NotePoseApplied(device_object, pose.frame);
static thread_local std::uint64_t last_logged = 0;
if (pose.frame != last_logged && (pose.frame == 1 || pose.frame % 20 == 0)) {
last_logged = pose.frame;
Log(" [vr-device][evidence] pose-applied frame=%llu owner=%p "
"pos=(%.4f,%.4f,%.4f) ori=(%.4f,%.4f,%.4f,%.4f)\n",
static_cast<unsigned long long>(pose.frame), device_object,
pose.position[0], pose.position[1], pose.position[2],
pose.orientation[0], pose.orientation[1], pose.orientation[2], pose.orientation[3]);
}
}
}
}
if (active_.load(std::memory_order_acquire) && result_buffer != nullptr &&
internal::InspectEyeResources(device_object) !=
internal::EyeResourceState::kReady) {
// Fail closed in the returned state, without changing guest ownership or
// manufacturing readiness. Partial/failed resources must never be rendered.
static_cast<std::uint8_t *>(
result_buffer)[internal::kStateReadyOffsetInCopy] = 0;
}
}
void RobloxVrDeviceBridge::TryInitializeEyes(void *self) {
if (self == nullptr || !ValidateObjectHeader(self)) {
Log(" [vr-device] eye-init aborted: object header no longer valid "
"(self=%p)\n",
self);
return;
}
const auto *flag = reinterpret_cast<const std::uint8_t *>(
image_base_.load(std::memory_order_acquire) +
profile_.emulator_flag_storage_rva);
if (*flag == 0) {
bool expected = false;
if (flag_warning_logged_.compare_exchange_strong(expected, true)) {
Log(" [vr-device] eye-init deferred: FFlagDebugEnableVREmulator "
"storage byte is 0\n");
}
return;
}
void *device = ValidatedGraphicsDevice(self);
if (device == nullptr) {
Log(" [vr-device] eye-init failed: graphics device at self+0x198 did "
"not pass the machine contract (self=%p)\n",
self);
return;
}
captured_count_.fetch_add(1, std::memory_order_relaxed);
auto *bytes = static_cast<std::uint8_t *>(self);
Log(" [vr-device] captured DebugDeviceVR object self=%p device=%p\n", self,
device);
const auto initializer = reinterpret_cast<EyeInitializerFn>(
image_base_.load(std::memory_order_acquire) +
profile_.eye_initializer_rva);
Log(" [vr-device][evidence] eye-init-begin self=%p device=%p tid=%" PRIu64
" presents=%" PRIu64 "\n",
self, device, CurrentThreadId(),
present_count_.load(std::memory_order_relaxed));
g_initializing_object = self;
initializer(self, device);
g_initializing_object = nullptr;
Log(" [vr-device][evidence] eye-init-returned self=%p\n", self);
const std::uint8_t ready = bytes[kReadyByteOffset];
const void *framebuffer0 =
*reinterpret_cast<void *const *>(bytes + kFramebufferSlotOffset);
const void *framebuffer1 = *reinterpret_cast<void *const *>(
bytes + kFramebufferSlotOffset + internal::kEyeSlotStride);
const void *texture0 =
*reinterpret_cast<void *const *>(bytes + kTextureSlotOffset);
const void *texture1 = *reinterpret_cast<void *const *>(
bytes + kTextureSlotOffset + internal::kEyeSlotStride);
const std::uint32_t width =
*reinterpret_cast<const std::uint32_t *>(bytes + kWidthOffset);
const std::uint32_t height =
*reinterpret_cast<const std::uint32_t *>(bytes + kHeightOffset);
const bool slots_valid = framebuffer0 != nullptr && framebuffer1 != nullptr &&
texture0 != nullptr && texture1 != nullptr &&
framebuffer0 != framebuffer1 && texture0 != texture1;
if (ready == 1 && slots_valid &&
internal::InspectEyeResources(self) ==
internal::EyeResourceState::kReady) {
initialized_count_.fetch_add(1, std::memory_order_relaxed);
Log(" [vr-device][evidence] eye-init-complete self=%p ready=1 fb0=%p "
"fb1=%p tex0=%p tex1=%p width=%u height=%u\n",
self, framebuffer0, framebuffer1, texture0, texture1, width, height);
const auto *eye0_view =
reinterpret_cast<const float *>(bytes + internal::kEye0ViewOffset);
const auto *eye1_view =
reinterpret_cast<const float *>(bytes + internal::kEye1ViewOffset);
const auto *eye0_projection = reinterpret_cast<const float *>(
bytes + internal::kEye0ProjectionOffset);
const auto *eye1_projection = reinterpret_cast<const float *>(
bytes + internal::kEye1ProjectionOffset);
const auto *field_of_view =
reinterpret_cast<const float *>(bytes + internal::kFieldOfViewOffset);
const float half_ipd = *reinterpret_cast<const float *>(
bytes + internal::kHalfInterpupillaryOffset);
Log(" [vr-device][evidence] eye-params self=%p eye0_view=(%.4f,%.4f,"
"%.4f) eye1_view=(%.4f,%.4f,%.4f) eye0_proj=(%.4f,%.4f,%.4f,%.4f) "
"eye1_proj=(%.4f,%.4f,%.4f,%.4f) fov_deg=(%.1f,%.1f,%.1f,%.1f) "
"half_ipd=%.4f\n",
self, eye0_view[0], eye0_view[1], eye0_view[2], eye1_view[0],
eye1_view[1], eye1_view[2], eye0_projection[0], eye0_projection[1],
eye0_projection[2], eye0_projection[3], eye1_projection[0],
eye1_projection[1], eye1_projection[2], eye1_projection[3],
field_of_view[0], field_of_view[1], field_of_view[2], field_of_view[3],
half_ipd);
} else {
Log(" [vr-device][evidence] eye-init-failed self=%p ready=%u fb0=%p "
"fb1=%p tex0=%p tex1=%p width=%u height=%u\n",
self, ready, framebuffer0, framebuffer1, texture0, texture1, width,
height);
}
}
void RobloxVrDeviceBridge::OnEyeGetterCall(void *device_object, int eye_index,
void *framebuffer) {
if (!active_.load(std::memory_order_acquire)) {
return;
}
if (eye_index < 0 || eye_index > 1) {
Log(" [vr-device] eye getter called with unexpected index %d self=%p\n",
eye_index, device_object);
return;
}
g_eye_object = device_object;
g_eye_framebuffer = framebuffer;
g_eye_index = eye_index;
const std::uint64_t calls =
eye_call_totals_[eye_index].fetch_add(1, std::memory_order_relaxed) + 1;
if (calls <= 4) {
Log(" [vr-device][evidence] eye-getter self=%p eye=%d framebuffer=%p "
"call=%" PRIu64 " tid=%" PRIu64 "\n",
device_object, eye_index, framebuffer, calls, CurrentThreadId());
} else if (calls % kEyeGetterSummaryInterval == 0) {
Log(" [vr-device][evidence] eye-getter-summary self=%p eye0=%" PRIu64
" eye1=%" PRIu64 " last_framebuffer=%p\n",
device_object, eye_getter_call_count(0), eye_getter_call_count(1),
framebuffer);
}
}
int RobloxVrDeviceBridge::captured_device_count() const {
return captured_count_.load(std::memory_order_relaxed);
}
int RobloxVrDeviceBridge::initialized_device_count() const {
return initialized_count_.load(std::memory_order_relaxed);
}
std::uint64_t RobloxVrDeviceBridge::eye_getter_call_count(int eye_index) const {
if (eye_index < 0 || eye_index > 1) {
return 0;
}
return eye_call_totals_[eye_index].load(std::memory_order_relaxed);
}
Status NotifyRobloxVrImageLoaded(std::uintptr_t image_base) {
RobloxVrDeviceBridge *bridge =
g_active_bridge.load(std::memory_order_acquire);
if (bridge == nullptr) {
return Status::Ok();
}
if (image_base == 0) {
return FailedPrecondition(
"Roblox VR device bridge is armed but libroblox base is unknown");
}
return bridge->Activate(image_base);
}
} // namespace mocktail::vr
+82
View File
@@ -0,0 +1,82 @@
#ifndef MOCKTAIL_VR_ROBLOX_VR_DEVICE_BRIDGE_INTERNAL_H_
#define MOCKTAIL_VR_ROBLOX_VR_DEVICE_BRIDGE_INTERNAL_H_
#include <cstddef>
#include <cstdint>
#include "compat/build_profile.h"
namespace mocktail::vr { struct ScriptedPoseSample; }
namespace mocktail::vr::internal {
// Writes only a caller-owned state copy; preserves all resource readiness.
bool ApplyXrPose(void* state, const ScriptedPoseSample& pose);
// Guest ABI layout constants of RBX::Graphics::DebugDeviceVR. They are pinned
// per exact build by the machine-code contracts below; changing payload
// requires re-deriving both.
inline constexpr std::size_t kObjectNameOffset = 0x8;
inline constexpr std::size_t kObjectTypeOffset = 0x10;
inline constexpr std::uint32_t kExpectedObjectType = 6;
inline constexpr std::size_t kStateOffset = 0x14;
inline constexpr std::size_t kStateCopySize = 0x138;
inline constexpr std::size_t kStateReadyOffsetInCopy = 0x133;
inline constexpr std::size_t kReadyByteOffset = 0x147;
inline constexpr std::size_t kFramebufferSlotOffset = 0x150;
inline constexpr std::size_t kTextureSlotOffset = 0x170;
inline constexpr std::size_t kEyeSlotStride = 0x10;
inline constexpr std::size_t kWidthOffset = 0x190;
inline constexpr std::size_t kHeightOffset = 0x194;
inline constexpr std::size_t kGraphicsDeviceOffset = 0x198;
// Per-eye camera parameters filled by the constructor: symmetric +-0.25 stud
// view offsets (half IPD), tan-space projection values and FOV degrees.
inline constexpr std::size_t kEye0ViewOffset = 0x10c;
inline constexpr std::size_t kEye1ViewOffset = 0x118;
inline constexpr std::size_t kEye0ProjectionOffset = 0x124;
inline constexpr std::size_t kEye1ProjectionOffset = 0x134;
inline constexpr std::size_t kFieldOfViewOffset = 0x1a0;
inline constexpr std::size_t kHalfInterpupillaryOffset = 0x1b8;
inline constexpr std::size_t kStateGetterVtableSlot = 0x28;
inline constexpr std::size_t kEyeGetterVtableSlot = 0x48;
inline constexpr std::size_t kEyeInitializerVtableSlot = 0xd0;
inline constexpr std::size_t kStateGetterContractSize = 33;
inline constexpr std::size_t kEyeGetterContractSize = 16;
inline constexpr std::size_t kConstructorContractSize = 664;
inline constexpr std::size_t kInitializerContractSize = 700;
enum class EyeResourceState { kEmpty, kReady, kInvalid };
// Reads only the current live object's ABI bytes. A ready byte alone is
// insufficient; dimensions and both distinct framebuffer/texture slots count.
EyeResourceState InspectEyeResources(const void *object);
// Exact 33-byte state getter: sret ABI (rdi = hidden 0x138-byte result
// buffer, rsi = device), copies state from self+0x14 and returns the buffer.
bool HasExpectedVrStateGetterContract(const std::uint8_t *code,
std::size_t size);
// Exact 16-byte eye framebuffer getter: self + 0x150 + eyeIndex * 16.
bool HasExpectedVrEyeGetterContract(const std::uint8_t *code, std::size_t size);
// Constructor: captures rcx (graphics device) into self+0x198, width/height
// into +0x190/+0x194, type dword 6 into +0x10, and clears the state block.
bool HasExpectedVrConstructorContract(const std::uint8_t *code,
std::size_t size);
// Eye-resource initializer: raises ready byte self+0x147 plus +0x94/+0x144
// and creates the per-eye resources through the graphics-device vtable slot
// taken from the profile (2998 uses +0x180; other builds differ).
bool HasExpectedVrEyeInitializerContract(
const std::uint8_t *code, std::size_t size,
const compat::VrDebugDeviceBridgeProfile &profile);
// DebugDeviceVR vtable must point the three interposed/used slots at the
// exact profile RVAs before any interposition.
bool HasExpectedVrDebugDeviceVtable(
const std::uintptr_t *vtable, std::uintptr_t image_base,
const compat::VrDebugDeviceBridgeProfile &profile);
} // namespace mocktail::vr::internal
#endif // MOCKTAIL_VR_ROBLOX_VR_DEVICE_BRIDGE_INTERNAL_H_
+268
View File
@@ -0,0 +1,268 @@
#include "mocktail/vr/vr_mirror.h"
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include <atomic>
#include <cerrno>
#include <cstdio>
#include <cstring>
namespace mocktail::vr {
namespace {
// Seqlock writers/reader fences: the header is plain shared memory, so
// ordering between the sequence counter and the payload is enforced here.
void StoreFence() { std::atomic_thread_fence(std::memory_order_release); }
std::uint32_t FindHostVisibleMemoryType(const MirrorVkProcs& vk,
VkPhysicalDevice physical,
std::uint32_t type_bits) {
VkPhysicalDeviceMemoryProperties properties{};
vk.GetPhysicalDeviceMemoryProperties(physical, &properties);
const auto required = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT;
// Prefer HOST_CACHED system memory: CPU reads of device-local BAR1 memory
// cost ~10ms per 2 MB pair on this GPU, while cached RAM readback is ~1ms.
for (std::uint32_t index = 0; index < properties.memoryTypeCount; ++index) {
if ((type_bits & (1u << index)) != 0 &&
(properties.memoryTypes[index].propertyFlags &
(required | VK_MEMORY_PROPERTY_HOST_CACHED_BIT)) ==
(required | VK_MEMORY_PROPERTY_HOST_CACHED_BIT)) {
return index;
}
}
for (std::uint32_t index = 0; index < properties.memoryTypeCount; ++index) {
if ((type_bits & (1u << index)) != 0 &&
(properties.memoryTypes[index].propertyFlags & required) == required) {
return index;
}
}
return UINT32_MAX;
}
} // namespace
VrMirror::~VrMirror() { Close(); }
bool VrMirror::Open(const std::string& shm_name, std::uint32_t width,
std::uint32_t height, std::string* error) {
if (mapping_ != nullptr) {
const auto* header = static_cast<const VrMirrorHeader*>(mapping_);
if (shm_name_ == shm_name && header->width == width && header->height == height) {
return true;
}
Close();
}
if (shm_name.empty() || shm_name[0] != '/' || width == 0 || height == 0) {
if (error != nullptr) {
*error = "invalid mirror segment name or extent";
}
return false;
}
const std::uint64_t bytes = VrMirrorBytes(width, height);
const int descriptor =
shm_open(shm_name.c_str(), O_CREAT | O_RDWR | O_CLOEXEC, 0600);
if (descriptor < 0) {
if (error != nullptr) {
*error = "shm_open failed: " + std::string(std::strerror(errno));
}
return false;
}
if (ftruncate(descriptor, static_cast<off_t>(bytes)) != 0) {
if (error != nullptr) {
*error = "ftruncate failed: " + std::string(std::strerror(errno));
}
close(descriptor);
(void)shm_unlink(shm_name.c_str());
return false;
}
void* mapping = mmap(nullptr, bytes, PROT_READ | PROT_WRITE, MAP_SHARED,
descriptor, 0);
close(descriptor);
if (mapping == MAP_FAILED) {
if (error != nullptr) {
*error = "mmap failed: " + std::string(std::strerror(errno));
}
(void)shm_unlink(shm_name.c_str());
return false;
}
mapping_ = mapping;
mapping_bytes_ = bytes;
shm_name_ = shm_name;
auto* header = static_cast<VrMirrorHeader*>(mapping_);
std::memset(header, 0, sizeof(*header));
header->magic = kVrMirrorMagic;
header->header_bytes = kVrMirrorHeaderBytes;
header->width = width;
header->height = height;
StoreFence();
return true;
}
void VrMirror::Close() {
if (mapping_ != nullptr) {
munmap(mapping_, mapping_bytes_);
mapping_ = nullptr;
mapping_bytes_ = 0;
}
if (!shm_name_.empty()) {
(void)shm_unlink(shm_name_.c_str());
shm_name_.clear();
}
published_pairs_ = 0;
}
bool VrMirror::EnsureStaging(const MirrorVkProcs& vk, VkDevice device,
VkPhysicalDevice physical, std::uint32_t width,
std::uint32_t height, std::string* error) {
if (staging_device_ == device && staging_[0] != VK_NULL_HANDLE &&
staging_[1] != VK_NULL_HANDLE && staging_width_ == width &&
staging_height_ == height) {
return true;
}
DestroyStaging(vk);
staging_device_ = device; // Also owns partially constructed allocations.
const VkDeviceSize size =
static_cast<VkDeviceSize>(width) * height * 4u;
VkPhysicalDeviceMemoryProperties properties{};
vk.GetPhysicalDeviceMemoryProperties(physical, &properties);
for (int eye = 0; eye < 2; ++eye) {
VkBufferCreateInfo buffer_info{};
buffer_info.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
buffer_info.size = size;
buffer_info.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT;
if (vk.CreateBuffer(device, &buffer_info, nullptr, &staging_[eye]) !=
VK_SUCCESS) {
if (error != nullptr) {
*error = "mirror staging buffer creation failed";
}
DestroyStaging(vk);
return false;
}
VkMemoryRequirements requirements{};
vk.GetBufferMemoryRequirements(device, staging_[eye], &requirements);
const std::uint32_t type =
FindHostVisibleMemoryType(vk, physical, requirements.memoryTypeBits);
if (type == UINT32_MAX) {
if (error != nullptr) {
*error = "no host-visible coherent memory for the mirror";
}
DestroyStaging(vk);
return false;
}
VkMemoryAllocateInfo allocation{};
allocation.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocation.allocationSize = requirements.size;
allocation.memoryTypeIndex = type;
if (vk.AllocateMemory(device, &allocation, nullptr,
&staging_memory_[eye]) != VK_SUCCESS) {
if (error != nullptr) {
*error = "mirror staging allocation failed";
}
DestroyStaging(vk);
return false;
}
if (vk.BindBufferMemory(device, staging_[eye], staging_memory_[eye], 0) !=
VK_SUCCESS ||
vk.MapMemory(device, staging_memory_[eye], 0, size, 0,
&staging_map_[eye]) != VK_SUCCESS) {
if (error != nullptr) {
*error = "mirror staging bind/map failed";
}
DestroyStaging(vk);
return false;
}
}
staging_device_ = device;
staging_width_ = width;
staging_height_ = height;
return true;
}
void VrMirror::DestroyStaging(const MirrorVkProcs& vk) {
for (int eye = 0; eye < 2; ++eye) {
if (staging_map_[eye] != nullptr && staging_memory_[eye] != VK_NULL_HANDLE) {
vk.UnmapMemory(staging_device_, staging_memory_[eye]);
staging_map_[eye] = nullptr;
}
if (staging_memory_[eye] != VK_NULL_HANDLE) {
vk.FreeMemory(staging_device_, staging_memory_[eye], nullptr);
staging_memory_[eye] = VK_NULL_HANDLE;
}
if (staging_[eye] != VK_NULL_HANDLE) {
vk.DestroyBuffer(staging_device_, staging_[eye], nullptr);
staging_[eye] = VK_NULL_HANDLE;
}
}
staging_width_ = 0;
staging_height_ = 0;
staging_device_ = VK_NULL_HANDLE;
}
void VrMirror::RecordEyeCopy(const MirrorVkProcs& vk, VkCommandBuffer cmd,
int eye, VkImage source,
VkImageLayout /*final_layout*/,
std::uint32_t width, std::uint32_t height) {
// The caller's batched command buffer already holds the source in
// TRANSFER_SRC_OPTIMAL for the XR swapchain copy and restores its final
// layout afterwards; recording only the copy here keeps one transition pair
// per eye per frame.
if (eye < 0 || eye > 1 || staging_[eye] == VK_NULL_HANDLE ||
staging_width_ != width || staging_height_ != height) {
return;
}
VkBufferImageCopy region{};
region.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
region.imageExtent = {width, height, 1};
vk.CmdCopyImageToBuffer(cmd, source, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
staging_[eye], 1, &region);
}
void VrMirror::Publish(std::uint64_t frame, VkFormat format,
std::uint32_t width, std::uint32_t height,
std::uint64_t now_ns) {
if (mapping_ == nullptr || staging_map_[0] == nullptr ||
staging_map_[1] == nullptr || staging_width_ != width ||
staging_height_ != height) {
return;
}
PublishFromBytesForTest(frame, format, width, height, now_ns, staging_map_[0],
staging_map_[1]);
}
void VrMirror::PublishFromBytesForTest(std::uint64_t frame, VkFormat format,
std::uint32_t width,
std::uint32_t height,
std::uint64_t now_ns, const void* eye0,
const void* eye1) {
if (mapping_ == nullptr || eye0 == nullptr || eye1 == nullptr) {
return;
}
auto* header = static_cast<VrMirrorHeader*>(mapping_);
auto* pixels = static_cast<std::uint8_t*>(mapping_) + kVrMirrorHeaderBytes;
const std::uint64_t eye_bytes =
static_cast<std::uint64_t>(width) * height * 4u;
if (kVrMirrorHeaderBytes + 2 * eye_bytes > mapping_bytes_) {
return;
}
// Seqlock: odd sequence marks the payload unstable for readers.
const std::uint64_t sequence = header->seq + 1;
header->seq = sequence | 1ull;
StoreFence();
header->width = width;
header->height = height;
header->format = static_cast<std::uint32_t>(format);
header->frame = frame;
header->producer_ns = now_ns;
std::memcpy(pixels, eye0, eye_bytes);
std::memcpy(pixels + eye_bytes, eye1, eye_bytes);
header->flags = kVrMirrorFlagValid;
header->published_pairs = ++published_pairs_;
StoreFence();
header->seq = sequence + 1;
}
} // namespace mocktail::vr
+220
View File
@@ -0,0 +1,220 @@
#include "mocktail/vr/vr_perf.h"
#include <time.h>
#include <algorithm>
#include <cstdio>
namespace mocktail::vr::perf {
namespace {
Collector& Global() {
static Collector collector;
return collector;
}
} // namespace
bool Enabled() {
static const bool enabled = [] {
const char* value = std::getenv("MOCKTAIL_VR_PERF");
return value != nullptr && value[0] != '\0' && *value != '0';
}();
if (!enabled) return false;
static const std::uint64_t start = [] {
const char* value = std::getenv("MOCKTAIL_VR_MEASURE_START_NS");
return value ? std::strtoull(value, nullptr, 10) : 0ULL;
}();
static const std::uint64_t end = [] {
const char* value = std::getenv("MOCKTAIL_VR_MEASURE_END_NS");
return value ? std::strtoull(value, nullptr, 10) : ~0ULL;
}();
if (start == 0 && end == ~0ULL) return true;
const auto now = NowNs();
return now >= start && now <= end;
}
std::uint64_t NowNs() {
timespec ts{};
if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) {
return 0;
}
return static_cast<std::uint64_t>(ts.tv_sec) * 1'000'000'000ULL +
static_cast<std::uint64_t>(ts.tv_nsec);
}
const char* StageName(Stage stage) {
switch (stage) {
case Stage::kFrameCycle: return "frame_cycle";
case Stage::kXrWaitFrame: return "xr_wait_frame";
case Stage::kXrBeginFrame: return "xr_begin_frame";
case Stage::kXrLocateViews: return "xr_locate_views";
case Stage::kSwapchainAcquire: return "swapchain_acquire";
case Stage::kSwapchainWait: return "swapchain_wait";
case Stage::kCopyRecord: return "copy_record";
case Stage::kCopySubmit: return "copy_submit";
case Stage::kCopyFenceWait: return "copy_fence_wait";
case Stage::kMirrorPublish: return "mirror_publish";
case Stage::kEvidence: return "evidence";
case Stage::kStageCount: break;
}
return "unknown";
}
const char* CounterName(Counter counter) {
switch (counter) {
case Counter::kPresents: return "presents";
case Counter::kFramesBegun: return "frames_begun";
case Counter::kFramesSubmitted: return "frames_submitted";
case Counter::kFramesSkipped: return "frames_skipped";
case Counter::kSkipShouldRender: return "skip_should_render";
case Counter::kSkipPoseInvalid: return "skip_pose_invalid";
case Counter::kSkipEyesUnbound: return "skip_eyes_unbound";
case Counter::kSkipEyeFrameMismatch: return "skip_eye_frame_mismatch";
case Counter::kSubmits: return "submits";
case Counter::kFenceWaits: return "fence_waits";
case Counter::kMirrorPairs: return "mirror_pairs";
case Counter::kMirrorDrops: return "mirror_drops";
case Counter::kSkipVisibilityLost: return "skip_visibility_lost";
case Counter::kReferenceSpaceChanges: return "reference_space_changes";
case Counter::kVisibilityLost: return "visibility_lost";
case Counter::kVisibilityGained: return "visibility_gained";
case Counter::kFramesInvalidated: return "frames_invalidated";
case Counter::kCounterCount: break;
}
return "unknown";
}
void Collector::Record(Stage stage, std::uint64_t nanos) {
if (!Enabled()) {
return;
}
const std::size_t index = static_cast<std::size_t>(stage);
if (index >= static_cast<std::size_t>(Stage::kStageCount)) {
return;
}
StageStat& stat = stages_[index];
if (stat.samples.size() < kReservoir) {
stat.samples.resize(kReservoir, 0);
}
if (stat.count == kReservoir) {
stat.total_ns -= stat.samples[stat.head];
}
stat.samples[stat.head] = nanos;
stat.head = (stat.head + 1) % kReservoir;
if (stat.count < kReservoir) {
++stat.count;
}
stat.total_ns += nanos;
}
void Collector::Count(Counter counter, std::uint64_t delta) {
if (!Enabled()) {
return;
}
const std::size_t index = static_cast<std::size_t>(counter);
if (index >= static_cast<std::size_t>(Counter::kCounterCount)) {
return;
}
counters_[index] += delta;
}
std::vector<std::uint64_t> Collector::Percentiles(Stage stage) const {
const std::size_t index = static_cast<std::size_t>(stage);
if (index >= static_cast<std::size_t>(Stage::kStageCount)) {
return {};
}
const StageStat& stat = stages_[index];
if (stat.count == 0) {
return {0, 0, 0, 0};
}
std::vector<std::uint64_t> sorted(stat.samples.begin(),
stat.samples.begin() + stat.count);
std::sort(sorted.begin(), sorted.end());
auto pick = [&sorted](double fraction) {
const std::size_t position = static_cast<std::size_t>(
fraction * static_cast<double>(sorted.size() - 1));
return sorted[position];
};
return {pick(0.50), pick(0.95), pick(0.99), sorted.back()};
}
std::size_t Collector::Samples(Stage stage) const {
const std::size_t index = static_cast<std::size_t>(stage);
if (index >= static_cast<std::size_t>(Stage::kStageCount)) {
return 0;
}
return stages_[index].count;
}
std::uint64_t Collector::counter(Counter counter_id) const {
const std::size_t index = static_cast<std::size_t>(counter_id);
if (index >= static_cast<std::size_t>(Counter::kCounterCount)) {
return 0;
}
return counters_[index];
}
void Collector::Reset() {
for (StageStat& stat : stages_) {
stat.head = 0;
stat.count = 0;
stat.total_ns = 0;
}
for (std::uint64_t& value : counters_) {
value = 0;
}
last_emit_ns_ = 0;
window_start_ns_ = 0;
}
void Collector::EmitIfDue(std::uint64_t now_ns, const char* context) {
if (!Enabled() || now_ns == 0) {
return;
}
if (window_start_ns_ == 0) {
window_start_ns_ = now_ns;
last_emit_ns_ = now_ns;
return;
}
if (now_ns - last_emit_ns_ < kIntervalNs) {
return;
}
const double window_seconds =
static_cast<double>(now_ns - window_start_ns_) / 1e9;
std::fprintf(stderr, " [vr-perf] window=%.1fs context=%s\n",
window_seconds, context != nullptr ? context : "");
for (std::size_t index = 0;
index < static_cast<std::size_t>(Stage::kStageCount); ++index) {
const StageStat& stat = stages_[index];
if (stat.count == 0) {
continue;
}
const std::vector<std::uint64_t> ranked =
Percentiles(static_cast<Stage>(index));
std::fprintf(stderr,
" [vr-perf] stage=%-16s n=%zu avg=%.2fms p50=%.2fms "
"p95=%.2fms p99=%.2fms max=%.2fms\n",
StageName(static_cast<Stage>(index)), stat.count,
static_cast<double>(stat.total_ns) /
static_cast<double>(stat.count) / 1e6,
ranked[0] / 1e6, ranked[1] / 1e6, ranked[2] / 1e6,
ranked[3] / 1e6);
}
for (std::size_t index = 0;
index < static_cast<std::size_t>(Counter::kCounterCount); ++index) {
const std::uint64_t value = counters_[index];
if (value == 0) {
continue;
}
std::fprintf(stderr, " [vr-perf] counter=%-22s total=%llu rate=%.2f/s\n",
CounterName(static_cast<Counter>(index)),
static_cast<unsigned long long>(value),
static_cast<double>(value) / std::max(window_seconds, 1e-9));
}
last_emit_ns_ = now_ns;
}
Collector& ProcessCollector() { return Global(); }
} // namespace mocktail::vr::perf
+57 -3
View File
@@ -3,10 +3,60 @@
# Licensed under the Apache License, Version 2.0 (the "License")
find_package(Python3 COMPONENTS Interpreter REQUIRED)
add_test(NAME OpenXrSimulatorLauncher
COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/vr/simulator_launcher_test.py"
"${CMAKE_BINARY_DIR}/mocktail-vr-simulated")
# Test-only implicit Vulkan layer that captures GPU-level native stereo
# evidence (eye render-pass counts and per-eye pixel readback).
# Never linked into production code.
add_library(mocktail_vr_evidence_layer MODULE vr/vr_evidence_layer.cc)
target_link_libraries(mocktail_vr_evidence_layer PRIVATE Vulkan::Headers
${CMAKE_DL_LIBS})
mocktail_apply_compile_options(mocktail_vr_evidence_layer)
set_target_properties(mocktail_vr_evidence_layer PROPERTIES
PREFIX "lib" OUTPUT_NAME "mocktail_vr_evidence_layer")
file(GENERATE
OUTPUT "${CMAKE_BINARY_DIR}/tests/vr/VkLayer_mocktail_vr_evidence.json"
CONTENT [=[{
"file_format_version": "1.2.0",
"layer": {
"name": "VK_LAYER_MOCKTAIL_vr_evidence",
"type": "GLOBAL",
"library_path": "$<TARGET_FILE:mocktail_vr_evidence_layer>",
"api_version": "1.3.280",
"implementation_version": "1",
"description": "Mocktail VR native stereo evidence capture (test-only)",
"disable_environment": {"MOCKTAIL_VR_EVIDENCE_LAYER_DISABLE": "1"}
}
}
]=])
add_executable(vr_mirror_test vr/vr_mirror_test.cc)
target_link_libraries(vr_mirror_test PRIVATE Mocktail::VR GTest::gtest_main)
mocktail_apply_compile_options(vr_mirror_test)
add_executable(vr_pose_math_test vr/vr_pose_math_test.cc)
target_include_directories(vr_pose_math_test PRIVATE "${PROJECT_SOURCE_DIR}/include")
target_link_libraries(vr_pose_math_test PRIVATE GTest::gtest_main)
mocktail_apply_compile_options(vr_pose_math_test)
add_executable(vr_evidence_layer_test vr/vr_evidence_layer_test.cc)
target_link_libraries(vr_evidence_layer_test PRIVATE Vulkan::Headers
${CMAKE_DL_LIBS} GTest::gtest_main)
mocktail_apply_compile_options(vr_evidence_layer_test)
add_executable(roblox_vr_device_bridge_test vr/roblox_vr_device_bridge_test.cc)
target_include_directories(roblox_vr_device_bridge_test PRIVATE
"${PROJECT_SOURCE_DIR}/src")
target_compile_definitions(roblox_vr_device_bridge_test PRIVATE
"MOCKTAIL_TEST_BINARY_DIRECTORY=\"${CMAKE_CURRENT_BINARY_DIR}\"")
target_link_libraries(roblox_vr_device_bridge_test PRIVATE
Mocktail::VR
Mocktail::Compat
GTest::gtest_main
)
mocktail_apply_compile_options(roblox_vr_device_bridge_test)
if(MOCKTAIL_ENABLE_VR)
add_executable(openxr_backend_test vr/openxr_backend_test.cc)
target_include_directories(openxr_backend_test PRIVATE "${PROJECT_SOURCE_DIR}/third_party/OpenXR-SDK/include")
target_link_libraries(openxr_backend_test PRIVATE Mocktail::VR GTest::gtest_main)
gtest_discover_tests(openxr_backend_test)
add_library(mocktail_test_openxr_runtime MODULE
vr/fake_openxr_runtime.cc vr/fake_openxr_preview.cc)
target_link_libraries(mocktail_test_openxr_runtime PRIVATE
@@ -888,6 +938,10 @@ target_link_libraries(headless_graphics_test PRIVATE
)
include(GoogleTest)
gtest_discover_tests(roblox_vr_device_bridge_test)
gtest_discover_tests(vr_evidence_layer_test)
gtest_discover_tests(vr_mirror_test)
gtest_discover_tests(vr_pose_math_test)
gtest_discover_tests(jnivm_test)
gtest_discover_tests(runtime_config_test)
gtest_discover_tests(runtime_config_file_test)
+14
View File
@@ -12,6 +12,20 @@ namespace mocktail {
namespace runtime {
namespace {
TEST(CommandLineTest, ShortVrAliasSurvivesReexecAndLastOverrideWins) {
const char* arguments[] = {"mocktail", "-vr"};
const auto parsed = ParseCommandLine(2, arguments);
ASSERT_TRUE(parsed);
ASSERT_TRUE(parsed.options.vr_enabled.has_value());
EXPECT_TRUE(*parsed.options.vr_enabled);
std::vector<std::string> reexec;
std::string error;
ASSERT_TRUE(BuildCommandLineReexecArguments(parsed.options, 2, arguments, &reexec, &error));
EXPECT_EQ(reexec, (std::vector<std::string>{"-vr"}));
const char* disabled[] = {"mocktail", "-vr", "--no-vr"};
EXPECT_FALSE(*ParseCommandLine(3, disabled).options.vr_enabled);
}
TEST(CommandLineTest, VrOverrideSurvivesReexecAndCanDisableEnvironmentDefault) {
const std::array<const char*, 4> arguments = {"mocktail", "--vr",
"--headless", "--no-vr"};
+16
View File
@@ -283,6 +283,22 @@ TEST(PerformancePolicyTest, PermissionProtocolDoesNotUseTheTestBypass) {
EXPECT_EQ(parsed.at("DFFlagVoiceChatSkipPermissionCheckForTests"), "False");
}
TEST(PerformancePolicyTest,
VrModeOverridesConflictingFlagAndPreservesSettings) {
std::string merged, error;
ASSERT_TRUE(MergeVrClientSettingsOverrides(
true, R"({"FFlagDebugEnableVREmulator":"False","FIntUnrelated":"42"})",
&merged, &error));
auto json = nlohmann::json::parse(merged);
EXPECT_EQ(json["FFlagDebugEnableVREmulator"], "True");
EXPECT_EQ(json["FIntUnrelated"], "42");
ASSERT_TRUE(MergeVrClientSettingsOverrides(false, merged, &merged, &error));
EXPECT_EQ(nlohmann::json::parse(merged)["FFlagDebugEnableVREmulator"],
"False");
EXPECT_FALSE(MergeVrClientSettingsOverrides(true, "[]", &merged, &error));
EXPECT_FALSE(MergeVrClientSettingsOverrides(true, "bad", &merged, &error));
}
} // namespace
} // namespace runtime
} // namespace mocktail
+3 -3
View File
@@ -99,7 +99,7 @@ TEST(RuntimeConfigBootstrapTest, CreatesCompletePrivateFirstRunFile) {
LoadRuntimeConfig(MapEnvironment(), file);
ASSERT_TRUE(loaded) << loaded.error;
EXPECT_TRUE(loaded.file_loaded);
EXPECT_FALSE(loaded.config.vr_enabled());
EXPECT_EQ(loaded.config.vr_enabled(), kDefaultVrEnabled);
EXPECT_EQ(loaded.config.graphics_backend(), GraphicsBackend::kVulkan);
EXPECT_EQ(loaded.config.theme_mode(), "roblox");
EXPECT_EQ(loaded.config.frame_rate().mode, FrameRateLimitMode::kUnmanaged);
@@ -156,12 +156,12 @@ TEST(RuntimeConfigFileTest, ExportsResolvedVrStateAndRejectsInvalidState) {
EXPECT_TRUE(ExportRuntimeConfigEnvironment(
RuntimeConfig::FromEnvironment(MapEnvironment()), &error))
<< error;
EXPECT_STREQ(std::getenv("MOCKTAIL_VR_ENABLED"), "0");
EXPECT_STREQ(std::getenv("MOCKTAIL_VR_ENABLED"), kDefaultVrEnabled ? "1" : "0");
EXPECT_FALSE(ExportRuntimeConfigEnvironment(
RuntimeConfig::FromEnvironment(
MapEnvironment({{"MOCKTAIL_VR_ENABLED", "invalid"}})),
&error));
EXPECT_STREQ(std::getenv("MOCKTAIL_VR_ENABLED"), "0");
EXPECT_STREQ(std::getenv("MOCKTAIL_VR_ENABLED"), kDefaultVrEnabled ? "1" : "0");
if (saved)
setenv("MOCKTAIL_VR_ENABLED", saved->c_str(), 1);
else
+2 -2
View File
@@ -37,7 +37,7 @@ TEST(RuntimeConfigTest, UsesSupportedDefaults) {
const RuntimeConfig config = RuntimeConfig::FromEnvironment(environment);
EXPECT_FALSE(config.headless());
EXPECT_FALSE(config.vr_enabled());
EXPECT_EQ(config.vr_enabled(), kDefaultVrEnabled);
EXPECT_TRUE(config.vr_valid());
EXPECT_EQ(config.roblox_library_path(), "rbx_bin/libroblox.so");
EXPECT_EQ(config.graphics_backend(), GraphicsBackend::kVulkan);
@@ -95,7 +95,7 @@ TEST(RuntimeConfigTest, RejectsMalformedVrEnvironmentInsteadOfEnablingIt) {
const auto config = RuntimeConfig::FromEnvironment(
MapEnvironment({{"MOCKTAIL_VR_ENABLED", "maybe"}}));
EXPECT_FALSE(config.vr_valid());
EXPECT_FALSE(config.vr_enabled());
EXPECT_EQ(config.vr_enabled(), kDefaultVrEnabled);
}
TEST(RuntimeConfigTest, ReadsTypedRuntimeValues) {
+191
View File
@@ -0,0 +1,191 @@
#include <gtest/gtest.h>
#include <openxr/openxr.h>
#include "mocktail/vr/openxr_backend.h"
#include "mocktail/vr/roblox_vr_device_bridge.h"
namespace mocktail::vr {
namespace internal {
struct VrBridgeTestAccess {
static void Enable(RobloxVrDeviceBridge& bridge) { bridge.active_ = true; }
};
}
struct OpenXrBackendTestAccess {
template<class T> static T Handle(std::uintptr_t n) { return reinterpret_cast<T>(n); }
static void Configure(OpenXrBackend& backend) {
backend.vk_instance_ = Handle<VkInstance>(1);
backend.vk_device_ = Handle<VkDevice>(2);
backend.recording_ = true;
backend.published_pose_.frame = 7;
backend.published_pose_.valid = true;
}
static VkInstance Instance(const OpenXrBackend& b) { return b.vk_instance_; }
static void Attachment(OpenXrBackend& b, int id, void* owner, VkDevice device) {
auto image = Handle<VkImage>(id);
auto& r = b.images_[image];
r.image = image; r.device = device; r.owner = owner;
r.width = r.height = 500; r.format = VK_FORMAT_R8G8B8A8_UNORM;
r.usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT;
b.image_views_[Handle<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) {
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);
}
static VkImage Image(const OpenXrBackend& b, int eye) { return b.eyes_[eye].image; }
static VkImageLayout Layout(const OpenXrBackend& b, int eye) { return b.eyes_[eye].final_layout; }
static void Invalidate(OpenXrBackend& b) {
b.InvalidateInFlightPose("test");
}
static void SetFrameOpen(OpenXrBackend& b, bool open) { b.frame_open_ = open; }
static void SetVisibilityLost(OpenXrBackend& b, bool lost) { b.visibility_lost_ = lost; }
static bool VisibilityLost(const OpenXrBackend& b) { return b.visibility_lost_; }
static void NotePose(OpenXrBackend& b, void* owner) {
b.NotePoseApplied(owner, b.published_pose_.frame);
}
static std::size_t AppliedPoses(const OpenXrBackend& b) {
return b.applied_poses_.size();
}
static void State(OpenXrBackend& b, XrSessionState state) { b.HandleSessionState(state); }
static void ReferenceChange(OpenXrBackend& b, std::int64_t time) { b.HandleReferenceSpaceChange(time); }
static void FrameAt(OpenXrBackend& b, std::uint64_t time) {
b.frame_open_ = true; b.frame_views_valid_ = true; b.frame_display_time_ = time;
b.published_pose_.valid = true;
}
static bool Recovering(OpenXrBackend& b) { return b.session_recovery_pending_; }
static VkDevice Device(OpenXrBackend& b) { return b.vk_device_; }
static std::size_t Resources(OpenXrBackend& b) { return b.images_.size(); }
static void SeedResource(OpenXrBackend& b) { b.images_[Handle<VkImage>(99)] = {}; }
static void LatchCanted(OpenXrBackend& b) { b.canted_rejection_logged_ = true; }
static bool CantedLogged(const OpenXrBackend& b) { return b.canted_rejection_logged_; }
};
using Access = OpenXrBackendTestAccess;
TEST(OpenXrBackendLifecycle, DeviceDestructionPreservesLiveInstance) {
OpenXrBackend b;
Access::Configure(b);
b.NoteDeviceDestroyed(Access::Handle<VkDevice>(3));
EXPECT_TRUE(b.WantsResourceRecords());
b.NoteDeviceDestroyed(Access::Handle<VkDevice>(2));
EXPECT_EQ(Access::Instance(b), Access::Handle<VkInstance>(1));
EXPECT_FALSE(b.WantsResourceRecords());
EXPECT_FALSE(b.PublishedHeadPose().valid);
b.NoteInstanceDestroyed(Access::Handle<VkInstance>(1));
EXPECT_EQ(Access::Instance(b), VK_NULL_HANDLE);
}
TEST(OpenXrBackendBinding, SwitchesOwnerWithoutWaitingForOldResourcesToDie) {
OpenXrBackend b;
RobloxVrDeviceBridge bridge;
internal::VrBridgeTestAccess::Enable(bridge);
Access::Configure(b);
auto* first = Access::Handle<void*>(100);
auto* second = Access::Handle<void*>(200);
for (int id = 10; id < 14; ++id)
Access::Attachment(b, id, id < 12 ? first : second, Access::Handle<VkDevice>(2));
Access::Pass(b, bridge, 0, 10, first);
Access::Pass(b, bridge, 1, 11, first);
ASSERT_TRUE(b.EyeBindingComplete());
Access::Pass(b, bridge, 0, 12, second);
EXPECT_FALSE(b.EyeBindingComplete());
Access::Pass(b, bridge, 1, 13, second);
EXPECT_TRUE(b.EyeBindingComplete());
EXPECT_EQ(Access::Image(b, 0), Access::Handle<VkImage>(12));
EXPECT_EQ(Access::Image(b, 1), Access::Handle<VkImage>(13));
Access::Pass(b, bridge, 0, 12, second, VK_IMAGE_LAYOUT_GENERAL);
EXPECT_EQ(Access::Layout(b, 0), VK_IMAGE_LAYOUT_GENERAL);
}
TEST(OpenXrBackendBinding, RejectsAnotherDeviceImages) {
OpenXrBackend b;
RobloxVrDeviceBridge bridge;
internal::VrBridgeTestAccess::Enable(bridge);
Access::Configure(b);
auto* owner = Access::Handle<void*>(100);
Access::Attachment(b, 10, owner, Access::Handle<VkDevice>(3));
Access::Pass(b, bridge, 0, 10, owner);
EXPECT_EQ(Access::Image(b, 0), VK_NULL_HANDLE);
EXPECT_FALSE(b.EyeBindingComplete());
}
TEST(OpenXrBackendLifecycle, FutureRecenterPreservesEarlierFrameAndLateChangeDropsIt) {
OpenXrBackend b;
Access::Configure(b);
Access::FrameAt(b, 100);
Access::ReferenceChange(b, 200);
EXPECT_TRUE(b.PublishedHeadPose().valid);
Access::ReferenceChange(b, 100);
EXPECT_FALSE(b.PublishedHeadPose().valid);
Access::FrameAt(b, 300);
Access::ReferenceChange(b, 200);
EXPECT_FALSE(b.PublishedHeadPose().valid);
}
TEST(OpenXrBackendLifecycle, SynchronizedVisibilityTransitionUsesActualStateHandler) {
OpenXrBackend b;
Access::Configure(b);
Access::State(b, XR_SESSION_STATE_VISIBLE);
Access::State(b, XR_SESSION_STATE_SYNCHRONIZED);
EXPECT_TRUE(Access::VisibilityLost(b));
EXPECT_FALSE(b.PublishedHeadPose().valid);
Access::FrameAt(b, 100);
Access::State(b, XR_SESSION_STATE_VISIBLE);
EXPECT_FALSE(Access::VisibilityLost(b));
EXPECT_FALSE(b.PublishedHeadPose().valid);
}
TEST(OpenXrBackendLifecycle, SessionLossKeepsLiveDeviceProvenanceButDeviceDestructionCancelsRecovery) {
OpenXrBackend b;
Access::Configure(b);
Access::SeedResource(b);
Access::State(b, XR_SESSION_STATE_LOSS_PENDING);
EXPECT_TRUE(Access::Recovering(b));
EXPECT_EQ(Access::Device(b), Access::Handle<VkDevice>(2));
EXPECT_EQ(Access::Resources(b), 1u);
EXPECT_TRUE(b.WantsResourceRecords());
EXPECT_FALSE(b.PublishedHeadPose().valid);
b.NoteDestroyImage(Access::Handle<VkImage>(99));
EXPECT_EQ(Access::Resources(b), 0u);
b.NoteDeviceDestroyed(Access::Handle<VkDevice>(2));
EXPECT_FALSE(Access::Recovering(b));
EXPECT_EQ(Access::Device(b), VK_NULL_HANDLE);
}
TEST(OpenXrBackendLifecycle, InvalidationDropsPoseAndAppliedRecord) {
OpenXrBackend b;
Access::Configure(b);
Access::SetFrameOpen(b, true);
auto* owner = Access::Handle<void*>(100);
Access::NotePose(b, owner);
ASSERT_EQ(Access::AppliedPoses(b), 1u);
ASSERT_TRUE(b.PublishedHeadPose().valid);
// A reference-space change / visibility transition must drop the pose and
// the per-owner application record so the frame cycle cannot retag the
// in-flight images with a pose from the previous origin.
Access::Invalidate(b);
EXPECT_FALSE(b.PublishedHeadPose().valid);
EXPECT_EQ(Access::AppliedPoses(b), 0u);
}
TEST(OpenXrBackendLifecycle, VisibilityLostStateSurvivesUntilSessionReset) {
OpenXrBackend b;
Access::Configure(b);
Access::SetVisibilityLost(b, true);
EXPECT_TRUE(Access::VisibilityLost(b));
// Teardown (device destroyed) resets the flag so a recreated session starts
// visible; it must not inherit IDLE state from the previous session.
b.NoteDeviceDestroyed(Access::Handle<VkDevice>(2));
EXPECT_FALSE(Access::VisibilityLost(b));
}
TEST(OpenXrBackendLifecycle, TeardownClearsCantedRejectionLatch) {
OpenXrBackend b;
Access::Configure(b);
Access::LatchCanted(b);
ASSERT_TRUE(Access::CantedLogged(b));
b.NoteDeviceDestroyed(Access::Handle<VkDevice>(2));
EXPECT_FALSE(Access::CantedLogged(b));
}
} // namespace mocktail::vr
+2 -2
View File
@@ -137,5 +137,5 @@ with tempfile.TemporaryDirectory(prefix="mocktail-xr-preview-") as directory:
capture_output=True, text=True, timeout=5)
assert result.returncode == 1, result
assert "XR_ERROR_RUNTIME_UNAVAILABLE" in result.stdout, result.stdout
assert "mocktail-vr-simulated" in result.stdout, result.stdout
print("missing runtime diagnostic offers simulator: passed")
assert "XR_RUNTIME_JSON" in result.stdout, result.stdout
print("missing runtime diagnostic explains runtime selection: passed")
+503
View File
@@ -0,0 +1,503 @@
// Contract tests for the experimental DebugDeviceVR bridge. They pin the
// machine-code contracts of the exact 2998 payload ABI and the fail-closed
// bridge lifecycle; they do not execute any guest code.
#include <gtest/gtest.h>
#include <algorithm>
#include <array>
#include <cstdint>
#include <cmath>
#include <limits>
#include "mocktail/vr/openxr_backend.h"
#include <cstdio>
#include <cstring>
#include <fstream>
#include <string>
#include <vector>
#include "compat/build_profile.h"
#include "mocktail/vr/roblox_vr_device_bridge.h"
#include "vr/roblox_vr_device_bridge_internal.h"
namespace mocktail::vr {
namespace internal {
// Inject host fixture callbacks without executing or relaxing production ELF
// activation checks. Objects retain the guest byte layout, never host C++ ABI.
struct VrBridgeTestAccess {
static void Configure(RobloxVrDeviceBridge &bridge, std::uintptr_t base,
std::uintptr_t size,
const compat::VrDebugDeviceBridgeProfile &profile,
RobloxVrDeviceBridge::StateGetterFn getter) {
bridge.profile_ = profile;
bridge.image_base_ = base;
bridge.image_size_ = size;
bridge.original_state_getter_ = getter;
bridge.active_ = true;
}
};
} // namespace internal
namespace {
using internal::kConstructorContractSize;
using internal::kEyeGetterContractSize;
using internal::kInitializerContractSize;
using internal::kStateGetterContractSize;
// Real 2998 machine code, transcribed from the verified payload disassembly
// (ade08266c67aee88ec9c1d00902150e1684dad3a).
const std::vector<uint8_t> kStateGetter = {
0x55, 0x48, 0x89, 0xe5, 0x53, 0x50, 0x48, 0x89, 0xfb, 0x48, 0x83,
0xc6, 0x14, 0xba, 0x38, 0x01, 0x00, 0x00, 0xe8, 0xd3, 0xb5, 0x36,
0x03, 0x48, 0x89, 0xd8, 0x48, 0x83, 0xc4, 0x08, 0x5b, 0x5d, 0xc3};
const std::vector<uint8_t> kEyeGetter = {0x48, 0x63, 0xc6, 0x48, 0xc1, 0xe0,
0x04, 0x48, 0x8b, 0x84, 0x07, 0x50,
0x01, 0x00, 0x00, 0xc3};
std::vector<uint8_t> ValidConstructor() {
std::vector<uint8_t> code(kConstructorContractSize, 0x90);
const std::vector<uint8_t> prologue = {
0x55, 0x48, 0x89, 0xe5, 0x41, 0x57, 0x41, 0x56, 0x41, 0x55,
0x41, 0x54, 0x53, 0x48, 0x83, 0xec, 0x28, 0x49, 0x89, 0xcf,
0x41, 0x89, 0xd4, 0x41, 0x89, 0xf5, 0x48, 0x89, 0xfb};
std::memcpy(code.data(), prologue.data(), prologue.size());
const std::vector<std::vector<uint8_t>> markers = {
{0x48, 0x8d, 0x05, 0xf2, 0x74, 0x47, 0x03},
{0x48, 0x89, 0x07},
{0xc7, 0x47, 0x10, 0x06, 0x00, 0x00, 0x00},
{0x4c, 0x8d, 0x77, 0x14, 0xba, 0x34, 0x01, 0x00, 0x00},
{0x44, 0x89, 0xab, 0x90, 0x01, 0x00, 0x00, 0x44, 0x89, 0xa3, 0x94, 0x01,
0x00, 0x00},
{0x4c, 0x89, 0xbb, 0x98, 0x01, 0x00, 0x00},
};
size_t offset = 64;
for (const auto &marker : markers) {
std::memcpy(code.data() + offset, marker.data(), marker.size());
offset += marker.size() + 8;
}
return code;
}
compat::VrDebugDeviceBridgeProfile TestProfile() {
compat::VrDebugDeviceBridgeProfile profile{};
profile.vtable_rva = 0x6c87b38;
profile.constructor_rva = 0x3810614;
profile.state_getter_rva = 0x38109f6;
profile.eye_getter_rva = 0x38109e6;
profile.eye_initializer_rva = 0x3810a18;
profile.emulator_flag_storage_rva = 0x7369920;
profile.device_create_framebuffer_vtable_offset = 0x180;
return profile;
}
std::vector<uint8_t>
ValidInitializer(const compat::VrDebugDeviceBridgeProfile &profile) {
std::vector<uint8_t> code(kInitializerContractSize, 0x90);
const std::vector<uint8_t> prologue = {
0x55, 0x48, 0x89, 0xe5, 0x41, 0x57, 0x41, 0x56, 0x41, 0x55, 0x41, 0x54,
0x53, 0x48, 0x81, 0xec, 0xa8, 0x00, 0x00, 0x00, 0x48, 0x89, 0xb5, 0x50};
std::memcpy(code.data(), prologue.data(), prologue.size());
const uint32_t slot =
static_cast<uint32_t>(profile.device_create_framebuffer_vtable_offset);
const std::vector<std::vector<uint8_t>> markers = {
{0xb1, 0x01, 0x88, 0x8f, 0x47, 0x01, 0x00, 0x00, 0x88, 0x8f,
0x94, 0x00, 0x00, 0x00, 0x88, 0x8f, 0x44, 0x01, 0x00, 0x00},
{0x48, 0x8d, 0x87, 0x70, 0x01, 0x00, 0x00},
{0x48, 0x8d, 0x87, 0x50, 0x01, 0x00, 0x00},
{0x48, 0x8b, 0x06, 0x48, 0x8b, 0x80, static_cast<uint8_t>(slot),
static_cast<uint8_t>(slot >> 8), static_cast<uint8_t>(slot >> 16),
static_cast<uint8_t>(slot >> 24)},
{0x41, 0xbf, 0x01, 0x00, 0x00, 0x00, 0xf6, 0x85, 0x5c, 0xff, 0xff, 0xff,
0x01, 0xb9, 0x00},
};
size_t offset = 64;
for (const auto &marker : markers) {
std::memcpy(code.data() + offset, marker.data(), marker.size());
offset += marker.size() + 8;
}
return code;
}
compat::BuildProfile MakeBuildProfile(bool with_vr, bool allow_bridges) {
compat::BuildProfile profile{};
profile.version_name = "2.736.1408";
profile.version_code = 2998;
profile.elf_build_id = "ade08266c67aee88ec9c1d00902150e1684dad3a";
profile.status = compat::BuildStatus::kSupported;
profile.default_allowed = true;
profile.allow_host_abi_bridges = allow_bridges;
if (with_vr) {
profile.vr_debug_device_bridge = TestProfile();
}
return profile;
}
struct alignas(8) GuestObject {
std::array<std::uint8_t, 0x228> bytes{};
template <typename T> void Put(std::size_t offset, T value) {
std::memcpy(bytes.data() + offset, &value, sizeof(value));
}
};
std::uint8_t fixture_flag = 1;
std::uintptr_t fixture_device_vtable[1];
int fixture_initializations = 0;
bool fixture_partial = false;
void FixtureInitialize(void *object, void *) {
auto *guest = static_cast<GuestObject *>(object);
++fixture_initializations;
guest->bytes[internal::kReadyByteOffset] = 1;
guest->Put<std::uintptr_t>(internal::kFramebufferSlotOffset, 0x100);
if (fixture_partial)
return;
guest->Put<std::uintptr_t>(internal::kFramebufferSlotOffset + 16, 0x200);
guest->Put<std::uintptr_t>(internal::kTextureSlotOffset, 0x300);
guest->Put<std::uintptr_t>(internal::kTextureSlotOffset + 16, 0x400);
}
void *FixtureState(void *output, void *object) {
std::memcpy(output,
static_cast<GuestObject *>(object)->bytes.data() +
internal::kStateOffset,
internal::kStateCopySize);
return output;
}
TEST(VrBridgeLifecycle, ReinitializesReusedAddressesAndMoreThanFourObjects) {
RobloxVrDeviceBridge bridge;
fixture_initializations = 0;
fixture_partial = false;
const auto init = reinterpret_cast<std::uintptr_t>(&FixtureInitialize);
const auto flag = reinterpret_cast<std::uintptr_t>(&fixture_flag);
const auto vtable = reinterpret_cast<std::uintptr_t>(fixture_device_vtable);
const auto base = std::min({init, flag, vtable}) - 8;
auto profile = TestProfile();
profile.eye_initializer_rva = init - base;
profile.emulator_flag_storage_rva = flag - base;
profile.device_create_framebuffer_vtable_offset = 0;
fixture_device_vtable[0] = init;
std::uintptr_t device = vtable;
internal::VrBridgeTestAccess::Configure(
bridge, base, std::max({init, flag, vtable}) - base + 64, profile,
&FixtureState);
bridge.NoteHostPresent();
std::array<std::uint8_t, internal::kStateCopySize> output{};
std::array<GuestObject, 8> guests;
for (int cycle = 0; cycle < 2; ++cycle) {
for (auto &guest : guests) {
guest = {}; // Constructor at a new or previously used address.
guest.Put<std::uintptr_t>(0, base + profile.vtable_rva);
guest.Put<const char *>(internal::kObjectNameOffset, "DebugDevice");
guest.Put<std::uint32_t>(internal::kObjectTypeOffset, 6);
guest.Put<std::uint32_t>(internal::kWidthOffset, 500);
guest.Put<std::uint32_t>(internal::kHeightOffset, 500);
guest.Put<void *>(internal::kGraphicsDeviceOffset, &device);
const int before = fixture_initializations;
bridge.OnStateGetterCall(output.data(), &guest);
EXPECT_EQ(fixture_initializations, before + 1);
EXPECT_EQ(output[internal::kStateReadyOffsetInCopy], 1);
bridge.OnStateGetterCall(output.data(), &guest);
EXPECT_EQ(fixture_initializations, before + 1);
}
}
EXPECT_EQ(bridge.initialized_device_count(), 16);
// A partially constructed device must not expose ready to the renderer or
// repeatedly invoke the initializer on top of its existing allocations.
auto &guest = guests[0];
for (std::size_t i = internal::kFramebufferSlotOffset;
i < internal::kWidthOffset; ++i)
guest.bytes[i] = 0;
guest.bytes[internal::kReadyByteOffset] = 0;
fixture_partial = true;
bridge.OnStateGetterCall(output.data(), &guest);
EXPECT_EQ(output[internal::kStateReadyOffsetInCopy], 0);
const int before = fixture_initializations;
bridge.OnStateGetterCall(output.data(), &guest);
EXPECT_EQ(fixture_initializations, before);
EXPECT_EQ(output[internal::kStateReadyOffsetInCopy], 0);
fixture_partial = false;
}
TEST(VrResourceReadiness,
RejectsReadyByteAloneAliasedEyesAndInvalidDimensions) {
GuestObject guest;
guest.Put<std::uint32_t>(internal::kWidthOffset, 500);
guest.Put<std::uint32_t>(internal::kHeightOffset, 500);
EXPECT_EQ(internal::InspectEyeResources(&guest),
internal::EyeResourceState::kEmpty);
guest.bytes[internal::kReadyByteOffset] = 1;
EXPECT_EQ(internal::InspectEyeResources(&guest),
internal::EyeResourceState::kInvalid);
FixtureInitialize(&guest, nullptr);
EXPECT_EQ(internal::InspectEyeResources(&guest),
internal::EyeResourceState::kReady);
guest.Put<std::uintptr_t>(internal::kFramebufferSlotOffset + 16, 0x100);
EXPECT_EQ(internal::InspectEyeResources(&guest),
internal::EyeResourceState::kInvalid);
FixtureInitialize(&guest, nullptr);
guest.Put<std::uint32_t>(internal::kWidthOffset, 0);
EXPECT_EQ(internal::InspectEyeResources(&guest),
internal::EyeResourceState::kInvalid);
}
TEST(VrStateGetterContract, AcceptsExact2998Bytes) {
ASSERT_EQ(kStateGetter.size(), kStateGetterContractSize);
EXPECT_TRUE(internal::HasExpectedVrStateGetterContract(kStateGetter.data(),
kStateGetter.size()));
}
TEST(VrStateGetterContract, RejectsCorruptionAndShortInput) {
EXPECT_FALSE(internal::HasExpectedVrStateGetterContract(nullptr, 0));
EXPECT_FALSE(internal::HasExpectedVrStateGetterContract(
kStateGetter.data(), kStateGetter.size() - 1));
std::vector<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()));
}
TEST(VrEyeGetterContract, AcceptsExact2998Bytes) {
ASSERT_EQ(kEyeGetter.size(), kEyeGetterContractSize);
EXPECT_TRUE(internal::HasExpectedVrEyeGetterContract(kEyeGetter.data(),
kEyeGetter.size()));
}
TEST(VrEyeGetterContract, RejectsDifferentEyeSlotOffset) {
std::vector<uint8_t> shifted = kEyeGetter;
// +0x150 -> +0x160: a different eye slot layout must not pass.
shifted[11] = 0x60;
EXPECT_FALSE(
internal::HasExpectedVrEyeGetterContract(shifted.data(), shifted.size()));
}
TEST(VrConstructorContract, AcceptsMarkersAndRejectsEachMissingOne) {
const std::vector<uint8_t> valid = ValidConstructor();
EXPECT_TRUE(
internal::HasExpectedVrConstructorContract(valid.data(), valid.size()));
EXPECT_FALSE(internal::HasExpectedVrConstructorContract(valid.data(),
valid.size() - 1));
// Corrupting the graphics-device capture (+0x198 store) must fail closed.
std::vector<uint8_t> no_device_store = valid;
for (size_t index = 0; index + 7 <= no_device_store.size(); ++index) {
if (no_device_store[index] == 0x4c && no_device_store[index + 1] == 0x89 &&
no_device_store[index + 2] == 0xbb) {
no_device_store[index + 3] ^= 0xff;
break;
}
}
EXPECT_FALSE(internal::HasExpectedVrConstructorContract(
no_device_store.data(), no_device_store.size()));
}
TEST(VrInitializerContract, PinsDeviceVtableSlotFromProfile) {
const compat::VrDebugDeviceBridgeProfile profile = TestProfile();
const std::vector<uint8_t> valid = ValidInitializer(profile);
EXPECT_TRUE(internal::HasExpectedVrEyeInitializerContract(
valid.data(), valid.size(), profile));
// The 3092 payload uses +0x188; a profile mismatch must fail closed.
compat::VrDebugDeviceBridgeProfile other = profile;
other.device_create_framebuffer_vtable_offset = 0x188;
EXPECT_FALSE(internal::HasExpectedVrEyeInitializerContract(
valid.data(), valid.size(), other));
std::vector<uint8_t> no_ready_byte = valid;
for (size_t index = 0; index + 8 <= no_ready_byte.size(); ++index) {
if (no_ready_byte[index] == 0xb1 && no_ready_byte[index + 1] == 0x01 &&
no_ready_byte[index + 2] == 0x88) {
no_ready_byte[index + 4] ^= 0xff;
break;
}
}
EXPECT_FALSE(internal::HasExpectedVrEyeInitializerContract(
no_ready_byte.data(), no_ready_byte.size(), profile));
}
TEST(VrVtableContract, RequiresExactProfileRvas) {
const compat::VrDebugDeviceBridgeProfile profile = TestProfile();
const std::uintptr_t base = 0x10000000;
std::vector<std::uintptr_t> vtable(internal::kEyeInitializerVtableSlot / 8 +
1);
vtable[internal::kStateGetterVtableSlot / 8] =
base + profile.state_getter_rva;
vtable[internal::kEyeGetterVtableSlot / 8] = base + profile.eye_getter_rva;
vtable[internal::kEyeInitializerVtableSlot / 8] =
base + profile.eye_initializer_rva;
EXPECT_TRUE(
internal::HasExpectedVrDebugDeviceVtable(vtable.data(), base, profile));
vtable[internal::kEyeInitializerVtableSlot / 8] += 16;
EXPECT_FALSE(
internal::HasExpectedVrDebugDeviceVtable(vtable.data(), base, profile));
}
TEST(VrBridgeLifecycle, InstallRejectsMissingMetadataAndWrongPermissions) {
{
RobloxVrDeviceBridge bridge;
const compat::BuildProfile profile = MakeBuildProfile(false, true);
const Status status = bridge.Install(profile);
EXPECT_FALSE(status.ok());
EXPECT_EQ(status.code(), StatusCode::kFailedPrecondition);
}
{
RobloxVrDeviceBridge bridge;
const compat::BuildProfile profile = MakeBuildProfile(true, false);
const Status status = bridge.Install(profile);
EXPECT_FALSE(status.ok());
EXPECT_EQ(status.code(), StatusCode::kFailedPrecondition);
}
}
TEST(VrBridgeLifecycle, SingleProcessOwnerAndFailClosedActivation) {
RobloxVrDeviceBridge first;
ASSERT_TRUE(first.Install(MakeBuildProfile(true, true)).ok());
{
RobloxVrDeviceBridge second;
const Status status = second.Install(MakeBuildProfile(true, true));
EXPECT_FALSE(status.ok());
EXPECT_EQ(status.code(), StatusCode::kFailedPrecondition);
}
// A non-ELF image base must be rejected without touching memory.
const Status bad_image = first.Activate(0);
EXPECT_FALSE(bad_image.ok());
EXPECT_EQ(bad_image.code(), StatusCode::kInvalidArgument);
EXPECT_FALSE(first.active());
// While armed, a failed activation must surface to the caller.
const Status notified = NotifyRobloxVrImageLoaded(0);
EXPECT_FALSE(notified.ok());
first.Shutdown();
}
TEST(VrBridgeLifecycle, NotificationIsNoOpWithoutArmedBridge) {
EXPECT_TRUE(NotifyRobloxVrImageLoaded(0).ok());
}
TEST(VrProfileParsing, ParsesAndRejectsBridgeSection) {
const std::string directory = std::string(MOCKTAIL_TEST_BINARY_DIRECTORY);
const auto write_manifest = [&directory](const char *name,
const std::string &contents) {
const std::string path = directory + "/" + name;
std::ofstream output(path);
output << contents;
output.close();
return path;
};
const std::string base_profile = R"({
"schema_version": 1,
"profiles": [{
"version_name": "2.736.1408",
"version_code": 2998,
"elf_build_id": "ade08266c67aee88ec9c1d00902150e1684dad3a",
"status": "supported",
"default_allowed": true,
"allow_legacy_binary_patches": false,
"allow_host_abi_bridges": true,
"allow_host_constructor_replay": true,
"reason": "test"
}]
})";
{
const std::string path =
write_manifest("vr_profile_absent.json", base_profile);
const auto result = compat::FindBuildProfile(
path, "ade08266c67aee88ec9c1d00902150e1684dad3a");
ASSERT_TRUE(result);
ASSERT_TRUE(result.profile.has_value());
EXPECT_FALSE(result.profile->vr_debug_device_bridge.has_value());
std::remove(path.c_str());
}
{
std::string manifest = base_profile;
const std::string anchor = R"("reason": "test")";
manifest.replace(manifest.find(anchor), anchor.size(),
R"("vr_debug_device_bridge": {
"vtable_rva": "0x6c87b38",
"constructor_rva": "0x3810614",
"state_getter_rva": "0x38109f6",
"eye_getter_rva": "0x38109e6",
"eye_initializer_rva": "0x3810a18",
"emulator_flag_storage_rva": "0x7369920",
"device_create_framebuffer_vtable_offset": "0x180"
},
"reason": "test")");
const std::string path = write_manifest("vr_profile_valid.json", manifest);
const auto result = compat::FindBuildProfile(
path, "ade08266c67aee88ec9c1d00902150e1684dad3a");
ASSERT_TRUE(result);
ASSERT_TRUE(result.profile.has_value());
ASSERT_TRUE(result.profile->vr_debug_device_bridge.has_value());
const auto &bridge = *result.profile->vr_debug_device_bridge;
EXPECT_EQ(bridge.vtable_rva, 0x6c87b38u);
EXPECT_EQ(bridge.eye_initializer_rva, 0x3810a18u);
EXPECT_EQ(bridge.device_create_framebuffer_vtable_offset, 0x180u);
std::remove(path.c_str());
}
{
std::string manifest = base_profile;
const std::string anchor = R"("reason": "test")";
manifest.replace(manifest.find(anchor), anchor.size(),
R"("vr_debug_device_bridge": {
"vtable_rva": "0x6c87b38"
},
"reason": "test")");
const std::string path =
write_manifest("vr_profile_incomplete.json", manifest);
const auto result = compat::FindBuildProfile(
path, "ade08266c67aee88ec9c1d00902150e1684dad3a");
EXPECT_FALSE(result);
std::remove(path.c_str());
}
}
} // namespace
} // namespace mocktail::vr
namespace mocktail::vr {
TEST(VrPoseBridge, ConvertsRuntimePoseAndAsymmetricProjectionWithoutChangingReadiness) {
std::array<std::uint8_t, internal::kStateCopySize> state;
state.fill(0xa5);
ScriptedPoseSample pose;
pose.valid = true;
pose.position[0] = 0.4f;
pose.position[1] = 1.6f;
pose.orientation[1] = std::sin(0.2f);
pose.orientation[3] = std::cos(0.2f);
for (int eye = 0; eye < 2; ++eye) {
pose.eye_offset[eye][0] = eye ? 0.032f : -0.032f;
const float fov[] = {-0.7f, 0.8f, 0.9f, -0.6f};
std::copy_n(fov, 4, pose.eye_fov[eye]);
}
ASSERT_TRUE(internal::ApplyXrPose(state.data(), pose));
EXPECT_EQ(state[0], 1);
EXPECT_EQ(state[0x40], 0);
EXPECT_EQ(state[internal::kStateReadyOffsetInCopy], 0xa5);
float position[3], orientation[4], left[3], right[3], projection[4];
std::memcpy(position, state.data()+4, 12);
std::memcpy(orientation, state.data()+0x10, 16);
std::memcpy(left, state.data()+0xf8, 12);
std::memcpy(right, state.data()+0x104, 12);
std::memcpy(projection, state.data()+0x110, 16);
EXPECT_FLOAT_EQ(position[0], 0.4f); // no double metre-to-stud conversion
EXPECT_FLOAT_EQ(position[1], 1.6f);
EXPECT_FLOAT_EQ(orientation[1], std::sin(0.2f));
EXPECT_FLOAT_EQ(left[0], -0.032f);
EXPECT_FLOAT_EQ(right[0], 0.032f);
EXPECT_FLOAT_EQ(projection[0], std::tan(0.9f)); // up/down/left/right
EXPECT_FLOAT_EQ(projection[1], std::tan(0.6f));
EXPECT_FLOAT_EQ(projection[2], std::tan(0.7f));
EXPECT_FLOAT_EQ(projection[3], std::tan(0.8f));
}
TEST(VrPoseBridge, InvalidTrackingAndNonfiniteValuesNeverBecomeValid) {
std::array<std::uint8_t, internal::kStateCopySize> state{};
state[0] = state[internal::kStateReadyOffsetInCopy] = 1;
ScriptedPoseSample pose;
EXPECT_FALSE(internal::ApplyXrPose(state.data(), pose));
EXPECT_EQ(state[0], 0);
EXPECT_EQ(state[internal::kStateReadyOffsetInCopy], 1);
pose.valid = true;
pose.position[2] = std::numeric_limits<float>::quiet_NaN();
EXPECT_FALSE(internal::ApplyXrPose(state.data(), pose));
EXPECT_EQ(state[0], 0);
pose.position[2] = 0;
pose.orientation[3] = 0;
EXPECT_FALSE(internal::ApplyXrPose(state.data(), pose));
}
} // namespace mocktail::vr
-89
View File
@@ -1,89 +0,0 @@
"""Check process-local runtime selection and preparation independently of VR hardware."""
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
launcher = Path(sys.argv[1]).resolve()
with tempfile.TemporaryDirectory(prefix="mocktail-simulator-launcher-") as directory:
root = Path(directory)
build = root / "build with spaces"
build.mkdir()
source = root / "source"
source.mkdir()
library = source / "runtime.so"
library.write_bytes(b"runtime fixture for preparation only")
manifest = source / "manifest.json"
manifest.write_text(json.dumps({"runtime": {"library_path": "./runtime.so"}}))
command = [sys.executable, str(launcher), "--build-dir", str(build)]
result = subprocess.run([*command, "--prepare", str(manifest)],
capture_output=True, text=True, timeout=10)
assert result.returncode == 0, result.stderr
prepared = build / "vr-simulator/runtime.json"
document = json.loads(prepared.read_text())
relative_library = Path(document["runtime"]["library_path"])
assert not relative_library.is_absolute()
assert (prepared.parent / relative_library).read_bytes() == library.read_bytes()
# Moving the prepared build must not depend on the original /tmp/source path.
relocated = root / "relocated build"
build.rename(relocated)
build = relocated
prepared = build / "vr-simulator/runtime.json"
command = [sys.executable, str(launcher), "--build-dir", str(build)]
probe = build / "mocktail-vr-probe"
probe.write_text("""#!/usr/bin/env python3
import json, os, sys
names = ('XR_RUNTIME_JSON', 'SIMULATED_ENABLE', 'XRT_COMPOSITOR_NULL',
'XRT_COMPOSITOR_FORCE_XCB', 'XRT_COMPOSITOR_FORCE_WAYLAND',
'XDG_CONFIG_HOME', 'XDG_RUNTIME_DIR')
print(json.dumps({'args': sys.argv[1:], 'env': {key: os.environ.get(key) for key in names}}))
sys.exit(7)
""")
probe.chmod(0o755)
env = os.environ.copy()
env.update(DISPLAY=":123", WAYLAND_DISPLAY="wayland-test",
XR_RUNTIME_JSON=str(root / "real-headset.json"),
XRT_COMPOSITOR_NULL="1", XRT_COMPOSITOR_FORCE_WAYLAND="1",
XDG_CONFIG_HOME=str(root / "user-config"), XDG_RUNTIME_DIR=str(root / "desktop-runtime"))
original_selection = env["XR_RUNTIME_JSON"]
original_config = Path(env["XDG_CONFIG_HOME"])
original_config.mkdir()
marker = original_config / "keep"
marker.write_text("preserve user settings")
for graphical_backend in ("xcb", "wayland"):
if graphical_backend == "wayland":
env.pop("DISPLAY")
result = subprocess.run([*command, "--frames", "4"], env=env,
capture_output=True, text=True, timeout=10)
assert result.returncode == 7, (result.stdout, result.stderr)
child = json.loads(result.stdout.splitlines()[-1])
assert child["args"] == ["--scene", "--frames", "4"]
selected = child["env"]
assert selected["XR_RUNTIME_JSON"] == str(prepared)
assert selected["SIMULATED_ENABLE"] == "1" and selected["XRT_COMPOSITOR_NULL"] == "0"
assert selected["XRT_COMPOSITOR_FORCE_XCB"] == ("1" if graphical_backend == "xcb" else "0")
assert selected["XRT_COMPOSITOR_FORCE_WAYLAND"] == ("0" if graphical_backend == "xcb" else "1")
assert selected["XDG_CONFIG_HOME"] != str(original_config)
assert selected["XDG_RUNTIME_DIR"] == env["XDG_RUNTIME_DIR"]
assert env["XR_RUNTIME_JSON"] == original_selection
assert marker.read_text() == "preserve user settings"
assert not (original_config / "openxr").exists()
print(graphical_backend + " process-local selection: passed")
env.pop("WAYLAND_DISPLAY")
result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=10)
assert result.returncode == 2 and "graphical desktop" in result.stderr, result
result = subprocess.run([*command, "--runtime", str(root / "missing.json")], env=env,
capture_output=True, text=True, timeout=10)
assert result.returncode == 2 and "No simulated Monado runtime" in result.stderr, result
for count in ("0", "-1", "4294967296"):
result = subprocess.run([*command, "--frames", count], env=env,
capture_output=True, text=True, timeout=10)
assert result.returncode == 2 and "positive 32-bit integer" in result.stderr, result
print("missing runtime/display and frame limit diagnostics: passed")
File diff suppressed because it is too large. Load diff
+131
View File
@@ -0,0 +1,131 @@
// Exercise the layer's provenance joins without a GPU or guest payload.
#include "vr_evidence_layer.cc"
#include <gtest/gtest.h>
namespace {
void *test_owner = nullptr;
void *test_request_owner = nullptr;
int test_eye = -1;
std::uintptr_t next_handle = 0x1000;
void *InitializerOwner() { return test_owner; }
bool CurrentEye(void **owner, int *index, void **framebuffer) {
*owner = test_request_owner;
*index = test_eye;
*framebuffer = reinterpret_cast<void *>(0x8000);
return test_request_owner != nullptr;
}
void ConsumeEye() { test_request_owner = nullptr; }
VKAPI_ATTR VkResult VKAPI_CALL CreateImageStub(VkDevice,
const VkImageCreateInfo *,
const VkAllocationCallbacks *,
VkImage *image) {
*image = reinterpret_cast<VkImage>(++next_handle);
return VK_SUCCESS;
}
VKAPI_ATTR VkResult VKAPI_CALL CreateFramebufferStub(
VkDevice, const VkFramebufferCreateInfo *, const VkAllocationCallbacks *,
VkFramebuffer *framebuffer) {
*framebuffer = reinterpret_cast<VkFramebuffer>(++next_handle);
return VK_SUCCESS;
}
VKAPI_ATTR void VKAPI_CALL RenderPassStub(VkCommandBuffer,
const VkRenderPassBeginInfo *,
VkSubpassContents) {}
VKAPI_ATTR void VKAPI_CALL DestroyImageStub(VkDevice, VkImage,
const VkAllocationCallbacks *) {}
VKAPI_ATTR void VKAPI_CALL DestroyFramebufferStub(
VkDevice, VkFramebuffer, const VkAllocationCallbacks *) {}
class VrEvidenceProvenance : public ::testing::Test {
protected:
void *key = this;
VkDevice device = reinterpret_cast<VkDevice>(&key);
VkCommandBuffer command = reinterpret_cast<VkCommandBuffer>(&key);
void SetUp() override {
g_active = true;
g_images.clear();
g_image_views.clear();
g_framebuffer_images.clear();
g_dispatches.clear();
g_eyes[0] = {};
g_eyes[1] = {};
g_initializing_object = InitializerOwner;
g_current_eye = CurrentEye;
g_consume_eye = ConsumeEye;
test_owner = nullptr;
test_request_owner = nullptr;
auto &dispatch = g_dispatches[key];
dispatch.device = device;
dispatch.create_image = CreateImageStub;
dispatch.create_framebuffer = CreateFramebufferStub;
dispatch.cmd_begin_render_pass = RenderPassStub;
dispatch.destroy_image = DestroyImageStub;
dispatch.destroy_framebuffer = DestroyFramebufferStub;
}
VkImage Image() {
VkImageCreateInfo info{};
info.extent = {500, 500, 1};
info.format = VK_FORMAT_R8G8B8A8_UNORM;
info.usage =
VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT;
VkImage image;
EXPECT_EQ(LayerCreateImage(device, &info, nullptr, &image), VK_SUCCESS);
return image;
}
VkFramebuffer Framebuffer(VkImage image) {
VkImageView view = reinterpret_cast<VkImageView>(++next_handle);
g_image_views[view] = image;
VkFramebufferCreateInfo info{};
info.attachmentCount = 1;
info.pAttachments = &view;
VkFramebuffer framebuffer;
EXPECT_EQ(LayerCreateFramebuffer(device, &info, nullptr, &framebuffer),
VK_SUCCESS);
return framebuffer;
}
void Render(VkFramebuffer framebuffer, void *owner, int eye) {
test_request_owner = owner;
test_eye = eye;
VkRenderPassBeginInfo info{};
info.framebuffer = framebuffer;
LayerCmdBeginRenderPass(command, &info, VK_SUBPASS_CONTENTS_INLINE);
}
};
TEST_F(VrEvidenceProvenance,
IgnoresSameSizeTargetsOutsideInitializerAndWrongOwner) {
void *owner = reinterpret_cast<void *>(0x1234);
auto unrelated = Image();
Render(Framebuffer(unrelated), owner, 0);
EXPECT_FALSE(g_eyes[0].valid);
EXPECT_TRUE(g_images.empty());
test_owner = owner;
auto image = Image();
auto framebuffer = Framebuffer(image);
Render(framebuffer, reinterpret_cast<void *>(0x5678), 0);
EXPECT_FALSE(g_eyes[0].valid);
Render(framebuffer, owner, 0);
EXPECT_TRUE(g_eyes[0].valid);
EXPECT_EQ(g_eyes[0].image.image, image);
}
TEST_F(VrEvidenceProvenance,
EyeIndexComesFromGetterAndDestroyedHandlesAreForgotten) {
test_owner = reinterpret_cast<void *>(0x1234);
auto right = Image();
auto left = Image();
auto right_fb = Framebuffer(right);
auto left_fb = Framebuffer(left);
Render(right_fb, test_owner, 1);
Render(left_fb, test_owner, 0);
EXPECT_EQ(g_eyes[0].image.image, left);
EXPECT_EQ(g_eyes[1].image.image, right);
LayerDestroyImage(device, left, nullptr);
EXPECT_FALSE(g_eyes[0].valid);
EXPECT_FALSE(g_eyes[1].valid);
Render(left_fb, test_owner, 0);
EXPECT_FALSE(g_eyes[0].valid);
LayerDestroyFramebuffer(device, right_fb, nullptr);
EXPECT_EQ(g_framebuffer_images.count(right_fb), 0u);
}
} // namespace
+200
View File
@@ -0,0 +1,200 @@
#include <gtest/gtest.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#include <cstring>
#include <cstdlib>
#include "mocktail/vr/vr_perf.h"
#include "mocktail/vr/openxr_backend.h"
#include <fstream>
#include <string>
#include <vector>
#include "mocktail/vr/vr_mirror.h"
#include "mocktail/vr/vr_mirror_shm.h"
namespace mocktail::vr {
namespace {
constexpr std::uint32_t kWidth = 8;
constexpr std::uint32_t kHeight = 4;
std::vector<std::uint8_t> EyePattern(std::uint8_t seed) {
std::vector<std::uint8_t> bytes(kWidth * kHeight * 4);
for (std::size_t index = 0; index < bytes.size(); ++index) {
bytes[index] = static_cast<std::uint8_t>(seed + index);
}
return bytes;
}
// Maps the segment read-only exactly like the Python viewer does.
class SegmentReader {
public:
SegmentReader(const std::string& name, std::uint32_t width,
std::uint32_t height, bool* ok) {
*ok = false;
const int descriptor = shm_open(name.c_str(), O_RDONLY, 0);
if (descriptor < 0) {
return;
}
bytes_ = VrMirrorBytes(width, height);
mapping_ = mmap(nullptr, bytes_, PROT_READ, MAP_SHARED, descriptor, 0);
close(descriptor);
*ok = mapping_ != MAP_FAILED;
}
~SegmentReader() {
if (mapping_ != nullptr && mapping_ != MAP_FAILED) {
munmap(mapping_, bytes_);
}
}
const VrMirrorHeader& header() const {
return *static_cast<const VrMirrorHeader*>(mapping_);
}
const std::uint8_t* eye(int index) const {
return static_cast<const std::uint8_t*>(mapping_) + kVrMirrorHeaderBytes +
static_cast<std::size_t>(index) * kWidth * kHeight * 4;
}
SegmentReader(const SegmentReader&) = delete;
SegmentReader& operator=(const SegmentReader&) = delete;
private:
void* mapping_ = nullptr;
std::uint64_t bytes_ = 0;
};
TEST(VrRuntimeSelection, RuntimeErrorsPreserveSelectionProvenance) {
EXPECT_NE(VrRuntimeUnavailableHint(true, "/custom.json").find("User-selected"), std::string::npos);
EXPECT_NE(VrRuntimeUnavailableHint(false, "/usr/share/openxr/1/openxr_wivrn.json").find("Auto-selected WiVRn"), std::string::npos);
EXPECT_NE(VrRuntimeUnavailableHint(false, "").find("registered"), std::string::npos);
}
TEST(VrRuntimeSelection, ExplicitManifestWinsAndMissingCandidatesUseLoader) {
EXPECT_EQ(SelectVrRuntimeManifest("/explicit/runtime.json", {"/dev/null"}),
"/explicit/runtime.json");
EXPECT_TRUE(SelectVrRuntimeManifest("", {"/definitely-missing-wivrn/runtime.json"}).empty());
char path[] = "/tmp/mocktail-wivrn-manifest-XXXXXX";
const int fd = mkstemp(path);
ASSERT_GE(fd, 0);
close(fd);
EXPECT_EQ(SelectVrRuntimeManifest("", {"/missing/runtime.json", path}), path);
unlink(path);
}
TEST(VrMirrorProducer, AllocationFailureUsesOwningDevice) {
MirrorVkProcs vk{};
vk.GetPhysicalDeviceMemoryProperties = [](VkPhysicalDevice, VkPhysicalDeviceMemoryProperties* out) {
*out = {};
out->memoryTypeCount = 1;
out->memoryTypes[0].propertyFlags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT;
};
vk.CreateBuffer = [](VkDevice, const VkBufferCreateInfo*, const VkAllocationCallbacks*, VkBuffer* out) {
*out = reinterpret_cast<VkBuffer>(0x22);
return VK_SUCCESS;
};
vk.GetBufferMemoryRequirements = [](VkDevice, VkBuffer, VkMemoryRequirements* out) {
*out = {}; out->size = 256; out->memoryTypeBits = 1;
};
vk.AllocateMemory = [](VkDevice, const VkMemoryAllocateInfo*, const VkAllocationCallbacks*, VkDeviceMemory*) {
return VK_ERROR_OUT_OF_DEVICE_MEMORY;
};
vk.DestroyBuffer = [](VkDevice device, VkBuffer, const VkAllocationCallbacks*) {
EXPECT_EQ(device, reinterpret_cast<VkDevice>(0x11));
};
VrMirror mirror;
std::string error;
EXPECT_FALSE(mirror.EnsureStaging(vk, reinterpret_cast<VkDevice>(0x11),
VK_NULL_HANDLE, 8, 8, &error));
}
TEST(VrMirrorPerf, RingAverageDoesNotAccumulateOverwrittenSamples) {
setenv("MOCKTAIL_VR_PERF", "1", 1);
unsetenv("MOCKTAIL_VR_MEASURE_START_NS");
perf::Collector collector;
for (int i = 0; i < 8192; ++i) collector.Record(perf::Stage::kFrameCycle, 1000000);
testing::internal::CaptureStderr();
collector.EmitIfDue(1, "test");
collector.EmitIfDue(1000000001, "test");
const auto output = testing::internal::GetCapturedStderr();
EXPECT_NE(output.find("avg=1.00ms"), std::string::npos) << output;
}
TEST(VrMirrorProducer, PublishesLatestPairUnderSeqlockAndUnlinksOnClose) {
const std::string name = "/mocktail-vr-mirror-unittest-a";
{
VrMirror mirror;
std::string error;
ASSERT_TRUE(mirror.Open(name, kWidth, kHeight, &error)) << error;
EXPECT_TRUE(mirror.enabled());
bool opened = false;
SegmentReader reader(name, kWidth, kHeight, &opened);
ASSERT_TRUE(opened);
EXPECT_EQ(reader.header().magic, kVrMirrorMagic);
EXPECT_EQ(reader.header().header_bytes, kVrMirrorHeaderBytes);
EXPECT_EQ(reader.header().seq, 0u);
EXPECT_EQ(reader.header().flags & kVrMirrorFlagValid, 0u);
const auto eye0 = EyePattern(10);
const auto eye1 = EyePattern(90);
mirror.PublishFromBytesForTest(41, VK_FORMAT_R8G8B8A8_UNORM, kWidth,
kHeight, 12345, eye0.data(), eye1.data());
// Even sequence: the pair is stable for readers.
EXPECT_EQ(reader.header().seq % 2, 0u);
EXPECT_EQ(reader.header().seq, 2u);
EXPECT_EQ(reader.header().frame, 41u);
EXPECT_EQ(reader.header().producer_ns, 12345u);
EXPECT_EQ(reader.header().width, kWidth);
EXPECT_EQ(reader.header().height, kHeight);
EXPECT_EQ(reader.header().flags & kVrMirrorFlagValid, kVrMirrorFlagValid);
EXPECT_EQ(reader.header().published_pairs, 1u);
EXPECT_EQ(std::memcmp(reader.eye(0), eye0.data(), eye0.size()), 0);
EXPECT_EQ(std::memcmp(reader.eye(1), eye1.data(), eye1.size()), 0);
// Eyes must never alias into a single buffer.
EXPECT_NE(std::memcmp(reader.eye(0), reader.eye(1), eye0.size()), 0);
// A newer publish replaces the single latest slot; readers see only the
// newest pair and the sequence advances by two per publish.
const auto next0 = EyePattern(33);
const auto next1 = EyePattern(77);
mirror.PublishFromBytesForTest(42, VK_FORMAT_B8G8R8A8_UNORM, kWidth,
kHeight, 54321, next0.data(), next1.data());
EXPECT_EQ(reader.header().seq, 4u);
EXPECT_EQ(reader.header().frame, 42u);
EXPECT_EQ(reader.header().format,
static_cast<std::uint32_t>(VK_FORMAT_B8G8R8A8_UNORM));
EXPECT_EQ(std::memcmp(reader.eye(0), next0.data(), next0.size()), 0);
EXPECT_EQ(std::memcmp(reader.eye(1), next1.data(), next1.size()), 0);
EXPECT_EQ(mirror.published_pairs(), 2u);
mirror.Close();
EXPECT_FALSE(mirror.enabled());
}
// The producer owns the segment lifetime: after Close the name is unlinked.
EXPECT_EQ(shm_open(name.c_str(), O_RDONLY, 0), -1);
}
TEST(VrMirrorProducer, RejectsInvalidNameAndOversizedPublish) {
VrMirror mirror;
std::string error;
EXPECT_FALSE(mirror.Open("", kWidth, kHeight, &error));
EXPECT_FALSE(mirror.Open("relative-name", kWidth, kHeight, &error));
EXPECT_FALSE(mirror.Open("/mocktail-vr-mirror-unittest-b", 0, kHeight,
&error));
EXPECT_FALSE(mirror.enabled());
const std::string name = "/mocktail-vr-mirror-unittest-c";
ASSERT_TRUE(mirror.Open(name, kWidth, kHeight, &error)) << error;
// Publishing an extent larger than the mapped segment is refused, not
// truncated into foreign memory.
const auto big0 = std::vector<std::uint8_t>(kWidth * kHeight * 4 * 4, 7);
mirror.PublishFromBytesForTest(1, VK_FORMAT_R8G8B8A8_UNORM, kWidth * 2,
kHeight * 2, 0, big0.data(), big0.data());
EXPECT_EQ(mirror.published_pairs(), 0u);
mirror.Close();
}
} // namespace
} // namespace mocktail::vr
+187
View File
@@ -0,0 +1,187 @@
#include <gtest/gtest.h>
#include <cmath>
#include <limits>
#include "mocktail/vr/vr_pose_math.h"
namespace mocktail::vr {
namespace {
using namespace pose;
constexpr float kTol = 1e-5f;
Vec3 Forward() { return Vec3{0.f, 0.f, -1.f}; } // OpenXR: -Z is forward
Vec3 Up() { return Vec3{0.f, 1.f, 0.f}; }
Vec3 Right() { return Vec3{1.f, 0.f, 0.f}; }
void ExpectVec(const Vec3& actual, const Vec3& expected, float tolerance) {
EXPECT_NEAR(actual.x, expected.x, tolerance) << "x";
EXPECT_NEAR(actual.y, expected.y, tolerance) << "y";
EXPECT_NEAR(actual.z, expected.z, tolerance) << "z";
}
// ---- rotation axes verified independently, not only in combination ----
TEST(VrPoseMath, YawAloneRotatesForwardWithoutAxisInversion) {
const float yaw = 0.6f;
const Quat q = QuatFromYawPitch(yaw, 0.f);
// Rotation about +Y by yaw takes forward (0,0,-1) to (-sin, 0, -cos).
const Vec3 rotated = QuatRotate(q, Forward());
ExpectVec(rotated, Vec3{-std::sin(yaw), 0.f, -std::cos(yaw)}, kTol);
// Up is untouched by yaw: an inverted axis would move it.
ExpectVec(QuatRotate(q, Up()), Up(), kTol);
}
TEST(VrPoseMath, PitchAloneRotatesForwardWithoutAxisInversion) {
const float pitch = 0.4f;
const Quat q = QuatFromYawPitch(0.f, pitch);
// Rotation about +X by pitch takes (0,0,-1) to (0, sin, -cos): looking up
// (positive pitch) raises the forward vector's Y, matching head tilt up.
const Vec3 rotated = QuatRotate(q, Forward());
ExpectVec(rotated, Vec3{0.f, std::sin(pitch), -std::cos(pitch)}, kTol);
ExpectVec(QuatRotate(q, Right()), Right(), kTol);
}
TEST(VrPoseMath, RollAloneRotatesUpAroundForwardAxis) {
const float roll = 0.35f;
const Quat q = QuatFromAxisAngle(Vec3{0.f, 0.f, 1.f}, roll);
// Right-handed rotation about +Z takes up (0,1,0) to (-sin, cos, 0): the
// head tilting right rotates the world's up toward the viewer's left.
const Vec3 rotated_up = QuatRotate(q, Up());
ExpectVec(rotated_up, Vec3{-std::sin(roll), std::cos(roll), 0.f}, kTol);
// The roll axis itself is invariant.
ExpectVec(QuatRotate(q, Vec3{0.f, 0.f, 1.f}), Vec3{0.f, 0.f, 1.f}, kTol);
}
TEST(VrPoseMath, YawPitchCombinationMatchesIndependentComposition) {
const float yaw = 0.5f, pitch = 0.3f;
const Quat combined = QuatFromYawPitch(yaw, pitch);
// Documented order: R = Ry(yaw) * Rx(pitch).
const Quat yaw_only = QuatFromYawPitch(yaw, 0.f);
const Quat pitch_only = QuatFromYawPitch(0.f, pitch);
const Quat expected = QuatMultiply(yaw_only, pitch_only);
EXPECT_TRUE(QuatSameRotation(combined, expected, kTol));
// A forward vector must agree between both constructions.
const Vec3 a = QuatRotate(combined, Forward());
const Vec3 b = QuatRotate(expected, Forward());
ExpectVec(a, b, kTol);
// And against explicit sequential rotation: pitch first, then yaw.
const Vec3 c = QuatRotate(yaw_only, QuatRotate(pitch_only, Forward()));
ExpectVec(a, c, kTol);
}
TEST(VrPoseMath, PoseMultiplyAndInverseRoundTrip) {
Pose a;
a.position = Vec3{0.3f, -1.2f, 0.7f};
a.orientation = QuatFromYawPitch(0.4f, -0.25f);
const Pose identity = PoseMultiply(a, PoseInverse(a));
ExpectVec(identity.position, Vec3{0.f, 0.f, 0.f}, kTol);
EXPECT_TRUE(QuatSameRotation(identity.orientation, Quat{0.f, 0.f, 0.f, 1.f},
kTol));
}
TEST(VrPoseMath, EyeOffsetIsHeadInverseTimesRuntimeEye) {
// The backend computes relative = inverse(head) * runtime_eye and reprojects
// final_eye = head * relative, which must reproduce the runtime eye exactly.
Pose head;
head.position = Vec3{0.2f, 1.5f, -0.4f};
head.orientation = QuatFromYawPitch(0.7f, 0.15f);
Pose runtime_eye = head;
runtime_eye.position = PoseMultiply(head, Pose{Vec3{-0.032f, 0.f, 0.f},
Quat{}}).position;
const Pose relative = PoseMultiply(PoseInverse(head), runtime_eye);
const Pose final_eye = PoseMultiply(head, relative);
ExpectVec(final_eye.position, runtime_eye.position, kTol);
}
// ---- quaternion sign/normalization contract ----
TEST(VrPoseMath, QuaternionSignDoesNotChangeRotation) {
const Quat q = QuatFromYawPitch(0.9f, -0.4f);
const Quat negated{-q.x, -q.y, -q.z, -q.w};
EXPECT_TRUE(QuatSameRotation(q, negated, kTol));
ExpectVec(QuatRotate(q, Forward()), QuatRotate(negated, Forward()), kTol);
const Pose p{Vec3{0.1f, 0.2f, 0.3f}, q};
const Pose pn{Vec3{0.1f, 0.2f, 0.3f}, negated};
const Pose other{Vec3{-0.5f, 1.f, 0.25f}, QuatFromAxisAngle(Up(), 0.3f)};
const Pose a = PoseMultiply(p, other);
const Pose b = PoseMultiply(pn, other);
ExpectVec(a.position, b.position, kTol);
EXPECT_TRUE(QuatSameRotation(a.orientation, b.orientation, kTol));
}
TEST(VrPoseMath, NormalizeRejectsNonfiniteAndNearZeroWithoutWritingOutput) {
Quat out{9.f, 9.f, 9.f, 9.f};
Quat input{0.f, 0.f, 0.f, 2.f};
ASSERT_TRUE(QuatNormalize(input, &out));
ExpectVec(Vec3{out.x, out.y, out.z}, Vec3{0.f, 0.f, 0.f}, kTol);
EXPECT_NEAR(out.w, 1.f, kTol);
const float nan = std::numeric_limits<float>::quiet_NaN();
input = Quat{nan, 0.f, 0.f, 1.f};
out = Quat{9.f, 9.f, 9.f, 9.f};
EXPECT_FALSE(QuatNormalize(input, &out));
EXPECT_FLOAT_EQ(out.x, 9.f); // output untouched on rejection
input = Quat{0.f, 0.f, 0.f, 0.f};
EXPECT_FALSE(QuatNormalize(input, &out));
EXPECT_FALSE(QuatNormalize(input, nullptr));
}
// ---- FOV order/sign contract (asymmetric, pinned to the 2998 guest layout) --
TEST(VrPoseMath, FovToTangentsPinsUpDownLeftRightOrderAndSigns) {
EyeFov fov;
fov.angle_left = -0.7f;
fov.angle_right = 0.8f;
fov.angle_up = 0.9f;
fov.angle_down = -0.6f;
const ProjectionTangents tangents = FovToTangents(fov);
// The guest state block stores up, down, left, right in this order; down and
// left are negated relative to the OpenXR angle signs. The existing bridge
// test pins the same expectation through ApplyXrPose; this pins the math.
EXPECT_FLOAT_EQ(tangents.up, std::tan(0.9f));
EXPECT_FLOAT_EQ(tangents.down, std::tan(0.6f));
EXPECT_FLOAT_EQ(tangents.left, std::tan(0.7f));
EXPECT_FLOAT_EQ(tangents.right, std::tan(0.8f));
// Asymmetric FOV must not collapse to a symmetric projection.
EXPECT_NE(tangents.left, tangents.right);
EXPECT_NE(tangents.up, tangents.down);
}
// ---- canted-view rejection ----
TEST(VrPoseMath, ParallelEyeOrientationsAcceptedCantedRejected) {
EXPECT_TRUE(EyeOrientationIsParallel(Quat{0.f, 0.f, 0.f, 1.f}));
// Sign flip only (q == -q) is still parallel.
EXPECT_TRUE(EyeOrientationIsParallel(Quat{0.f, 0.f, 0.f, -1.f}));
// Exactly at tolerance.
EXPECT_TRUE(EyeOrientationIsParallel(
Quat{kCantedTolerance, 0.f, 0.f, 1.f}));
// A canted runtime rotates the eye about its view axis (+Z): must reject.
const Quat canted = QuatFromAxisAngle(Vec3{0.f, 0.f, 1.f}, 0.1f);
EXPECT_FALSE(EyeOrientationIsParallel(canted));
// Any axis above tolerance rejects.
EXPECT_FALSE(EyeOrientationIsParallel(
Quat{0.f, 2.f * kCantedTolerance, 0.f, 1.f}));
}
// ---- units: the 0.10 m translation check through the native conversion ----
TEST(VrPoseMath, TenthMetreConversionIsDocumentedAndNotPreApplied) {
// The exact-build native helper converts metres to studs itself with factor
// 10/3; 0.10 m must arrive in the guest state as metres (0.10) and become
// 1/3 stud natively. This layer only documents the expected value.
EXPECT_FLOAT_EQ(MetresToStuds(0.10f), 0.10f * kMetresToStuds);
EXPECT_NEAR(MetresToStuds(0.10f), 1.0f / 3.0f, 1e-6f);
// Engine HeadScale multiplies head.position again inside the camera
// composition (out.position = base + base.rotation * head.position *
// HeadScale); it is an engine property distinct from the 10/3 conversion and
// was verified numerically with the native-camera runtime observer.
EXPECT_NE(kMetresToStuds, 1.f);
}
} // namespace
} // namespace mocktail::vr