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