From f9289d9947c65c5d00e9ca14d862caca136fec79 Mon Sep 17 00:00:00 2001 From: vii Date: Tue, 13 Oct 2026 18:37:26 -0500 Subject: [PATCH] fix(vr): read the pointer hand and HeadScale offsets per build and check them at start-up --- src/vr/roblox_vr_device_bridge.cc | 47 ++++++++++++++++++--- src/vr/roblox_vr_device_bridge_internal.h | 8 ++++ tests/vr/roblox_vr_device_bridge_test.cc | 50 +++++++++++++++++++++++ 3 files changed, 99 insertions(+), 6 deletions(-) diff --git a/src/vr/roblox_vr_device_bridge.cc b/src/vr/roblox_vr_device_bridge.cc index 4cfc1cd..b494c63 100644 --- a/src/vr/roblox_vr_device_bridge.cc +++ b/src/vr/roblox_vr_device_bridge.cc @@ -403,6 +403,8 @@ constexpr VrBuildContract kContracts[] = { 0x3a8, 0x73677a8, 0x1d48b70, + 0x118, + 0x140, "2998 (2.736.1408)"}, {"5f0704edd9064f566ee3d6df2bd2fabbcc709f03", {0x6d08108, 0x37f9fe4, 0x37fa3c6, 0x37fa3b6, 0x37fa3e8, 0x73f4690, @@ -413,6 +415,8 @@ constexpr VrBuildContract kContracts[] = { 0x3a0, 0x73f24c8, 0x1d8f8c0, + 0x118, + 0x140, "3092 (2.738.1397)"}, }; @@ -431,11 +435,26 @@ bool HasExpectedVrPointerContract(const std::uint8_t* code, std::size_t size, code[0x59] != 0xe8 || code[0x6d] != 0xff || code[0x6e] != 0x91) { return false; } - std::uint32_t camera_slot; + // movsxd rax,[rsi+selector] at +0x24; movss xmm0,[r14+HeadScale] at +0x13d. + static constexpr std::array kSelectorLoad = {0x48, 0x63, + 0x86}; + static constexpr std::array kHeadScaleLoad = { + 0xf3, 0x41, 0x0f, 0x10, 0x86}; + if (std::memcmp(code + 0x24, kSelectorLoad.data(), kSelectorLoad.size()) != + 0 || + std::memcmp(code + 0x13d, kHeadScaleLoad.data(), + kHeadScaleLoad.size()) != 0) { + return false; + } + std::uint32_t camera_slot, selector, head_scale; std::memcpy(&camera_slot, code + 0x6f, sizeof(camera_slot)); + std::memcpy(&selector, code + 0x27, sizeof(selector)); + std::memcpy(&head_scale, code + 0x142, sizeof(head_scale)); return RelativeTarget(code + 0x5a, contract.pointer_frame_rva + 0x5e) == contract.workspace_getter_rva && - camera_slot == contract.camera_vtable_offset; + camera_slot == contract.camera_vtable_offset && + selector == contract.pointer_hand_selector_offset && + head_scale == contract.camera_head_scale_offset; } EyeResourceState InspectEyeResources(const void *object) { @@ -676,6 +695,24 @@ std::string DescribeSupportedVrBuilds() { namespace internal { +int ReadPointerHandIndex(const void* service, const VrBuildContract& contract) { + int index = 0; + std::memcpy(&index, + static_cast(service) + + contract.pointer_hand_selector_offset, + sizeof(index)); + return index; +} + +float ReadCameraHeadScale(const void* camera, const VrBuildContract& contract) { + float scale = 0.f; + std::memcpy(&scale, + static_cast(camera) + + contract.camera_head_scale_offset, + sizeof(scale)); + return scale; +} + float GetHandPitchOffsetDeg() { static const float offset = [] { if (const char* env = std::getenv("MOCKTAIL_VR_HAND_PITCH_OFFSET")) { @@ -805,8 +842,7 @@ void RobloxVrDeviceBridge::OnPointerFrameCall(void* result, void* service) { if (!backend) return; const auto current = backend->PublishedHeadPose(); if (!current.valid) return; - int index = 0; - std::memcpy(&index, static_cast(service) + 0x118, 4); + const int index = internal::ReadPointerHandIndex(service, *contract_); if (index != 1 && index != 2) return; if (!current.hands[index - 1].aim_valid) return; const auto& hand = native_pose.hands[index - 1]; @@ -821,9 +857,8 @@ void RobloxVrDeviceBridge::OnPointerFrameCall(void* result, void* service) { void* camera = reinterpret_cast( vtable[contract_->camera_vtable_offset / sizeof(void*)])(workspace); if (!camera) return; - float scale = 0; + const float scale = internal::ReadCameraHeadScale(camera, *contract_); int degrees = 0; - std::memcpy(&scale, static_cast(camera) + 0x140, 4); std::memcpy(°rees, reinterpret_cast(base + contract_->hand_pitch_storage_rva), 4); diff --git a/src/vr/roblox_vr_device_bridge_internal.h b/src/vr/roblox_vr_device_bridge_internal.h index 65083f7..c901479 100644 --- a/src/vr/roblox_vr_device_bridge_internal.h +++ b/src/vr/roblox_vr_device_bridge_internal.h @@ -20,12 +20,20 @@ struct VrBuildContract { std::uintptr_t camera_vtable_offset; std::uintptr_t hand_pitch_storage_rva; std::uintptr_t haptic_sink_rva; + // Field offsets the pointer hook reads: the hand index in the pointer + // service and HeadScale in the workspace camera. + std::uintptr_t pointer_hand_selector_offset; + std::uintptr_t camera_head_scale_offset; // Roblox version code and name, for messages that list supported builds. std::string_view version; }; const VrBuildContract* FindVrBuildContract(std::string_view build_id); +// The hand the pointer service asks about: 1 left, 2 right. +int ReadPointerHandIndex(const void* service, const VrBuildContract& contract); +float ReadCameraHeadScale(const void* camera, const VrBuildContract& contract); + // Computes the record orientation for a hand pose: the aim pose (or, with // MOCKTAIL_VR_HAND_POSE_SOURCE=grip, the grip pose) pitched by the engine's // own hand pitch plus the configurable offset. diff --git a/tests/vr/roblox_vr_device_bridge_test.cc b/tests/vr/roblox_vr_device_bridge_test.cc index 91e7087..93b278d 100644 --- a/tests/vr/roblox_vr_device_bridge_test.cc +++ b/tests/vr/roblox_vr_device_bridge_test.cc @@ -317,6 +317,15 @@ TEST(VrPointerContract, PinsWorkspaceAndCameraSlotForEachBuild) { "5f0704edd9064f566ee3d6df2bd2fabbcc709f03"}) { const auto& contract = *internal::FindVrBuildContract(id); std::array code{}; + // movsxd rax,[rsi+selector] and movss xmm0,[r14+HeadScale]. + const std::uint8_t selector_load[] = {0x48, 0x63, 0x86}; + std::memcpy(code.data() + 0x24, selector_load, sizeof(selector_load)); + const std::uint32_t selector = contract.pointer_hand_selector_offset; + std::memcpy(code.data() + 0x27, &selector, sizeof(selector)); + const std::uint8_t scale_load[] = {0xf3, 0x41, 0x0f, 0x10, 0x86}; + std::memcpy(code.data() + 0x13d, scale_load, sizeof(scale_load)); + const std::uint32_t head_scale = contract.camera_head_scale_offset; + std::memcpy(code.data() + 0x142, &head_scale, sizeof(head_scale)); const std::uint8_t prologue[] = {0x55, 0x48, 0x89, 0xe5, 0x41, 0x57, 0x41, 0x56, 0x41, 0x54, 0x53, 0x48, 0x83, 0xec, 0x70}; @@ -336,6 +345,14 @@ TEST(VrPointerContract, PinsWorkspaceAndCameraSlotForEachBuild) { EXPECT_FALSE(internal::HasExpectedVrPointerContract(code.data(), code.size(), contract)); code[0x6f] ^= 8; + code[0x27] ^= 0x10; + EXPECT_FALSE(internal::HasExpectedVrPointerContract(code.data(), + code.size(), contract)); + code[0x27] ^= 0x10; + code[0x142] ^= 0x10; + EXPECT_FALSE(internal::HasExpectedVrPointerContract(code.data(), + code.size(), contract)); + code[0x142] ^= 0x10; code[0x5a] ^= 1; EXPECT_FALSE(internal::HasExpectedVrPointerContract(code.data(), code.size(), contract)); @@ -940,3 +957,36 @@ TEST(VrHandOrientation, GripSourceKeepsTheGripRotation) { } // namespace } // namespace mocktail::vr + +namespace mocktail::vr { +namespace { + +TEST(VrPointerLayout, ReadsHandIndexAndHeadScaleAtTheBuildsOwnOffsets) { + // Roblox moves these fields between builds; a fixed offset reads garbage. + internal::VrBuildContract contract{}; + contract.pointer_hand_selector_offset = 0x108; + contract.camera_head_scale_offset = 0x130; + std::array service{}, camera{}; + const int hand = 2, stale_hand = 7; + const float scale = 1.5f, stale_scale = 9.f; + std::memcpy(service.data() + 0x108, &hand, 4); + std::memcpy(service.data() + 0x118, &stale_hand, 4); + std::memcpy(camera.data() + 0x130, &scale, 4); + std::memcpy(camera.data() + 0x140, &stale_scale, 4); + EXPECT_EQ(internal::ReadPointerHandIndex(service.data(), contract), 2); + EXPECT_FLOAT_EQ(internal::ReadCameraHeadScale(camera.data(), contract), + 1.5f); +} + +TEST(VrPointerLayout, VerifiedBuildsKeepTheirOffsets) { + for (const char* id : {"ade08266c67aee88ec9c1d00902150e1684dad3a", + "5f0704edd9064f566ee3d6df2bd2fabbcc709f03"}) { + const auto* contract = internal::FindVrBuildContract(id); + ASSERT_NE(contract, nullptr); + EXPECT_EQ(contract->pointer_hand_selector_offset, 0x118u) << id; + EXPECT_EQ(contract->camera_head_scale_offset, 0x140u) << id; + } +} + +} // namespace +} // namespace mocktail::vr