diff --git a/cmake/MocktailInput.cmake b/cmake/MocktailInput.cmake index c00d8b5..83775d7 100644 --- a/cmake/MocktailInput.cmake +++ b/cmake/MocktailInput.cmake @@ -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 diff --git a/cmake/MocktailVR.cmake b/cmake/MocktailVR.cmake index f46ee95..783f35d 100644 --- a/cmake/MocktailVR.cmake +++ b/cmake/MocktailVR.cmake @@ -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) diff --git a/include/mocktail/vr/openxr_backend.h b/include/mocktail/vr/openxr_backend.h index 6276582..ba4302f 100644 --- a/include/mocktail/vr/openxr_backend.h +++ b/include/mocktail/vr/openxr_backend.h @@ -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(); diff --git a/include/mocktail/vr/roblox_vr_device_bridge.h b/include/mocktail/vr/roblox_vr_device_bridge.h index dddc0c4..c6b45b5 100644 --- a/include/mocktail/vr/roblox_vr_device_bridge.h +++ b/include/mocktail/vr/roblox_vr_device_bridge.h @@ -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 image_base_{0}; std::atomic 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 original_state_getter_{nullptr}; diff --git a/include/mocktail/vr/vr_perf.h b/include/mocktail/vr/vr_perf.h index 6fe8ef6..fd6cd43 100644 --- a/include/mocktail/vr/vr_perf.h +++ b/include/mocktail/vr/vr_perf.h @@ -24,6 +24,7 @@ enum class Stage { kCopyFenceWait, kMirrorPublish, kEvidence, + kControllerSync, kStageCount, }; @@ -45,6 +46,7 @@ enum class Counter { kVisibilityLost, kVisibilityGained, kFramesInvalidated, + kControllerSyncs, kCounterCount, }; diff --git a/include/mocktail/vr/xr_actions.h b/include/mocktail/vr/xr_actions.h new file mode 100644 index 0000000..1ae9b57 --- /dev/null +++ b/include/mocktail/vr/xr_actions.h @@ -0,0 +1,101 @@ +#ifndef MOCKTAIL_VR_XR_ACTIONS_H_ +#define MOCKTAIL_VR_XR_ACTIONS_H_ + +#include +#include + +#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_ diff --git a/include/mocktail/vr/xr_controller.h b/include/mocktail/vr/xr_controller.h new file mode 100644 index 0000000..e9beb47 --- /dev/null +++ b/include/mocktail/vr/xr_controller.h @@ -0,0 +1,236 @@ +#ifndef MOCKTAIL_VR_XR_CONTROLLER_H_ +#define MOCKTAIL_VR_XR_CONTROLLER_H_ + +#include "mocktail/vr/xr_controller_abi.h" +#include +#include +#include +#include +#include +#include + +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(ControllerTouch::kCount)> touches{}; + std::array(ControllerTouch::kCount)> + touch_active{}; + std::array(ControllerButton::kCount)> + buttons{}; + std::array(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 *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 *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(ControllerButton::kCount)>, 2> + published_buttons_{}; + // Hysteresis latch per (hand, analog control): [hand*2 + axis]. + std::array 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(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 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 queue_; + bool overflowed_ = false; + std::uint64_t dropped_frames_ = 0; +}; + +} // namespace mocktail::vr + +#endif // MOCKTAIL_VR_XR_CONTROLLER_H_ diff --git a/include/mocktail/vr/xr_controller_abi.h b/include/mocktail/vr/xr_controller_abi.h new file mode 100644 index 0000000..0e0569e --- /dev/null +++ b/include/mocktail/vr/xr_controller_abi.h @@ -0,0 +1,76 @@ +#ifndef MOCKTAIL_VR_XR_CONTROLLER_ABI_H_ +#define MOCKTAIL_VR_XR_CONTROLLER_ABI_H_ + +#include +#include + +/* 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_ */ diff --git a/include/runtime/roblox_window_input_runtime.h b/include/runtime/roblox_window_input_runtime.h index 00c6c45..eea2162 100644 --- a/include/runtime/roblox_window_input_runtime.h +++ b/include/runtime/roblox_window_input_runtime.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 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; diff --git a/include/runtime/roblox_xr_controller_input.h b/include/runtime/roblox_xr_controller_input.h new file mode 100644 index 0000000..7ebc4fa --- /dev/null +++ b/include/runtime/roblox_xr_controller_input.h @@ -0,0 +1,100 @@ +#ifndef MOCKTAIL_RUNTIME_ROBLOX_XR_CONTROLLER_INPUT_H_ +#define MOCKTAIL_RUNTIME_ROBLOX_XR_CONTROLLER_INPUT_H_ + +#include + +#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(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_ diff --git a/src/legacy/legacy_runtime.cc b/src/legacy/legacy_runtime.cc index 157ad94..6bfa5de 100644 --- a/src/legacy/legacy_runtime.cc +++ b/src/legacy/legacy_runtime.cc @@ -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) { diff --git a/src/runtime/roblox_window_input_runtime.cc b/src/runtime/roblox_window_input_runtime.cc index fefb6ed..3f2de54 100644 --- a/src/runtime/roblox_window_input_runtime.cc +++ b/src/runtime/roblox_window_input_runtime.cc @@ -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(&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(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) { diff --git a/src/runtime/roblox_xr_controller_input.cc b/src/runtime/roblox_xr_controller_input.cc new file mode 100644 index 0000000..e3afdd1 --- /dev/null +++ b/src/runtime/roblox_xr_controller_input.cc @@ -0,0 +1,272 @@ +#include "runtime/roblox_xr_controller_input.h" + +#include + +#include +#include +#include + +#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::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::lround(clamped * kAxisFull)); +} + +std::uint64_t NowNs() { + timespec ts{}; + clock_gettime(CLOCK_MONOTONIC, &ts); + return static_cast(ts.tv_sec) * 1000000000ULL + + static_cast(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( + dlsym(RTLD_DEFAULT, "mocktail_vr_controller_pop_delivery")); + auto haptics = reinterpret_cast( + dlsym(RTLD_DEFAULT, "mocktail_vr_controller_request_haptics")); + auto stop_haptics = reinterpret_cast( + 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(pop); + haptics_ = reinterpret_cast(haptics); + stop_haptics_ = reinterpret_cast(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(vr::ControllerButtonToSdlOrdinal( + 0, vr::ControllerButton::kFaceWest)), + static_cast(vr::ControllerButtonToSdlOrdinal( + 0, vr::ControllerButton::kFaceNorth)), + static_cast(vr::ControllerButtonToSdlOrdinal( + 1, vr::ControllerButton::kFaceSouth)), + static_cast(vr::ControllerButtonToSdlOrdinal( + 1, vr::ControllerButton::kFaceEast)), + static_cast( + vr::ControllerButtonToSdlOrdinal(0, vr::ControllerButton::kMenu)), + static_cast(vr::ControllerButtonToSdlOrdinal( + 0, vr::ControllerButton::kThumbstick)), + static_cast(vr::ControllerButtonToSdlOrdinal( + 1, vr::ControllerButton::kThumbstick)), + static_cast(vr::ControllerButtonToSdlOrdinal( + 0, vr::ControllerButton::kSqueeze)), + static_cast(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(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(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(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(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(haptics_)( + hand, amplitude, duration_ns, frequency_hz); +} + +void RobloxXrControllerInput::StopHaptics(int hand) { + if (stop_haptics_ == nullptr || (hand != 0 && hand != 1)) { + return; + } + reinterpret_cast(stop_haptics_)(hand); +} + +} // namespace runtime +} // namespace mocktail diff --git a/src/vr/openxr_backend.cc b/src/vr/openxr_backend.cc index b468dfb..0b21d72 100644 --- a/src/vr/openxr_backend.cc +++ b/src/vr/openxr_backend.cc @@ -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 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 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 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 lock(controller_mutex_); actions_.Detach(); } if (xr_view_space_ != nullptr) { (void)xrDestroySpace(static_cast(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 lock(controller_mutex_); + return actions_.TakeDelivery(out); +} + +ControllerSnapshot OpenXrBackend::ControllerSnapshotForFrame() const { + std::lock_guard lock(controller_mutex_); + return actions_.snapshot(); +} + +void OpenXrBackend::RequestControllerHaptics(int hand, float amplitude, + std::uint64_t duration_ns, + float frequency_hz) { + std::lock_guard lock(controller_mutex_); + if (actions_created_) { + actions_.RequestHaptics(hand, amplitude, duration_ns, frequency_hz); + } +} + +void OpenXrBackend::StopControllerHaptics(int hand) { + std::lock_guard 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 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 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(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 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 *)) { diff --git a/src/vr/openxr_disabled.cc b/src/vr/openxr_disabled.cc index 3c75de6..28470a0 100644 --- a/src/vr/openxr_disabled.cc +++ b/src/vr/openxr_disabled.cc @@ -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 {}; } diff --git a/src/vr/roblox_vr_device_bridge.cc b/src/vr/roblox_vr_device_bridge.cc index ea30375..5a58cde 100644 --- a/src/vr/roblox_vr_device_bridge.cc +++ b/src/vr/roblox_vr_device_bridge.cc @@ -11,6 +11,7 @@ #include #include #include "mocktail/vr/openxr_backend.h" +#include "mocktail/vr/vr_pose_math.h" #include #include #include @@ -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 g_pointer_original{nullptr}; +constexpr std::uintptr_t kPointerFrameRva = 0x4d573ee; +// Whole instructions, no RIP-relative operands. 2998's sret prologue. +constexpr std::array 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 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(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(base + 0x263778c); + void *workspace = get_workspace(service); + if (!workspace) + return result; + auto *vtable = *static_cast(workspace); + void *camera = + reinterpret_cast(vtable[0x3a8 / 8])(workspace); + if (!camera) + return result; + float scale = 0; + int degrees = 0; + std::memcpy(&scale, static_cast(camera) + 0x140, 4); + std::memcpy(°rees, reinterpret_cast(base + 0x73677a8), 4); + if (internal::ApplyAimToWorldFrame(static_cast(result), hand, scale, + -static_cast(degrees))) { + static std::atomic 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(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(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(base + kPointerFrameRva); + if (std::memcmp(target, kPointerPrologue.data(), kPointerPrologue.size()) != + 0) + return false; + const auto page_size = static_cast(sysconf(_SC_PAGESIZE)); + auto *code = static_cast( + 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(code), + std::memory_order_release); + AbsoluteJump(target, + reinterpret_cast(&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(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(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(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(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(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 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(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(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 lock(mutex_); + if (!active() || !ValidateObjectHeader(device_object) || hand < 0 || hand > 1) + return; + if (auto *backend = ActiveVrBackend()) { + static std::atomic 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 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(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(); +} diff --git a/src/vr/roblox_vr_device_bridge_internal.h b/src/vr/roblox_vr_device_bridge_internal.h index d7b3987..fe42469 100644 --- a/src/vr/roblox_vr_device_bridge_internal.h +++ b/src/vr/roblox_vr_device_bridge_internal.h @@ -5,12 +5,17 @@ #include #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; diff --git a/src/vr/vr_perf.cc b/src/vr/vr_perf.cc index 933cd5b..544ba83 100644 --- a/src/vr/vr_perf.cc +++ b/src/vr/vr_perf.cc @@ -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"; diff --git a/src/vr/xr_actions.cc b/src/vr/xr_actions.cc new file mode 100644 index 0000000..10c7e00 --- /dev/null +++ b/src/vr/xr_actions.cc @@ -0,0 +1,909 @@ +#include "mocktail/vr/xr_actions.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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(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(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(xr_instance); + delete impl_; + impl_ = new Impl(); + impl_->instance = instance; + try { + auto set_info = + XrInfo(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 subactions{impl_->subaction_left, + impl_->subaction_right}; + + auto make = [&](XrActionType type, const char *name) { + auto info = XrInfo(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> binds) { + auto touch = [&](ControllerTouch kind, const char *component) { + binds.emplace_back(impl_->touches[static_cast(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 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( + XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING); + info.interactionProfile = path(profile); + info.countSuggestedBindings = + static_cast(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(xr_instance); + const auto session = static_cast(xr_session); + try { + auto attach_info = XrInfo( + 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(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(xr_local_space); + attached_ = true; + session_generation_++; + Log(" [vr-input] actions attached to session (generation %llu)\n", + static_cast(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 lock(impl_->haptic_mutex); + for (int hand = 0; hand < 2; ++hand) { + impl_->haptics[hand] = {}; + if (attached_ && impl_->session) { + auto stop = XrInfo(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(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 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 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 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(xr_session); + const auto instance = impl_->instance; + const auto time = static_cast(predicted_display_time); + try { + auto sync_info = XrInfo(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(ControllerButton::kCount); ++i) + builder_.SetButton(hand, static_cast(i), false, true); + for (size_t i = 0; i < static_cast(ControllerAxis::kCount); ++i) + builder_.SetAxis(hand, static_cast(i), 0.f, true); + for (std::size_t i = 0; + i < static_cast(ControllerTouch::kCount); ++i) + builder_.SetTouch(hand, static_cast(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(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(ControllerButton::kCount); + ++index) { + builder_.SetButton(hand, static_cast(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(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(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(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(XR_TYPE_ACTION_STATE_GET_INFO); + get_info.subactionPath = subaction; + + get_info.action = impl_->trigger; + XrActionStateFloat trigger_state = + XrInfo(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(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(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(ControllerTouch::kCount); ++i) { + get_info.action = impl_->touches[i]; + auto state = XrInfo(XR_TYPE_ACTION_STATE_BOOLEAN); + const bool available = + XR_SUCCEEDED(xrGetActionStateBoolean(session, &get_info, &state)) && + state.isActive; + builder_.SetTouch(hand, static_cast(i), + state.currentState, available); + } + + // Boolean inputs. + const std::pair 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(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( + 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(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 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(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(ControllerAxis::kThumbstickX)]; + delivery.axes[stick_y] = + -axes[static_cast(ControllerAxis::kThumbstickY)]; + delivery.axes[trigger] = + axes[static_cast(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 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(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(XR_TYPE_HAPTIC_VIBRATION); + vibration.amplitude = request.amplitude; + vibration.duration = request.duration_ns == 0 + ? XR_MIN_HAPTIC_DURATION + : static_cast(std::min( + 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(&vibration)); + } + } + return true; + } catch (const std::exception &error) { + Log(" [vr-input] action sync failed: %s\n", error.what()); + ResetInput(); + return false; + } +} + +} // namespace mocktail::vr diff --git a/src/vr/xr_actions_disabled.cc b/src/vr/xr_actions_disabled.cc new file mode 100644 index 0000000..724d788 --- /dev/null +++ b/src/vr/xr_actions_disabled.cc @@ -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 diff --git a/src/vr/xr_controller.cc b/src/vr/xr_controller.cc new file mode 100644 index 0000000..e52c3bb --- /dev/null +++ b/src/vr/xr_controller.cc @@ -0,0 +1,376 @@ +#include "mocktail/vr/xr_controller.h" + +#include +#include + +namespace mocktail::vr { +namespace { + +constexpr std::size_t kButtons = + static_cast(ControllerButton::kCount); +constexpr std::size_t kAxes = static_cast(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(touch); + if (!IsValidHand(hand) || + i >= static_cast(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(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(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(axis)]; + const std::size_t axis_index = static_cast(axis); + // Only two analog controls are click-derivable; index them 0/1. + const std::size_t latch_index = static_cast(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(axis_index); + return next; +} + +ControllerSnapshot ControllerStateBuilder::Publish(std::uint64_t frame, + std::uint64_t generation, + std::vector *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(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 *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(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(b)] ? 1.f : 0.f; + }; + const int base = hand * 9; + channels[base] = button(ControllerButton::kThumbstick); + channels[base + 1] = + input.axes[static_cast(ControllerAxis::kThumbstickX)]; + channels[base + 2] = + input.axes[static_cast(ControllerAxis::kThumbstickY)]; + channels[base + 3] = + input.axes[static_cast(ControllerAxis::kTrigger)]; + channels[base + 4] = + input.axes[static_cast(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(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(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( + delivery.edges.size(), MOCKTAIL_VR_CONTROLLER_MAX_EDGES); + out->edge_count = static_cast(edges); + for (std::size_t index = 0; index < edges; ++index) { + out->edges[index].hand = delivery.edges[index].hand; + out->edges[index].button = + static_cast(delivery.edges[index].button); + out->edges[index].pressed = delivery.edges[index].pressed ? 1 : 0; + } +} + +void ControllerDeliveryQueue::Push(ControllerDelivery delivery) { + std::lock_guard 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 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 lock(mutex_); + return queue_.size(); +} + +void ControllerDeliveryQueue::Clear() { + std::lock_guard lock(mutex_); + queue_.clear(); + overflowed_ = false; +} + +} // namespace mocktail::vr diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8487620..aa51072 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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() diff --git a/tests/roblox_xr_controller_input_test.cc b/tests/roblox_xr_controller_input_test.cc new file mode 100644 index 0000000..fe95d02 --- /dev/null +++ b/tests/roblox_xr_controller_input_test.cc @@ -0,0 +1,295 @@ +#include + +#include +#include +#include +#include +#include + +#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 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 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 std::vector Collect() { + std::vector out; + for (const auto &event : g_events) { + if (const auto *value = std::get_if(&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(); + ASSERT_EQ(connections.size(), 1u); + EXPECT_TRUE(connections[0].connected); + EXPECT_EQ(connections[0].instance_id, RobloxXrControllerInput::kInstanceLeft); + const auto buttons = Collect(); + 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(); + 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().empty()); + d.button_levels[1][MOCKTAIL_VR_BUTTON_MENU] = 0; + g_backend.queue.push_back(d); + input_.Drain(); + auto buttons = Collect(); + 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(); + 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(); + 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(); + 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(); + 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(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(); + ASSERT_FALSE(connections.empty()); + EXPECT_FALSE(connections.back().connected); + EXPECT_FALSE(input_.connected()); +} + +} // namespace +} // namespace runtime +} // namespace mocktail diff --git a/tests/vr/roblox_vr_device_bridge_test.cc b/tests/vr/roblox_vr_device_bridge_test.cc index 6f9cbe3..0dc9d16 100644 --- a/tests/vr/roblox_vr_device_bridge_test.cc +++ b/tests/vr/roblox_vr_device_bridge_test.cc @@ -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 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 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::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 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 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 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::quiet_NaN(); + EXPECT_FALSE(internal::ApplyAimToWorldFrame(frame.data(), hand, 1, 0)); + EXPECT_EQ(frame, original); +} +} diff --git a/tests/vr/xr_actions_test.cc b/tests/vr/xr_actions_test.cc new file mode 100644 index 0000000..cd2c653 --- /dev/null +++ b/tests/vr/xr_actions_test.cc @@ -0,0 +1,372 @@ +#include "mocktail/vr/xr_actions.h" +#include "runtime/roblox_input_router.h" +#include "runtime/roblox_xr_controller_input.h" +#include +#include +#include +#include +#include + +namespace { +using namespace mocktail::vr; +template T Handle(uintptr_t n) { return reinterpret_cast(n); } +std::map names; +std::map 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(++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(++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(++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(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(1)).ok()); + ASSERT_TRUE( + actions.Attach(Handle(1), Handle(2), Handle(3)) + .ok()); + } + bool Sync(uint64_t frame = 1) { + return actions.Sync(Handle(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(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( + 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(ControllerButton::kSqueeze)]); + EXPECT_TRUE( + d.button_levels[1][static_cast(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(1), Handle(4), Handle(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 buttons; + std::map 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(p)->connects; + return Status::Ok(); + }, + [](void *p, int32_t) { + ++static_cast(p)->disconnects; + return Status::Ok(); + }, + [](void *p, int32_t, int32_t key, bool pressed) { + static_cast(p)->buttons[key] = pressed; + return Status::Ok(); + }, + [](void *p, int32_t, int32_t axis, float x, float y, float z) { + static_cast(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(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 diff --git a/tests/vr/xr_controller_test.cc b/tests/vr/xr_controller_test.cc new file mode 100644 index 0000000..09728af --- /dev/null +++ b/tests/vr/xr_controller_test.cc @@ -0,0 +1,298 @@ +#include + +#include + +#include "mocktail/vr/xr_controller.h" + +namespace mocktail::vr { +namespace { + +using Button = ControllerButton; +using Axis = ControllerAxis; + +TEST(XrControllerState, + PublishEmitsOneEdgePerTransitionDespiteRepeatedGetters) { + ControllerStateBuilder builder; + std::vector 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 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(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 edges; + b.Publish(2, 0, &edges); + ASSERT_EQ(edges.size(), 1u); + EXPECT_FALSE(edges[0].pressed); +} + +TEST(XrControllerState, ReleaseAllEmitsReleaseForHeldButtonsOnlyAndZeroesAxes) { + ControllerStateBuilder builder; + std::vector 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(Axis::kThumbstickX)], + 0.f); + EXPECT_FLOAT_EQ( + snapshot.hands[0].axes[static_cast(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(Axis::kThumbstickY)], + 1.f); + EXPECT_FLOAT_EQ(s.hands[1].axes[static_cast(Axis::kTrigger)], + 0.f); + // Invalid input must neutralize any previously held trigger. + EXPECT_FLOAT_EQ(s.hands[0].axes[static_cast(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(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(mocktail::vr::ControllerButton::kFaceWest)] = + true; + snapshot.hands[1] + .buttons[static_cast(mocktail::vr::ControllerButton::kFaceEast)] = + true; + snapshot.hands[0] + .buttons[static_cast(mocktail::vr::ControllerButton::kMenu)] = + true; + snapshot.hands[1] + .axes[static_cast(mocktail::vr::ControllerAxis::kTrigger)] = .8f; + snapshot.hands[0] + .axes[static_cast(mocktail::vr::ControllerAxis::kThumbstickY)] = + 1; + snapshot.hands[0] + .touches[static_cast(mocktail::vr::ControllerTouch::kA)] = true; + snapshot.hands[0] + .axes[static_cast(mocktail::vr::ControllerAxis::kSqueeze)] = .37f; + snapshot.hands[0] + .buttons[static_cast(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); +}