bla bla bla

This commit is contained in:
komaruworld committed 2026-09-14 16:38:31 +03:00
1 parent 7e057232e4
commit a014751cd4
26 files changed
+3876 -30

No files matched your search

+4
View File
@@ -22,6 +22,7 @@ add_library(mocktail_input_runtime STATIC
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_font_resolver.cc
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_surface_overlay.cc
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_window_input_runtime.cc
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_xr_controller_input.cc
)
add_library(Mocktail::InputRuntime ALIAS mocktail_input_runtime)
target_include_directories(mocktail_input_runtime PUBLIC
@@ -33,12 +34,14 @@ target_include_directories(mocktail_input_runtime PRIVATE
target_link_libraries(mocktail_input_runtime PUBLIC
Mocktail::PlatformSdl
Mocktail::Runtime
Mocktail::VR
mocktail_window
PkgConfig::UTF8PROC
PRIVATE
PkgConfig::FONTCONFIG
SDL3_ttf::SDL3_ttf
nlohmann_json::nlohmann_json
${CMAKE_DL_LIBS}
)
target_compile_features(mocktail_input_runtime PUBLIC cxx_std_17)
mocktail_apply_compile_options(mocktail_input_runtime)
@@ -47,6 +50,7 @@ if(BUILD_TESTING AND TARGET GTest::gtest_main)
add_executable(roblox_input_router_test
${MOCKTAIL_INPUT_ROOT}/tests/roblox_gamepad_input_test.cc
${MOCKTAIL_INPUT_ROOT}/tests/roblox_input_router_test.cc
${MOCKTAIL_INPUT_ROOT}/tests/roblox_xr_controller_input_test.cc
)
target_link_libraries(roblox_input_router_test PRIVATE
Mocktail::InputRuntime
+5 -2
View File
@@ -10,6 +10,7 @@ add_library(mocktail_vr STATIC
src/vr/openxr_backend_mode.cc
src/vr/vr_perf.cc
src/vr/vr_mirror.cc
src/vr/xr_controller.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")
@@ -47,17 +48,19 @@ if(MOCKTAIL_ENABLE_VR)
endfunction()
mocktail_add_openxr_sdk()
target_sources(mocktail_vr PRIVATE src/vr/openxr_probe.cc src/vr/openxr_preview.cc
src/vr/openxr_backend.cc src/vr/gles_transport.cc)
src/vr/openxr_backend.cc src/vr/gles_transport.cc src/vr/xr_actions.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
src/vr/xr_actions.cc
PROPERTIES COMPILE_OPTIONS -fexceptions)
target_compile_definitions(mocktail_vr PRIVATE XR_USE_GRAPHICS_API_VULKAN)
set_source_files_properties(src/vr/openxr_backend.cc PROPERTIES COMPILE_DEFINITIONS "XR_USE_GRAPHICS_API_OPENGL_ES;XR_USE_PLATFORM_EGL")
target_include_directories(mocktail_vr PRIVATE ${MOCKTAIL_EGL_INCLUDE_DIR} ${MOCKTAIL_GLES3_INCLUDE_DIR})
target_link_libraries(mocktail_vr PRIVATE OpenXR::openxr_loader Vulkan::Headers ${CMAKE_DL_LIBS})
else()
target_sources(mocktail_vr PRIVATE src/vr/openxr_disabled.cc)
target_sources(mocktail_vr PRIVATE src/vr/openxr_disabled.cc
src/vr/xr_actions_disabled.cc)
endif()
add_executable(mocktail_vr_probe src/vr/probe_main.cc)
+34
View File
@@ -12,6 +12,8 @@
#include "mocktail/status.h"
#include "mocktail/vr/vr_mirror.h"
#include "mocktail/vr/xr_actions.h"
#include "mocktail/vr/xr_controller.h"
namespace mocktail::vr {
@@ -46,11 +48,17 @@ 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.
// VrHandPose (tracked controller hands) is defined in xr_controller.h.
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
// Index 0 = left, 1 = right (Roblox UserCFrame LeftHand/RightHand).
VrHandPose hands[2];
// Exact 2998 DebugDeviceVR input channels, published with the same pose.
float controller_channels[28] = {};
bool controllers_connected = false;
std::uint64_t frame = 0;
bool valid = false;
};
@@ -150,9 +158,23 @@ class OpenXrBackend final {
// 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.
// hands[0]=left, hands[1]=right carry this frame's tracked controller grip
// poses (metres, LOCAL space) when a controller layer is active.
ScriptedPoseSample PublishedHeadPose() const;
void NotePoseApplied(void* owner, std::uint64_t frame);
// Controller input delivery, consumed by the window-thread input runtime
// through the C ABI in xr_controller_abi.h. TakeControllerDelivery pops one
// batch produced by the once-per-frame action sync; it returns false when
// no controller layer is active or nothing is queued. The snapshot exposes
// the current levels for overflow resync. Haptics requests are thread-safe.
bool TakeControllerDelivery(ControllerDelivery* out);
ControllerSnapshot ControllerSnapshotForFrame() const;
void RequestControllerHaptics(int hand, float amplitude,
std::uint64_t duration_ns, float frequency_hz);
void StopControllerHaptics(int hand);
// Diagnostics.
std::uint64_t submitted_frames() const {
return submitted_frames_.load(std::memory_order_relaxed);
@@ -203,6 +225,18 @@ class OpenXrBackend final {
VkDevice device,
const VkDeviceCreateInfo* create_info,
std::string* error);
// Session-owned controller action layer (poses, buttons, axes, haptics).
// Created at instance level, attached per session, detached on teardown so
// a replacement session never reuses stale spaces.
XrActions actions_;
bool actions_created_ = false;
// Shared by action sync, snapshots, haptics and lifecycle operations.
// Acquired after mutex_ on the render thread, but never held across
// xrWaitFrame; the input thread takes only this controller lock.
mutable std::mutex controller_mutex_;
// Syncs actions once per frame at the predicted display time and folds the
// resulting hand poses into published_pose_ before the guest renders.
void SyncControllersLocked(std::uint64_t predicted_display_time);
bool InitializeGlesSessionLocked(std::string *error);
bool InitializeSessionResourcesLocked(std::string *error);
bool CopyGlesEyesIntoSwapchains();
@@ -53,6 +53,7 @@ public:
void NoteHostPresent();
// Guest-side hooks invoked by the interposed vtable stubs.
void OnHapticsCall(void* device_object, int hand, float amplitude);
void OnStateGetterCall(void *result_buffer, void *device_object);
void OnEyeGetterCall(void *device_object, int eye_index, void *framebuffer);
@@ -89,6 +90,7 @@ private:
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_haptic_slot_ = 0;
std::uintptr_t original_state_slot_ = 0;
std::uintptr_t original_eye_slot_ = 0;
std::atomic<StateGetterFn> original_state_getter_{nullptr};
+2
View File
@@ -24,6 +24,7 @@ enum class Stage {
kCopyFenceWait,
kMirrorPublish,
kEvidence,
kControllerSync,
kStageCount,
};
@@ -45,6 +46,7 @@ enum class Counter {
kVisibilityLost,
kVisibilityGained,
kFramesInvalidated,
kControllerSyncs,
kCounterCount,
};
+101
View File
@@ -0,0 +1,101 @@
#ifndef MOCKTAIL_VR_XR_ACTIONS_H_
#define MOCKTAIL_VR_XR_ACTIONS_H_
#include <cstdint>
#include <string>
#include "mocktail/status.h"
#include "mocktail/vr/vr_pose_math.h"
#include "mocktail/vr/xr_controller.h"
namespace mocktail::vr {
// Session-owned OpenXR action layer: one action set, left/right subactions,
// grip+aim pose spaces, and interaction-profile bindings for Touch (primary),
// Quest 2, Index, Vive wands and the simple controller. All XR handles stay
// opaque here so non-OpenXR consumers (bridge, tests) need no XR headers.
//
// Frame contract: Sync() runs exactly once per frame from the backend frame
// cycle, immediately after xrWaitFrame returned the predicted display time and
// before the guest renders, so located hand poses share the head/eye frame.
// There is no second frame wait and no polling from guest getters.
class XrActions final {
public:
XrActions() = default;
~XrActions();
XrActions(const XrActions &) = delete;
XrActions &operator=(const XrActions &) = delete;
// Creates the action set/actions and suggests bindings. Instance-level:
// valid before any session exists. Failure is non-fatal for XR output; the
// backend logs and continues without controller input.
Status Create(void *xr_instance);
// Attaches to a session and creates the per-hand pose spaces. Must be
// called after xrCreateSession and before the first Sync; recreated on every
// session replacement (no stale handles survive).
Status Attach(void *xr_instance, void *xr_session, void *xr_local_space);
// Destroys spaces and detaches; the action set survives for reattach.
void Detach();
void Destroy();
bool attached() const { return attached_; }
// One per-frame sync at predicted_display_time. Locates grip and aim poses
// in the session's LOCAL space, reads analog/boolean states, refreshes
// connection state from the runtime's current interaction profile, and
// builds the coherent snapshot + delivery batch. Returns false when actions
// are unavailable (runtime refused sync); input is reset and a disconnect
// remains drainable even while the session is detached.
bool Sync(void *xr_session, std::uint64_t predicted_display_time,
std::uint64_t frame, std::uint64_t session_generation);
// Latest coherent snapshot (frame/session generation tagged).
const ControllerSnapshot &snapshot() const { return snapshot_; }
// Hand poses in metres/LOCAL space for the state-copy records.
const VrHandPose &hand(int index) const { return hands_[index & 1]; }
// Drains the next pending delivery batch built by Sync. Returns false when
// nothing is queued. Thread-safe: Sync runs on the render thread, the drain
// runs on the window thread. Backed by a bounded queue, so a slow consumer
// drops oldest frames and converges via resync rather than growing.
bool TakeDelivery(ControllerDelivery *out);
// Queues a vibration request for one hand. Safe from any thread; applied on
// the next Sync's XR call window. amplitude [0,1], duration in nanoseconds
// (0 = runtime default). A newer request for the same hand replaces the
// pending one. StopHaptics cancels.
void RequestHaptics(int hand, float amplitude, std::uint64_t duration_ns,
float frequency_hz);
void StopHaptics(int hand);
// Interaction-profile diagnostics (refreshed on profile-change events and
// every Sync that observes a change).
std::string ActiveProfile(int hand) const;
// Test seam: publish an externally sourced profile name (event dispatch in
// the backend) without XR calls.
void NoteProfileChanged();
// Called by the backend on focus/session loss, under controller ownership.
void ResetInput();
private:
struct Impl;
Impl *impl_ = nullptr;
bool created_ = false;
bool attached_ = false;
ControllerStateBuilder builder_;
ControllerSnapshot snapshot_;
VrHandPose hands_[2];
ControllerDeliveryQueue delivery_queue_;
std::uint64_t session_generation_ = 0;
// Last connection level handed to the consumer, to mark connection changes.
bool last_delivered_connected_[2] = {false, false};
};
} // namespace mocktail::vr
#endif // MOCKTAIL_VR_XR_ACTIONS_H_
+236
View File
@@ -0,0 +1,236 @@
#ifndef MOCKTAIL_VR_XR_CONTROLLER_H_
#define MOCKTAIL_VR_XR_CONTROLLER_H_
#include "mocktail/vr/xr_controller_abi.h"
#include <array>
#include <cstdint>
#include <deque>
#include <mutex>
#include <string>
#include <vector>
namespace mocktail::vr {
// One tracked controller hand, located at the same predicted display time as
// the head. Grip is the held-object pose; aim is the pointing pose. They are
// not interchangeable: the guest consumes grip for hands/tools and aim for UI
// rays. Position is metres, orientation a quaternion (x, y, z, w) in the
// OpenXR LOCAL reference space. The guest applies its own metres-to-studs
// conversion and hand pitch offset, so neither is pre-applied here.
struct VrHandPose {
float grip_position[3] = {0.f, 0.f, 0.f};
float grip_orientation[4] = {0.f, 0.f, 0.f, 1.f};
float aim_position[3] = {0.f, 0.f, 0.f};
float aim_orientation[4] = {0.f, 0.f, 0.f, 1.f};
// Pose validity, reported separately from button activity and connection so
// a controller can be connected with an untracked pose.
bool grip_valid = false;
bool aim_valid = false;
};
// Engine-facing controller capability, independent of which OpenXR
// interaction profile supplied it. The guest receives buttons and analog axes
// through its verified Android gamepad route, so this layer normalizes every
// supported profile onto that one stable contract.
enum class ControllerButton {
kTrigger, // analog click semantics; value reported separately
kSqueeze, // grip/squeeze
kThumbstick, // stick click
kFaceNorth, // right B / left Y
kFaceSouth, // right A / left X
kFaceEast, // right B equivalent on layouts that have it
kFaceWest, // left X equivalent
kMenu, // application-accessible menu only; never a system button
kCount,
};
// Capacitive contacts are distinct from presses. Some runtimes/profiles do
// not expose them; active=false means unavailable, not an inferred contact.
enum class ControllerTouch {
kTrigger,
kThumbstick,
kThumbrest,
kA,
kB,
kX,
kY,
kCount
};
enum class ControllerAxis {
kTrigger, // 0..1
kSqueeze, // 0..1
kThumbstickX, // -1..1
kThumbstickY, // -1..1
kCount,
};
// Analog trigger/squeeze thresholds used only where the runtime exposes a
// boolean click for an analog control. Hysteresis prevents edge chattering at
// the boundary; native analog values are always preserved alongside it.
struct AnalogClickThresholds {
float press = 0.75f;
float release = 0.6f;
};
// One coherent per-frame controller snapshot. Built once per frame by the
// backend after a single xrSyncActions, then read by native getters without
// re-polling. Button booleans are level state; edge detection happens once, at
// publish time, so repeated native getters never emit duplicate edges.
struct ControllerHandState {
bool connected = false;
bool pose_valid = false;
std::array<bool, static_cast<std::size_t>(ControllerTouch::kCount)> touches{};
std::array<bool, static_cast<std::size_t>(ControllerTouch::kCount)>
touch_active{};
std::array<bool, static_cast<std::size_t>(ControllerButton::kCount)>
buttons{};
std::array<float, static_cast<std::size_t>(ControllerAxis::kCount)> axes{};
// Interaction profile actually reported by the runtime for this hand, for
// diagnostics only (never used to gate input).
std::string profile;
};
struct ControllerSnapshot {
ControllerHandState hands[2]; // 0 = left, 1 = right
std::uint64_t frame = 0;
std::uint64_t session_generation = 0;
};
// Pure state machine: maps raw OpenXR action values onto the normalized
// contract and derives edges exactly once per published frame. No OpenXR or
// JNI types, so it is unit-testable with injected values.
class ControllerStateBuilder {
public:
explicit ControllerStateBuilder(AnalogClickThresholds thresholds = {});
// Records one raw action value for the given hand. Values not set by the
// runtime are neutral when explicitly marked inactive.
void SetTouch(int hand, ControllerTouch touch, bool value, bool active);
void SetButton(int hand, ControllerButton button, bool value, bool active);
void SetAxis(int hand, ControllerAxis axis, float value, bool active);
void SetConnected(int hand, bool connected);
void SetProfile(int hand, std::string profile);
void SetPoseValid(int hand, bool valid);
// Produces the snapshot for this frame and records which buttons changed so
// the caller can emit exactly one native edge per transition. frame must
// advance monotonically; publishing the same frame twice yields no edges.
struct Edge {
int hand = 0;
ControllerButton button = ControllerButton::kTrigger;
bool pressed = false;
};
ControllerSnapshot Publish(std::uint64_t frame, std::uint64_t generation,
std::vector<Edge> *edges);
// Neutralizes all axes and releases held buttons once (focus loss,
// disconnect, session stop). Produces release edges for anything held so no
// button can stay stuck down.
void ReleaseAll(std::uint64_t frame, std::uint64_t generation,
std::vector<Edge> *edges);
// Derives the click state for an analog control with hysteresis: latches on
// at `press`, off at `release`. Call after SetAxis for the same hand. Used
// for profiles that expose a trigger value without a click action; native
// analog values are always preserved regardless.
bool AnalogClick(int hand, ControllerAxis axis);
const ControllerSnapshot &snapshot() const { return snapshot_; }
private:
AnalogClickThresholds thresholds_;
ControllerSnapshot snapshot_;
// Last published level per hand/button, used for edge detection.
std::array<
std::array<bool, static_cast<std::size_t>(ControllerButton::kCount)>, 2>
published_buttons_{};
// Hysteresis latch per (hand, analog control): [hand*2 + axis].
std::array<bool, 4> analog_latch_{};
bool have_published_ = false;
};
// Maps the normalized contract onto the guest's verified Android gamepad
// identifiers, so XR controllers reuse the single authoritative input route
// and cannot double-fire alongside a physical SDL gamepad. hand selects the
// per-hand squeeze/thumbstick/trigger identifiers (0 = left, 1 = right).
// Returns -1 when the control has no keycode (analog click).
int32_t ControllerButtonToAndroidKey(int hand, ControllerButton button);
// Returns the Android MotionEvent axis id for one axis of the given hand
// (left/right trigger differ), or -1 when the control has no analog axis.
int32_t ControllerAxisToAndroidAxis(int hand, ControllerAxis axis);
// How many float components the guest expects for this axis (sticks 2, triggers
// 1).
int32_t ControllerAxisComponentCount(ControllerAxis axis);
// SDL3 gamepad ordinals. XR controller delivery reuses the existing router
// (RobloxInputRouter::HandleEvent) as the single authoritative event route:
// the router performs the verified SDL->Android key/axis conversion, level
// deduplication, focus gating and release-on-disconnect itself. Synthesized
// platform events must therefore carry SDL ordinals, not Android codes.
// hand selects the per-hand squeeze/thumbstick/trigger ordinals.
int32_t ControllerButtonToSdlOrdinal(int hand, ControllerButton button);
// SDL axis ordinal per hand for stick/trigger delivery; -1 when not an axis.
// Left hand: LEFTX/LEFTY/TRIGGERLEFT; right: RIGHTX/RIGHTY/TRIGGERRIGHT.
int32_t ControllerAxisToSdlOrdinal(int hand, ControllerAxis axis);
// One drained delivery batch from the XR backend to the input route. Built
// once per frame after xrSyncActions; edges appear exactly once. `resync`
// requests full level re-delivery (recovery after queue overflow or an
// unfocused drop) — the router deduplicates, so re-delivery is idempotent and
// guarantees no button sticks down.
struct ControllerDelivery {
std::uint64_t session_generation = 0;
bool button_levels[2][static_cast<std::size_t>(ControllerButton::kCount)] =
{};
bool connected[2] = {false, false};
bool connection_changed[2] = {false, false};
struct ButtonEdge {
int hand = 0;
ControllerButton button = ControllerButton::kTrigger;
bool pressed = false;
};
std::vector<ButtonEdge> edges;
// SDL ordinal order: LEFTX, LEFTY, RIGHTX, RIGHTY, TRIGGERLEFT, TRIGGERRIGHT.
float axes[6] = {0.f, 0.f, 0.f, 0.f, 0.f, 0.f};
bool axes_valid[2] = {false, false};
bool resync = false;
std::uint64_t frame = 0;
// Bounded growth guard: the backend caps the edge queue and sets resync
// instead of growing without limit.
static constexpr std::size_t kMaxEdges = 64;
};
bool EncodeNativeControllerChannels(const ControllerSnapshot &snapshot,
float (&channels)[28]);
void EncodeControllerDelivery(const ControllerDelivery &delivery,
MocktailVrControllerDelivery *out);
// Thread-safe bounded queue between the XR render-thread producer (one push
// per frame after xrSyncActions) and the window-thread consumer that drains
// into the verified input route. Overflow drops the oldest frames and arms a
// resync on the next delivered frame, so the consumer always converges to the
// current levels and a full queue can never leave a button stuck down.
class ControllerDeliveryQueue {
public:
static constexpr std::size_t kCapacity = 8;
void Push(ControllerDelivery delivery);
bool Pop(ControllerDelivery *out);
std::size_t size() const;
bool overflowed() const { return overflowed_; }
std::uint64_t dropped_frames() const { return dropped_frames_; }
void Clear();
private:
mutable std::mutex mutex_;
std::deque<ControllerDelivery> queue_;
bool overflowed_ = false;
std::uint64_t dropped_frames_ = 0;
};
} // namespace mocktail::vr
#endif // MOCKTAIL_VR_XR_CONTROLLER_H_
+76
View File
@@ -0,0 +1,76 @@
#ifndef MOCKTAIL_VR_XR_CONTROLLER_ABI_H_
#define MOCKTAIL_VR_XR_CONTROLLER_ABI_H_
#include <stddef.h>
#include <stdint.h>
/* Plain-C mirror of mocktail::vr::ControllerDelivery for crossing the
* dlsym module boundary (XR backend -> input runtime) without exporting C++
* types or requiring the consumer to link OpenXR. Field order and types are
* the ABI; both sides include this header. Keep it POD: no constructors, no
* std, fixed arrays only. */
#ifdef __cplusplus
extern "C" {
#endif
#define MOCKTAIL_VR_CONTROLLER_MAX_EDGES 64
#define MOCKTAIL_VR_CONTROLLER_BUTTON_COUNT 8
#define MOCKTAIL_VR_CONTROLLER_AXIS_COUNT 6
/* Button indices: mirror mocktail::vr::ControllerButton enum order. */
enum {
MOCKTAIL_VR_BUTTON_TRIGGER = 0,
MOCKTAIL_VR_BUTTON_SQUEEZE = 1,
MOCKTAIL_VR_BUTTON_THUMBSTICK = 2,
MOCKTAIL_VR_BUTTON_FACE_NORTH = 3,
MOCKTAIL_VR_BUTTON_FACE_SOUTH = 4,
MOCKTAIL_VR_BUTTON_FACE_EAST = 5,
MOCKTAIL_VR_BUTTON_FACE_WEST = 6,
MOCKTAIL_VR_BUTTON_MENU = 7
};
typedef struct MocktailVrControllerEdge {
int32_t hand; /* 0 = left, 1 = right */
int32_t button; /* MOCKTAIL_VR_BUTTON_* */
int32_t pressed; /* 0/1 */
} MocktailVrControllerEdge;
typedef struct MocktailVrControllerDelivery {
uint64_t frame;
uint64_t session_generation;
int32_t connected[2];
int32_t connection_changed[2];
/* Current button levels per hand (MOCKTAIL_VR_BUTTON_* order). Used for
* resync after queue overflow: the consumer re-delivers every level and the
* router's deduplication makes it idempotent. */
int32_t button_levels[2][MOCKTAIL_VR_CONTROLLER_BUTTON_COUNT];
/* Analog levels in SDL gamepad axis order:
* 0=LEFTX 1=LEFTY 2=RIGHTX 3=RIGHTY 4=TRIGGERLEFT 5=TRIGGERRIGHT. */
float axes[MOCKTAIL_VR_CONTROLLER_AXIS_COUNT];
int32_t axes_valid[2];
int32_t resync;
int32_t edge_count;
MocktailVrControllerEdge edges[MOCKTAIL_VR_CONTROLLER_MAX_EDGES];
} MocktailVrControllerDelivery;
/* Pops the next delivery batch produced by the XR backend's once-per-frame
* action sync. Returns 1 and fills *out when a batch was queued, 0 when the
* queue is empty or no XR controller layer is active. Inert (returns 0) in
* non-VR builds and while no backend is armed. */
typedef int32_t (*MocktailVrControllerPopFn)(MocktailVrControllerDelivery *out);
/* Queues a vibration request for one hand (0=left, 1=right). amplitude is
* clamped to [0,1]; duration_ns 0 means the runtime default. Safe from any
* thread; applied on the next XR sync. No-op when no controller layer is
* active. */
typedef void (*MocktailVrControllerHapticsFn)(int32_t hand, float amplitude,
uint64_t duration_ns,
float frequency_hz);
typedef void (*MocktailVrControllerStopHapticsFn)(int32_t hand);
#ifdef __cplusplus
}
#endif
#endif /* MOCKTAIL_VR_XR_CONTROLLER_ABI_H_ */
@@ -8,6 +8,7 @@
#include "mocktail/status.h"
#include "runtime/roblox_input_native_adapter.h"
#include "runtime/roblox_text_surface_overlay.h"
#include "runtime/roblox_xr_controller_input.h"
namespace mocktail {
namespace runtime {
@@ -35,19 +36,39 @@ class RobloxWindowInputRuntime final {
Status QueryCurrentTextBoxInfo(RobloxNativeTextBoxInfoQueryResult* result);
Status UpdateTextFocusProperties(uint64_t generation,
const RobloxTextFocusProperties& properties);
// Feeds a synthetic platform event through the same authoritative router as
// real SDL events. Used by the XR controller delivery path so tracked
// controllers reuse the verified gamepad route (single event source, router
// deduplication and focus gating) instead of a second JNI input path.
void InjectPlatformEvent(const platform::PlatformEvent& event);
RobloxInputSnapshot Snapshot() const;
// Drains one batch of XR tracked-controller input into the same router.
// Called once per main-loop iteration on the input thread; inert in non-VR
// builds or while no OpenXR controller layer is active.
void DrainXrControllers();
// Routes an engine/experience vibration request to the active XR hand.
void RequestXrControllerHaptics(int hand, float amplitude,
std::uint64_t duration_ns, float frequency_hz);
void StopXrControllerHaptics(int hand);
private:
static void PlatformEventCallback(void* context,
const platform::PlatformEvent& event);
static bool MouseLockQueryCallback(void* context, bool* locked_center);
static void GamepadEventCallback(void* context,
const platform::PlatformEvent& event);
static void XrControllerEventCallback(void* context,
const platform::PlatformEvent& event);
RobloxTextSurfaceOverlay text_surface_overlay_;
std::unique_ptr<platform::TextClipboard> text_clipboard_;
RobloxInputRuntime runtime_;
platform::SdlGamepadManager gamepads_;
// XR tracked-controller producer. Feeds the same runtime_ router as SDL so
// there is exactly one authoritative input route.
RobloxXrControllerInput xr_controllers_;
bool xr_controllers_initialized_ = false;
bool observer_registered_ = false;
bool mouse_lock_query_registered_ = false;
bool initialized_ = false;
@@ -0,0 +1,100 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_XR_CONTROLLER_INPUT_H_
#define MOCKTAIL_RUNTIME_ROBLOX_XR_CONTROLLER_INPUT_H_
#include <cstdint>
#include "mocktail/platform/platform_runtime.h"
#include "mocktail/status.h"
#include "mocktail/vr/xr_controller_abi.h"
namespace mocktail {
namespace runtime {
// Delivers OpenXR tracked-controller input through the one authoritative
// Roblox input route (RobloxInputRouter::HandleEvent), the same verified path
// physical SDL gamepads use. Because both sources converge on that single
// route, the router's own level deduplication, focus gating and
// release-on-disconnect logic apply unchanged and an XR controller can never
// double-fire alongside a physical gamepad.
//
// Both hands are merged into ONE synthetic gamepad: the left hand supplies
// LEFTX/LEFTY, the left shoulder (squeeze) and left stick click plus X/Y, the
// right hand supplies RIGHTX/RIGHTY, the right shoulder, right stick click and
// A/B. That matches how Roblox reads VR controller buttons through
// GamepadService, and it means the guest sees a single normal controller
// rather than two competing devices.
//
// The XR backend is resolved through dlsym so this module links and runs in
// non-VR builds and while no OpenXR backend is armed; then every drain is a
// no-op. No OpenXR headers are required here.
class RobloxXrControllerInput final {
public:
using EventSink = void (*)(void *context,
const platform::PlatformEvent &event);
// Synthetic device identity. Deliberately far outside the SDL joystick id
// range so it can never collide with a physical controller.
static constexpr std::int64_t kInstanceLeft =
static_cast<std::int64_t>(0x7FFFFFFFFFFFFFF0LL);
RobloxXrControllerInput() = default;
~RobloxXrControllerInput() = default;
RobloxXrControllerInput(const RobloxXrControllerInput &) = delete;
RobloxXrControllerInput &operator=(const RobloxXrControllerInput &) = delete;
// Resolves the XR controller ABI. Returns false (and stays inert) when the
// symbols are absent, which is the normal non-VR or no-backend case; that is
// not an error condition.
bool Initialize(EventSink sink, void *context);
// Test/injection seam: binds explicit ABI function pointers instead of
// resolving them through dlsym. A null pop makes the module inert, matching
// the non-VR case. Production callers use Initialize().
bool InitializeWithAbi(EventSink sink, void *context,
MocktailVrControllerPopFn pop,
MocktailVrControllerHapticsFn haptics,
MocktailVrControllerStopHapticsFn stop_haptics);
void Shutdown();
bool active() const { return pop_ != nullptr; }
// Drains every queued delivery and forwards it as platform events. Call once
// per main-loop iteration, after window::PumpEvents(), on the same thread
// that delivers SDL input. Bounded: drains at most kMaxDrainPerPump batches.
void Drain();
void ResendState() { force_resync_ = true; }
// Forwards a vibration request to the XR hand output. hand: 0 left, 1 right.
// No-op when the XR layer is inactive. amplitude is clamped by the backend.
void RequestHaptics(int hand, float amplitude, std::uint64_t duration_ns,
float frequency_hz);
void StopHaptics(int hand);
// Last observed state, for diagnostics/tests.
bool connected() const { return connected_; }
std::uint64_t delivered_events() const { return delivered_events_; }
std::uint64_t resyncs() const { return resyncs_; }
private:
static constexpr int kMaxDrainPerPump = 4;
void EmitConnection(bool connected);
void EmitButton(int sdl_button, bool pressed);
void EmitAxis(int sdl_axis, std::int16_t value);
void *context_ = nullptr;
EventSink sink_ = nullptr;
void *pop_ = nullptr;
void *haptics_ = nullptr;
void *stop_haptics_ = nullptr;
bool force_resync_ = false;
std::uint64_t generation_ = 0;
bool connected_ = false;
bool initialized_ = false;
std::uint64_t delivered_events_ = 0;
std::uint64_t resyncs_ = 0;
// Last delivered button levels, for resync and to avoid redundant events.
bool last_buttons_[16] = {};
};
} // namespace runtime
} // namespace mocktail
#endif // MOCKTAIL_RUNTIME_ROBLOX_XR_CONTROLLER_INPUT_H_
+6
View File
@@ -6826,6 +6826,12 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
while (true) {
const uint64_t fps_tick_start_ns = fps_trace ? MonotonicNanos() : 0;
const bool keep_window_open = mocktail::window::PumpEvents();
// Deliver XR tracked-controller input on the same thread and through the
// same router as SDL input, once per loop iteration after the SDL pump.
// Inert in non-VR builds or while no OpenXR controller layer is active.
if (window_input_runtime != nullptr) {
window_input_runtime->DrainXrControllers();
}
game_surface_events_completed = drain_game_surface_events();
const uint64_t fps_after_sdl_ns = fps_trace ? MonotonicNanos() : 0;
if (!game_surface_events_completed || !keep_window_open) {
@@ -80,6 +80,14 @@ Status RobloxWindowInputRuntime::Initialize() {
gamepad_status.message().c_str());
}
}
// XR tracked controllers join the same authoritative route. Initialize is a
// no-op (false) in non-VR builds or without an armed OpenXR backend; that is
// the normal state, not an error.
xr_controllers_initialized_ = xr_controllers_.Initialize(
&RobloxWindowInputRuntime::XrControllerEventCallback, this);
if (xr_controllers_initialized_) {
std::fprintf(stderr, " [vr-input] XR controller delivery armed\n");
}
return Status::Ok();
}
@@ -108,6 +116,7 @@ void RobloxWindowInputRuntime::PlatformEventCallback(
std::get_if<platform::WindowFocusEvent>(&event.payload)) {
if (focus->focused) {
self->gamepads_.ResendState();
self->xr_controllers_.ResendState();
}
}
}
@@ -124,6 +133,10 @@ Status RobloxWindowInputRuntime::Shutdown() {
window::ClearMouseLockQueryCallback();
mouse_lock_query_registered_ = false;
}
// Disconnect the synthetic XR controller while the router still processes
// events, so its release path runs through the normal route.
xr_controllers_.Shutdown();
xr_controllers_initialized_ = false;
gamepads_.Shutdown();
const RobloxInputSnapshot snapshot = runtime_.Snapshot();
Status status = runtime_.Deactivate();
@@ -199,6 +212,37 @@ RobloxInputSnapshot RobloxWindowInputRuntime::Snapshot() const {
return runtime_.Snapshot();
}
void RobloxWindowInputRuntime::InjectPlatformEvent(
const platform::PlatformEvent& event) {
// Same router and same thread as SDL delivery: the XR controller path is a
// producer of platform events, not a parallel input stack.
(void)runtime_.HandleEvent(event);
}
void RobloxWindowInputRuntime::XrControllerEventCallback(
void* context, const platform::PlatformEvent& event) {
auto* self = static_cast<RobloxWindowInputRuntime*>(context);
if (self != nullptr) {
self->InjectPlatformEvent(event);
}
}
void RobloxWindowInputRuntime::DrainXrControllers() {
if (!xr_controllers_initialized_) {
return;
}
xr_controllers_.Drain();
}
void RobloxWindowInputRuntime::RequestXrControllerHaptics(
int hand, float amplitude, std::uint64_t duration_ns, float frequency_hz) {
xr_controllers_.RequestHaptics(hand, amplitude, duration_ns, frequency_hz);
}
void RobloxWindowInputRuntime::StopXrControllerHaptics(int hand) {
xr_controllers_.StopHaptics(hand);
}
bool RobloxWindowInputRuntime::MouseLockQueryCallback(void* context,
bool* locked_center) {
if (context == nullptr || locked_center == nullptr) {
+272
View File
@@ -0,0 +1,272 @@
#include "runtime/roblox_xr_controller_input.h"
#include <dlfcn.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include "mocktail/vr/xr_controller.h"
#include "mocktail/vr/xr_controller_abi.h"
namespace mocktail {
namespace runtime {
namespace {
// SDL3 gamepad ordinals for the merged synthetic controller. Both hands feed
// one device: left hand -> left stick/left shoulder/X/Y, right hand -> right
// stick/right shoulder/A/B; menu -> START.
constexpr std::int16_t kAxisFull = 32767;
std::int16_t FloatToRaw(float value) {
const float clamped =
std::clamp(std::isfinite(value) ? value : 0.f, -1.f, 1.f);
return static_cast<std::int16_t>(std::lround(clamped * kAxisFull));
}
std::int16_t TriggerToRaw(float value) {
const float clamped =
std::clamp(std::isfinite(value) ? value : 0.f, 0.f, 1.f);
return static_cast<std::int16_t>(std::lround(clamped * kAxisFull));
}
std::uint64_t NowNs() {
timespec ts{};
clock_gettime(CLOCK_MONOTONIC, &ts);
return static_cast<std::uint64_t>(ts.tv_sec) * 1000000000ULL +
static_cast<std::uint64_t>(ts.tv_nsec);
}
} // namespace
bool RobloxXrControllerInput::Initialize(EventSink sink, void *context) {
if (sink == nullptr) {
return false;
}
// Resolve the XR controller ABI through dlsym so this module links and runs
// in non-VR builds (symbols absent) and stays inert while no backend is
// armed. Never a hard dependency on OpenXR here.
auto pop = reinterpret_cast<MocktailVrControllerPopFn>(
dlsym(RTLD_DEFAULT, "mocktail_vr_controller_pop_delivery"));
auto haptics = reinterpret_cast<MocktailVrControllerHapticsFn>(
dlsym(RTLD_DEFAULT, "mocktail_vr_controller_request_haptics"));
auto stop_haptics = reinterpret_cast<MocktailVrControllerStopHapticsFn>(
dlsym(RTLD_DEFAULT, "mocktail_vr_controller_stop_haptics"));
return InitializeWithAbi(sink, context, pop, haptics, stop_haptics);
}
bool RobloxXrControllerInput::InitializeWithAbi(
EventSink sink, void *context, MocktailVrControllerPopFn pop,
MocktailVrControllerHapticsFn haptics,
MocktailVrControllerStopHapticsFn stop_haptics) {
if (sink == nullptr || pop == nullptr) {
// No XR controller layer (non-VR build or backend without controllers).
// This is the normal case for --no-vr and must not be treated as failure.
sink_ = nullptr;
context_ = nullptr;
pop_ = nullptr;
haptics_ = nullptr;
stop_haptics_ = nullptr;
initialized_ = false;
return false;
}
sink_ = sink;
context_ = context;
pop_ = reinterpret_cast<void *>(pop);
haptics_ = reinterpret_cast<void *>(haptics);
stop_haptics_ = reinterpret_cast<void *>(stop_haptics);
initialized_ = true;
return true;
}
void RobloxXrControllerInput::Shutdown() {
if (connected_) {
// Release the synthetic controller once so no button or stick stays live
// after teardown.
EmitConnection(false);
}
initialized_ = false;
generation_ = 0;
force_resync_ = false;
sink_ = nullptr;
context_ = nullptr;
pop_ = nullptr;
haptics_ = nullptr;
stop_haptics_ = nullptr;
}
void RobloxXrControllerInput::EmitConnection(bool connected) {
if (sink_ == nullptr) {
return;
}
platform::GamepadDescriptor descriptor;
descriptor.family = platform::GamepadFamily::kXbox;
descriptor.name = connected ? "Mocktail VR Controllers" : "";
if (connected) {
// Advertise exactly the buttons/axes this route can emit so the router
// accepts them: SOUTH/EAST/WEST/NORTH, START, both stick clicks, both
// shoulders.
for (const std::uint8_t button :
{static_cast<std::uint8_t>(vr::ControllerButtonToSdlOrdinal(
0, vr::ControllerButton::kFaceWest)),
static_cast<std::uint8_t>(vr::ControllerButtonToSdlOrdinal(
0, vr::ControllerButton::kFaceNorth)),
static_cast<std::uint8_t>(vr::ControllerButtonToSdlOrdinal(
1, vr::ControllerButton::kFaceSouth)),
static_cast<std::uint8_t>(vr::ControllerButtonToSdlOrdinal(
1, vr::ControllerButton::kFaceEast)),
static_cast<std::uint8_t>(
vr::ControllerButtonToSdlOrdinal(0, vr::ControllerButton::kMenu)),
static_cast<std::uint8_t>(vr::ControllerButtonToSdlOrdinal(
0, vr::ControllerButton::kThumbstick)),
static_cast<std::uint8_t>(vr::ControllerButtonToSdlOrdinal(
1, vr::ControllerButton::kThumbstick)),
static_cast<std::uint8_t>(vr::ControllerButtonToSdlOrdinal(
0, vr::ControllerButton::kSqueeze)),
static_cast<std::uint8_t>(vr::ControllerButtonToSdlOrdinal(
1, vr::ControllerButton::kSqueeze))}) {
descriptor.buttons |= (std::uint32_t{1} << button);
}
// Six axes: both sticks (x/y) and both triggers.
descriptor.axes = 0b111111;
}
platform::GamepadConnectionEvent event;
event.instance_id = kInstanceLeft;
event.connected = connected;
event.descriptor = std::move(descriptor);
event.remapped = false;
sink_(context_, platform::PlatformEvent{NowNs(), std::move(event)});
connected_ = connected;
std::fill(std::begin(last_buttons_), std::end(last_buttons_), false);
if (connected) {
std::fprintf(stderr, " [vr-input] XR controllers connected to Roblox as a "
"single synthetic gamepad\n");
} else {
std::fprintf(stderr,
" [vr-input] XR controllers disconnected from Roblox\n");
}
}
void RobloxXrControllerInput::EmitButton(int sdl_button, bool pressed) {
if (sink_ == nullptr || sdl_button < 0) {
return;
}
platform::GamepadButtonEvent event;
event.instance_id = kInstanceLeft;
event.button = static_cast<std::uint8_t>(sdl_button);
event.pressed = pressed;
sink_(context_, platform::PlatformEvent{NowNs(), std::move(event)});
++delivered_events_;
}
void RobloxXrControllerInput::EmitAxis(int sdl_axis, std::int16_t value) {
if (sink_ == nullptr || sdl_axis < 0) {
return;
}
platform::GamepadAxisEvent event;
event.instance_id = kInstanceLeft;
event.axis = static_cast<std::uint8_t>(sdl_axis);
event.value = value;
sink_(context_, platform::PlatformEvent{NowNs(), std::move(event)});
++delivered_events_;
}
void RobloxXrControllerInput::Drain() {
if (!initialized_ || pop_ == nullptr || sink_ == nullptr) {
return;
}
auto pop = reinterpret_cast<MocktailVrControllerPopFn>(pop_);
for (int batch = 0; batch < kMaxDrainPerPump; ++batch) {
MocktailVrControllerDelivery delivery{};
if (pop(&delivery) == 0) {
return;
}
if (delivery.session_generation < generation_)
continue;
if (delivery.session_generation != generation_) {
if (connected_)
EmitConnection(false);
generation_ = delivery.session_generation;
}
// Connection transitions first so subsequent button/axis events land on a
// registered device. Either hand being connected presents the merged
// controller as connected.
const bool connected = delivery.connected[0] || delivery.connected[1];
if (connected != connected_) {
EmitConnection(connected);
}
if (!connected_) {
// Nothing to deliver while disconnected; the disconnect already released
// every control through the router.
continue;
}
if (force_resync_) {
delivery.resync = 1;
force_resync_ = false;
}
if (delivery.resync)
++resyncs_;
// Levels belong to this delivery, not a separately read newer snapshot.
// Merge first so a release/unbound button on one hand cannot cancel the
// other.
bool merged[16] = {};
for (int hand = 0; hand < 2; ++hand) {
if (!delivery.connected[hand])
continue;
for (int index = 0; index < MOCKTAIL_VR_CONTROLLER_BUTTON_COUNT;
++index) {
const int button = vr::ControllerButtonToSdlOrdinal(
hand, static_cast<vr::ControllerButton>(index));
if (button >= 0 && button < 16)
merged[button] |= delivery.button_levels[hand][index] != 0;
}
}
for (int button = 0; button < 16; ++button) {
if (delivery.resync || merged[button] != last_buttons_[button])
EmitButton(button, merged[button]);
last_buttons_[button] = merged[button];
}
// Analog axes. Delivered as raw int16 so the router applies its single
// deadzone; we never pre-filter (avoids a double deadzone).
if (delivery.axes_valid[0] || delivery.axes_valid[1]) {
EmitAxis(
vr::ControllerAxisToSdlOrdinal(0, vr::ControllerAxis::kThumbstickX),
FloatToRaw(delivery.axes[0]));
EmitAxis(
vr::ControllerAxisToSdlOrdinal(0, vr::ControllerAxis::kThumbstickY),
FloatToRaw(delivery.axes[1]));
EmitAxis(
vr::ControllerAxisToSdlOrdinal(1, vr::ControllerAxis::kThumbstickX),
FloatToRaw(delivery.axes[2]));
EmitAxis(
vr::ControllerAxisToSdlOrdinal(1, vr::ControllerAxis::kThumbstickY),
FloatToRaw(delivery.axes[3]));
EmitAxis(vr::ControllerAxisToSdlOrdinal(0, vr::ControllerAxis::kTrigger),
TriggerToRaw(delivery.axes[4]));
EmitAxis(vr::ControllerAxisToSdlOrdinal(1, vr::ControllerAxis::kTrigger),
TriggerToRaw(delivery.axes[5]));
}
}
}
void RobloxXrControllerInput::RequestHaptics(int hand, float amplitude,
std::uint64_t duration_ns,
float frequency_hz) {
if (haptics_ == nullptr || (hand != 0 && hand != 1)) {
return;
}
reinterpret_cast<MocktailVrControllerHapticsFn>(haptics_)(
hand, amplitude, duration_ns, frequency_hz);
}
void RobloxXrControllerInput::StopHaptics(int hand) {
if (stop_haptics_ == nullptr || (hand != 0 && hand != 1)) {
return;
}
reinterpret_cast<MocktailVrControllerStopHapticsFn>(stop_haptics_)(hand);
}
} // namespace runtime
} // namespace mocktail
+149 -4
View File
@@ -366,6 +366,17 @@ Status OpenXrBackend::Arm(VrGraphicsApi graphics_api) {
gl_min_version_ = requirements.minApiVersionSupported;
gl_max_version_ = requirements.maxApiVersionSupported;
}
// Controller actions are instance-level: create them once here so every
// session (including a recreated one) can attach without re-suggesting
// bindings. A failure degrades to no controller input, not to no XR.
std::lock_guard<std::mutex> controller_lock(controller_mutex_);
const Status actions_status = actions_.Create(instance);
actions_created_ = actions_status.ok();
if (!actions_created_) {
Log(" [vr-backend] controller actions unavailable: %s (XR output "
"continues without controller input)\n",
actions_status.message().c_str());
}
armed_.store(true, std::memory_order_release);
Log(" [vr-backend] armed: OpenXR instance/system ready; awaiting guest "
"%s\n",
@@ -385,6 +396,9 @@ void OpenXrBackend::Disarm() {
void OpenXrBackend::DisarmLocked() {
TeardownSessionLocked("disarm");
{ std::lock_guard<std::mutex> lock(controller_mutex_);
actions_.Destroy();
actions_created_ = false; }
if (gles_)
gles_->Destroy();
delete gles_;
@@ -868,6 +882,17 @@ bool OpenXrBackend::InitializeSessionResourcesLocked(std::string *error) {
TeardownSessionLocked("swapchain failure", session_recovery_pending_);
return false;
}
// Attach the action set to this session and create its pose spaces. A
// failure leaves XR output working without controller input.
std::lock_guard<std::mutex> controller_lock(controller_mutex_);
if (actions_created_) {
const Status attach_status =
actions_.Attach(xr_instance_, xr_session_, xr_local_space_);
if (!attach_status.ok()) {
Log(" [vr-backend] controller actions not attached: %s\n",
attach_status.message().c_str());
}
}
recording_.store(true, std::memory_order_release);
return true;
} catch (const std::exception &exception) {
@@ -1019,6 +1044,9 @@ void OpenXrBackend::TeardownSessionLocked(const char* reason, bool preserve_devi
mirror_.Close();
DestroyCopyResources();
DestroySwapchains();
// Action pose spaces belong to this session; detach before the session and
// its spaces go away so a recreated session attaches cleanly.
{ std::lock_guard<std::mutex> lock(controller_mutex_); actions_.Detach(); }
if (xr_view_space_ != nullptr) {
(void)xrDestroySpace(static_cast<XrSpace>(xr_view_space_));
xr_view_space_ = nullptr;
@@ -1326,6 +1354,37 @@ ScriptedPoseSample OpenXrBackend::PublishedHeadPose() const {
return published_pose_;
}
bool OpenXrBackend::TakeControllerDelivery(ControllerDelivery* out) {
if (out == nullptr) {
return false;
}
// controller_mutex_ (never mutex_): the frame cycle holds mutex_ across
// xrWaitFrame runtime pacing, and this drain runs on the window thread.
std::lock_guard<std::mutex> lock(controller_mutex_);
return actions_.TakeDelivery(out);
}
ControllerSnapshot OpenXrBackend::ControllerSnapshotForFrame() const {
std::lock_guard<std::mutex> lock(controller_mutex_);
return actions_.snapshot();
}
void OpenXrBackend::RequestControllerHaptics(int hand, float amplitude,
std::uint64_t duration_ns,
float frequency_hz) {
std::lock_guard<std::mutex> lock(controller_mutex_);
if (actions_created_) {
actions_.RequestHaptics(hand, amplitude, duration_ns, frequency_hz);
}
}
void OpenXrBackend::StopControllerHaptics(int hand) {
std::lock_guard<std::mutex> lock(controller_mutex_);
if (actions_created_) {
actions_.StopHaptics(hand);
}
}
void OpenXrBackend::PollSessionEvents() {
if (xr_instance_ == nullptr) {
return;
@@ -1364,10 +1423,8 @@ void OpenXrBackend::PollSessionEvents() {
continue;
}
if (event.type == XR_TYPE_EVENT_DATA_INTERACTION_PROFILE_CHANGED) {
// Controllers are out of scope; recorded so the log shows the runtime
// offered a profile change rather than silently ignoring it.
Log(" [vr-backend] interaction profile changed (controller input is not "
"implemented in this build)\n");
{ std::lock_guard<std::mutex> lock(controller_mutex_); actions_.NoteProfileChanged(); }
Log(" [vr-backend] controller interaction profile changed\n");
continue;
}
if (event.type != XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) {
@@ -1384,6 +1441,14 @@ void OpenXrBackend::PollSessionEvents() {
}
void OpenXrBackend::HandleSessionState(int state) {
if (state != XR_SESSION_STATE_FOCUSED) {
std::lock_guard<std::mutex> lock(controller_mutex_);
actions_.ResetInput();
published_pose_.hands[0] = {};
published_pose_.hands[1] = {};
published_pose_.controllers_connected = false;
std::fill(std::begin(published_pose_.controller_channels), std::end(published_pose_.controller_channels), 0.f);
}
switch (static_cast<XrSessionState>(state)) {
case XR_SESSION_STATE_READY: {
auto begin =
@@ -1897,6 +1962,44 @@ void OpenXrBackend::RunFrameCycle(VkQueue queue, VkDevice device) {
} else {
published_pose_.valid = false;
}
// Sync controllers exactly once per frame at the same predicted display time
// used for the head/eyes, then fold the resulting hand poses into the
// already-published frame. This adds no second frame wait and no per-getter
// polling; the device bridge reads published_pose_.hands when it injects the
// state for this frame.
SyncControllersLocked(predicted_display_time);
}
void OpenXrBackend::SyncControllersLocked(std::uint64_t predicted_display_time) {
std::lock_guard<std::mutex> lock(controller_mutex_);
published_pose_.hands[0] = {};
published_pose_.hands[1] = {};
published_pose_.controllers_connected = false;
std::fill(std::begin(published_pose_.controller_channels), std::end(published_pose_.controller_channels), 0.f);
if (!actions_created_ || !actions_.attached()) {
return;
}
const ScopedStageTimer sync_timer(perf::Stage::kControllerSync);
const bool synced = actions_.Sync(xr_session_, predicted_display_time,
published_pose_.frame, 0);
if (!synced) {
// Hands were invalidated above; Sync queued the input reset.
return;
}
// Fold this frame's hand poses into the published sample. Only valid when
// the head pose for this frame is itself valid, so hands and head always
// describe the same predicted instant.
if (published_pose_.valid) {
published_pose_.hands[0] = actions_.hand(0);
published_pose_.hands[1] = actions_.hand(1);
published_pose_.controllers_connected = EncodeNativeControllerChannels(
actions_.snapshot(), published_pose_.controller_channels);
} else {
published_pose_.hands[0] = VrHandPose{};
published_pose_.hands[1] = VrHandPose{};
}
perf::ProcessCollector().Count(perf::Counter::kControllerSyncs);
}
bool OpenXrBackend::EnsureCopyResources(VkDevice device,
@@ -2560,6 +2663,48 @@ void mocktail_vr_xr_note_present(VkQueue queue, VkDevice device) {
} // extern "C"
// Controller delivery ABI consumed by the input runtime through dlsym. These
// are defined only in the VR build; a non-VR build omits them and the consumer
// resolves nullptr, staying inert.
#include "mocktail/vr/xr_controller_abi.h"
extern "C" int32_t
mocktail_vr_controller_pop_delivery(MocktailVrControllerDelivery* out) {
if (out == nullptr) {
return 0;
}
auto* backend = mocktail::vr::ActiveVrBackend();
if (backend == nullptr) {
return 0;
}
// Exact native VR input owns Gamepad1 when the device bridge is active.
// The JNI fallback must not inject a second copy into that same device.
extern bool mocktail_vr_native_controller_input_active();
if (mocktail_vr_native_controller_input_active()) return 0;
mocktail::vr::ControllerDelivery delivery;
if (!backend->TakeControllerDelivery(&delivery)) {
return 0;
}
mocktail::vr::EncodeControllerDelivery(delivery, out);
return 1;
}
extern "C" void mocktail_vr_controller_request_haptics(int32_t hand,
float amplitude,
uint64_t duration_ns,
float frequency_hz) {
if (auto* backend = mocktail::vr::ActiveVrBackend()) {
backend->RequestControllerHaptics(hand, amplitude, duration_ns,
frequency_hz);
}
}
extern "C" void mocktail_vr_controller_stop_haptics(int32_t hand) {
if (auto* backend = mocktail::vr::ActiveVrBackend()) {
backend->StopControllerHaptics(hand);
}
}
extern "C" bool mocktail_vr_attach_egl(void *display, void *config,
void *context, void *egl_get_proc,
void *(*resolve)(const char *)) {
+2
View File
@@ -8,6 +8,8 @@ Status OpenXrBackend::Arm(VrGraphicsApi) {
return Status::Error(StatusCode::kUnavailable, "OpenXR support is disabled in this build");
}
void OpenXrBackend::Disarm() {}
void OpenXrBackend::RequestControllerHaptics(int, float, std::uint64_t, float) {}
void OpenXrBackend::StopControllerHaptics(int) {}
OpenXrBackend* ActiveVrBackend() { return nullptr; }
void OpenXrBackend::NotePoseApplied(void*, std::uint64_t) {}
ScriptedPoseSample OpenXrBackend::PublishedHeadPose() const { return {}; }
+336 -24
View File
@@ -11,6 +11,7 @@
#include <cinttypes>
#include <cmath>
#include "mocktail/vr/openxr_backend.h"
#include "mocktail/vr/vr_pose_math.h"
#include <cstdarg>
#include <cstddef>
#include <cstdint>
@@ -50,6 +51,137 @@ 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;
std::mutex g_native_pose_mutex;
ScriptedPoseSample g_native_pose;
using PointerFrameFn = void *(*)(void *, void *);
std::atomic<PointerFrameFn> g_pointer_original{nullptr};
constexpr std::uintptr_t kPointerFrameRva = 0x4d573ee;
// Whole instructions, no RIP-relative operands. 2998's sret prologue.
constexpr std::array<unsigned char, 15> kPointerPrologue = {
0x55, 0x48, 0x89, 0xe5, 0x41, 0x57, 0x41, 0x56,
0x41, 0x54, 0x53, 0x48, 0x83, 0xec, 0x70};
std::uintptr_t g_pointer_target = 0;
void AbsoluteJump(unsigned char *code, std::uintptr_t target) {
const unsigned char opcode[6] = {0xff, 0x25, 0, 0, 0, 0};
std::memcpy(code, opcode, 6);
std::memcpy(code + 6, &target, 8);
}
extern "C" void *mocktail_vr_pointer_frame(void *result, void *service) {
auto original = g_pointer_original.load(std::memory_order_acquire);
if (!original)
return result;
original(result, service);
auto *bridge = g_active_bridge.load(std::memory_order_acquire);
if (!bridge || !bridge->active()) return result;
ScriptedPoseSample native_pose;
{
std::lock_guard<std::mutex> lock(g_native_pose_mutex);
native_pose = g_native_pose;
}
if (!native_pose.valid) return result;
auto *backend = ActiveVrBackend();
if (!backend)
return result;
const auto current = backend->PublishedHeadPose();
if (!current.valid)
return result;
int index = 0;
std::memcpy(&index, static_cast<unsigned char *>(service) + 0x118, 4);
if (index != 1 && index != 2)
return result;
if (!current.hands[index - 1].aim_valid)
return result;
const auto &hand = native_pose.hands[index - 1];
if (!hand.grip_valid || !hand.aim_valid)
return result;
const auto base = bridge->image_base();
// Same camera lookup as the original getter (see pointer-world-2998.asm).
auto get_workspace = reinterpret_cast<void *(*)(void *)>(base + 0x263778c);
void *workspace = get_workspace(service);
if (!workspace)
return result;
auto *vtable = *static_cast<std::uintptr_t **>(workspace);
void *camera =
reinterpret_cast<void *(*)(void *)>(vtable[0x3a8 / 8])(workspace);
if (!camera)
return result;
float scale = 0;
int degrees = 0;
std::memcpy(&scale, static_cast<unsigned char *>(camera) + 0x140, 4);
std::memcpy(&degrees, reinterpret_cast<void *>(base + 0x73677a8), 4);
if (internal::ApplyAimToWorldFrame(static_cast<float *>(result), hand, scale,
-static_cast<float>(degrees))) {
static std::atomic<bool> logged[2]{};
if (!logged[index - 1].exchange(true))
std::fprintf(
stderr, " [vr-input] native menu pointer uses OpenXR aim: hand=%d\n",
index - 1);
}
return result;
}
bool SetCodeWritable(std::uintptr_t target, bool writable) {
const auto page_size = static_cast<std::uintptr_t>(sysconf(_SC_PAGESIZE));
if (!page_size)
return false;
const auto first = target & ~(page_size - 1);
const auto end =
(target + kPointerPrologue.size() + page_size - 1) & ~(page_size - 1);
return mprotect(reinterpret_cast<void *>(first), end - first,
PROT_READ | PROT_EXEC | (writable ? PROT_WRITE : 0)) == 0;
}
bool InstallPointerHook(std::uintptr_t base) {
if (g_pointer_target)
return false;
auto *target = reinterpret_cast<unsigned char *>(base + kPointerFrameRva);
if (std::memcmp(target, kPointerPrologue.data(), kPointerPrologue.size()) !=
0)
return false;
const auto page_size = static_cast<std::size_t>(sysconf(_SC_PAGESIZE));
auto *code = static_cast<unsigned char *>(
mmap(nullptr, page_size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0));
if (code == MAP_FAILED)
return false;
std::memcpy(code, target, kPointerPrologue.size());
AbsoluteJump(code + kPointerPrologue.size(),
base + kPointerFrameRva + kPointerPrologue.size());
if (mprotect(code, page_size, PROT_READ | PROT_EXEC) != 0 ||
!SetCodeWritable(base + kPointerFrameRva, true)) {
munmap(code, page_size);
return false;
}
g_pointer_original.store(reinterpret_cast<PointerFrameFn>(code),
std::memory_order_release);
AbsoluteJump(target,
reinterpret_cast<std::uintptr_t>(&mocktail_vr_pointer_frame));
target[14] = 0x90;
if (!SetCodeWritable(base + kPointerFrameRva, false)) {
std::memcpy(target, kPointerPrologue.data(), kPointerPrologue.size());
(void)SetCodeWritable(base + kPointerFrameRva, false);
return false;
}
// Retain this single RX page for process lifetime: an in-flight guest call
// may still be returning through it when the entrypoint is restored.
g_pointer_target = base + kPointerFrameRva;
return true;
}
bool RestorePointerHook() {
if (!g_pointer_target)
return true;
if (!SetCodeWritable(g_pointer_target, true))
return false;
std::memcpy(reinterpret_cast<void *>(g_pointer_target),
kPointerPrologue.data(), kPointerPrologue.size());
const bool restored = SetCodeWritable(g_pointer_target, false);
if (restored)
g_pointer_target = 0;
return restored;
}
Status InvalidArgument(std::string message) {
return Status::Error(StatusCode::kInvalidArgument, std::move(message));
@@ -339,6 +471,11 @@ namespace {
// 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_haptics(void* object, int hand, float amplitude) {
if (auto* bridge = g_active_bridge.load(std::memory_order_acquire))
bridge->OnHapticsCall(object, hand, amplitude);
}
extern "C" void *mocktail_vr_debug_device_state_getter(void *result_buffer,
void *device_object) {
RobloxVrDeviceBridge *bridge =
@@ -408,6 +545,8 @@ Status RobloxVrDeviceBridge::Install(const compat::BuildProfile &profile) {
"experimental Roblox VR requires vr_debug_device_bridge metadata in "
"the exact-build compatibility profile");
}
if (profile.elf_build_id != "ade08266c67aee88ec9c1d00902150e1684dad3a")
return FailedPrecondition("VR controller/pointer/haptic contracts are verified only for Build 2998");
if (!profile.allow_host_abi_bridges) {
return FailedPrecondition(
"Roblox VR device bridge profile requires host ABI bridges");
@@ -507,14 +646,25 @@ Status RobloxVrDeviceBridge::Activate(std::uintptr_t image_base) {
"match the profile");
}
if (!IsExecutableImageRange(image_base, kPointerFrameRva, 0x1b2) ||
std::memcmp(reinterpret_cast<void*>(image_base + kPointerFrameRva),
kPointerPrologue.data(), kPointerPrologue.size()) != 0)
return FailedPrecondition("2998 VR pointer function contract mismatch");
image_base_.store(image_base, std::memory_order_relaxed);
image_size_.store(ImageSize(image_base), std::memory_order_relaxed);
vtable_ =
reinterpret_cast<std::uintptr_t *>(image_base + profile_.vtable_rva);
// 2998's haptic sink is a verified no-op (ret at 0x1d48b70).
// The caller at 0x28a767e passes self, hand 0/1 and float amplitude.
if (vtable_[3] != image_base + 0x1d48b70 ||
!IsExecutableImageRange(image_base, 0x1d48b70, 1) ||
*reinterpret_cast<const unsigned char*>(image_base + 0x1d48b70) != 0xc3)
{ vtable_ = nullptr; return FailedPrecondition("2998 VR haptic vtable contract mismatch"); }
original_haptic_slot_ = vtable_[3];
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 *first_slot = vtable_ + 3;
std::uintptr_t *last_slot_exclusive =
vtable_ + kEyeInitializerVtableSlot / 8 + 1;
if (!SetVtableRangeWritable(first_slot, last_slot_exclusive, true)) {
@@ -527,6 +677,7 @@ Status RobloxVrDeviceBridge::Activate(std::uintptr_t image_base) {
std::memory_order_release);
original_eye_getter_.store(reinterpret_cast<EyeGetterFn>(original_eye_slot_),
std::memory_order_release);
__atomic_store_n(&vtable_[3], FunctionAddress(&mocktail_vr_debug_device_haptics), __ATOMIC_RELEASE);
__atomic_store_n(&vtable_[kStateGetterVtableSlot / 8],
FunctionAddress(&mocktail_vr_debug_device_state_getter),
__ATOMIC_RELEASE);
@@ -534,6 +685,7 @@ Status RobloxVrDeviceBridge::Activate(std::uintptr_t image_base) {
FunctionAddress(&mocktail_vr_debug_device_eye_getter),
__ATOMIC_RELEASE);
if (!SetVtableRangeWritable(first_slot, last_slot_exclusive, false)) {
__atomic_store_n(&vtable_[3], original_haptic_slot_, __ATOMIC_RELEASE);
__atomic_store_n(&vtable_[kStateGetterVtableSlot / 8], original_state_slot_,
__ATOMIC_RELEASE);
__atomic_store_n(&vtable_[kEyeGetterVtableSlot / 8], original_eye_slot_,
@@ -547,8 +699,16 @@ Status RobloxVrDeviceBridge::Activate(std::uintptr_t image_base) {
"protection");
}
if (!InstallPointerHook(image_base)) {
(void)RestoreVtableLocked();
return FailedPrecondition("could not install verified VR aim pointer hook");
}
{
std::lock_guard<std::mutex> pose_lock(g_native_pose_mutex);
g_native_pose = {};
}
active_.store(true, std::memory_order_release);
Log(" [vr-device] activated: DebugDeviceVR vtable slots +0x28/+0x48 "
Log(" [vr-device] activated: DebugDeviceVR input/eyes/haptics +0x18/+0x28/+0x48 and aim pointer "
"interposed at image base 0x%" PRIxPTR "\n",
image_base);
return Status::Ok();
@@ -583,15 +743,17 @@ void RobloxVrDeviceBridge::Shutdown() {
}
bool RobloxVrDeviceBridge::RestoreVtableLocked() {
if (!RestorePointerHook()) return false;
if (vtable_ == nullptr) {
return false;
}
std::uintptr_t *first_slot = vtable_ + kStateGetterVtableSlot / 8;
std::uintptr_t *first_slot = vtable_ + 3;
std::uintptr_t *last_slot_exclusive =
vtable_ + kEyeInitializerVtableSlot / 8 + 1;
if (!SetVtableRangeWritable(first_slot, last_slot_exclusive, true)) {
return false;
}
__atomic_store_n(&vtable_[3], original_haptic_slot_, __ATOMIC_RELEASE);
__atomic_store_n(&vtable_[kStateGetterVtableSlot / 8], original_state_slot_,
__ATOMIC_RELEASE);
__atomic_store_n(&vtable_[kEyeGetterVtableSlot / 8], original_eye_slot_,
@@ -662,36 +824,171 @@ void *RobloxVrDeviceBridge::ValidatedGraphicsDevice(void *self) const {
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;
bool internal::ApplyAimToWorldFrame(float *frame, const VrHandPose &hand,
float head_scale, float degrees) {
if (!frame || !hand.grip_valid || !hand.aim_valid ||
!std::isfinite(head_scale) || head_scale <= 0 ||
!std::isfinite(degrees) || std::abs(degrees) > 360)
return false;
for (int i = 0; i < 12; ++i)
if (!std::isfinite(frame[i]))
return false;
for (int i = 0; i < 3; ++i)
if (!std::isfinite(hand.grip_position[i]) ||
!std::isfinite(hand.aim_position[i]))
return false;
pose::Quat grip, aim;
if (!pose::QuatNormalize({hand.grip_orientation[0], hand.grip_orientation[1],
hand.grip_orientation[2], hand.grip_orientation[3]},
&grip) ||
!pose::QuatNormalize({hand.aim_orientation[0], hand.aim_orientation[1],
hand.aim_orientation[2], hand.aim_orientation[3]},
&aim))
return false;
const float angle = degrees * pose::kPi / 360.f;
grip = pose::QuatMultiply(grip, {std::sin(angle), 0, 0, std::cos(angle)});
const auto inverse = pose::QuatConjugate(grip);
const auto delta = pose::QuatMultiply(inverse, aim);
auto translation =
pose::QuatRotate(inverse, {hand.aim_position[0] - hand.grip_position[0],
hand.aim_position[1] - hand.grip_position[1],
hand.aim_position[2] - hand.grip_position[2]});
const float scale = (10.f / 3.f) * head_scale;
const float offset[3] = {translation.x * scale, translation.y * scale,
translation.z * scale};
float out[12];
for (int col = 0; col < 3; ++col) {
const auto axis =
pose::QuatRotate(delta, {col == 0 ? 1.f : 0.f, col == 1 ? 1.f : 0.f,
col == 2 ? 1.f : 0.f});
const float component[3] = {axis.x, axis.y, axis.z};
for (int row = 0; row < 3; ++row) {
out[row * 3 + col] = 0;
for (int k = 0; k < 3; ++k)
out[row * 3 + col] += frame[row * 3 + k] * component[k];
}
}
for (int row = 0; row < 3; ++row) {
out[9 + row] = frame[9 + row];
for (int k = 0; k < 3; ++k)
out[9 + row] += frame[row * 3 + k] * offset[k];
}
for (float value : out)
if (!std::isfinite(value))
return false;
std::memcpy(frame, out, sizeof(out));
return true;
}
bool internal::ApplyXrPose(void *state, const ScriptedPoseSample &pose) {
if (!state)
return false;
auto *bytes = static_cast<std::uint8_t *>(state);
bytes[0] = 0;
bytes[0x80] = bytes[0x132] = 0;
const std::uint32_t changed = 0x0fffffff;
std::memcpy(bytes + 0x84, &changed, sizeof(changed));
std::memset(bytes + 0x88, 0, 28 * sizeof(float));
// Hand records are cleared unless a tracked grip pose is supplied below.
// Clearing the valid byte (never the whole record) makes the guest report
// the hand as untracked instead of holding a stale pose. The extra index-3
// record is unused by this backend and stays invalid.
bytes[kExtraRecordOffset + kRecordValidOffset] = 0;
bytes[kLeftHandRecordOffset + kRecordValidOffset] = 0;
bytes[kRightHandRecordOffset + kRecordValidOffset] = 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;
}
bytes[0x80] = pose.valid && pose.controllers_connected;
for (int channel = 0; channel < 28; ++channel) {
const float raw = pose.controller_channels[channel];
const float value =
pose.valid && pose.controllers_connected && std::isfinite(raw)
? std::clamp(raw, -1.f, 1.f)
: 0.f;
std::memcpy(bytes + 0x88 + channel * 4, &value, sizeof(value));
}
bytes[0x132] = pose.valid && pose.controllers_connected;
// 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));
// Tracked hands go into the same state copy the head uses, so the guest's
// own conversion (10/3 studs) and -20 deg hand pitch offset apply once,
// exactly as they do for the head. Grip is the held-object pose; a hand
// without a valid grip pose stays marked untracked rather than being
// fabricated at the origin.
for (int hand = 0; hand < 2; ++hand) {
const auto &source = pose.hands[hand];
if (!source.grip_valid)
continue;
float hand_norm = 0;
bool finite = true;
for (float v : source.grip_position)
finite = finite && std::isfinite(v);
for (float v : source.grip_orientation) {
finite = finite && std::isfinite(v);
hand_norm += v * v;
}
if (!finite || hand_norm < 0.99f || hand_norm > 1.01f)
continue;
const std::size_t record =
hand == 0 ? kLeftHandRecordOffset : kRightHandRecordOffset;
std::memcpy(bytes + record + kRecordPositionOffset, source.grip_position,
sizeof(source.grip_position));
std::memcpy(bytes + record + kRecordOrientationOffset,
source.grip_orientation, sizeof(source.grip_orientation));
bytes[record + kRecordValidOffset] = 1;
}
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];
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));
-std::tan(fov[0]), std::tan(fov[1])};
std::memcpy(bytes + 0x110 + eye * 16, tangents, sizeof(tangents));
}
bytes[0] = 1;
return true;
}
void RobloxVrDeviceBridge::OnHapticsCall(void *device_object, int hand,
float amplitude) {
std::lock_guard<std::mutex> lock(mutex_);
if (!active() || !ValidateObjectHeader(device_object) || hand < 0 || hand > 1)
return;
if (auto *backend = ActiveVrBackend()) {
static std::atomic<bool> logged[2]{};
if (!logged[hand].exchange(true))
Log(" [vr-input] native haptic callback observed: hand=%d "
"amplitude=%.3f\n",
hand, amplitude);
if (!std::isfinite(amplitude) || amplitude <= 0)
backend->StopControllerHaptics(hand);
// Native pull refreshes once per engine frame. A short lease prevents a
// stalled or disconnected engine from leaving the controller vibrating.
else
backend->RequestControllerHaptics(hand, amplitude, 50000000, 0);
}
}
void RobloxVrDeviceBridge::OnStateGetterCall(void *result_buffer,
void *device_object) {
// Serialize initialization with all copies of readiness: the initializer
@@ -709,16 +1006,21 @@ void RobloxVrDeviceBridge::OnStateGetterCall(void *result_buffer,
if (active_.load(std::memory_order_acquire) && result_buffer && original) {
if (auto* backend = ActiveVrBackend()) {
const auto pose = backend->PublishedHeadPose();
{
std::lock_guard<std::mutex> pose_lock(g_native_pose_mutex);
g_native_pose = pose;
}
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",
"pos=(%.4f,%.4f,%.4f) ori=(%.4f,%.4f,%.4f,%.4f) hands=%d/%d input=%d\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]);
pose.orientation[0], pose.orientation[1], pose.orientation[2], pose.orientation[3],
pose.hands[0].grip_valid, pose.hands[1].grip_valid, pose.controllers_connected);
}
}
}
@@ -882,4 +1184,14 @@ Status NotifyRobloxVrImageLoaded(std::uintptr_t image_base) {
return bridge->Activate(image_base);
}
bool NativeControllerInputActive() {
auto* bridge = g_active_bridge.load(std::memory_order_acquire);
return bridge && bridge->active() && ActiveVrBackend();
}
} // namespace mocktail::vr
extern "C" bool mocktail_vr_native_controller_input_active() {
// Query through the bridge's public ownership API below.
return mocktail::vr::NativeControllerInputActive();
}
+25
View File
@@ -5,12 +5,17 @@
#include <cstdint>
#include "compat/build_profile.h"
#include "mocktail/vr/xr_controller.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);
// Adjust a world-space grip CoordinateFrame to OpenXR aim without changing
// the engine's hand pose. Native frame layout: row-major R[9], position[3].
bool ApplyAimToWorldFrame(float* frame, const VrHandPose& hand,
float head_scale, float native_hand_pitch_degrees);
// Guest ABI layout constants of RBX::Graphics::DebugDeviceVR. They are pinned
// per exact build by the machine-code contracts below; changing payload
@@ -21,6 +26,26 @@ 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;
// Per-record pose layout inside the 0x138 state copy. Verified against 2998
// consumer 0x28a6242 (docs/vr-agent-evidence/controller-2998): four 0x20-byte
// records are each converted by helper 0x37eacb8 (metres->studs x10/3, quat
// normalized) and stored via setUserCFrame 0x4d56906 at VRService+0x174+idx*0x30.
// copy+0x00 head -> UserCFrame index 0
// copy+0x20 extra -> index 3
// copy+0x40 left hand -> index 1
// copy+0x60 right hand-> index 2
// Record field layout (same for head and hands): valid byte +0, position
// (3 floats, metres) +4, quaternion xyzw (4 floats) +0x10. The guest applies
// the 10/3 conversion and a -20 deg hand pitch offset itself; neither is
// pre-applied here.
inline constexpr std::size_t kPoseRecordStride = 0x20;
inline constexpr std::size_t kHeadRecordOffset = 0x00;
inline constexpr std::size_t kExtraRecordOffset = 0x20;
inline constexpr std::size_t kLeftHandRecordOffset = 0x40;
inline constexpr std::size_t kRightHandRecordOffset = 0x60;
inline constexpr std::size_t kRecordValidOffset = 0x00;
inline constexpr std::size_t kRecordPositionOffset = 0x04;
inline constexpr std::size_t kRecordOrientationOffset = 0x10;
inline constexpr std::size_t kReadyByteOffset = 0x147;
inline constexpr std::size_t kFramebufferSlotOffset = 0x150;
inline constexpr std::size_t kTextureSlotOffset = 0x170;
+2
View File
@@ -56,6 +56,7 @@ const char* StageName(Stage stage) {
case Stage::kCopyFenceWait: return "copy_fence_wait";
case Stage::kMirrorPublish: return "mirror_publish";
case Stage::kEvidence: return "evidence";
case Stage::kControllerSync: return "controller_sync";
case Stage::kStageCount: break;
}
return "unknown";
@@ -80,6 +81,7 @@ const char* CounterName(Counter counter) {
case Counter::kVisibilityLost: return "visibility_lost";
case Counter::kVisibilityGained: return "visibility_gained";
case Counter::kFramesInvalidated: return "frames_invalidated";
case Counter::kControllerSyncs: return "controller_syncs";
case Counter::kCounterCount: break;
}
return "unknown";
+909
View File
@@ -0,0 +1,909 @@
#include "mocktail/vr/xr_actions.h"
#include <openxr/openxr.h>
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <stdexcept>
#include <utility>
#include <vector>
namespace mocktail::vr {
namespace {
void Log(const char *format, ...) {
va_list arguments;
va_start(arguments, format);
std::vfprintf(stderr, format, arguments);
va_end(arguments);
std::fflush(stderr);
}
template <typename T> T XrInfo(XrStructureType type) {
T value{};
value.type = type;
return value;
}
std::string XrResultText(XrInstance instance, XrResult result) {
char text[XR_MAX_RESULT_STRING_SIZE]{};
if (instance != XR_NULL_HANDLE) {
(void)xrResultToString(instance, result, text);
}
if (!text[0]) {
return std::to_string(static_cast<int>(result));
}
return text;
}
void CheckXr(XrInstance instance, XrResult result, const char *operation) {
if (XR_SUCCEEDED(result)) {
return;
}
throw std::runtime_error(std::string(operation) +
" failed: " + XrResultText(instance, result));
}
// Registry-verified interaction profiles
// (docs/vr-agent-evidence/controller-2998/openxr-interaction-profiles.md).
constexpr const char *kProfileTouch =
"/interaction_profiles/oculus/touch_controller";
constexpr const char *kProfileQuest2 =
"/interaction_profiles/meta/touch_controller_quest_2";
constexpr const char *kProfileIndex =
"/interaction_profiles/valve/index_controller";
constexpr const char *kProfileVive =
"/interaction_profiles/htc/vive_controller";
constexpr const char *kProfileSimple =
"/interaction_profiles/khr/simple_controller";
} // namespace
// All XR objects of one XrActions instance.
struct XrActions::Impl {
XrInstance instance = XR_NULL_HANDLE;
XrSession session = XR_NULL_HANDLE;
XrSpace local_space = XR_NULL_HANDLE; // borrowed from the backend
XrActionSet set = XR_NULL_HANDLE;
XrAction grip[2] = {XR_NULL_HANDLE, XR_NULL_HANDLE};
XrAction aim[2] = {XR_NULL_HANDLE, XR_NULL_HANDLE};
XrSpace grip_space[2] = {XR_NULL_HANDLE, XR_NULL_HANDLE};
XrSpace aim_space[2] = {XR_NULL_HANDLE, XR_NULL_HANDLE};
XrAction touches[static_cast<std::size_t>(ControllerTouch::kCount)] = {};
XrAction trigger = XR_NULL_HANDLE;
XrAction squeeze = XR_NULL_HANDLE;
XrAction stick = XR_NULL_HANDLE;
XrAction trigger_click = XR_NULL_HANDLE;
XrAction squeeze_click = XR_NULL_HANDLE;
XrAction stick_click = XR_NULL_HANDLE;
XrAction face_a = XR_NULL_HANDLE;
XrAction face_b = XR_NULL_HANDLE;
XrAction face_x = XR_NULL_HANDLE;
XrAction face_y = XR_NULL_HANDLE;
XrAction menu = XR_NULL_HANDLE;
XrAction haptic = XR_NULL_HANDLE;
XrPath subaction_left = XR_NULL_PATH;
XrPath subaction_right = XR_NULL_PATH;
std::mutex haptic_mutex;
struct HapticRequest {
bool pending = false;
bool stop = false;
float amplitude = 0.f;
std::uint64_t duration_ns = 0;
float frequency_hz = 0.f;
};
HapticRequest haptics[2];
XrPath active_profile[2] = {XR_NULL_PATH, XR_NULL_PATH};
std::string active_profile_name[2];
// Trigger-click actions that reported active this frame, per hand: the
// analog-derived click must not override a native click action.
bool native_trigger_click[2] = {false, false};
};
XrActions::~XrActions() { Destroy(); }
Status XrActions::Create(void *xr_instance) {
if (created_) {
return Status::Ok();
}
if (xr_instance == nullptr) {
return Status::Error(StatusCode::kInvalidArgument,
"XR actions require an instance");
}
const auto instance = static_cast<XrInstance>(xr_instance);
delete impl_;
impl_ = new Impl();
impl_->instance = instance;
try {
auto set_info =
XrInfo<XrActionSetCreateInfo>(XR_TYPE_ACTION_SET_CREATE_INFO);
std::strncpy(set_info.actionSetName, "mocktail_vr",
XR_MAX_ACTION_SET_NAME_SIZE - 1);
std::strncpy(set_info.localizedActionSetName, "Mocktail VR controls",
XR_MAX_LOCALIZED_ACTION_SET_NAME_SIZE - 1);
set_info.priority = 0;
CheckXr(instance, xrCreateActionSet(instance, &set_info, &impl_->set),
"xrCreateActionSet");
CheckXr(instance,
xrStringToPath(instance, "/user/hand/left", &impl_->subaction_left),
"xrStringToPath left");
CheckXr(
instance,
xrStringToPath(instance, "/user/hand/right", &impl_->subaction_right),
"xrStringToPath right");
const std::array<XrPath, 2> subactions{impl_->subaction_left,
impl_->subaction_right};
auto make = [&](XrActionType type, const char *name) {
auto info = XrInfo<XrActionCreateInfo>(XR_TYPE_ACTION_CREATE_INFO);
info.actionType = type;
info.countSubactionPaths = 2;
info.subactionPaths = subactions.data();
std::strncpy(info.actionName, name, XR_MAX_ACTION_NAME_SIZE - 1);
// A non-empty localized name is mandatory; runtimes reject an empty one
// with XR_ERROR_LOCALIZED_NAME_INVALID.
std::strncpy(info.localizedActionName, name,
XR_MAX_LOCALIZED_ACTION_NAME_SIZE - 1);
XrAction action = XR_NULL_HANDLE;
CheckXr(instance, xrCreateAction(impl_->set, &info, &action), name);
return action;
};
impl_->grip[0] = make(XR_ACTION_TYPE_POSE_INPUT, "grip_left");
impl_->grip[1] = make(XR_ACTION_TYPE_POSE_INPUT, "grip_right");
impl_->aim[0] = make(XR_ACTION_TYPE_POSE_INPUT, "aim_left");
impl_->aim[1] = make(XR_ACTION_TYPE_POSE_INPUT, "aim_right");
impl_->trigger = make(XR_ACTION_TYPE_FLOAT_INPUT, "trigger");
impl_->squeeze = make(XR_ACTION_TYPE_FLOAT_INPUT, "squeeze");
impl_->stick = make(XR_ACTION_TYPE_VECTOR2F_INPUT, "thumbstick");
impl_->trigger_click = make(XR_ACTION_TYPE_BOOLEAN_INPUT, "trigger_click");
impl_->squeeze_click = make(XR_ACTION_TYPE_BOOLEAN_INPUT, "squeeze_click");
impl_->stick_click = make(XR_ACTION_TYPE_BOOLEAN_INPUT, "thumbstick_click");
impl_->face_a = make(XR_ACTION_TYPE_BOOLEAN_INPUT, "face_a");
impl_->face_b = make(XR_ACTION_TYPE_BOOLEAN_INPUT, "face_b");
impl_->face_x = make(XR_ACTION_TYPE_BOOLEAN_INPUT, "face_x");
impl_->face_y = make(XR_ACTION_TYPE_BOOLEAN_INPUT, "face_y");
impl_->menu = make(XR_ACTION_TYPE_BOOLEAN_INPUT, "menu");
const char *touch_names[] = {
"trigger_touch", "thumbstick_touch", "thumbrest_touch", "a_touch",
"b_touch", "x_touch", "y_touch"};
for (std::size_t i = 0; i < std::size(touch_names); ++i)
impl_->touches[i] = make(XR_ACTION_TYPE_BOOLEAN_INPUT, touch_names[i]);
impl_->haptic = make(XR_ACTION_TYPE_VIBRATION_OUTPUT, "haptic");
auto path = [&](const char *text) {
XrPath result = XR_NULL_PATH;
CheckXr(instance, xrStringToPath(instance, text, &result), text);
return result;
};
// Each profile is suggested independently: a runtime that does not know an
// optional profile rejects only that suggestion, never the primary Touch
// binding. The system-reserved button is not bound on any profile.
auto suggest = [&](const char *profile,
std::vector<std::pair<XrAction, XrPath>> binds) {
auto touch = [&](ControllerTouch kind, const char *component) {
binds.emplace_back(impl_->touches[static_cast<std::size_t>(kind)],
path(component));
};
const bool is_touch = std::strcmp(profile, kProfileTouch) == 0 ||
std::strcmp(profile, kProfileQuest2) == 0;
const bool is_index = std::strcmp(profile, kProfileIndex) == 0;
if (is_touch || is_index) {
touch(ControllerTouch::kTrigger, "/user/hand/left/input/trigger/touch");
touch(ControllerTouch::kTrigger,
"/user/hand/right/input/trigger/touch");
touch(ControllerTouch::kThumbstick,
"/user/hand/left/input/thumbstick/touch");
touch(ControllerTouch::kThumbstick,
"/user/hand/right/input/thumbstick/touch");
touch(ControllerTouch::kA, "/user/hand/right/input/a/touch");
touch(ControllerTouch::kB, "/user/hand/right/input/b/touch");
touch(ControllerTouch::kX, is_touch ? "/user/hand/left/input/x/touch"
: "/user/hand/left/input/a/touch");
touch(ControllerTouch::kY, is_touch ? "/user/hand/left/input/y/touch"
: "/user/hand/left/input/b/touch");
}
if (is_touch) {
touch(ControllerTouch::kThumbrest,
"/user/hand/left/input/thumbrest/touch");
touch(ControllerTouch::kThumbrest,
"/user/hand/right/input/thumbrest/touch");
}
std::vector<XrActionSuggestedBinding> suggested;
suggested.reserve(binds.size());
for (const auto &bind : binds) {
XrActionSuggestedBinding entry{};
entry.action = bind.first;
entry.binding = bind.second;
suggested.push_back(entry);
}
auto info = XrInfo<XrInteractionProfileSuggestedBinding>(
XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING);
info.interactionProfile = path(profile);
info.countSuggestedBindings =
static_cast<std::uint32_t>(suggested.size());
info.suggestedBindings = suggested.data();
const XrResult result =
xrSuggestInteractionProfileBindings(instance, &info);
if (XR_FAILED(result)) {
Log(" [vr-input] bindings for %s not accepted (%s)\n", profile,
XrResultText(instance, result).c_str());
}
};
const XrPath l_grip = path("/user/hand/left/input/grip/pose");
const XrPath r_grip = path("/user/hand/right/input/grip/pose");
const XrPath l_aim = path("/user/hand/left/input/aim/pose");
const XrPath r_aim = path("/user/hand/right/input/aim/pose");
const XrPath l_haptic = path("/user/hand/left/output/haptic");
const XrPath r_haptic = path("/user/hand/right/output/haptic");
for (const char *profile : {kProfileTouch, kProfileQuest2}) {
suggest(
profile,
{
{impl_->grip[0], l_grip},
{impl_->grip[1], r_grip},
{impl_->aim[0], l_aim},
{impl_->aim[1], r_aim},
{impl_->trigger, path("/user/hand/left/input/trigger/value")},
{impl_->trigger, path("/user/hand/right/input/trigger/value")},
{impl_->squeeze, path("/user/hand/left/input/squeeze/value")},
{impl_->squeeze, path("/user/hand/right/input/squeeze/value")},
{impl_->stick, path("/user/hand/left/input/thumbstick")},
{impl_->stick, path("/user/hand/right/input/thumbstick")},
{impl_->stick_click,
path("/user/hand/left/input/thumbstick/click")},
{impl_->stick_click,
path("/user/hand/right/input/thumbstick/click")},
{impl_->face_x, path("/user/hand/left/input/x/click")},
{impl_->face_y, path("/user/hand/left/input/y/click")},
{impl_->face_a, path("/user/hand/right/input/a/click")},
{impl_->face_b, path("/user/hand/right/input/b/click")},
{impl_->menu, path("/user/hand/left/input/menu/click")},
{impl_->haptic, l_haptic},
{impl_->haptic, r_haptic},
});
}
suggest(
kProfileIndex,
{
{impl_->grip[0], l_grip},
{impl_->grip[1], r_grip},
{impl_->aim[0], l_aim},
{impl_->aim[1], r_aim},
{impl_->trigger, path("/user/hand/left/input/trigger/value")},
{impl_->trigger, path("/user/hand/right/input/trigger/value")},
{impl_->trigger_click, path("/user/hand/left/input/trigger/click")},
{impl_->trigger_click,
path("/user/hand/right/input/trigger/click")},
{impl_->squeeze, path("/user/hand/left/input/squeeze/force")},
{impl_->squeeze, path("/user/hand/right/input/squeeze/force")},
{impl_->stick, path("/user/hand/left/input/thumbstick")},
{impl_->stick, path("/user/hand/right/input/thumbstick")},
{impl_->stick_click,
path("/user/hand/left/input/thumbstick/click")},
{impl_->stick_click,
path("/user/hand/right/input/thumbstick/click")},
{impl_->face_x, path("/user/hand/left/input/a/click")},
{impl_->face_a, path("/user/hand/right/input/a/click")},
{impl_->face_y, path("/user/hand/left/input/b/click")},
{impl_->face_b, path("/user/hand/right/input/b/click")},
{impl_->haptic, l_haptic},
{impl_->haptic, r_haptic},
});
suggest(
kProfileVive,
{
{impl_->squeeze_click, path("/user/hand/left/input/squeeze/click")},
{impl_->squeeze_click,
path("/user/hand/right/input/squeeze/click")},
{impl_->grip[0], l_grip},
{impl_->grip[1], r_grip},
{impl_->aim[0], l_aim},
{impl_->aim[1], r_aim},
{impl_->trigger, path("/user/hand/left/input/trigger/value")},
{impl_->trigger, path("/user/hand/right/input/trigger/value")},
{impl_->stick, path("/user/hand/left/input/trackpad")},
{impl_->stick, path("/user/hand/right/input/trackpad")},
{impl_->stick_click, path("/user/hand/left/input/trackpad/click")},
{impl_->stick_click, path("/user/hand/right/input/trackpad/click")},
{impl_->menu, path("/user/hand/left/input/menu/click")},
{impl_->menu, path("/user/hand/right/input/menu/click")},
{impl_->haptic, l_haptic},
{impl_->haptic, r_haptic},
});
suggest(
kProfileSimple,
{
{impl_->grip[0], l_grip},
{impl_->grip[1], r_grip},
{impl_->aim[0], l_aim},
{impl_->aim[1], r_aim},
{impl_->trigger_click, path("/user/hand/left/input/select/click")},
{impl_->trigger_click, path("/user/hand/right/input/select/click")},
{impl_->menu, path("/user/hand/left/input/menu/click")},
{impl_->menu, path("/user/hand/right/input/menu/click")},
{impl_->haptic, l_haptic},
{impl_->haptic, r_haptic},
});
created_ = true;
Log(" [vr-input] action set created: grip+aim poses, trigger/squeeze "
"analog, thumbstick, face/menu buttons, haptic output; bindings "
"suggested for Touch/Quest2/Index/Vive/Simple (system button never "
"bound)\n");
return Status::Ok();
} catch (const std::exception &error) {
Destroy();
return Status::Error(StatusCode::kUnavailable, error.what());
}
}
Status XrActions::Attach(void *xr_instance, void *xr_session,
void *xr_local_space) {
if (!created_ || impl_ == nullptr) {
return Status::Error(StatusCode::kFailedPrecondition,
"XR actions are not created");
}
if (xr_session == nullptr || xr_local_space == nullptr) {
return Status::Error(StatusCode::kInvalidArgument,
"XR actions attach requires a session and space");
}
const auto instance = static_cast<XrInstance>(xr_instance);
const auto session = static_cast<XrSession>(xr_session);
try {
auto attach_info = XrInfo<XrSessionActionSetsAttachInfo>(
XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO);
attach_info.countActionSets = 1;
attach_info.actionSets = &impl_->set;
CheckXr(instance, xrAttachSessionActionSets(session, &attach_info),
"xrAttachSessionActionSets");
for (int hand = 0; hand < 2; ++hand) {
const XrPath subaction =
hand == 0 ? impl_->subaction_left : impl_->subaction_right;
auto space_info =
XrInfo<XrActionSpaceCreateInfo>(XR_TYPE_ACTION_SPACE_CREATE_INFO);
space_info.subactionPath = subaction;
space_info.poseInActionSpace.orientation.w = 1.f;
space_info.action = impl_->grip[hand];
CheckXr(
instance,
xrCreateActionSpace(session, &space_info, &impl_->grip_space[hand]),
"xrCreateActionSpace grip");
space_info.action = impl_->aim[hand];
CheckXr(
instance,
xrCreateActionSpace(session, &space_info, &impl_->aim_space[hand]),
"xrCreateActionSpace aim");
}
impl_->session = session;
impl_->local_space = static_cast<XrSpace>(xr_local_space);
attached_ = true;
session_generation_++;
Log(" [vr-input] actions attached to session (generation %llu)\n",
static_cast<unsigned long long>(session_generation_));
return Status::Ok();
} catch (const std::exception &error) {
Detach();
return Status::Error(StatusCode::kUnavailable, error.what());
}
}
void XrActions::ResetInput() {
const bool had_input =
snapshot_.hands[0].connected || snapshot_.hands[1].connected;
const auto frame = snapshot_.frame;
builder_ = ControllerStateBuilder{};
snapshot_ = {};
hands_[0] = {};
hands_[1] = {};
if (had_input) {
delivery_queue_.Clear();
ControllerDelivery reset;
reset.frame = frame;
reset.session_generation = session_generation_;
reset.resync = true;
delivery_queue_.Push(reset);
}
last_delivered_connected_[0] = last_delivered_connected_[1] = false;
if (impl_) {
std::lock_guard<std::mutex> lock(impl_->haptic_mutex);
for (int hand = 0; hand < 2; ++hand) {
impl_->haptics[hand] = {};
if (attached_ && impl_->session) {
auto stop = XrInfo<XrHapticActionInfo>(XR_TYPE_HAPTIC_ACTION_INFO);
stop.action = impl_->haptic;
stop.subactionPath =
hand ? impl_->subaction_right : impl_->subaction_left;
(void)xrStopHapticFeedback(impl_->session, &stop);
}
}
}
}
void XrActions::Detach() {
ResetInput();
if (impl_ == nullptr) {
attached_ = false;
return;
}
// Haptics must not outlive the session.
if (attached_ && impl_->session != XR_NULL_HANDLE) {
for (int hand = 0; hand < 2; ++hand) {
auto stop = XrInfo<XrHapticActionInfo>(XR_TYPE_HAPTIC_ACTION_INFO);
stop.action = impl_->haptic;
stop.subactionPath =
hand == 0 ? impl_->subaction_left : impl_->subaction_right;
(void)xrStopHapticFeedback(impl_->session, &stop);
}
}
for (int hand = 0; hand < 2; ++hand) {
if (impl_->grip_space[hand] != XR_NULL_HANDLE) {
(void)xrDestroySpace(impl_->grip_space[hand]);
impl_->grip_space[hand] = XR_NULL_HANDLE;
}
if (impl_->aim_space[hand] != XR_NULL_HANDLE) {
(void)xrDestroySpace(impl_->aim_space[hand]);
impl_->aim_space[hand] = XR_NULL_HANDLE;
}
impl_->active_profile[hand] = XR_NULL_PATH;
impl_->active_profile_name[hand].clear();
impl_->native_trigger_click[hand] = false;
}
impl_->session = XR_NULL_HANDLE;
impl_->local_space = XR_NULL_HANDLE;
attached_ = false;
hands_[0] = VrHandPose{};
hands_[1] = VrHandPose{};
// Queued deliveries belong to the detached session; a replacement session
// must start from a clean slate with fresh connection levels.
// Preserve queued reset until the input thread drains it.
last_delivered_connected_[0] = false;
last_delivered_connected_[1] = false;
}
void XrActions::Destroy() {
Detach();
if (impl_ != nullptr) {
const auto instance = impl_->instance;
if (instance != XR_NULL_HANDLE) {
const std::array<XrAction, 16> actions{
impl_->grip[0], impl_->grip[1], impl_->aim[0],
impl_->aim[1], impl_->trigger, impl_->squeeze,
impl_->stick, impl_->trigger_click, impl_->squeeze_click,
impl_->stick_click, impl_->face_a, impl_->face_b,
impl_->face_x, impl_->face_y, impl_->menu,
impl_->haptic};
for (const XrAction action : actions) {
if (action != XR_NULL_HANDLE) {
(void)xrDestroyAction(action);
}
}
for (const auto action : impl_->touches)
if (action != XR_NULL_HANDLE)
(void)xrDestroyAction(action);
if (impl_->set != XR_NULL_HANDLE) {
(void)xrDestroyActionSet(impl_->set);
}
}
delete impl_;
impl_ = nullptr;
}
created_ = false;
attached_ = false;
snapshot_ = ControllerSnapshot{};
hands_[0] = VrHandPose{};
hands_[1] = VrHandPose{};
// Preserve queued reset until the input thread drains it.
last_delivered_connected_[0] = false;
last_delivered_connected_[1] = false;
session_generation_ = 0;
}
void XrActions::RequestHaptics(int hand, float amplitude,
std::uint64_t duration_ns, float frequency_hz) {
if (impl_ == nullptr || !attached_ || (hand != 0 && hand != 1)) {
return;
}
std::lock_guard<std::mutex> lock(impl_->haptic_mutex);
auto &request = impl_->haptics[hand];
request = {};
request.pending = true;
request.stop = false;
request.amplitude =
std::clamp(std::isfinite(amplitude) ? amplitude : 0.f, 0.f, 1.f);
request.duration_ns = duration_ns;
request.frequency_hz =
std::isfinite(frequency_hz) ? std::max(0.f, frequency_hz) : 0.f;
}
void XrActions::StopHaptics(int hand) {
if (impl_ == nullptr || !attached_ || (hand != 0 && hand != 1)) {
return;
}
std::lock_guard<std::mutex> lock(impl_->haptic_mutex);
impl_->haptics[hand] = Impl::HapticRequest{};
impl_->haptics[hand].pending = true;
impl_->haptics[hand].stop = true;
}
void XrActions::NoteProfileChanged() {
if (impl_ == nullptr) {
return;
}
for (int hand = 0; hand < 2; ++hand) {
impl_->active_profile[hand] = XR_NULL_PATH;
impl_->active_profile_name[hand].clear();
}
}
std::string XrActions::ActiveProfile(int hand) const {
if (impl_ == nullptr || (hand != 0 && hand != 1)) {
return {};
}
return impl_->active_profile_name[hand];
}
bool XrActions::TakeDelivery(ControllerDelivery *out) {
return delivery_queue_.Pop(out);
}
bool XrActions::Sync(void *xr_session, std::uint64_t predicted_display_time,
std::uint64_t frame, std::uint64_t /*generation*/) {
if (!attached_ || impl_ == nullptr) {
return false;
}
const auto session = static_cast<XrSession>(xr_session);
const auto instance = impl_->instance;
const auto time = static_cast<XrTime>(predicted_display_time);
try {
auto sync_info = XrInfo<XrActionsSyncInfo>(XR_TYPE_ACTIONS_SYNC_INFO);
const XrActiveActionSet active{impl_->set, XR_NULL_PATH};
sync_info.countActiveActionSets = 1;
sync_info.activeActionSets = &active;
const XrResult sync_result = xrSyncActions(session, &sync_info);
if (sync_result == XR_SESSION_NOT_FOCUSED) {
// Normal while the system compositor owns input: no fresh state exists
// this frame and nothing may be delivered.
ResetInput();
return false;
}
if (XR_FAILED(sync_result)) {
Log(" [vr-input] xrSyncActions failed: %s\n",
XrResultText(instance, sync_result).c_str());
ResetInput();
return false;
}
for (int hand = 0; hand < 2; ++hand) {
const XrPath subaction =
hand == 0 ? impl_->subaction_left : impl_->subaction_right;
// Inactive actions must neutralize previous state, even if a profile
// remains assigned after the device sleeps or the runtime changes
// bindings.
for (size_t i = 0; i < static_cast<size_t>(ControllerButton::kCount); ++i)
builder_.SetButton(hand, static_cast<ControllerButton>(i), false, true);
for (size_t i = 0; i < static_cast<size_t>(ControllerAxis::kCount); ++i)
builder_.SetAxis(hand, static_cast<ControllerAxis>(i), 0.f, true);
for (std::size_t i = 0;
i < static_cast<std::size_t>(ControllerTouch::kCount); ++i)
builder_.SetTouch(hand, static_cast<ControllerTouch>(i), false, false);
// Connection state comes from the runtime's current profile for the
// hand; profile changes refresh diagnostics here and on the event.
auto profile_info =
XrInfo<XrInteractionProfileState>(XR_TYPE_INTERACTION_PROFILE_STATE);
bool connected = false;
if (XR_SUCCEEDED(xrGetCurrentInteractionProfile(session, subaction,
&profile_info)) &&
profile_info.interactionProfile != XR_NULL_PATH) {
connected = true;
if (profile_info.interactionProfile != impl_->active_profile[hand]) {
impl_->active_profile[hand] = profile_info.interactionProfile;
char buffer[XR_MAX_PATH_LENGTH]{};
std::uint32_t length = 0;
if (XR_SUCCEEDED(xrPathToString(instance,
profile_info.interactionProfile,
sizeof(buffer), &length, buffer))) {
impl_->active_profile_name[hand] = buffer;
}
builder_.SetProfile(hand, impl_->active_profile_name[hand]);
Log(" [vr-input] hand %d interaction profile: %s\n", hand,
impl_->active_profile_name[hand].c_str());
}
} else {
if (impl_->active_profile[hand] != XR_NULL_PATH) {
Log(" [vr-input] hand %d controller disconnected\n", hand);
}
impl_->active_profile[hand] = XR_NULL_PATH;
impl_->active_profile_name[hand].clear();
builder_.SetProfile(hand, {});
}
if (connected && builder_.snapshot().hands[hand].profile !=
impl_->active_profile_name[hand])
builder_.SetProfile(hand, impl_->active_profile_name[hand]);
builder_.SetConnected(hand, connected);
if (!connected) {
// A disconnected hand must release anything held: force every button
// level to false so Publish emits at most one release edge per button
// and nothing can stay stuck down.
for (std::size_t index = 0;
index < static_cast<std::size_t>(ControllerButton::kCount);
++index) {
builder_.SetButton(hand, static_cast<ControllerButton>(index), false,
true);
}
builder_.SetAxis(hand, ControllerAxis::kTrigger, 0.f, true);
builder_.SetAxis(hand, ControllerAxis::kSqueeze, 0.f, true);
builder_.SetAxis(hand, ControllerAxis::kThumbstickX, 0.f, true);
builder_.SetAxis(hand, ControllerAxis::kThumbstickY, 0.f, true);
builder_.SetPoseValid(hand, false);
hands_[hand] = VrHandPose{};
continue;
}
// Grip and aim poses located at the very predicted display time the head
// and eyes used for this frame.
VrHandPose hand_pose;
bool hand_active = false;
for (int which = 0; which < 2; ++which) {
const XrSpace space =
which == 0 ? impl_->grip_space[hand] : impl_->aim_space[hand];
XrSpaceLocation location =
XrInfo<XrSpaceLocation>(XR_TYPE_SPACE_LOCATION);
const XrResult located =
xrLocateSpace(space, impl_->local_space, time, &location);
const bool orientation_valid =
XR_SUCCEEDED(located) &&
(location.locationFlags &
XR_SPACE_LOCATION_ORIENTATION_VALID_BIT) != 0;
const bool position_valid = XR_SUCCEEDED(located) &&
(location.locationFlags &
XR_SPACE_LOCATION_POSITION_VALID_BIT) != 0;
// The guest record has a single valid gate and the consumer converts
// position+quaternion together: orientation-only tracking is therefore
// reported untracked rather than paired with a stale position.
auto pose_info =
XrInfo<XrActionStateGetInfo>(XR_TYPE_ACTION_STATE_GET_INFO);
pose_info.action = which == 0 ? impl_->grip[hand] : impl_->aim[hand];
pose_info.subactionPath = subaction;
auto pose_state = XrInfo<XrActionStatePose>(XR_TYPE_ACTION_STATE_POSE);
const bool active = XR_SUCCEEDED(xrGetActionStatePose(
session, &pose_info, &pose_state)) &&
pose_state.isActive;
hand_active |= active;
const bool usable = active && orientation_valid && position_valid;
float *position =
which == 0 ? hand_pose.grip_position : hand_pose.aim_position;
float *orientation =
which == 0 ? hand_pose.grip_orientation : hand_pose.aim_orientation;
if (usable) {
position[0] = location.pose.position.x;
position[1] = location.pose.position.y;
position[2] = location.pose.position.z;
orientation[0] = location.pose.orientation.x;
orientation[1] = location.pose.orientation.y;
orientation[2] = location.pose.orientation.z;
orientation[3] = location.pose.orientation.w;
}
if (which == 0) {
hand_pose.grip_valid = usable;
} else {
hand_pose.aim_valid = usable;
}
}
hands_[hand] = hand_pose;
builder_.SetPoseValid(hand, hand_pose.grip_valid);
// Analog inputs. XrActionState{Float,Vector2f} carry the value in
// currentState and validity in isActive.
auto get_info =
XrInfo<XrActionStateGetInfo>(XR_TYPE_ACTION_STATE_GET_INFO);
get_info.subactionPath = subaction;
get_info.action = impl_->trigger;
XrActionStateFloat trigger_state =
XrInfo<XrActionStateFloat>(XR_TYPE_ACTION_STATE_FLOAT);
if (XR_SUCCEEDED(
xrGetActionStateFloat(session, &get_info, &trigger_state)) &&
trigger_state.isActive) {
hand_active = true;
builder_.SetAxis(hand, ControllerAxis::kTrigger,
trigger_state.currentState, true);
}
get_info.action = impl_->squeeze;
XrActionStateFloat squeeze_state =
XrInfo<XrActionStateFloat>(XR_TYPE_ACTION_STATE_FLOAT);
if (XR_SUCCEEDED(
xrGetActionStateFloat(session, &get_info, &squeeze_state)) &&
squeeze_state.isActive) {
hand_active = true;
builder_.SetAxis(hand, ControllerAxis::kSqueeze,
squeeze_state.currentState, true);
}
get_info.action = impl_->stick;
XrActionStateVector2f stick_state =
XrInfo<XrActionStateVector2f>(XR_TYPE_ACTION_STATE_VECTOR2F);
if (XR_SUCCEEDED(
xrGetActionStateVector2f(session, &get_info, &stick_state)) &&
stick_state.isActive) {
hand_active = true;
builder_.SetAxis(hand, ControllerAxis::kThumbstickX,
stick_state.currentState.x, true);
builder_.SetAxis(hand, ControllerAxis::kThumbstickY,
stick_state.currentState.y, true);
}
for (std::size_t i = 0;
i < static_cast<std::size_t>(ControllerTouch::kCount); ++i) {
get_info.action = impl_->touches[i];
auto state = XrInfo<XrActionStateBoolean>(XR_TYPE_ACTION_STATE_BOOLEAN);
const bool available =
XR_SUCCEEDED(xrGetActionStateBoolean(session, &get_info, &state)) &&
state.isActive;
builder_.SetTouch(hand, static_cast<ControllerTouch>(i),
state.currentState, available);
}
// Boolean inputs.
const std::pair<XrAction, ControllerButton> booleans[] = {
{impl_->trigger_click, ControllerButton::kTrigger},
{impl_->squeeze_click, ControllerButton::kSqueeze},
{impl_->stick_click, ControllerButton::kThumbstick},
{impl_->face_a, ControllerButton::kFaceSouth},
{impl_->face_b, ControllerButton::kFaceEast},
{impl_->face_x, ControllerButton::kFaceWest},
{impl_->face_y, ControllerButton::kFaceNorth},
{impl_->menu, ControllerButton::kMenu},
};
bool native_trigger_click = false;
bool native_squeeze_click = false;
for (const auto &entry : booleans) {
get_info.action = entry.first;
XrActionStateBoolean state =
XrInfo<XrActionStateBoolean>(XR_TYPE_ACTION_STATE_BOOLEAN);
if (!XR_SUCCEEDED(
xrGetActionStateBoolean(session, &get_info, &state)) ||
!state.isActive) {
continue;
}
hand_active = true;
const bool pressed = state.currentState != XR_FALSE;
builder_.SetButton(hand, entry.second, pressed, true);
if (entry.first == impl_->squeeze_click)
native_squeeze_click = true;
if (entry.first == impl_->trigger_click) {
native_trigger_click = true;
}
}
// Native VR has analog shoulder channels as well as trigger channels.
// Only click-only profiles (Vive) need a synthesized 0/1 squeeze value.
if (native_squeeze_click && !squeeze_state.isActive)
builder_.SetAxis(
hand, ControllerAxis::kSqueeze,
builder_.snapshot().hands[hand].buttons[static_cast<std::size_t>(
ControllerButton::kSqueeze)]
? 1.f
: 0.f,
true);
if (native_trigger_click && !trigger_state.isActive)
builder_.SetAxis(
hand, ControllerAxis::kTrigger,
builder_.snapshot()
.hands[hand]
.buttons[static_cast<size_t>(ControllerButton::kTrigger)]
? 1.f
: 0.f,
true);
if (!native_squeeze_click)
builder_.SetButton(hand, ControllerButton::kSqueeze,
builder_.AnalogClick(hand, ControllerAxis::kSqueeze),
true);
builder_.SetConnected(hand, hand_active);
impl_->native_trigger_click[hand] = native_trigger_click;
// Profiles without a trigger click action (Touch, Vive) derive the click
// from the analog value with hysteresis; the native analog value itself
// is always preserved.
if (!native_trigger_click) {
builder_.SetButton(hand, ControllerButton::kTrigger,
builder_.AnalogClick(hand, ControllerAxis::kTrigger),
true);
}
}
// One coherent snapshot for the frame; edges fire exactly once here.
std::vector<ControllerStateBuilder::Edge> edges;
snapshot_ = builder_.Publish(frame, session_generation_, &edges);
ControllerDelivery delivery;
delivery.frame = frame;
delivery.session_generation = session_generation_;
for (int hand = 0; hand < 2; ++hand) {
for (size_t i = 0; i < static_cast<size_t>(ControllerButton::kCount); ++i)
delivery.button_levels[hand][i] = snapshot_.hands[hand].buttons[i];
delivery.connected[hand] = snapshot_.hands[hand].connected;
delivery.connection_changed[hand] =
delivery.connected[hand] != last_delivered_connected_[hand];
last_delivered_connected_[hand] = delivery.connected[hand];
delivery.axes_valid[hand] = snapshot_.hands[hand].connected;
const auto &axes = snapshot_.hands[hand].axes;
const int stick_x =
ControllerAxisToSdlOrdinal(hand, ControllerAxis::kThumbstickX);
const int stick_y =
ControllerAxisToSdlOrdinal(hand, ControllerAxis::kThumbstickY);
const int trigger =
ControllerAxisToSdlOrdinal(hand, ControllerAxis::kTrigger);
delivery.axes[stick_x] =
axes[static_cast<std::size_t>(ControllerAxis::kThumbstickX)];
delivery.axes[stick_y] =
-axes[static_cast<std::size_t>(ControllerAxis::kThumbstickY)];
delivery.axes[trigger] =
axes[static_cast<std::size_t>(ControllerAxis::kTrigger)];
}
for (const auto &edge : edges) {
if (delivery.edges.size() >= ControllerDelivery::kMaxEdges) {
// Bounded growth: remaining edges are dropped, but a level resync is
// demanded so the consumer converges and nothing sticks down.
delivery.resync = true;
break;
}
delivery.edges.push_back(
ControllerDelivery::ButtonEdge{edge.hand, edge.button, edge.pressed});
}
delivery_queue_.Push(std::move(delivery));
// Apply queued haptic requests inside this XR call window.
{
std::lock_guard<std::mutex> lock(impl_->haptic_mutex);
for (int hand = 0; hand < 2; ++hand) {
auto &request = impl_->haptics[hand];
if (!snapshot_.hands[hand].connected) {
request = {};
request.pending = true;
request.stop = true;
}
if (!request.pending) {
continue;
}
request.pending = false;
auto haptic_info =
XrInfo<XrHapticActionInfo>(XR_TYPE_HAPTIC_ACTION_INFO);
haptic_info.action = impl_->haptic;
haptic_info.subactionPath =
hand == 0 ? impl_->subaction_left : impl_->subaction_right;
if (request.stop || !snapshot_.hands[hand].connected) {
// Never vibrate a disconnected controller.
(void)xrStopHapticFeedback(session, &haptic_info);
continue;
}
auto vibration = XrInfo<XrHapticVibration>(XR_TYPE_HAPTIC_VIBRATION);
vibration.amplitude = request.amplitude;
vibration.duration = request.duration_ns == 0
? XR_MIN_HAPTIC_DURATION
: static_cast<XrDuration>(std::min<uint64_t>(
request.duration_ns, INT64_MAX));
// 0 lets the runtime choose its resonant frequency; the runtime
// clamps unsupported frequencies itself.
vibration.frequency = request.frequency_hz;
(void)xrApplyHapticFeedback(
session, &haptic_info,
reinterpret_cast<const XrHapticBaseHeader *>(&vibration));
}
}
return true;
} catch (const std::exception &error) {
Log(" [vr-input] action sync failed: %s\n", error.what());
ResetInput();
return false;
}
}
} // namespace mocktail::vr
+53
View File
@@ -0,0 +1,53 @@
// Inert XrActions implementation for non-VR builds. The public header is
// OpenXR-free (opaque Impl pointer), so these definitions let OpenXrBackend
// hold an XrActions member and link without the OpenXR SDK. Every entry point
// is a no-op: a non-VR build has no session, no runtime and no controllers.
#include "mocktail/vr/xr_actions.h"
namespace mocktail::vr {
struct XrActions::Impl {};
XrActions::~XrActions() = default;
Status XrActions::Create(void *) {
return Status::Error(StatusCode::kUnavailable,
"OpenXR support is disabled in this build");
}
Status XrActions::Attach(void *, void *, void *) {
return Status::Error(StatusCode::kUnavailable,
"OpenXR support is disabled in this build");
}
void XrActions::ResetInput() {}
void XrActions::Detach() { attached_ = false; }
void XrActions::Destroy() {
attached_ = false;
created_ = false;
snapshot_ = ControllerSnapshot{};
hands_[0] = VrHandPose{};
hands_[1] = VrHandPose{};
delivery_queue_.Clear();
last_delivered_connected_[0] = false;
last_delivered_connected_[1] = false;
session_generation_ = 0;
}
bool XrActions::Sync(void *, std::uint64_t, std::uint64_t, std::uint64_t) {
return false;
}
bool XrActions::TakeDelivery(ControllerDelivery *) { return false; }
void XrActions::RequestHaptics(int, float, std::uint64_t, float) {}
void XrActions::StopHaptics(int) {}
void XrActions::NoteProfileChanged() {}
std::string XrActions::ActiveProfile(int) const { return {}; }
} // namespace mocktail::vr
+376
View File
@@ -0,0 +1,376 @@
#include "mocktail/vr/xr_controller.h"
#include <algorithm>
#include <cmath>
namespace mocktail::vr {
namespace {
constexpr std::size_t kButtons =
static_cast<std::size_t>(ControllerButton::kCount);
constexpr std::size_t kAxes = static_cast<std::size_t>(ControllerAxis::kCount);
bool IsValidHand(int hand) { return hand == 0 || hand == 1; }
} // namespace
ControllerStateBuilder::ControllerStateBuilder(AnalogClickThresholds thresholds)
: thresholds_(thresholds) {}
void ControllerStateBuilder::SetTouch(int hand, ControllerTouch touch,
bool value, bool active) {
const auto i = static_cast<std::size_t>(touch);
if (!IsValidHand(hand) ||
i >= static_cast<std::size_t>(ControllerTouch::kCount))
return;
snapshot_.hands[hand].touches[i] = active && value;
snapshot_.hands[hand].touch_active[i] = active;
}
void ControllerStateBuilder::SetButton(int hand, ControllerButton button,
bool value, bool active) {
if (!IsValidHand(hand)) {
return;
}
const std::size_t index = static_cast<std::size_t>(button);
if (index >= kButtons) {
return;
}
snapshot_.hands[hand].buttons[index] = active && value;
}
void ControllerStateBuilder::SetAxis(int hand, ControllerAxis axis, float value,
bool active) {
if (!IsValidHand(hand)) {
return;
}
const std::size_t index = static_cast<std::size_t>(axis);
if (index >= kAxes) {
return;
}
if (!active || !std::isfinite(value)) {
snapshot_.hands[hand].axes[index] = 0.f;
return;
}
// Clamp to the contract range; runtimes may overshoot slightly on analog
// controls and the guest expects normalized values.
if (axis == ControllerAxis::kTrigger || axis == ControllerAxis::kSqueeze) {
snapshot_.hands[hand].axes[index] = std::clamp(value, 0.f, 1.f);
} else {
snapshot_.hands[hand].axes[index] = std::clamp(value, -1.f, 1.f);
}
}
void ControllerStateBuilder::SetConnected(int hand, bool connected) {
if (!IsValidHand(hand)) {
return;
}
snapshot_.hands[hand].connected = connected;
if (!connected) {
snapshot_.hands[hand].touches = {};
snapshot_.hands[hand].touch_active = {};
analog_latch_[hand * 2] = false;
analog_latch_[hand * 2 + 1] = false;
}
}
void ControllerStateBuilder::SetProfile(int hand, std::string profile) {
if (!IsValidHand(hand)) {
return;
}
snapshot_.hands[hand].profile = std::move(profile);
}
void ControllerStateBuilder::SetPoseValid(int hand, bool valid) {
if (!IsValidHand(hand)) {
return;
}
snapshot_.hands[hand].pose_valid = valid;
}
bool ControllerStateBuilder::AnalogClick(int hand, ControllerAxis axis) {
if (!IsValidHand(hand) ||
(axis != ControllerAxis::kTrigger && axis != ControllerAxis::kSqueeze)) {
return false;
}
const float value =
snapshot_.hands[hand].axes[static_cast<std::size_t>(axis)];
const std::size_t axis_index = static_cast<std::size_t>(axis);
// Only two analog controls are click-derivable; index them 0/1.
const std::size_t latch_index = static_cast<std::size_t>(hand) * 2 +
(axis == ControllerAxis::kTrigger ? 0u : 1u);
const bool latched = analog_latch_[latch_index];
const bool next =
latched ? value > thresholds_.release : value >= thresholds_.press;
analog_latch_[latch_index] = next;
static_cast<void>(axis_index);
return next;
}
ControllerSnapshot ControllerStateBuilder::Publish(std::uint64_t frame,
std::uint64_t generation,
std::vector<Edge> *edges) {
snapshot_.frame = frame;
snapshot_.session_generation = generation;
// Derive click levels for analog controls whose profile has no click action,
// then emit exactly one edge per transition. published_buttons_ starts
// all-false, so the first publish legitimately reports already-held buttons
// as press edges; later publishes report only real transitions, so repeated
// native getters reading the snapshot never see duplicate edges.
for (int hand = 0; hand < 2; ++hand) {
for (std::size_t index = 0; index < kButtons; ++index) {
const bool level = snapshot_.hands[hand].buttons[index];
const bool previous = published_buttons_[hand][index];
if (level != previous && edges != nullptr) {
edges->push_back(
Edge{hand, static_cast<ControllerButton>(index), level});
}
published_buttons_[hand][index] = level;
}
}
have_published_ = true;
return snapshot_;
}
void ControllerStateBuilder::ReleaseAll(std::uint64_t frame,
std::uint64_t generation,
std::vector<Edge> *edges) {
snapshot_.frame = frame;
snapshot_.session_generation = generation;
for (int hand = 0; hand < 2; ++hand) {
snapshot_.hands[hand].touches = {};
snapshot_.hands[hand].touch_active = {};
for (std::size_t index = 0; index < kAxes; ++index) {
snapshot_.hands[hand].axes[index] = 0.f;
}
for (std::size_t index = 0; index < kButtons; ++index) {
if (snapshot_.hands[hand].buttons[index] && edges != nullptr) {
edges->push_back(
Edge{hand, static_cast<ControllerButton>(index), false});
}
snapshot_.hands[hand].buttons[index] = false;
published_buttons_[hand][index] = false;
}
// Connection and pose validity are properties of the runtime state, not of
// the press levels; callers set them explicitly around this call.
}
analog_latch_.fill(false);
have_published_ = true;
}
int32_t ControllerButtonToAndroidKey(int hand, ControllerButton button) {
// Android KEYCODE_BUTTON_* values already verified end-to-end by the
// existing physical-gamepad route (MapSdlGamepadButtonToAndroid). The
// per-hand squeeze/thumbstick map onto the shoulder/thumb identifiers the
// guest already accepts; the right hand uses the R-side variants.
const bool right = hand == 1;
switch (button) {
case ControllerButton::kFaceSouth:
return 96; // BUTTON_A
case ControllerButton::kFaceEast:
return 97; // BUTTON_B
case ControllerButton::kFaceWest:
return 99; // BUTTON_X
case ControllerButton::kFaceNorth:
return 100; // BUTTON_Y
case ControllerButton::kSqueeze:
return right ? 103 : 102; // R1 / L1
case ControllerButton::kThumbstick:
return right ? 107 : 106; // THUMB_R / L
case ControllerButton::kMenu:
return 108; // BUTTON_START
case ControllerButton::kTrigger:
return -1; // analog click, no keycode
case ControllerButton::kCount:
break;
}
return -1;
}
int32_t ControllerAxisToAndroidAxis(int hand, ControllerAxis axis) {
// Android MotionEvent AXIS_* values from kAndroidAxes in the verified
// gamepad route: {AXIS_X=0, AXIS_Y=1, AXIS_Z=11, AXIS_RZ=14,
// AXIS_LTRIGGER=17, AXIS_RTRIGGER=18}.
switch (axis) {
case ControllerAxis::kThumbstickX:
return hand == 1 ? 11 : 0; // Z / X
case ControllerAxis::kThumbstickY:
return hand == 1 ? 14 : 1; // RZ / Y
case ControllerAxis::kTrigger:
return hand == 1 ? 18 : 17; // R / L trigger
case ControllerAxis::kSqueeze:
return -1; // squeeze has no analog axis id
case ControllerAxis::kCount:
break;
}
return -1;
}
int32_t ControllerAxisComponentCount(ControllerAxis axis) {
switch (axis) {
case ControllerAxis::kThumbstickX:
case ControllerAxis::kThumbstickY:
return 2; // sticks are delivered as an (x, y) motion pair
case ControllerAxis::kTrigger:
return 1;
default:
return 0;
}
}
int32_t ControllerButtonToSdlOrdinal(int hand, ControllerButton button) {
// SDL_GamepadButton ordinals (SDL3): SOUTH=0, EAST=1, WEST=2, NORTH=3,
// BACK=4, START=6, LEFT_STICK=7, RIGHT_STICK=8, LEFT_SHOULDER=9,
// RIGHT_SHOULDER=10. The router converts these to the verified Android
// keycodes; XR delivery must not bypass that conversion.
switch (button) {
case ControllerButton::kFaceSouth:
return 0;
case ControllerButton::kFaceEast:
return 1;
case ControllerButton::kFaceWest:
return 2;
case ControllerButton::kFaceNorth:
return 3;
case ControllerButton::kThumbstick:
return hand == 1 ? 8 : 7;
case ControllerButton::kSqueeze:
return hand == 1 ? 10 : 9;
case ControllerButton::kMenu:
return 6;
case ControllerButton::kTrigger:
return -1; // analog click, no button
case ControllerButton::kCount:
break;
}
return -1;
}
int32_t ControllerAxisToSdlOrdinal(int hand, ControllerAxis axis) {
// SDL_GamepadAxis ordinals (SDL3): LEFTX=0, LEFTY=1, RIGHTX=2, RIGHTY=3,
// TRIGGERLEFT=4, TRIGGERRIGHT=5.
switch (axis) {
case ControllerAxis::kThumbstickX:
return hand == 1 ? 2 : 0;
case ControllerAxis::kThumbstickY:
return hand == 1 ? 3 : 1;
case ControllerAxis::kTrigger:
return hand == 1 ? 5 : 4;
case ControllerAxis::kSqueeze:
return -1; // delivered as a button only
case ControllerAxis::kCount:
break;
}
return -1;
}
bool EncodeNativeControllerChannels(const ControllerSnapshot &snapshot,
float (&channels)[28]) {
std::fill(std::begin(channels), std::end(channels), 0.f);
for (int hand = 0; hand < 2; ++hand) {
const auto &input = snapshot.hands[hand];
if (!input.connected)
continue;
auto button = [&](ControllerButton b) {
return input.buttons[static_cast<std::size_t>(b)] ? 1.f : 0.f;
};
const int base = hand * 9;
channels[base] = button(ControllerButton::kThumbstick);
channels[base + 1] =
input.axes[static_cast<std::size_t>(ControllerAxis::kThumbstickX)];
channels[base + 2] =
input.axes[static_cast<std::size_t>(ControllerAxis::kThumbstickY)];
channels[base + 3] =
input.axes[static_cast<std::size_t>(ControllerAxis::kTrigger)];
channels[base + 4] =
input.axes[static_cast<std::size_t>(ControllerAxis::kSqueeze)];
channels[hand == 0 ? 24 : 22] = button(
hand == 0 ? ControllerButton::kFaceWest : ControllerButton::kFaceSouth);
channels[hand == 0 ? 25 : 23] = button(
hand == 0 ? ControllerButton::kFaceNorth : ControllerButton::kFaceEast);
channels[27] = std::max(channels[27], button(ControllerButton::kMenu));
}
return snapshot.hands[0].connected || snapshot.hands[1].connected;
}
void EncodeControllerDelivery(const ControllerDelivery &delivery,
MocktailVrControllerDelivery *out) {
if (!out)
return;
*out = MocktailVrControllerDelivery{};
out->frame = delivery.frame;
out->resync = delivery.resync ? 1 : 0;
out->session_generation = delivery.session_generation;
const std::size_t button_count =
static_cast<std::size_t>(mocktail::vr::ControllerButton::kCount);
for (int hand = 0; hand < 2; ++hand) {
out->connected[hand] = delivery.connected[hand] ? 1 : 0;
out->connection_changed[hand] = delivery.connection_changed[hand] ? 1 : 0;
out->axes_valid[hand] = delivery.axes_valid[hand] ? 1 : 0;
for (std::size_t index = 0;
index < button_count &&
index < static_cast<std::size_t>(MOCKTAIL_VR_CONTROLLER_BUTTON_COUNT);
++index) {
out->button_levels[hand][index] =
delivery.button_levels[hand][index] ? 1 : 0;
}
}
for (int axis = 0; axis < MOCKTAIL_VR_CONTROLLER_AXIS_COUNT; ++axis) {
out->axes[axis] = delivery.axes[axis];
}
const std::size_t edges = std::min<std::size_t>(
delivery.edges.size(), MOCKTAIL_VR_CONTROLLER_MAX_EDGES);
out->edge_count = static_cast<int32_t>(edges);
for (std::size_t index = 0; index < edges; ++index) {
out->edges[index].hand = delivery.edges[index].hand;
out->edges[index].button =
static_cast<int32_t>(delivery.edges[index].button);
out->edges[index].pressed = delivery.edges[index].pressed ? 1 : 0;
}
}
void ControllerDeliveryQueue::Push(ControllerDelivery delivery) {
std::lock_guard<std::mutex> lock(mutex_);
if (overflowed_) {
// The next delivered frame must re-sync full levels because intermediate
// edges were dropped with the discarded frames.
delivery.resync = true;
overflowed_ = false;
}
if (queue_.size() >= kCapacity) {
queue_.pop_front();
++dropped_frames_;
overflowed_ = true;
// Edges inside the dropped frame are lost; converge through resync on the
// newest frame so nothing can stick down.
delivery.resync = true;
}
queue_.push_back(std::move(delivery));
}
bool ControllerDeliveryQueue::Pop(ControllerDelivery *out) {
if (out == nullptr) {
return false;
}
std::lock_guard<std::mutex> lock(mutex_);
if (queue_.empty()) {
return false;
}
*out = std::move(queue_.front());
queue_.pop_front();
return true;
}
std::size_t ControllerDeliveryQueue::size() const {
std::lock_guard<std::mutex> lock(mutex_);
return queue_.size();
}
void ControllerDeliveryQueue::Clear() {
std::lock_guard<std::mutex> lock(mutex_);
queue_.clear();
overflowed_ = false;
}
} // namespace mocktail::vr
+11
View File
@@ -36,6 +36,9 @@ 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(xr_controller_test vr/xr_controller_test.cc)
target_link_libraries(xr_controller_test PRIVATE Mocktail::VR GTest::gtest_main)
mocktail_apply_compile_options(xr_controller_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)
@@ -947,6 +950,7 @@ 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(xr_controller_test)
gtest_discover_tests(jnivm_test)
gtest_discover_tests(runtime_config_test)
gtest_discover_tests(runtime_config_file_test)
@@ -1135,3 +1139,10 @@ add_test(
${CMAKE_BINARY_DIR}
)
endif()
if(MOCKTAIL_ENABLE_VR)
add_executable(xr_actions_test vr/xr_actions_test.cc)
target_link_libraries(xr_actions_test PRIVATE Mocktail::InputRuntime GTest::gtest_main)
target_include_directories(xr_actions_test PRIVATE "${CMAKE_SOURCE_DIR}/third_party/OpenXR-SDK/include")
gtest_discover_tests(xr_actions_test)
endif()
+295
View File
@@ -0,0 +1,295 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <cstring>
#include <deque>
#include <variant>
#include <vector>
#include "mocktail/platform/platform_runtime.h"
#include "mocktail/vr/xr_controller_abi.h"
#include "runtime/roblox_xr_controller_input.h"
namespace mocktail {
namespace runtime {
namespace {
using platform::GamepadAxisEvent;
using platform::GamepadButtonEvent;
using platform::GamepadConnectionEvent;
using platform::PlatformEvent;
// The XR controller ABI is context-free (resolved through dlsym in
// production), so the test doubles live at file scope exactly like the real
// exported symbols would.
struct FakeBackend {
std::deque<MocktailVrControllerDelivery> queue;
int haptic_calls = 0;
int stop_calls = 0;
float last_amplitude = 0.f;
std::uint64_t last_duration_ns = 0;
int last_hand = -1;
} g_backend;
std::vector<PlatformEvent> g_events;
int32_t FakePop(MocktailVrControllerDelivery *out) {
if (g_backend.queue.empty()) {
return 0;
}
*out = g_backend.queue.front();
g_backend.queue.pop_front();
return 1;
}
void FakeHaptics(int32_t hand, float amplitude, std::uint64_t duration_ns,
float) {
g_backend.haptic_calls++;
g_backend.last_hand = hand;
g_backend.last_amplitude = amplitude;
g_backend.last_duration_ns = duration_ns;
}
void FakeStop(int32_t hand) {
g_backend.stop_calls++;
g_backend.last_hand = hand;
}
void FakeSink(void *, const PlatformEvent &event) { g_events.push_back(event); }
template <typename T> std::vector<T> Collect() {
std::vector<T> out;
for (const auto &event : g_events) {
if (const auto *value = std::get_if<T>(&event.payload)) {
out.push_back(*value);
}
}
return out;
}
MocktailVrControllerDelivery MakeDelivery() {
MocktailVrControllerDelivery delivery{};
delivery.connected[0] = 1;
delivery.connected[1] = 1;
delivery.axes_valid[0] = 1;
delivery.axes_valid[1] = 1;
return delivery;
}
class XrControllerInputTest : public ::testing::Test {
protected:
void SetUp() override {
g_backend = FakeBackend{};
g_events.clear();
ASSERT_TRUE(input_.InitializeWithAbi(&FakeSink, nullptr, &FakePop,
&FakeHaptics, &FakeStop));
ASSERT_TRUE(input_.active());
}
RobloxXrControllerInput input_;
};
TEST_F(XrControllerInputTest, ConnectsOnceAndMergesBothHandsIntoOneGamepad) {
auto delivery = MakeDelivery();
delivery.frame = 1;
// Left hand presses X (face west), right hand presses A (face south).
delivery.button_levels[0][MOCKTAIL_VR_BUTTON_FACE_WEST] = 1;
delivery.button_levels[1][MOCKTAIL_VR_BUTTON_FACE_SOUTH] = 1;
delivery.edge_count = 2;
delivery.edges[0] = {0, MOCKTAIL_VR_BUTTON_FACE_WEST, 1};
delivery.edges[1] = {1, MOCKTAIL_VR_BUTTON_FACE_SOUTH, 1};
g_backend.queue.push_back(delivery);
input_.Drain();
const auto connections = Collect<GamepadConnectionEvent>();
ASSERT_EQ(connections.size(), 1u);
EXPECT_TRUE(connections[0].connected);
EXPECT_EQ(connections[0].instance_id, RobloxXrControllerInput::kInstanceLeft);
const auto buttons = Collect<GamepadButtonEvent>();
ASSERT_EQ(buttons.size(), 2u);
EXPECT_EQ(buttons[0].instance_id, RobloxXrControllerInput::kInstanceLeft);
EXPECT_TRUE(buttons[0].pressed);
EXPECT_TRUE(buttons[1].pressed);
EXPECT_NE(buttons[0].button, buttons[1].button);
}
TEST_F(XrControllerInputTest,
ResyncDoesNotCancelLeftHandWithUnboundRightButton) {
auto d = MakeDelivery();
d.resync = 1;
d.button_levels[0][MOCKTAIL_VR_BUTTON_FACE_WEST] = 1;
g_backend.queue.push_back(d);
input_.Drain();
auto buttons = Collect<GamepadButtonEvent>();
bool west = false;
for (const auto &b : buttons)
if (b.button == 2)
west = b.pressed;
EXPECT_TRUE(west);
}
TEST_F(XrControllerInputTest, SharedMenuReleasesOnlyAfterBothHandsRelease) {
auto d = MakeDelivery();
d.button_levels[0][MOCKTAIL_VR_BUTTON_MENU] = 1;
d.button_levels[1][MOCKTAIL_VR_BUTTON_MENU] = 1;
g_backend.queue.push_back(d);
input_.Drain();
g_events.clear();
d.button_levels[0][MOCKTAIL_VR_BUTTON_MENU] = 0;
g_backend.queue.push_back(d);
input_.Drain();
EXPECT_TRUE(Collect<GamepadButtonEvent>().empty());
d.button_levels[1][MOCKTAIL_VR_BUTTON_MENU] = 0;
g_backend.queue.push_back(d);
input_.Drain();
auto buttons = Collect<GamepadButtonEvent>();
ASSERT_EQ(buttons.size(), 1u);
EXPECT_FALSE(buttons[0].pressed);
}
TEST_F(XrControllerInputTest, NewGenerationDisconnectsAndRejectsLateOldFrames) {
auto d = MakeDelivery();
d.session_generation = 1;
g_backend.queue.push_back(d);
input_.Drain();
g_events.clear();
d.session_generation = 2;
g_backend.queue.push_back(d);
input_.Drain();
auto connections = Collect<GamepadConnectionEvent>();
ASSERT_EQ(connections.size(), 2u);
EXPECT_FALSE(connections[0].connected);
EXPECT_TRUE(connections[1].connected);
g_events.clear();
d.session_generation = 1;
g_backend.queue.push_back(d);
input_.Drain();
EXPECT_TRUE(g_events.empty());
}
TEST_F(XrControllerInputTest, AxesDeliveredAsRawInt16ForSingleDeadzone) {
auto delivery = MakeDelivery();
delivery.frame = 1;
delivery.axes[0] = -1.0f; // LEFTX
delivery.axes[5] = 0.5f; // TRIGGERRIGHT
g_backend.queue.push_back(delivery);
input_.Drain();
const auto axes = Collect<GamepadAxisEvent>();
ASSERT_FALSE(axes.empty());
bool saw_left_x = false, saw_right_trigger = false;
for (const auto &axis : axes) {
EXPECT_EQ(axis.instance_id, RobloxXrControllerInput::kInstanceLeft);
if (axis.axis == 0) {
EXPECT_EQ(axis.value, -32767);
saw_left_x = true;
}
if (axis.axis == 5) {
EXPECT_NEAR(axis.value, 16384, 2);
saw_right_trigger = true;
}
}
EXPECT_TRUE(saw_left_x);
EXPECT_TRUE(saw_right_trigger);
}
TEST_F(XrControllerInputTest, DisconnectReleasesAndStopsFurtherDelivery) {
auto connected = MakeDelivery();
connected.frame = 1;
connected.button_levels[1][MOCKTAIL_VR_BUTTON_FACE_SOUTH] = 1;
connected.edge_count = 1;
connected.edges[0] = {1, MOCKTAIL_VR_BUTTON_FACE_SOUTH, 1};
g_backend.queue.push_back(connected);
input_.Drain();
ASSERT_TRUE(input_.connected());
auto disconnected = MakeDelivery();
disconnected.frame = 2;
disconnected.connected[0] = 0;
disconnected.connected[1] = 0;
g_backend.queue.push_back(disconnected);
input_.Drain();
const auto connections = Collect<GamepadConnectionEvent>();
ASSERT_EQ(connections.size(), 2u);
EXPECT_TRUE(connections[0].connected);
EXPECT_FALSE(connections[1].connected);
EXPECT_FALSE(input_.connected());
}
TEST_F(XrControllerInputTest, ResyncReDeliversEveryHeldLevel) {
auto delivery = MakeDelivery();
delivery.frame = 5;
delivery.resync = 1;
// Right A held, left stick click held.
delivery.button_levels[1][MOCKTAIL_VR_BUTTON_FACE_SOUTH] = 1;
delivery.button_levels[0][MOCKTAIL_VR_BUTTON_THUMBSTICK] = 1;
g_backend.queue.push_back(delivery);
input_.Drain();
EXPECT_EQ(input_.resyncs(), 1u);
const auto buttons = Collect<GamepadButtonEvent>();
bool saw_face_south = false, saw_thumbstick = false;
for (const auto &button : buttons) {
if (!button.pressed) {
continue;
}
if (button.button == 0)
saw_face_south = true; // SOUTH ordinal 0
if (button.button == 7)
saw_thumbstick = true; // LEFT_STICK ordinal 7
}
EXPECT_TRUE(saw_face_south);
EXPECT_TRUE(saw_thumbstick);
}
TEST_F(XrControllerInputTest, DrainIsBoundedPerPump) {
for (int index = 0; index < 10; ++index) {
auto delivery = MakeDelivery();
delivery.frame = static_cast<std::uint64_t>(index);
g_backend.queue.push_back(delivery);
}
input_.Drain();
// kMaxDrainPerPump is 4, so at least 6 batches remain queued.
EXPECT_GE(g_backend.queue.size(), 6u);
}
TEST_F(XrControllerInputTest, HapticsForwardedAndStopOnRequest) {
input_.RequestHaptics(1, 0.75f, 120000000ULL, 160.f);
EXPECT_EQ(g_backend.haptic_calls, 1);
EXPECT_EQ(g_backend.last_hand, 1);
EXPECT_FLOAT_EQ(g_backend.last_amplitude, 0.75f);
EXPECT_EQ(g_backend.last_duration_ns, 120000000ULL);
input_.StopHaptics(0);
EXPECT_EQ(g_backend.stop_calls, 1);
EXPECT_EQ(g_backend.last_hand, 0);
// Invalid hand indices are ignored.
input_.RequestHaptics(5, 1.f, 0, 0.f);
EXPECT_EQ(g_backend.haptic_calls, 1);
}
TEST_F(XrControllerInputTest, InertWithoutPopFunction) {
RobloxXrControllerInput inert;
EXPECT_FALSE(
inert.InitializeWithAbi(&FakeSink, nullptr, nullptr, nullptr, nullptr));
EXPECT_FALSE(inert.active());
inert.Drain(); // safe no-op
EXPECT_TRUE(g_events.empty());
}
TEST_F(XrControllerInputTest, ShutdownDisconnectsSyntheticController) {
auto delivery = MakeDelivery();
delivery.frame = 1;
g_backend.queue.push_back(delivery);
input_.Drain();
ASSERT_TRUE(input_.connected());
input_.Shutdown();
const auto connections = Collect<GamepadConnectionEvent>();
ASSERT_FALSE(connections.empty());
EXPECT_FALSE(connections.back().connected);
EXPECT_FALSE(input_.connected());
}
} // namespace
} // namespace runtime
} // namespace mocktail
+145
View File
@@ -501,3 +501,148 @@ TEST(VrPoseBridge, InvalidTrackingAndNonfiniteValuesNeverBecomeValid) {
EXPECT_FALSE(internal::ApplyXrPose(state.data(), pose));
}
} // namespace mocktail::vr
namespace mocktail::vr {
TEST(VrHandPoseBridge, ValidGripPosesFillBothHandRecordsInMetres) {
std::array<std::uint8_t, internal::kStateCopySize> state;
state.fill(0xa5);
ScriptedPoseSample pose;
pose.valid = true;
pose.orientation[3] = 1.f;
pose.hands[0].grip_valid = true;
pose.hands[0].grip_position[0] = -0.25f;
pose.hands[0].grip_position[1] = 1.0f;
pose.hands[0].grip_orientation[1] = std::sin(0.3f);
pose.hands[0].grip_orientation[3] = std::cos(0.3f);
pose.hands[1].grip_valid = true;
pose.hands[1].grip_position[0] = 0.25f;
pose.hands[1].grip_orientation[3] = 1.f;
ASSERT_TRUE(internal::ApplyXrPose(state.data(), pose));
// Left hand record at copy+0x40 (UserCFrame index 1), right at +0x60 (2).
EXPECT_EQ(state[internal::kLeftHandRecordOffset], 1);
EXPECT_EQ(state[internal::kRightHandRecordOffset], 1);
// The unused extra record (index 3) must stay invalid.
EXPECT_EQ(state[internal::kExtraRecordOffset], 0);
float left_pos[3], left_ori[4], right_pos[3];
std::memcpy(left_pos, state.data() + internal::kLeftHandRecordOffset +
internal::kRecordPositionOffset, 12);
std::memcpy(left_ori, state.data() + internal::kLeftHandRecordOffset +
internal::kRecordOrientationOffset, 16);
std::memcpy(right_pos, state.data() + internal::kRightHandRecordOffset +
internal::kRecordPositionOffset, 12);
// Metres only: the guest's 0x37eacb8 applies the 10/3 stud conversion.
EXPECT_FLOAT_EQ(left_pos[0], -0.25f);
EXPECT_FLOAT_EQ(left_pos[1], 1.0f);
EXPECT_FLOAT_EQ(left_ori[1], std::sin(0.3f));
EXPECT_FLOAT_EQ(left_ori[3], std::cos(0.3f));
EXPECT_FLOAT_EQ(right_pos[0], 0.25f);
}
TEST(VrHandPoseBridge, UntrackedOrNonFiniteHandsStayInvalidWithoutTouchingHead) {
std::array<std::uint8_t, internal::kStateCopySize> state;
state.fill(0);
ScriptedPoseSample pose;
pose.valid = true;
pose.orientation[3] = 1.f;
// Left hand untracked (grip_valid false), right hand has a NaN quaternion.
pose.hands[0].grip_valid = false;
pose.hands[1].grip_valid = true;
pose.hands[1].grip_orientation[0] = std::numeric_limits<float>::quiet_NaN();
ASSERT_TRUE(internal::ApplyXrPose(state.data(), pose));
EXPECT_EQ(state[0], 1); // head still applied
EXPECT_EQ(state[internal::kLeftHandRecordOffset], 0);
EXPECT_EQ(state[internal::kRightHandRecordOffset], 0);
// A non-normalized quaternion must also be rejected.
pose.hands[1].grip_orientation[0] = 0.f;
pose.hands[1].grip_orientation[3] = 0.5f;
ASSERT_TRUE(internal::ApplyXrPose(state.data(), pose));
EXPECT_EQ(state[internal::kRightHandRecordOffset], 0);
}
TEST(VrHandPoseBridge, LosingTrackingClearsHandValidByteNotWholeRecord) {
std::array<std::uint8_t, internal::kStateCopySize> state;
state.fill(0);
ScriptedPoseSample pose;
pose.valid = true;
pose.orientation[3] = 1.f;
pose.hands[0].grip_valid = true;
pose.hands[0].grip_orientation[3] = 1.f;
ASSERT_TRUE(internal::ApplyXrPose(state.data(), pose));
ASSERT_EQ(state[internal::kLeftHandRecordOffset], 1);
// Tracking lost: valid byte clears so the guest reports untracked; the stale
// pose bytes remain but are unreachable while valid==0 (no teleport to origin).
pose.hands[0].grip_valid = false;
ASSERT_TRUE(internal::ApplyXrPose(state.data(), pose));
EXPECT_EQ(state[internal::kLeftHandRecordOffset], 0);
float stale[3];
std::memcpy(stale, state.data() + internal::kLeftHandRecordOffset +
internal::kRecordPositionOffset, 12);
EXPECT_FLOAT_EQ(stale[0], 0.f); // position was never written for this hand
}
} // namespace mocktail::vr
namespace mocktail::vr {
TEST(VrNativeController, StateCopyPublishesChannelsAndHapticsWithoutChangingReadiness) {
std::array<std::uint8_t, internal::kStateCopySize> state{};
state[0x133] = 77;
ScriptedPoseSample pose;
pose.valid = pose.controllers_connected = true;
pose.controller_channels[3] = .7f;
pose.controller_channels[11] = -1.f;
pose.controller_channels[24] = 1.f;
ASSERT_TRUE(internal::ApplyXrPose(state.data(), pose));
EXPECT_EQ(state[0x80], 1);
EXPECT_EQ(state[0x132], 1);
EXPECT_EQ(state[0x133], 77);
float trigger, y, x;
std::memcpy(&trigger, state.data() + 0x88 + 3*4, 4);
std::memcpy(&y, state.data() + 0x88 + 11*4, 4);
std::memcpy(&x, state.data() + 0x88 + 24*4, 4);
EXPECT_FLOAT_EQ(trigger, .7f);
EXPECT_FLOAT_EQ(y, -1.f);
EXPECT_FLOAT_EQ(x, 1.f);
pose.valid = false;
EXPECT_FALSE(internal::ApplyXrPose(state.data(), pose));
EXPECT_EQ(state[0x80], 0);
EXPECT_EQ(state[0x132], 0);
std::memcpy(&trigger, state.data() + 0x88 + 3*4, 4);
EXPECT_FLOAT_EQ(trigger, 0);
}
TEST(VrAimPointer, CorrectsOnlyPointerAndAppliesTranslationScaleOnce) {
float frame[12] = {1,0,0, 0,1,0, 0,0,1, 10,20,30};
VrHandPose hand;
hand.grip_valid = hand.aim_valid = true;
hand.aim_position[0] = .3f;
hand.aim_orientation[1] = std::sin(.3f);
hand.aim_orientation[3] = std::cos(.3f);
ASSERT_TRUE(internal::ApplyAimToWorldFrame(frame, hand, 2, 0));
EXPECT_NEAR(frame[9], 12, 1e-5);
EXPECT_FLOAT_EQ(frame[10], 20);
EXPECT_NEAR(frame[0], std::cos(.6f), 1e-5);
EXPECT_NEAR(frame[2], std::sin(.6f), 1e-5);
EXPECT_FLOAT_EQ(hand.grip_orientation[3], 1);
}
TEST(VrAimPointer, CancelsNativeHandPitchWithoutChangingWorldOrigin) {
const float a = -.3490658504f;
float frame[12] = {1,0,0, 0,std::cos(a),-std::sin(a), 0,std::sin(a),std::cos(a), 10,20,30};
VrHandPose hand;
hand.grip_valid = hand.aim_valid = true;
ASSERT_TRUE(internal::ApplyAimToWorldFrame(frame, hand, 1, -20));
EXPECT_NEAR(frame[0], 1, 1e-5);
EXPECT_NEAR(frame[5], 0, 1e-5);
EXPECT_NEAR(frame[8], 1, 1e-5);
EXPECT_FLOAT_EQ(frame[9], 10);
}
TEST(VrAimPointer, InvalidAimOrScaleKeepsOriginalFrame) {
const std::array<float,12> original{1,0,0, 0,1,0, 0,0,1, 10,20,30};
auto frame = original;
VrHandPose hand;
EXPECT_FALSE(internal::ApplyAimToWorldFrame(frame.data(), hand, 1, 0));
EXPECT_EQ(frame, original);
hand.grip_valid = hand.aim_valid = true;
EXPECT_FALSE(internal::ApplyAimToWorldFrame(frame.data(), hand, -1, 0));
hand.aim_orientation[0] = std::numeric_limits<float>::quiet_NaN();
EXPECT_FALSE(internal::ApplyAimToWorldFrame(frame.data(), hand, 1, 0));
EXPECT_EQ(frame, original);
}
}
+372
View File
@@ -0,0 +1,372 @@
#include "mocktail/vr/xr_actions.h"
#include "runtime/roblox_input_router.h"
#include "runtime/roblox_xr_controller_input.h"
#include <cstring>
#include <gtest/gtest.h>
#include <map>
#include <openxr/openxr.h>
#include <string>
namespace {
using namespace mocktail::vr;
template <class T> T Handle(uintptr_t n) { return reinterpret_cast<T>(n); }
std::map<XrAction, std::string> names;
std::map<std::string, XrPath> paths;
uintptr_t next_handle;
bool focused, input_active, simple, touching;
float trigger, squeeze, stick_y;
bool click;
int syncs, stops, vibrations;
XrActionSet action_set;
XrTime located_time;
float last_amplitude;
XrDuration last_duration;
XrPath last_haptic_hand;
int simple_haptic_bindings;
} // namespace
extern "C" {
XRAPI_ATTR XrResult XRAPI_CALL
xrResultToString(XrInstance, XrResult, char buffer[XR_MAX_RESULT_STRING_SIZE]) {
std::strcpy(buffer, "fake");
return XR_SUCCESS;
}
XRAPI_ATTR XrResult XRAPI_CALL xrStringToPath(XrInstance, const char *text,
XrPath *out) {
auto &p = paths[text];
if (!p)
p = ++next_handle;
*out = p;
return XR_SUCCESS;
}
XRAPI_ATTR XrResult XRAPI_CALL xrPathToString(XrInstance, XrPath path,
uint32_t cap, uint32_t *count,
char *out) {
for (auto &p : paths)
if (p.second == path) {
*count = p.first.size() + 1;
if (cap >= *count)
std::strcpy(out, p.first.c_str());
return XR_SUCCESS;
}
return XR_ERROR_PATH_INVALID;
}
XRAPI_ATTR XrResult XRAPI_CALL xrCreateActionSet(XrInstance,
const XrActionSetCreateInfo *,
XrActionSet *out) {
*out = action_set = Handle<XrActionSet>(++next_handle);
return XR_SUCCESS;
}
XRAPI_ATTR XrResult XRAPI_CALL xrDestroyActionSet(XrActionSet) {
return XR_SUCCESS;
}
XRAPI_ATTR XrResult XRAPI_CALL xrCreateAction(XrActionSet,
const XrActionCreateInfo *info,
XrAction *out) {
*out = Handle<XrAction>(++next_handle);
names[*out] = info->actionName;
return XR_SUCCESS;
}
XRAPI_ATTR XrResult XRAPI_CALL xrDestroyAction(XrAction) { return XR_SUCCESS; }
XRAPI_ATTR XrResult XRAPI_CALL xrSuggestInteractionProfileBindings(
XrInstance, const XrInteractionProfileSuggestedBinding *info) {
if (info->interactionProfile ==
paths["/interaction_profiles/khr/simple_controller"])
for (std::uint32_t i = 0; i < info->countSuggestedBindings; ++i)
if (names[info->suggestedBindings[i].action] == "haptic")
++simple_haptic_bindings;
return XR_SUCCESS;
}
XRAPI_ATTR XrResult XRAPI_CALL xrAttachSessionActionSets(
XrSession, const XrSessionActionSetsAttachInfo *info) {
EXPECT_EQ(info->countActionSets, 1u);
EXPECT_EQ(info->actionSets[0], action_set);
return XR_SUCCESS;
}
XRAPI_ATTR XrResult XRAPI_CALL
xrCreateActionSpace(XrSession, const XrActionSpaceCreateInfo *, XrSpace *out) {
*out = Handle<XrSpace>(++next_handle);
return XR_SUCCESS;
}
XRAPI_ATTR XrResult XRAPI_CALL xrDestroySpace(XrSpace) { return XR_SUCCESS; }
XRAPI_ATTR XrResult XRAPI_CALL xrSyncActions(XrSession,
const XrActionsSyncInfo *info) {
++syncs;
EXPECT_EQ(info->countActiveActionSets, 1u);
if (info->countActiveActionSets != 1 || !info->activeActionSets)
return XR_ERROR_VALIDATION_FAILURE;
EXPECT_EQ(info->activeActionSets[0].actionSet, action_set);
EXPECT_EQ(info->activeActionSets[0].subactionPath, XR_NULL_PATH);
return focused ? XR_SUCCESS : XR_SESSION_NOT_FOCUSED;
}
XRAPI_ATTR XrResult XRAPI_CALL xrGetCurrentInteractionProfile(
XrSession, XrPath, XrInteractionProfileState *out) {
return xrStringToPath(nullptr,
simple
? "/interaction_profiles/khr/simple_controller"
: "/interaction_profiles/oculus/touch_controller",
&out->interactionProfile);
}
XRAPI_ATTR XrResult XRAPI_CALL xrLocateSpace(XrSpace, XrSpace, XrTime time,
XrSpaceLocation *out) {
located_time = time;
out->locationFlags = XR_SPACE_LOCATION_POSITION_VALID_BIT |
XR_SPACE_LOCATION_ORIENTATION_VALID_BIT;
out->pose.orientation.w = 1;
out->pose.position.x = .1f;
return XR_SUCCESS;
}
XRAPI_ATTR XrResult XRAPI_CALL xrGetActionStatePose(
XrSession, const XrActionStateGetInfo *, XrActionStatePose *out) {
out->isActive = input_active;
return XR_SUCCESS;
}
XRAPI_ATTR XrResult XRAPI_CALL xrGetActionStateFloat(
XrSession, const XrActionStateGetInfo *info, XrActionStateFloat *out) {
out->isActive = input_active && !simple;
out->currentState = names[info->action] == "trigger" ? trigger : squeeze;
return XR_SUCCESS;
}
XRAPI_ATTR XrResult XRAPI_CALL xrGetActionStateVector2f(
XrSession, const XrActionStateGetInfo *, XrActionStateVector2f *out) {
out->isActive = input_active && !simple;
out->currentState = {0, stick_y};
return XR_SUCCESS;
}
XRAPI_ATTR XrResult XRAPI_CALL xrGetActionStateBoolean(
XrSession, const XrActionStateGetInfo *info, XrActionStateBoolean *out) {
out->isActive =
input_active && simple && names[info->action] == "trigger_click";
if (names[info->action] == "trigger_touch") {
out->isActive = input_active && !simple;
out->currentState = touching;
} else
out->currentState = click;
return XR_SUCCESS;
}
XRAPI_ATTR XrResult XRAPI_CALL
xrStopHapticFeedback(XrSession, const XrHapticActionInfo *) {
++stops;
return XR_SUCCESS;
}
XRAPI_ATTR XrResult XRAPI_CALL xrApplyHapticFeedback(
XrSession, const XrHapticActionInfo *info, const XrHapticBaseHeader *base) {
const auto *vibration = reinterpret_cast<const XrHapticVibration *>(base);
last_amplitude = vibration->amplitude;
last_duration = vibration->duration;
last_haptic_hand = info->subactionPath;
++vibrations;
return XR_SUCCESS;
}
}
namespace {
class XrActionsProduction : public testing::Test {
protected:
XrActions actions;
void SetUp() override {
names.clear();
paths.clear();
next_handle = 100;
focused = input_active = true;
simple = click = touching = false;
trigger = squeeze = stick_y = 0;
syncs = stops = vibrations = simple_haptic_bindings = 0;
last_amplitude = 0;
last_duration = 0;
last_haptic_hand = 0;
ASSERT_TRUE(actions.Create(Handle<void *>(1)).ok());
ASSERT_TRUE(
actions.Attach(Handle<void *>(1), Handle<void *>(2), Handle<void *>(3))
.ok());
}
bool Sync(uint64_t frame = 1) {
return actions.Sync(Handle<void *>(2), 123456, frame, 0);
}
ControllerDelivery Pop() {
ControllerDelivery d;
EXPECT_TRUE(actions.TakeDelivery(&d));
return d;
}
};
TEST_F(XrActionsProduction, HapticSamplesKeepHandLeaseAndClampAmplitude) {
EXPECT_EQ(simple_haptic_bindings, 2);
actions.RequestHaptics(1, 1.5f, 50000000, 0);
ASSERT_TRUE(Sync());
EXPECT_EQ(vibrations, 1);
EXPECT_FLOAT_EQ(last_amplitude, 1);
EXPECT_EQ(last_duration, 50000000);
EXPECT_EQ(last_haptic_hand, paths["/user/hand/right"]);
actions.StopHaptics(1);
ASSERT_TRUE(Sync(2));
EXPECT_EQ(stops, 1);
}
TEST_F(XrActionsProduction,
CapacitiveContactDoesNotPressTriggerAndResetsOnFocusLoss) {
touching = true;
ASSERT_TRUE(Sync());
const auto index = static_cast<std::size_t>(ControllerTouch::kTrigger);
EXPECT_TRUE(actions.snapshot().hands[0].touches[index]);
EXPECT_TRUE(actions.snapshot().hands[0].touch_active[index]);
EXPECT_FALSE(actions.snapshot().hands[0].buttons[static_cast<std::size_t>(
ControllerButton::kTrigger)]);
focused = false;
EXPECT_FALSE(Sync(2));
EXPECT_FALSE(actions.snapshot().hands[0].touches[index]);
EXPECT_FALSE(actions.snapshot().hands[0].touch_active[index]);
}
TEST_F(XrActionsProduction, ActivatesSetAndDeliversGripTriggerAndPose) {
trigger = .9f;
squeeze = .9f;
stick_y = 1.f;
ASSERT_TRUE(Sync());
auto d = Pop();
EXPECT_EQ(syncs, 1);
EXPECT_TRUE(
d.button_levels[0][static_cast<size_t>(ControllerButton::kSqueeze)]);
EXPECT_TRUE(
d.button_levels[1][static_cast<size_t>(ControllerButton::kSqueeze)]);
EXPECT_FLOAT_EQ(d.axes[4], .9f);
EXPECT_FLOAT_EQ(d.axes[1], -1.f);
EXPECT_TRUE(actions.hand(0).grip_valid);
EXPECT_TRUE(actions.hand(1).aim_valid);
EXPECT_EQ(located_time, 123456);
squeeze = .65f;
ASSERT_TRUE(Sync(2));
EXPECT_TRUE(Pop().button_levels[0][1]);
squeeze = .5f;
ASSERT_TRUE(Sync(3));
EXPECT_FALSE(Pop().button_levels[0][1]);
}
TEST_F(XrActionsProduction,
InactiveActionsReleaseEvenWithProfileStillAssigned) {
trigger = squeeze = 1;
ASSERT_TRUE(Sync());
Pop();
input_active = false;
ASSERT_TRUE(Sync(2));
auto d = Pop();
EXPECT_EQ(d.axes[4], 0);
EXPECT_FALSE(d.button_levels[0][1]);
EXPECT_FALSE(actions.hand(0).grip_valid);
}
TEST_F(XrActionsProduction, FocusLossDisconnectsAndStopsHaptics) {
squeeze = 1;
ASSERT_TRUE(Sync());
Pop();
actions.RequestHaptics(0, 1, 100, 0);
focused = false;
EXPECT_FALSE(Sync(2));
auto d = Pop();
EXPECT_FALSE(d.connected[0]);
EXPECT_TRUE(d.resync);
EXPECT_GT(stops, 0);
EXPECT_FALSE(actions.hand(0).grip_valid);
focused = true;
ASSERT_TRUE(Sync(3));
EXPECT_EQ(vibrations, 0);
}
TEST_F(XrActionsProduction, ResetSurvivesDetachAndNewSessionHasFreshState) {
squeeze = 1;
ASSERT_TRUE(Sync());
auto old = Pop();
actions.RequestHaptics(0, 1, 100, 0);
actions.Detach();
auto reset = Pop();
EXPECT_FALSE(reset.connected[0]);
ASSERT_TRUE(
actions.Attach(Handle<void *>(1), Handle<void *>(4), Handle<void *>(5))
.ok());
squeeze = .65f;
ASSERT_TRUE(Sync(2));
auto fresh = Pop();
EXPECT_GT(fresh.session_generation, old.session_generation);
EXPECT_FALSE(fresh.button_levels[0][1]);
EXPECT_EQ(vibrations, 0);
}
TEST_F(XrActionsProduction, SimpleSelectBecomesAnalogTrigger) {
simple = click = true;
ASSERT_TRUE(Sync());
EXPECT_FLOAT_EQ(Pop().axes[4], 1.f);
click = false;
ASSERT_TRUE(Sync(2));
EXPECT_FLOAT_EQ(Pop().axes[4], 0.f);
}
TEST_F(XrActionsProduction, QueuedLevelsBelongToTheirOwnFrames) {
squeeze = 1;
ASSERT_TRUE(Sync(1));
squeeze = 0;
ASSERT_TRUE(Sync(2));
auto first = Pop(), second = Pop();
EXPECT_EQ(first.frame, 1u);
EXPECT_TRUE(first.button_levels[0][1]);
EXPECT_EQ(second.frame, 2u);
EXPECT_FALSE(second.button_levels[0][1]);
}
XrActions *delivery_source;
int32_t DrainActions(MocktailVrControllerDelivery *out) {
ControllerDelivery d;
if (!delivery_source->TakeDelivery(&d))
return 0;
EncodeControllerDelivery(d, out);
return 1;
}
TEST_F(XrActionsProduction, RoutesActiveInputsThroughAbiAndNativeRouter) {
using namespace mocktail;
using namespace mocktail::runtime;
struct SinkState {
std::map<int, bool> buttons;
std::map<int, float> axes;
int connects = 0, disconnects = 0;
} state;
RobloxInputSink sink;
sink.gamepad = {
&state,
[](void *, int32_t, int32_t, bool, int32_t) { return Status::Ok(); },
[](void *, int32_t, int32_t, int32_t, bool, int32_t) {
return Status::Ok();
},
[](void *p, int32_t, int32_t) {
++static_cast<SinkState *>(p)->connects;
return Status::Ok();
},
[](void *p, int32_t) {
++static_cast<SinkState *>(p)->disconnects;
return Status::Ok();
},
[](void *p, int32_t, int32_t key, bool pressed) {
static_cast<SinkState *>(p)->buttons[key] = pressed;
return Status::Ok();
},
[](void *p, int32_t, int32_t axis, float x, float y, float z) {
static_cast<SinkState *>(p)->axes[axis] =
axis == 0 ? y : (axis >= 17 ? z : x);
return Status::Ok();
}};
RobloxInputRouter router(sink);
ASSERT_TRUE(router.Activate({800, 600, 800, 600, 1}, true).ok());
RobloxXrControllerInput input;
delivery_source = &actions;
ASSERT_TRUE(input.InitializeWithAbi(
[](void *p, const platform::PlatformEvent &e) {
EXPECT_TRUE(
static_cast<RobloxInputRouter *>(p)->HandleEvent(e).status.ok());
},
&router, DrainActions, nullptr, nullptr));
squeeze = trigger = stick_y = 1;
ASSERT_TRUE(Sync());
input.Drain();
EXPECT_EQ(state.connects, 1);
EXPECT_TRUE(state.buttons[102]);
EXPECT_TRUE(state.buttons[103]);
EXPECT_GT(state.axes[17], .99f);
EXPECT_GT(state.axes[0], .99f);
focused = false;
EXPECT_FALSE(Sync(2));
input.Drain();
EXPECT_EQ(state.disconnects, 1);
EXPECT_FALSE(state.buttons[102]);
EXPECT_FALSE(state.buttons[103]);
input.Shutdown();
}
} // namespace
+298
View File
@@ -0,0 +1,298 @@
#include <gtest/gtest.h>
#include <cmath>
#include "mocktail/vr/xr_controller.h"
namespace mocktail::vr {
namespace {
using Button = ControllerButton;
using Axis = ControllerAxis;
TEST(XrControllerState,
PublishEmitsOneEdgePerTransitionDespiteRepeatedGetters) {
ControllerStateBuilder builder;
std::vector<ControllerStateBuilder::Edge> edges;
builder.SetConnected(0, true);
builder.SetButton(0, Button::kFaceSouth, true, /*active=*/true);
const auto first = builder.Publish(1, 0, &edges);
ASSERT_EQ(edges.size(), 1u);
EXPECT_EQ(edges[0].hand, 0);
EXPECT_EQ(edges[0].button, Button::kFaceSouth);
EXPECT_TRUE(edges[0].pressed);
// Repeated publishes at the same level emit no further edges: native getters
// reading the snapshot must never see duplicate transitions.
edges.clear();
builder.SetButton(0, Button::kFaceSouth, true, true);
builder.Publish(2, 0, &edges);
builder.Publish(3, 0, &edges);
EXPECT_TRUE(edges.empty());
edges.clear();
builder.SetButton(0, Button::kFaceSouth, false, true);
builder.Publish(4, 0, &edges);
ASSERT_EQ(edges.size(), 1u);
EXPECT_FALSE(edges[0].pressed);
EXPECT_EQ(first.hands[0].connected, true);
}
TEST(XrControllerState, InactiveActionsNeverPressAndReleaseHeldControls) {
ControllerStateBuilder builder;
std::vector<ControllerStateBuilder::Edge> edges;
builder.SetButton(0, Button::kMenu, false, true);
builder.Publish(1, 0, &edges);
edges.clear();
// An inactive action reporting "true" must not fabricate a press.
builder.SetButton(0, Button::kMenu, true, /*active=*/false);
builder.Publish(2, 0, &edges);
EXPECT_TRUE(edges.empty());
EXPECT_FALSE(builder.snapshot()
.hands[0]
.buttons[static_cast<std::size_t>(Button::kMenu)]);
}
TEST(XrControllerState, InactiveActionReleasesPreviouslyHeldControl) {
ControllerStateBuilder b;
b.SetButton(0, Button::kMenu, true, true);
b.Publish(1, 0, nullptr);
b.SetButton(0, Button::kMenu, true, false);
std::vector<ControllerStateBuilder::Edge> edges;
b.Publish(2, 0, &edges);
ASSERT_EQ(edges.size(), 1u);
EXPECT_FALSE(edges[0].pressed);
}
TEST(XrControllerState, ReleaseAllEmitsReleaseForHeldButtonsOnlyAndZeroesAxes) {
ControllerStateBuilder builder;
std::vector<ControllerStateBuilder::Edge> edges;
builder.SetButton(0, Button::kFaceSouth, true, true);
builder.SetButton(1, Button::kSqueeze, true, true);
builder.SetAxis(0, Axis::kThumbstickX, 0.8f, true);
builder.SetAxis(0, Axis::kTrigger, 1.f, true);
builder.Publish(1, 0, &edges);
EXPECT_EQ(edges.size(), 2u);
edges.clear();
builder.ReleaseAll(2, 0, &edges);
ASSERT_EQ(edges.size(), 2u);
for (const auto &edge : edges) {
EXPECT_FALSE(edge.pressed);
}
const auto &snapshot = builder.snapshot();
EXPECT_FLOAT_EQ(
snapshot.hands[0].axes[static_cast<std::size_t>(Axis::kThumbstickX)],
0.f);
EXPECT_FLOAT_EQ(
snapshot.hands[0].axes[static_cast<std::size_t>(Axis::kTrigger)], 0.f);
// A second ReleaseAll must not re-emit anything (no stuck-down, no repeats).
edges.clear();
builder.ReleaseAll(3, 0, &edges);
EXPECT_TRUE(edges.empty());
}
TEST(XrControllerState, AxesAreClampedAndNonFiniteRejected) {
ControllerStateBuilder builder;
builder.SetAxis(0, Axis::kThumbstickY, 4.5f, true);
builder.SetAxis(1, Axis::kTrigger, -2.f, true);
builder.SetAxis(0, Axis::kTrigger, 0.5f, true);
builder.SetAxis(0, Axis::kTrigger, std::nanf(""), true);
builder.Publish(1, 0, nullptr);
const auto &s = builder.snapshot();
EXPECT_FLOAT_EQ(s.hands[0].axes[static_cast<std::size_t>(Axis::kThumbstickY)],
1.f);
EXPECT_FLOAT_EQ(s.hands[1].axes[static_cast<std::size_t>(Axis::kTrigger)],
0.f);
// Invalid input must neutralize any previously held trigger.
EXPECT_FLOAT_EQ(s.hands[0].axes[static_cast<std::size_t>(Axis::kTrigger)],
0.f);
}
TEST(XrControllerState, AnalogClickHysteresis) {
ControllerStateBuilder builder;
builder.SetAxis(0, Axis::kTrigger, 0.7f, true);
EXPECT_FALSE(builder.AnalogClick(0, Axis::kTrigger)); // below press 0.75
builder.SetAxis(0, Axis::kTrigger, 0.8f, true);
EXPECT_TRUE(builder.AnalogClick(0, Axis::kTrigger)); // latched on
builder.SetAxis(0, Axis::kTrigger, 0.65f, true);
EXPECT_TRUE(builder.AnalogClick(0, Axis::kTrigger)); // inside hysteresis band
builder.SetAxis(0, Axis::kTrigger, 0.55f, true);
EXPECT_FALSE(builder.AnalogClick(0, Axis::kTrigger)); // below release 0.6
// Non-click axes and invalid hands are rejected.
EXPECT_FALSE(builder.AnalogClick(0, Axis::kThumbstickX));
EXPECT_FALSE(builder.AnalogClick(5, Axis::kTrigger));
}
TEST(XrControllerState, SessionGenerationAndFrameTrackPublish) {
ControllerStateBuilder builder;
const auto snapshot = builder.Publish(42, 7, nullptr);
EXPECT_EQ(snapshot.frame, 42u);
EXPECT_EQ(snapshot.session_generation, 7u);
}
TEST(XrControllerAndroidMapping, PerHandKeysAndAxesMatchVerifiedGamepadRoute) {
// Values pinned against MapSdlGamepadButtonToAndroid/kAndroidAxes.
EXPECT_EQ(ControllerButtonToAndroidKey(0, Button::kFaceSouth), 96);
EXPECT_EQ(ControllerButtonToAndroidKey(1, Button::kFaceSouth), 96);
EXPECT_EQ(ControllerButtonToAndroidKey(0, Button::kFaceEast), 97);
EXPECT_EQ(ControllerButtonToAndroidKey(0, Button::kFaceWest), 99);
EXPECT_EQ(ControllerButtonToAndroidKey(0, Button::kFaceNorth), 100);
EXPECT_EQ(ControllerButtonToAndroidKey(0, Button::kSqueeze), 102); // L1
EXPECT_EQ(ControllerButtonToAndroidKey(1, Button::kSqueeze), 103); // R1
EXPECT_EQ(ControllerButtonToAndroidKey(0, Button::kThumbstick), 106);
EXPECT_EQ(ControllerButtonToAndroidKey(1, Button::kThumbstick), 107);
EXPECT_EQ(ControllerButtonToAndroidKey(0, Button::kMenu), 108);
EXPECT_EQ(ControllerButtonToAndroidKey(0, Button::kTrigger), -1); // analog
EXPECT_EQ(ControllerAxisToAndroidAxis(0, Axis::kThumbstickX), 0);
EXPECT_EQ(ControllerAxisToAndroidAxis(0, Axis::kThumbstickY), 1);
EXPECT_EQ(ControllerAxisToAndroidAxis(1, Axis::kThumbstickX), 11);
EXPECT_EQ(ControllerAxisToAndroidAxis(1, Axis::kThumbstickY), 14);
EXPECT_EQ(ControllerAxisToAndroidAxis(0, Axis::kTrigger), 17);
EXPECT_EQ(ControllerAxisToAndroidAxis(1, Axis::kTrigger), 18);
EXPECT_EQ(ControllerAxisToAndroidAxis(0, Axis::kSqueeze), -1);
EXPECT_EQ(ControllerAxisComponentCount(Axis::kThumbstickX), 2);
EXPECT_EQ(ControllerAxisComponentCount(Axis::kTrigger), 1);
EXPECT_EQ(ControllerAxisComponentCount(Axis::kSqueeze), 0);
}
TEST(XrControllerState, InvalidHandAndButtonIndicesAreIgnored) {
ControllerStateBuilder builder;
builder.SetButton(-1, Button::kFaceSouth, true, true);
builder.SetButton(9, Button::kFaceSouth, true, true);
builder.SetAxis(7, Axis::kTrigger, 1.f, true);
builder.SetConnected(3, true);
const auto snapshot = builder.Publish(1, 0, nullptr);
EXPECT_FALSE(snapshot.hands[0].connected);
EXPECT_FALSE(snapshot.hands[1].connected);
EXPECT_FALSE(
snapshot.hands[0].buttons[static_cast<std::size_t>(Button::kFaceSouth)]);
}
TEST(XrControllerQueue, BoundedCapacityDropsOldestAndArmsResync) {
ControllerDeliveryQueue queue;
for (std::uint64_t frame = 1; frame <= ControllerDeliveryQueue::kCapacity;
++frame) {
ControllerDelivery delivery;
delivery.frame = frame;
queue.Push(std::move(delivery));
}
EXPECT_EQ(queue.size(), ControllerDeliveryQueue::kCapacity);
EXPECT_FALSE(queue.overflowed());
// One past capacity: the oldest frame is dropped and the newest arrival
// carries the resync request.
ControllerDelivery extra;
extra.frame = 99;
queue.Push(std::move(extra));
EXPECT_EQ(queue.size(), ControllerDeliveryQueue::kCapacity);
EXPECT_EQ(queue.dropped_frames(), 1u);
ControllerDelivery out;
ASSERT_TRUE(queue.Pop(&out));
EXPECT_EQ(out.frame, 2u); // frame 1 was dropped, not frame 2
// Drain to the newest: it must demand a resync because edges were lost.
while (queue.Pop(&out)) {
}
EXPECT_TRUE(out.resync);
EXPECT_EQ(out.frame, 99u);
}
TEST(XrControllerQueue, DeferredOverflowResyncSurvivesDroppedCarrier) {
ControllerDeliveryQueue queue;
for (std::uint64_t frame = 0; frame < ControllerDeliveryQueue::kCapacity;
++frame) {
queue.Push(ControllerDelivery{});
}
// First overflow: this frame carries resync but is itself dropped next.
ControllerDelivery carrier;
carrier.frame = 100;
queue.Push(std::move(carrier));
ControllerDelivery newest;
newest.frame = 101;
queue.Push(std::move(newest));
ControllerDelivery out;
while (queue.Pop(&out)) {
}
// The resync must survive on the frame the consumer actually receives.
EXPECT_TRUE(out.resync);
EXPECT_EQ(out.frame, 101u);
}
TEST(XrControllerQueue, PopOnEmptyIsFalseAndClearEmpties) {
ControllerDeliveryQueue queue;
ControllerDelivery out;
EXPECT_FALSE(queue.Pop(&out));
EXPECT_FALSE(queue.Pop(nullptr));
queue.Push(ControllerDelivery{});
EXPECT_EQ(queue.size(), 1u);
queue.Clear();
EXPECT_EQ(queue.size(), 0u);
}
TEST(XrControllerSdlMapping, OrdinalsMatchRouterExpectations) {
// SDL3 ordinals the existing router converts through
// MapSdlGamepadButtonToAndroid/kAndroidAxes; verified pairs only.
EXPECT_EQ(ControllerButtonToSdlOrdinal(0, Button::kFaceSouth), 0);
EXPECT_EQ(ControllerButtonToSdlOrdinal(0, Button::kMenu), 6);
EXPECT_EQ(ControllerButtonToSdlOrdinal(0, Button::kSqueeze), 9);
EXPECT_EQ(ControllerButtonToSdlOrdinal(1, Button::kSqueeze), 10);
EXPECT_EQ(ControllerButtonToSdlOrdinal(0, Button::kThumbstick), 7);
EXPECT_EQ(ControllerButtonToSdlOrdinal(1, Button::kThumbstick), 8);
EXPECT_EQ(ControllerButtonToSdlOrdinal(0, Button::kTrigger), -1);
EXPECT_EQ(ControllerAxisToSdlOrdinal(0, Axis::kThumbstickX), 0);
EXPECT_EQ(ControllerAxisToSdlOrdinal(0, Axis::kThumbstickY), 1);
EXPECT_EQ(ControllerAxisToSdlOrdinal(1, Axis::kThumbstickX), 2);
EXPECT_EQ(ControllerAxisToSdlOrdinal(1, Axis::kThumbstickY), 3);
EXPECT_EQ(ControllerAxisToSdlOrdinal(0, Axis::kTrigger), 4);
EXPECT_EQ(ControllerAxisToSdlOrdinal(1, Axis::kTrigger), 5);
EXPECT_EQ(ControllerAxisToSdlOrdinal(0, Axis::kSqueeze), -1);
}
} // namespace
} // namespace mocktail::vr
TEST(XrControllerNative,
ExactChannelsMergeBothHandsAndNeverTurnTouchIntoClick) {
mocktail::vr::ControllerSnapshot snapshot;
snapshot.hands[0].connected = snapshot.hands[1].connected = true;
snapshot.hands[0]
.buttons[static_cast<size_t>(mocktail::vr::ControllerButton::kFaceWest)] =
true;
snapshot.hands[1]
.buttons[static_cast<size_t>(mocktail::vr::ControllerButton::kFaceEast)] =
true;
snapshot.hands[0]
.buttons[static_cast<size_t>(mocktail::vr::ControllerButton::kMenu)] =
true;
snapshot.hands[1]
.axes[static_cast<size_t>(mocktail::vr::ControllerAxis::kTrigger)] = .8f;
snapshot.hands[0]
.axes[static_cast<size_t>(mocktail::vr::ControllerAxis::kThumbstickY)] =
1;
snapshot.hands[0]
.touches[static_cast<size_t>(mocktail::vr::ControllerTouch::kA)] = true;
snapshot.hands[0]
.axes[static_cast<size_t>(mocktail::vr::ControllerAxis::kSqueeze)] = .37f;
snapshot.hands[0]
.buttons[static_cast<size_t>(mocktail::vr::ControllerButton::kSqueeze)] =
true;
float channels[28]{};
EXPECT_TRUE(mocktail::vr::EncodeNativeControllerChannels(snapshot, channels));
EXPECT_FLOAT_EQ(channels[4],
.37f); // preserve analog grip, not its click latch
EXPECT_FLOAT_EQ(channels[24], 1); // X
EXPECT_FLOAT_EQ(channels[23], 1); // B
EXPECT_FLOAT_EQ(channels[27], 1); // Start/Menu
EXPECT_FLOAT_EQ(channels[22], 0); // capacitive A is not a press
EXPECT_FLOAT_EQ(channels[12], .8f); // right trigger
EXPECT_FLOAT_EQ(channels[2], 1); // OpenXR up remains positive in native route
snapshot.hands[0].connected = false;
EXPECT_TRUE(mocktail::vr::EncodeNativeControllerChannels(snapshot, channels));
EXPECT_FLOAT_EQ(channels[24], 0);
EXPECT_FLOAT_EQ(channels[27], 0);
EXPECT_FLOAT_EQ(channels[23], 1);
}