mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-23 07:01:01 +02:00
Full support camera
NOT TESTED
This commit is contained in:
1 parent
dd23f06a8b
commit
b187efbedf
45 files changed
+7476
-333
No files matched your search
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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_
|
||||
@@ -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_
|
||||
@@ -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_
|
||||
@@ -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_
|
||||
@@ -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_
|
||||
@@ -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_
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
@@ -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&>(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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_
|
||||
@@ -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, ®ion);
|
||||
}
|
||||
|
||||
void VrMirror::Publish(std::uint64_t frame, VkFormat format,
|
||||
std::uint32_t width, std::uint32_t height,
|
||||
std::uint64_t now_ns) {
|
||||
if (mapping_ == nullptr || staging_map_[0] == nullptr ||
|
||||
staging_map_[1] == nullptr || staging_width_ != width ||
|
||||
staging_height_ != height) {
|
||||
return;
|
||||
}
|
||||
PublishFromBytesForTest(frame, format, width, height, now_ns, staging_map_[0],
|
||||
staging_map_[1]);
|
||||
}
|
||||
|
||||
void VrMirror::PublishFromBytesForTest(std::uint64_t frame, VkFormat format,
|
||||
std::uint32_t width,
|
||||
std::uint32_t height,
|
||||
std::uint64_t now_ns, const void* eye0,
|
||||
const void* eye1) {
|
||||
if (mapping_ == nullptr || eye0 == nullptr || eye1 == nullptr) {
|
||||
return;
|
||||
}
|
||||
auto* header = static_cast<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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"};
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user