From dfeed948901b8b471588e7ae61e027367e8a1caa Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:02:30 -0400 Subject: [PATCH 1/2] feat: Add GamepadStateAdapter and tests Introduce a platform-neutral GamepadStateAdapter to cache and submit partial gamepad updates and to handle output/callbacks. Adds header and implementation (gamepad_adapter.hpp/.cpp), extends types (rear paddle buttons and trigger rumble fields), and exposes the adapter from the main libvirtualhid header. Update examples to use the adapter, add unit tests for profile support and adapter behavior, and update CMakeLists to build the new sources and tests. Also expand README with a Sunshine replacement readiness plan and Windows/Linux/FreeBSD parity notes. --- README.md | 82 +++- examples/gamepad_adapter.cpp | 18 +- src/CMakeLists.txt | 1 + src/core/gamepad_adapter.cpp | 365 ++++++++++++++++++ src/include/libvirtualhid/gamepad_adapter.hpp | 362 +++++++++++++++++ src/include/libvirtualhid/libvirtualhid.hpp | 1 + src/include/libvirtualhid/types.hpp | 15 + tests/CMakeLists.txt | 1 + tests/unit/test_gamepad_adapter.cpp | 154 ++++++++ 9 files changed, 988 insertions(+), 11 deletions(-) create mode 100644 src/core/gamepad_adapter.cpp create mode 100644 src/include/libvirtualhid/gamepad_adapter.hpp create mode 100644 tests/unit/test_gamepad_adapter.cpp diff --git a/README.md b/README.md index 3d009af..d9e42db 100644 --- a/README.md +++ b/README.md @@ -519,7 +519,87 @@ third-party/googletest/ GoogleTest submodule - [x] Decide whether official Windows releases should ship signed driver packages in addition to source. -### Phase 5: macOS Research and Backend +### Phase 5: Sunshine Replacement Readiness + +The Windows driver package is part of the intended design, similar in role to +ViGEmBus as an installable user-mode virtual device component. The remaining +replacement blockers are compatibility, packaging integration, and feature +parity with Sunshine's current ViGEmBus and inputtino behavior, while keeping +one public API across supported operating systems. The API may expose a richer +cross-platform model than any one backend can implement, but backends must report +unsupported features through capabilities instead of forcing consumers onto +platform-specific calls. + +#### Phase 5A: Shared Sunshine Adapter + +- [x] Add a Sunshine-oriented adapter example or test that maps controller + arrival, state updates, touchpad contacts, motion samples, battery reports, + feedback callbacks, and removal through the existing platform-neutral + `Runtime` and `Gamepad` APIs. +- [x] Preserve Sunshine's asynchronous event shape by caching per-controller + `GamepadState` and resubmitting after separate button, axis, trigger, touch, + motion, and battery updates. +- [x] Expand or formally map the public button model so Sunshine's full + controller flag set is preserved, including guide/home, profile-specific + misc/share, and rear paddles where the emulated profile can expose them. +- [x] Add profile capability checks for rumble, trigger rumble, RGB LED, + adaptive triggers, motion, touchpad, battery, and profile-specific buttons so + Sunshine can keep one code path while warning only when a selected profile + cannot expose a client-reported feature. + +#### Phase 5B: Windows ViGEmBus Parity + +- [ ] Validate the UMDF/VHF backend as Sunshine's Windows gamepad replacement + against the consumers that currently work through ViGEmBus, including SDL, + HIDAPI, browser Gamepad API, DirectInput, GameInput, and games or clients that + rely on the current Xbox controller behavior. +- [ ] Decide and document the Windows Xbox compatibility story before replacing + ViGEmBus. If the HID backend is not accepted by XInput-only consumers, keep a + compatibility layer, consumer-side mapping, or a retained ViGEmBus path for + that class of application. +- [x] Add a DualShock 4 compatible profile for Sunshine's current DS4 mode, + including touchpad click, touch contacts, motion sensors, battery state, + lightbar feedback, rumble feedback, Bluetooth CRC handling, and timestamp + behavior. +- [ ] Validate the DualShock 4 profile through Sunshine's Windows path against + the same applications currently covered by ViGEmBus DS4 mode. +- [ ] Replace Sunshine's ViGEmBus installer, status API, and diagnostics with + equivalent libvirtualhid driver package checks, install/uninstall flows, and + signed release packaging. +- [ ] Run Windows lifecycle and multi-controller validation through Sunshine with + the installed driver package, including hot-plug, output-report callbacks, + process shutdown cleanup, and simultaneous controllers. + +#### Phase 5C: Linux and FreeBSD inputtino Parity + +- [ ] Implement a Sunshine Linux adapter that preserves the current `xone`, + `ds5`, `switch`, and `auto` selection behavior while using the same + platform-neutral libvirtualhid API as Windows. +- [ ] Validate Linux DualSense parity against inputtino's UHID path: USB and + Bluetooth reports, calibration/pairing/firmware feature replies, periodic + input reports, touchpad, motion, battery, rumble, RGB LED, and adaptive trigger + feedback. +- [ ] Validate Linux DualShock 4 parity against Sunshine's former Windows DS4 + behavior and Linux consumers: USB and Bluetooth reports, + calibration/pairing/firmware feature replies, periodic input reports, sensor + timestamps, touchpad, motion, battery, rumble, and RGB LED feedback. +- [ ] Validate Xbox One and Switch Pro behavior through SDL and evdev consumers, + including d-pad, sticks, triggers, guide/misc buttons, rumble, device names, + VID/PID/version identity, and stable device-node discovery. +- [ ] Add a FreeBSD backend plan instead of assuming the Linux backend applies + unchanged. Sunshine disables inputtino `USE_UHID` on FreeBSD, so the current + FreeBSD path uses the uinput/libevdev-style fallback and does not get the + UHID-only DualSense features. +- [ ] Keep the FreeBSD API surface identical to Linux and Windows, but report + FreeBSD's real backend capabilities separately. At minimum, validate basic + Xbox One, Switch Pro, and uinput-backed PS5-style gamepad creation; only mark + DualSense touch, motion, battery, RGB LED, adaptive triggers, and Bluetooth + identity as supported if a FreeBSD backend can actually deliver them. +- [ ] Add FreeBSD configure/build coverage and a smoke-test strategy for the + supported subset, with explicit documentation for required device nodes, + permissions, and any FreeBSD-specific package dependencies. + +### Phase 6: macOS Research and Backend - [ ] Prototype macOS virtual HID creation and report submission. - [ ] Document signing, entitlement, and installer constraints. diff --git a/examples/gamepad_adapter.cpp b/examples/gamepad_adapter.cpp index 962188c..92a9d1a 100644 --- a/examples/gamepad_adapter.cpp +++ b/examples/gamepad_adapter.cpp @@ -23,35 +23,33 @@ int main() { options.metadata.has_battery = true; options.metadata.stable_id = "remote-client-0"; - auto created = runtime->create_gamepad(options); + auto created = lvh::GamepadStateAdapter::create(*runtime, options); if (!created) { std::cerr << created.status.message() << '\n'; return 1; } - created.gamepad->set_output_callback([](const lvh::GamepadOutput &output) { + auto &adapter = *created.adapter; + adapter.set_output_callback([](const lvh::GamepadOutput &output) { if (output.kind == lvh::GamepadOutputKind::rumble) { std::cout << "rumble " << output.low_frequency_rumble << ' ' << output.high_frequency_rumble << '\n'; } }); - lvh::GamepadState state; - state.buttons.set(lvh::GamepadButton::a); - state.left_stick = {0.25F, -0.5F}; - state.right_trigger = 1.0F; - - if (const auto status = created.gamepad->submit(state); !status.ok()) { + if (const auto status = adapter.set_button(lvh::GamepadButton::a, true); !status.ok()) { std::cerr << status.message() << '\n'; return 1; } + adapter.set_left_stick({0.25F, -0.5F}); + adapter.set_right_trigger(1.0F); lvh::GamepadOutput rumble; rumble.kind = lvh::GamepadOutputKind::rumble; rumble.low_frequency_rumble = 0x4000; rumble.high_frequency_rumble = 0x2000; - created.gamepad->dispatch_output(rumble); - created.gamepad->close(); + adapter.dispatch_output(rumble); + adapter.close(); return 0; } diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0d6fc2b..de28cbb 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -4,6 +4,7 @@ add_library(libvirtualhid::libvirtualhid ALIAS ${PROJECT_NAME}) target_sources(${PROJECT_NAME} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/core/backend.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/core/gamepad_adapter.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/core/profiles.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/core/report.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/core/runtime.cpp" diff --git a/src/core/gamepad_adapter.cpp b/src/core/gamepad_adapter.cpp new file mode 100644 index 0000000..6e7d7a8 --- /dev/null +++ b/src/core/gamepad_adapter.cpp @@ -0,0 +1,365 @@ +/** + * @file src/core/gamepad_adapter.cpp + * @brief Platform-neutral gamepad adapter helper definitions. + */ + +// standard includes +#include +#include +#include +#include + +// local includes +#include + +namespace lvh { + namespace { + + using enum GamepadButton; + + constexpr std::array common_buttons { + a, + b, + x, + y, + back, + start, + guide, + left_stick, + right_stick, + left_shoulder, + right_shoulder, + dpad_up, + dpad_down, + dpad_left, + dpad_right, + }; + + bool is_common_button(GamepadButton button) { + return std::ranges::contains(common_buttons, button); + } + + bool supports_common_misc1_button(GamepadProfileKind kind) { + switch (kind) { + using enum GamepadProfileKind; + + case generic: + case xbox_360: + case xbox_one: + case xbox_series: + case dualsense: + case switch_pro: + return true; + case dualshock4: + return false; + } + + return false; + } + + OperationStatus missing_gamepad() { + return OperationStatus::failure(ErrorCode::device_closed, "gamepad adapter has no owned gamepad"); + } + + OperationStatus unsupported_feature(std::string feature) { + return OperationStatus::failure(ErrorCode::unsupported_profile, std::move(feature)); + } + + OperationStatus validate_gamepad(const Gamepad *gamepad) { + if (!gamepad) { + return missing_gamepad(); + } + return OperationStatus::success(); + } + + } // namespace + + GamepadProfileSupport gamepad_profile_support(const DeviceProfile &profile) { + GamepadProfileSupport support; + if (profile.device_type != DeviceType::gamepad) { + return support; + } + + support.supports_rumble = profile.capabilities.supports_rumble; + support.supports_rgb_led = profile.capabilities.supports_rgb_led; + support.supports_adaptive_triggers = profile.capabilities.supports_adaptive_triggers; + support.supports_motion = profile.capabilities.supports_motion; + support.supports_touchpad = profile.capabilities.supports_touchpad; + support.supports_battery = profile.capabilities.supports_battery; + support.supports_misc1_button = supports_common_misc1_button(profile.gamepad_kind); + support.supports_touchpad_button = profile.capabilities.supports_touchpad; + + return support; + } + + bool supports_gamepad_button(const DeviceProfile &profile, GamepadButton button) { + using enum GamepadButton; + + if (profile.device_type != DeviceType::gamepad) { + return false; + } + + const auto support = gamepad_profile_support(profile); + if (is_common_button(button)) { + return true; + } + if (button == misc1) { + return support.supports_misc1_button; + } + if (button == touchpad) { + return support.supports_touchpad_button; + } + + const auto paddle_count = support.supported_rear_paddle_count; + switch (button) { + case paddle1: + return paddle_count >= 1U; + case paddle2: + return paddle_count >= 2U; + case paddle3: + return paddle_count >= 3U; + case paddle4: + return paddle_count >= 4U; + default: + return false; + } + } + + bool supports_gamepad_output(const DeviceProfile &profile, GamepadOutputKind output_kind) { + using enum GamepadOutputKind; + + if (profile.device_type != DeviceType::gamepad) { + return false; + } + + const auto support = gamepad_profile_support(profile); + switch (output_kind) { + case rumble: + return support.supports_rumble; + case trigger_rumble: + return support.supports_trigger_rumble; + case rgb_led: + return support.supports_rgb_led; + case adaptive_triggers: + return support.supports_adaptive_triggers; + case raw_report: + return profile.output_report_size > 0U; + } + + return false; + } + + GamepadStateAdapter::GamepadStateAdapter(std::unique_ptr gamepad): + gamepad_ {std::move(gamepad)} { + if (gamepad_) { + support_ = gamepad_profile_support(gamepad_->profile()); + } + } + + GamepadStateAdapter::GamepadStateAdapter(GamepadStateAdapter &&) noexcept = default; + GamepadStateAdapter &GamepadStateAdapter::operator=(GamepadStateAdapter &&) noexcept = default; + + GamepadStateAdapter::~GamepadStateAdapter() { + if (gamepad_) { + static_cast(gamepad_->close()); + } + } + + GamepadAdapterCreationResult GamepadStateAdapter::create(Runtime &runtime, const CreateGamepadOptions &options) { + auto created = runtime.create_gamepad(options); + if (!created) { + return {std::move(created.status), nullptr}; + } + + return {OperationStatus::success(), std::make_unique(std::move(created.gamepad))}; + } + + Gamepad *GamepadStateAdapter::gamepad() { + return gamepad_.get(); + } + + const Gamepad *GamepadStateAdapter::gamepad() const { + return gamepad_.get(); + } + + const GamepadProfileSupport &GamepadStateAdapter::support() const { + return support_; + } + + const GamepadState &GamepadStateAdapter::state() const { + return state_; + } + + bool GamepadStateAdapter::is_open() const { + return gamepad_ && gamepad_->is_open(); + } + + OperationStatus GamepadStateAdapter::submit() { + if (const auto status = validate_gamepad(gamepad_.get()); !status.ok()) { + return status; + } + return gamepad_->submit(state_); + } + + OperationStatus GamepadStateAdapter::set_state(const GamepadState &state) { + state_ = state; + return submit(); + } + + OperationStatus GamepadStateAdapter::set_button(GamepadButton button, bool pressed) { + if (const auto status = validate_gamepad(gamepad_.get()); !status.ok()) { + return status; + } + if (!supports_gamepad_button(gamepad_->profile(), button)) { + return unsupported_feature("selected gamepad profile cannot expose the requested button"); + } + + state_.buttons.set(button, pressed); + return submit(); + } + + OperationStatus GamepadStateAdapter::set_left_stick(Stick stick) { + state_.left_stick = stick; + return submit(); + } + + OperationStatus GamepadStateAdapter::set_right_stick(Stick stick) { + state_.right_stick = stick; + return submit(); + } + + OperationStatus GamepadStateAdapter::set_left_trigger(float value) { + state_.left_trigger = value; + return submit(); + } + + OperationStatus GamepadStateAdapter::set_right_trigger(float value) { + state_.right_trigger = value; + return submit(); + } + + OperationStatus GamepadStateAdapter::set_acceleration(std::optional acceleration) { + if (const auto status = validate_gamepad(gamepad_.get()); !status.ok()) { + return status; + } + if (!support_.supports_motion) { + return unsupported_feature("selected gamepad profile cannot expose motion sensor input"); + } + + state_.acceleration = acceleration; + return submit(); + } + + OperationStatus GamepadStateAdapter::set_gyroscope(std::optional gyroscope) { + if (const auto status = validate_gamepad(gamepad_.get()); !status.ok()) { + return status; + } + if (!support_.supports_motion) { + return unsupported_feature("selected gamepad profile cannot expose motion sensor input"); + } + + state_.gyroscope = gyroscope; + return submit(); + } + + OperationStatus GamepadStateAdapter::set_motion(Vector3 acceleration, Vector3 gyroscope) { + if (const auto status = validate_gamepad(gamepad_.get()); !status.ok()) { + return status; + } + if (!support_.supports_motion) { + return unsupported_feature("selected gamepad profile cannot expose motion sensor input"); + } + + state_.acceleration = acceleration; + state_.gyroscope = gyroscope; + return submit(); + } + + OperationStatus GamepadStateAdapter::clear_motion() { + if (const auto status = validate_gamepad(gamepad_.get()); !status.ok()) { + return status; + } + if (!support_.supports_motion) { + return unsupported_feature("selected gamepad profile cannot expose motion sensor input"); + } + + state_.acceleration.reset(); + state_.gyroscope.reset(); + return submit(); + } + + OperationStatus GamepadStateAdapter::set_battery(GamepadBattery battery) { + if (const auto status = validate_gamepad(gamepad_.get()); !status.ok()) { + return status; + } + if (!support_.supports_battery) { + return unsupported_feature("selected gamepad profile cannot expose battery input"); + } + + state_.battery = battery; + return submit(); + } + + OperationStatus GamepadStateAdapter::clear_battery() { + if (const auto status = validate_gamepad(gamepad_.get()); !status.ok()) { + return status; + } + if (!support_.supports_battery) { + return unsupported_feature("selected gamepad profile cannot expose battery input"); + } + + state_.battery.reset(); + return submit(); + } + + OperationStatus GamepadStateAdapter::set_touchpad_contact(std::size_t index, GamepadTouchContact contact) { + if (const auto status = validate_gamepad(gamepad_.get()); !status.ok()) { + return status; + } + if (!support_.supports_touchpad) { + return unsupported_feature("selected gamepad profile cannot expose touchpad input"); + } + if (index >= state_.touchpad_contacts.size()) { + return OperationStatus::failure(ErrorCode::invalid_argument, "touchpad contact index is out of range"); + } + + state_.touchpad_contacts[index] = contact; + return submit(); + } + + OperationStatus GamepadStateAdapter::clear_touchpad_contact(std::size_t index) { + if (const auto status = validate_gamepad(gamepad_.get()); !status.ok()) { + return status; + } + if (!support_.supports_touchpad) { + return unsupported_feature("selected gamepad profile cannot expose touchpad input"); + } + if (index >= state_.touchpad_contacts.size()) { + return OperationStatus::failure(ErrorCode::invalid_argument, "touchpad contact index is out of range"); + } + + state_.touchpad_contacts[index] = {}; + return submit(); + } + + void GamepadStateAdapter::set_output_callback(const OutputCallback &callback) { + if (gamepad_) { + gamepad_->set_output_callback(callback); + } + } + + OperationStatus GamepadStateAdapter::dispatch_output(const GamepadOutput &output) { + if (const auto status = validate_gamepad(gamepad_.get()); !status.ok()) { + return status; + } + return gamepad_->dispatch_output(output); + } + + OperationStatus GamepadStateAdapter::close() { + if (const auto status = validate_gamepad(gamepad_.get()); !status.ok()) { + return status; + } + return gamepad_->close(); + } + +} // namespace lvh diff --git a/src/include/libvirtualhid/gamepad_adapter.hpp b/src/include/libvirtualhid/gamepad_adapter.hpp new file mode 100644 index 0000000..1ce7344 --- /dev/null +++ b/src/include/libvirtualhid/gamepad_adapter.hpp @@ -0,0 +1,362 @@ +/** + * @file src/include/libvirtualhid/gamepad_adapter.hpp + * @brief Platform-neutral gamepad adapter helpers. + */ +#pragma once + +// standard includes +#include +#include +#include +#include + +// local includes +#include +#include + +namespace lvh { + + /** + * @brief Profile support summary for portable gamepad adapter code. + */ + struct GamepadProfileSupport { + /** + * @brief Whether the profile supports main rumble output. + */ + bool supports_rumble = false; + + /** + * @brief Whether the profile supports independent trigger rumble output. + */ + bool supports_trigger_rumble = false; + + /** + * @brief Whether the profile supports RGB LED output. + */ + bool supports_rgb_led = false; + + /** + * @brief Whether the profile supports adaptive trigger output. + */ + bool supports_adaptive_triggers = false; + + /** + * @brief Whether the profile exposes motion sensor input. + */ + bool supports_motion = false; + + /** + * @brief Whether the profile exposes touchpad contact input. + */ + bool supports_touchpad = false; + + /** + * @brief Whether the profile exposes battery state input. + */ + bool supports_battery = false; + + /** + * @brief Whether the profile exposes the miscellaneous profile-specific button. + */ + bool supports_misc1_button = false; + + /** + * @brief Whether the profile exposes a touchpad click button. + */ + bool supports_touchpad_button = false; + + /** + * @brief Number of rear paddle buttons exposed by the profile. + */ + std::uint8_t supported_rear_paddle_count = 0; + }; + + /** + * @brief Get portable support flags for a gamepad profile. + * + * @param profile Gamepad profile to inspect. + * @return Profile support summary. + */ + GamepadProfileSupport gamepad_profile_support(const DeviceProfile &profile); + + /** + * @brief Check whether a gamepad profile can expose a logical button. + * + * @param profile Gamepad profile to inspect. + * @param button Logical gamepad button. + * @return `true` when the selected profile can carry the button. + */ + bool supports_gamepad_button(const DeviceProfile &profile, GamepadButton button); + + /** + * @brief Check whether a gamepad profile can emit an output category. + * + * @param profile Gamepad profile to inspect. + * @param output_kind Output category. + * @return `true` when the selected profile can emit the output category. + */ + bool supports_gamepad_output(const DeviceProfile &profile, GamepadOutputKind output_kind); + + struct GamepadAdapterCreationResult; + + /** + * @brief Caches a full gamepad state and resubmits it after partial updates. + */ + class GamepadStateAdapter final { + public: + /** + * @brief Copy construction is disabled because the adapter owns the gamepad handle. + */ + GamepadStateAdapter(const GamepadStateAdapter &) = delete; + + /** + * @brief Copy assignment is disabled because the adapter owns the gamepad handle. + * + * @return This adapter. + */ + GamepadStateAdapter &operator=(const GamepadStateAdapter &) = delete; + + /** + * @brief Move construct an adapter. + * + * @param other Adapter to move from. + */ + GamepadStateAdapter(GamepadStateAdapter &&other) noexcept; + + /** + * @brief Move assign an adapter. + * + * @param other Adapter to move from. + * @return This adapter. + */ + GamepadStateAdapter &operator=(GamepadStateAdapter &&other) noexcept; + + /** + * @brief Construct an adapter around a created gamepad handle. + * + * @param gamepad Gamepad handle owned by the adapter. + */ + explicit GamepadStateAdapter(std::unique_ptr gamepad); + + /** + * @brief Destroy the adapter and close the owned gamepad if still open. + */ + ~GamepadStateAdapter(); + + /** + * @brief Create a gamepad and wrap it in a state adapter. + * + * @param runtime Runtime used to create the gamepad. + * @param options Gamepad creation options. + * @return Adapter creation result. + */ + static GamepadAdapterCreationResult create(Runtime &runtime, const CreateGamepadOptions &options); + + /** + * @brief Get the owned gamepad handle. + * + * @return Owned gamepad handle, or `nullptr` after move. + */ + Gamepad *gamepad(); + + /** + * @brief Get the owned gamepad handle. + * + * @return Owned gamepad handle, or `nullptr` after move. + */ + const Gamepad *gamepad() const; + + /** + * @brief Get profile support flags captured at creation time. + * + * @return Profile support summary. + */ + const GamepadProfileSupport &support() const; + + /** + * @brief Get the cached full gamepad state. + * + * @return Cached gamepad state. + */ + const GamepadState &state() const; + + /** + * @brief Check whether the owned gamepad is open. + * + * @return `true` when the owned gamepad can accept operations. + */ + bool is_open() const; + + /** + * @brief Submit the cached full gamepad state. + * + * @return Submit operation status. + */ + OperationStatus submit(); + + /** + * @brief Replace and submit the cached full gamepad state. + * + * @param state New full gamepad state. + * @return Submit operation status. + */ + OperationStatus set_state(const GamepadState &state); + + /** + * @brief Update one logical button and submit the full cached state. + * + * @param button Logical button to update. + * @param pressed Whether the button is pressed. + * @return Submit operation status. + */ + OperationStatus set_button(GamepadButton button, bool pressed); + + /** + * @brief Update the left stick and submit the full cached state. + * + * @param stick Left stick state. + * @return Submit operation status. + */ + OperationStatus set_left_stick(Stick stick); + + /** + * @brief Update the right stick and submit the full cached state. + * + * @param stick Right stick state. + * @return Submit operation status. + */ + OperationStatus set_right_stick(Stick stick); + + /** + * @brief Update the left trigger and submit the full cached state. + * + * @param value Normalized left trigger value. + * @return Submit operation status. + */ + OperationStatus set_left_trigger(float value); + + /** + * @brief Update the right trigger and submit the full cached state. + * + * @param value Normalized right trigger value. + * @return Submit operation status. + */ + OperationStatus set_right_trigger(float value); + + /** + * @brief Update accelerometer data and submit the full cached state. + * + * @param acceleration Accelerometer data, or `std::nullopt` to clear it. + * @return Submit operation status. + */ + OperationStatus set_acceleration(std::optional acceleration); + + /** + * @brief Update gyroscope data and submit the full cached state. + * + * @param gyroscope Gyroscope data, or `std::nullopt` to clear it. + * @return Submit operation status. + */ + OperationStatus set_gyroscope(std::optional gyroscope); + + /** + * @brief Update accelerometer and gyroscope data and submit the full cached state. + * + * @param acceleration Accelerometer data. + * @param gyroscope Gyroscope data. + * @return Submit operation status. + */ + OperationStatus set_motion(Vector3 acceleration, Vector3 gyroscope); + + /** + * @brief Clear motion data and submit the full cached state. + * + * @return Submit operation status. + */ + OperationStatus clear_motion(); + + /** + * @brief Update battery metadata and submit the full cached state. + * + * @param battery Battery metadata. + * @return Submit operation status. + */ + OperationStatus set_battery(GamepadBattery battery); + + /** + * @brief Clear battery metadata and submit the full cached state. + * + * @return Submit operation status. + */ + OperationStatus clear_battery(); + + /** + * @brief Update one touchpad contact and submit the full cached state. + * + * @param index Touchpad contact slot. + * @param contact Touchpad contact state. + * @return Submit operation status. + */ + OperationStatus set_touchpad_contact(std::size_t index, GamepadTouchContact contact); + + /** + * @brief Clear one touchpad contact and submit the full cached state. + * + * @param index Touchpad contact slot. + * @return Submit operation status. + */ + OperationStatus clear_touchpad_contact(std::size_t index); + + /** + * @brief Register a callback for backend output events. + * + * @param callback Output callback copied into the owned gamepad. + */ + void set_output_callback(const OutputCallback &callback); + + /** + * @brief Dispatch an output event to the owned gamepad callback. + * + * @param output Output event. + * @return Dispatch operation status. + */ + OperationStatus dispatch_output(const GamepadOutput &output); + + /** + * @brief Close the owned gamepad. + * + * @return Close operation status. + */ + OperationStatus close(); + + private: + std::unique_ptr gamepad_; + GamepadState state_; + GamepadProfileSupport support_; + }; + + /** + * @brief Result returned by gamepad adapter creation. + */ + struct GamepadAdapterCreationResult { + /** + * @brief Creation status. + */ + OperationStatus status; + + /** + * @brief Created adapter when creation succeeds. + */ + std::unique_ptr adapter; + + /** + * @brief Check whether creation succeeded and produced an adapter. + * + * @return `true` when creation succeeded. + */ + explicit operator bool() const { + return status.ok() && adapter != nullptr; + } + }; + +} // namespace lvh diff --git a/src/include/libvirtualhid/libvirtualhid.hpp b/src/include/libvirtualhid/libvirtualhid.hpp index 52c8254..43e642e 100644 --- a/src/include/libvirtualhid/libvirtualhid.hpp +++ b/src/include/libvirtualhid/libvirtualhid.hpp @@ -5,6 +5,7 @@ #pragma once // local includes +#include #include #include #include diff --git a/src/include/libvirtualhid/types.hpp b/src/include/libvirtualhid/types.hpp index b065bde..36e2c91 100644 --- a/src/include/libvirtualhid/types.hpp +++ b/src/include/libvirtualhid/types.hpp @@ -509,6 +509,10 @@ namespace lvh { dpad_right, ///< Directional pad right. misc1, ///< Profile-specific miscellaneous button. touchpad, ///< Touchpad click button. + paddle1, ///< First rear paddle button. + paddle2, ///< Second rear paddle button. + paddle3, ///< Third rear paddle button. + paddle4, ///< Fourth rear paddle button. }; /** @@ -894,6 +898,7 @@ namespace lvh { rgb_led, ///< RGB LED color output. adaptive_triggers, ///< Adaptive trigger output. raw_report, ///< Raw output report bytes. + trigger_rumble, ///< Independent trigger rumble output. }; /** @@ -915,6 +920,16 @@ namespace lvh { */ std::uint16_t high_frequency_rumble = 0; + /** + * @brief Left trigger rumble motor strength. + */ + std::uint16_t left_trigger_rumble = 0; + + /** + * @brief Right trigger rumble motor strength. + */ + std::uint16_t right_trigger_rumble = 0; + /** * @brief Red LED channel value. */ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 24e438f..f79f468 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,6 +16,7 @@ set(TEST_BINARY test_libvirtualhid) set(LIBVIRTUALHID_TEST_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/fixtures.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_gamepad_adapter.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_gamepad_lifecycle.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_linux_backend.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_linux_consumers.cpp" diff --git a/tests/unit/test_gamepad_adapter.cpp b/tests/unit/test_gamepad_adapter.cpp new file mode 100644 index 0000000..1d1ac09 --- /dev/null +++ b/tests/unit/test_gamepad_adapter.cpp @@ -0,0 +1,154 @@ +/** + * @file tests/unit/test_gamepad_adapter.cpp + * @brief Unit tests for platform-neutral gamepad adapter helpers. + */ + +// local includes +#include "fixtures/fixtures.hpp" + +#include + +TEST(GamepadAdapterTest, ReportsProfileSupport) { + const auto generic = lvh::profiles::generic_gamepad(); + const auto dualshock4 = lvh::profiles::dualshock4(); + const auto dualsense = lvh::profiles::dualsense(); + + const auto generic_support = lvh::gamepad_profile_support(generic); + EXPECT_FALSE(generic_support.supports_rumble); + EXPECT_FALSE(generic_support.supports_touchpad); + EXPECT_TRUE(generic_support.supports_misc1_button); + EXPECT_FALSE(generic_support.supports_trigger_rumble); + + const auto dualshock4_support = lvh::gamepad_profile_support(dualshock4); + EXPECT_TRUE(dualshock4_support.supports_rumble); + EXPECT_TRUE(dualshock4_support.supports_rgb_led); + EXPECT_TRUE(dualshock4_support.supports_motion); + EXPECT_TRUE(dualshock4_support.supports_touchpad); + EXPECT_TRUE(dualshock4_support.supports_battery); + EXPECT_TRUE(dualshock4_support.supports_touchpad_button); + EXPECT_FALSE(dualshock4_support.supports_adaptive_triggers); + EXPECT_FALSE(dualshock4_support.supports_misc1_button); + + const auto dualsense_support = lvh::gamepad_profile_support(dualsense); + EXPECT_TRUE(dualsense_support.supports_rumble); + EXPECT_TRUE(dualsense_support.supports_rgb_led); + EXPECT_TRUE(dualsense_support.supports_adaptive_triggers); + EXPECT_TRUE(dualsense_support.supports_motion); + EXPECT_TRUE(dualsense_support.supports_touchpad); + EXPECT_TRUE(dualsense_support.supports_battery); + EXPECT_TRUE(dualsense_support.supports_misc1_button); + EXPECT_EQ(dualsense_support.supported_rear_paddle_count, 0U); +} + +TEST(GamepadAdapterTest, ChecksButtonsAndOutputsByProfile) { + const auto xbox = lvh::profiles::xbox_series(); + const auto dualshock4 = lvh::profiles::dualshock4(); + const auto dualsense = lvh::profiles::dualsense(); + + EXPECT_TRUE(lvh::supports_gamepad_button(xbox, lvh::GamepadButton::guide)); + EXPECT_TRUE(lvh::supports_gamepad_button(xbox, lvh::GamepadButton::misc1)); + EXPECT_FALSE(lvh::supports_gamepad_button(xbox, lvh::GamepadButton::touchpad)); + EXPECT_FALSE(lvh::supports_gamepad_button(xbox, lvh::GamepadButton::paddle1)); + + EXPECT_TRUE(lvh::supports_gamepad_button(dualshock4, lvh::GamepadButton::touchpad)); + EXPECT_FALSE(lvh::supports_gamepad_button(dualshock4, lvh::GamepadButton::misc1)); + EXPECT_TRUE(lvh::supports_gamepad_button(dualsense, lvh::GamepadButton::misc1)); + + EXPECT_TRUE(lvh::supports_gamepad_output(dualshock4, lvh::GamepadOutputKind::rumble)); + EXPECT_TRUE(lvh::supports_gamepad_output(dualshock4, lvh::GamepadOutputKind::rgb_led)); + EXPECT_FALSE(lvh::supports_gamepad_output(dualshock4, lvh::GamepadOutputKind::adaptive_triggers)); + EXPECT_FALSE(lvh::supports_gamepad_output(dualshock4, lvh::GamepadOutputKind::trigger_rumble)); + EXPECT_TRUE(lvh::supports_gamepad_output(dualsense, lvh::GamepadOutputKind::adaptive_triggers)); +} + +TEST(GamepadAdapterTest, CachesAndSubmitsPartialUpdates) { + auto runtime = lvh::Runtime::create(); + + lvh::CreateGamepadOptions options; + options.profile = lvh::profiles::dualsense(); + options.metadata.global_index = 2; + options.metadata.client_relative_index = 0; + options.metadata.client_type = lvh::ClientControllerType::playstation; + options.metadata.has_motion_sensors = true; + options.metadata.has_touchpad = true; + options.metadata.has_rgb_led = true; + options.metadata.has_battery = true; + options.metadata.stable_id = "remote-client-0"; + + auto created = lvh::GamepadStateAdapter::create(*runtime, options); + ASSERT_TRUE(created); + auto &adapter = *created.adapter; + ASSERT_NE(adapter.gamepad(), nullptr); + + EXPECT_EQ(adapter.gamepad()->metadata().global_index, 2); + EXPECT_TRUE(adapter.support().supports_motion); + EXPECT_TRUE(adapter.support().supports_touchpad); + + bool feedback_received = false; + adapter.set_output_callback([&feedback_received](const lvh::GamepadOutput &output) { + feedback_received = output.kind == lvh::GamepadOutputKind::rumble && + output.low_frequency_rumble == 0x4000 && + output.high_frequency_rumble == 0x2000; + }); + + EXPECT_TRUE(adapter.set_button(lvh::GamepadButton::a, true).ok()); + EXPECT_TRUE(adapter.set_left_stick({0.25F, -0.5F}).ok()); + EXPECT_TRUE(adapter.set_right_trigger(1.0F).ok()); + EXPECT_TRUE(adapter.set_touchpad_contact(0, {.id = 3, .active = true, .x = 0.5F, .y = 0.25F}).ok()); + EXPECT_TRUE(adapter.set_acceleration(lvh::Vector3 {.x = 1.0F, .y = 2.0F, .z = 3.0F}).ok()); + EXPECT_TRUE(adapter.set_gyroscope(lvh::Vector3 {.x = 4.0F, .y = 5.0F, .z = 6.0F}).ok()); + EXPECT_TRUE(adapter.set_battery({.state = lvh::GamepadBatteryState::charging, .percentage = 80}).ok()); + + const auto *gamepad = adapter.gamepad(); + ASSERT_NE(gamepad, nullptr); + EXPECT_EQ(gamepad->submit_count(), 7U); + + const auto submitted = gamepad->last_submitted_state(); + EXPECT_TRUE(submitted.buttons.test(lvh::GamepadButton::a)); + EXPECT_FLOAT_EQ(submitted.left_stick.x, 0.25F); + EXPECT_FLOAT_EQ(submitted.left_stick.y, -0.5F); + EXPECT_FLOAT_EQ(submitted.right_trigger, 1.0F); + ASSERT_TRUE(submitted.acceleration.has_value()); + EXPECT_FLOAT_EQ(submitted.acceleration->z, 3.0F); + ASSERT_TRUE(submitted.gyroscope.has_value()); + EXPECT_FLOAT_EQ(submitted.gyroscope->z, 6.0F); + ASSERT_TRUE(submitted.battery.has_value()); + EXPECT_EQ(submitted.battery->state, lvh::GamepadBatteryState::charging); + EXPECT_EQ(submitted.battery->percentage, 80U); + EXPECT_TRUE(submitted.touchpad_contacts[0].active); + + lvh::GamepadOutput rumble; + rumble.kind = lvh::GamepadOutputKind::rumble; + rumble.low_frequency_rumble = 0x4000; + rumble.high_frequency_rumble = 0x2000; + + EXPECT_TRUE(adapter.dispatch_output(rumble).ok()); + EXPECT_TRUE(feedback_received); + EXPECT_TRUE(adapter.close().ok()); + EXPECT_EQ(runtime->active_device_count(), 0U); +} + +TEST(GamepadAdapterTest, RejectsUnsupportedPartialUpdates) { + auto runtime = lvh::Runtime::create(); + + lvh::CreateGamepadOptions options; + options.profile = lvh::profiles::generic_gamepad(); + options.metadata.stable_id = "generic-client-0"; + + auto created = lvh::GamepadStateAdapter::create(*runtime, options); + ASSERT_TRUE(created); + auto &adapter = *created.adapter; + + EXPECT_EQ( + adapter.set_touchpad_contact(0, {.id = 1, .active = true, .x = 0.5F, .y = 0.25F}).code(), + lvh::ErrorCode::unsupported_profile + ); + EXPECT_EQ(adapter.set_acceleration(lvh::Vector3 {.x = 1.0F, .y = 0.0F, .z = 0.0F}).code(), lvh::ErrorCode::unsupported_profile); + EXPECT_EQ( + adapter.set_battery({.state = lvh::GamepadBatteryState::discharging, .percentage = 50}).code(), + lvh::ErrorCode::unsupported_profile + ); + EXPECT_EQ(adapter.set_button(lvh::GamepadButton::touchpad, true).code(), lvh::ErrorCode::unsupported_profile); + EXPECT_EQ(adapter.set_button(lvh::GamepadButton::paddle1, true).code(), lvh::ErrorCode::unsupported_profile); + EXPECT_EQ(adapter.gamepad()->submit_count(), 0U); +} From cd691ba5661ac2a543c4a2357255c4a70cca0f0b Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:41:47 -0400 Subject: [PATCH 2/2] test: improve coverage --- tests/unit/test_gamepad_adapter.cpp | 175 +++++++++++++++++++++++++++- 1 file changed, 174 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_gamepad_adapter.cpp b/tests/unit/test_gamepad_adapter.cpp index 1d1ac09..c42d4d7 100644 --- a/tests/unit/test_gamepad_adapter.cpp +++ b/tests/unit/test_gamepad_adapter.cpp @@ -3,6 +3,10 @@ * @brief Unit tests for platform-neutral gamepad adapter helpers. */ +// standard includes +#include +#include + // local includes #include "fixtures/fixtures.hpp" @@ -12,6 +16,7 @@ TEST(GamepadAdapterTest, ReportsProfileSupport) { const auto generic = lvh::profiles::generic_gamepad(); const auto dualshock4 = lvh::profiles::dualshock4(); const auto dualsense = lvh::profiles::dualsense(); + const auto keyboard = lvh::profiles::keyboard(); const auto generic_support = lvh::gamepad_profile_support(generic); EXPECT_FALSE(generic_support.supports_rumble); @@ -38,27 +43,49 @@ TEST(GamepadAdapterTest, ReportsProfileSupport) { EXPECT_TRUE(dualsense_support.supports_battery); EXPECT_TRUE(dualsense_support.supports_misc1_button); EXPECT_EQ(dualsense_support.supported_rear_paddle_count, 0U); + + const auto keyboard_support = lvh::gamepad_profile_support(keyboard); + EXPECT_FALSE(keyboard_support.supports_rumble); + EXPECT_FALSE(keyboard_support.supports_motion); + EXPECT_FALSE(keyboard_support.supports_touchpad); + EXPECT_FALSE(keyboard_support.supports_battery); + EXPECT_FALSE(keyboard_support.supports_misc1_button); + + auto invalid_kind = generic; + invalid_kind.gamepad_kind = static_cast(255); + EXPECT_FALSE(lvh::supports_gamepad_button(invalid_kind, lvh::GamepadButton::misc1)); } TEST(GamepadAdapterTest, ChecksButtonsAndOutputsByProfile) { const auto xbox = lvh::profiles::xbox_series(); + const auto generic = lvh::profiles::generic_gamepad(); const auto dualshock4 = lvh::profiles::dualshock4(); const auto dualsense = lvh::profiles::dualsense(); + const auto keyboard = lvh::profiles::keyboard(); EXPECT_TRUE(lvh::supports_gamepad_button(xbox, lvh::GamepadButton::guide)); EXPECT_TRUE(lvh::supports_gamepad_button(xbox, lvh::GamepadButton::misc1)); EXPECT_FALSE(lvh::supports_gamepad_button(xbox, lvh::GamepadButton::touchpad)); EXPECT_FALSE(lvh::supports_gamepad_button(xbox, lvh::GamepadButton::paddle1)); + EXPECT_FALSE(lvh::supports_gamepad_button(xbox, lvh::GamepadButton::paddle2)); + EXPECT_FALSE(lvh::supports_gamepad_button(xbox, lvh::GamepadButton::paddle3)); + EXPECT_FALSE(lvh::supports_gamepad_button(xbox, lvh::GamepadButton::paddle4)); EXPECT_TRUE(lvh::supports_gamepad_button(dualshock4, lvh::GamepadButton::touchpad)); EXPECT_FALSE(lvh::supports_gamepad_button(dualshock4, lvh::GamepadButton::misc1)); EXPECT_TRUE(lvh::supports_gamepad_button(dualsense, lvh::GamepadButton::misc1)); + EXPECT_FALSE(lvh::supports_gamepad_button(keyboard, lvh::GamepadButton::a)); + EXPECT_FALSE(lvh::supports_gamepad_button(generic, static_cast(255))); EXPECT_TRUE(lvh::supports_gamepad_output(dualshock4, lvh::GamepadOutputKind::rumble)); EXPECT_TRUE(lvh::supports_gamepad_output(dualshock4, lvh::GamepadOutputKind::rgb_led)); EXPECT_FALSE(lvh::supports_gamepad_output(dualshock4, lvh::GamepadOutputKind::adaptive_triggers)); EXPECT_FALSE(lvh::supports_gamepad_output(dualshock4, lvh::GamepadOutputKind::trigger_rumble)); + EXPECT_TRUE(lvh::supports_gamepad_output(dualshock4, lvh::GamepadOutputKind::raw_report)); EXPECT_TRUE(lvh::supports_gamepad_output(dualsense, lvh::GamepadOutputKind::adaptive_triggers)); + EXPECT_FALSE(lvh::supports_gamepad_output(generic, lvh::GamepadOutputKind::raw_report)); + EXPECT_FALSE(lvh::supports_gamepad_output(keyboard, lvh::GamepadOutputKind::rumble)); + EXPECT_FALSE(lvh::supports_gamepad_output(generic, static_cast(255))); } TEST(GamepadAdapterTest, CachesAndSubmitsPartialUpdates) { @@ -93,6 +120,8 @@ TEST(GamepadAdapterTest, CachesAndSubmitsPartialUpdates) { EXPECT_TRUE(adapter.set_button(lvh::GamepadButton::a, true).ok()); EXPECT_TRUE(adapter.set_left_stick({0.25F, -0.5F}).ok()); + EXPECT_TRUE(adapter.set_right_stick({-0.25F, 0.5F}).ok()); + EXPECT_TRUE(adapter.set_left_trigger(0.5F).ok()); EXPECT_TRUE(adapter.set_right_trigger(1.0F).ok()); EXPECT_TRUE(adapter.set_touchpad_contact(0, {.id = 3, .active = true, .x = 0.5F, .y = 0.25F}).ok()); EXPECT_TRUE(adapter.set_acceleration(lvh::Vector3 {.x = 1.0F, .y = 2.0F, .z = 3.0F}).ok()); @@ -101,12 +130,15 @@ TEST(GamepadAdapterTest, CachesAndSubmitsPartialUpdates) { const auto *gamepad = adapter.gamepad(); ASSERT_NE(gamepad, nullptr); - EXPECT_EQ(gamepad->submit_count(), 7U); + EXPECT_EQ(gamepad->submit_count(), 9U); const auto submitted = gamepad->last_submitted_state(); EXPECT_TRUE(submitted.buttons.test(lvh::GamepadButton::a)); EXPECT_FLOAT_EQ(submitted.left_stick.x, 0.25F); EXPECT_FLOAT_EQ(submitted.left_stick.y, -0.5F); + EXPECT_FLOAT_EQ(submitted.right_stick.x, -0.25F); + EXPECT_FLOAT_EQ(submitted.right_stick.y, 0.5F); + EXPECT_FLOAT_EQ(submitted.left_trigger, 0.5F); EXPECT_FLOAT_EQ(submitted.right_trigger, 1.0F); ASSERT_TRUE(submitted.acceleration.has_value()); EXPECT_FLOAT_EQ(submitted.acceleration->z, 3.0F); @@ -144,11 +176,152 @@ TEST(GamepadAdapterTest, RejectsUnsupportedPartialUpdates) { lvh::ErrorCode::unsupported_profile ); EXPECT_EQ(adapter.set_acceleration(lvh::Vector3 {.x = 1.0F, .y = 0.0F, .z = 0.0F}).code(), lvh::ErrorCode::unsupported_profile); + EXPECT_EQ(adapter.set_gyroscope(lvh::Vector3 {.x = 0.0F, .y = 1.0F, .z = 0.0F}).code(), lvh::ErrorCode::unsupported_profile); + EXPECT_EQ( + adapter.set_motion(lvh::Vector3 {.x = 1.0F, .y = 0.0F, .z = 0.0F}, lvh::Vector3 {.x = 0.0F, .y = 1.0F, .z = 0.0F}).code(), + lvh::ErrorCode::unsupported_profile + ); + EXPECT_EQ(adapter.clear_motion().code(), lvh::ErrorCode::unsupported_profile); EXPECT_EQ( adapter.set_battery({.state = lvh::GamepadBatteryState::discharging, .percentage = 50}).code(), lvh::ErrorCode::unsupported_profile ); + EXPECT_EQ(adapter.clear_battery().code(), lvh::ErrorCode::unsupported_profile); + EXPECT_EQ(adapter.clear_touchpad_contact(0).code(), lvh::ErrorCode::unsupported_profile); EXPECT_EQ(adapter.set_button(lvh::GamepadButton::touchpad, true).code(), lvh::ErrorCode::unsupported_profile); EXPECT_EQ(adapter.set_button(lvh::GamepadButton::paddle1, true).code(), lvh::ErrorCode::unsupported_profile); EXPECT_EQ(adapter.gamepad()->submit_count(), 0U); } + +TEST(GamepadAdapterTest, RejectsInvalidCreationAndClosedAdapterUpdates) { + auto runtime = lvh::Runtime::create(); + ASSERT_NE(runtime, nullptr); + + lvh::CreateGamepadOptions options; + options.profile = lvh::profiles::keyboard(); + options.metadata.stable_id = "adapter-keyboard"; + const auto failed = lvh::GamepadStateAdapter::create(*runtime, options); + EXPECT_FALSE(failed); + EXPECT_EQ(failed.status.code(), lvh::ErrorCode::unsupported_profile); + + lvh::GamepadStateAdapter adapter(nullptr); + const auto &const_adapter = adapter; + EXPECT_EQ(const_adapter.gamepad(), nullptr); + EXPECT_FALSE(const_adapter.is_open()); + + lvh::GamepadState state; + EXPECT_EQ(adapter.submit().code(), lvh::ErrorCode::device_closed); + EXPECT_EQ(adapter.set_state(state).code(), lvh::ErrorCode::device_closed); + EXPECT_EQ(adapter.set_button(lvh::GamepadButton::a, true).code(), lvh::ErrorCode::device_closed); + EXPECT_EQ(adapter.set_left_stick({0.25F, -0.25F}).code(), lvh::ErrorCode::device_closed); + EXPECT_EQ(adapter.set_right_stick({-0.25F, 0.25F}).code(), lvh::ErrorCode::device_closed); + EXPECT_EQ(adapter.set_left_trigger(0.5F).code(), lvh::ErrorCode::device_closed); + EXPECT_EQ(adapter.set_right_trigger(0.5F).code(), lvh::ErrorCode::device_closed); + EXPECT_EQ(adapter.set_acceleration(std::nullopt).code(), lvh::ErrorCode::device_closed); + EXPECT_EQ(adapter.set_gyroscope(std::nullopt).code(), lvh::ErrorCode::device_closed); + EXPECT_EQ( + adapter.set_motion(lvh::Vector3 {.x = 1.0F, .y = 0.0F, .z = 0.0F}, lvh::Vector3 {.x = 0.0F, .y = 1.0F, .z = 0.0F}).code(), + lvh::ErrorCode::device_closed + ); + EXPECT_EQ(adapter.clear_motion().code(), lvh::ErrorCode::device_closed); + EXPECT_EQ(adapter.set_battery({.state = lvh::GamepadBatteryState::discharging, .percentage = 25}).code(), lvh::ErrorCode::device_closed); + EXPECT_EQ(adapter.clear_battery().code(), lvh::ErrorCode::device_closed); + EXPECT_EQ( + adapter.set_touchpad_contact(0, {.id = 1, .active = true, .x = 0.5F, .y = 0.25F}).code(), + lvh::ErrorCode::device_closed + ); + EXPECT_EQ(adapter.clear_touchpad_contact(0).code(), lvh::ErrorCode::device_closed); + + bool callback_called = false; + adapter.set_output_callback([&callback_called](const lvh::GamepadOutput &) { + callback_called = true; + }); + EXPECT_EQ(adapter.dispatch_output({}).code(), lvh::ErrorCode::device_closed); + EXPECT_FALSE(callback_called); + EXPECT_EQ(adapter.close().code(), lvh::ErrorCode::device_closed); +} + +TEST(GamepadAdapterTest, ReplacesStateAndClearsOptionalInputs) { + auto runtime = lvh::Runtime::create(); + ASSERT_NE(runtime, nullptr); + + lvh::CreateGamepadOptions options; + options.profile = lvh::profiles::dualsense(); + options.metadata.stable_id = "adapter-dualsense-state"; + + auto created = lvh::GamepadStateAdapter::create(*runtime, options); + ASSERT_TRUE(created) << created.status.message(); + ASSERT_NE(created.adapter, nullptr); + + auto &adapter = *created.adapter; + lvh::GamepadState replacement; + replacement.buttons.set(lvh::GamepadButton::a); + replacement.left_stick = {.x = 0.1F, .y = -0.2F}; + replacement.right_stick = {.x = 0.3F, .y = -0.4F}; + replacement.left_trigger = 0.5F; + replacement.right_trigger = 0.6F; + + EXPECT_TRUE(adapter.set_state(replacement).ok()); + const auto &const_adapter = adapter; + ASSERT_NE(const_adapter.gamepad(), nullptr); + EXPECT_TRUE(const_adapter.is_open()); + EXPECT_TRUE(const_adapter.state().buttons.test(lvh::GamepadButton::a)); + + EXPECT_TRUE(adapter.set_motion(lvh::Vector3 {.x = 1.0F, .y = 2.0F, .z = 3.0F}, lvh::Vector3 {.x = 4.0F, .y = 5.0F, .z = 6.0F}).ok()); + ASSERT_TRUE(adapter.gamepad()->last_submitted_state().acceleration.has_value()); + ASSERT_TRUE(adapter.gamepad()->last_submitted_state().gyroscope.has_value()); + EXPECT_TRUE(adapter.clear_motion().ok()); + EXPECT_FALSE(adapter.gamepad()->last_submitted_state().acceleration.has_value()); + EXPECT_FALSE(adapter.gamepad()->last_submitted_state().gyroscope.has_value()); + + EXPECT_TRUE(adapter.set_battery({.state = lvh::GamepadBatteryState::charging, .percentage = 75}).ok()); + ASSERT_TRUE(adapter.gamepad()->last_submitted_state().battery.has_value()); + EXPECT_TRUE(adapter.clear_battery().ok()); + EXPECT_FALSE(adapter.gamepad()->last_submitted_state().battery.has_value()); + + const lvh::GamepadTouchContact contact {.id = 7, .active = true, .x = 0.2F, .y = 0.8F}; + EXPECT_EQ(adapter.set_touchpad_contact(2, contact).code(), lvh::ErrorCode::invalid_argument); + EXPECT_TRUE(adapter.set_touchpad_contact(1, contact).ok()); + EXPECT_TRUE(adapter.gamepad()->last_submitted_state().touchpad_contacts[1].active); + EXPECT_EQ(adapter.clear_touchpad_contact(2).code(), lvh::ErrorCode::invalid_argument); + EXPECT_TRUE(adapter.clear_touchpad_contact(1).ok()); + EXPECT_FALSE(adapter.gamepad()->last_submitted_state().touchpad_contacts[1].active); +} + +TEST(GamepadAdapterTest, MovesAdaptersAndClosesOwnedGamepadOnDestruction) { + auto runtime = lvh::Runtime::create(); + ASSERT_NE(runtime, nullptr); + + lvh::CreateGamepadOptions first_options; + first_options.profile = lvh::profiles::generic_gamepad(); + first_options.metadata.stable_id = "adapter-move-first"; + + lvh::CreateGamepadOptions second_options; + second_options.profile = lvh::profiles::generic_gamepad(); + second_options.metadata.stable_id = "adapter-move-second"; + + { + auto scoped = lvh::GamepadStateAdapter::create(*runtime, first_options); + ASSERT_TRUE(scoped) << scoped.status.message(); + ASSERT_NE(scoped.adapter, nullptr); + EXPECT_EQ(runtime->active_device_count(), 1U); + } + EXPECT_EQ(runtime->active_device_count(), 0U); + + auto first = lvh::GamepadStateAdapter::create(*runtime, first_options); + ASSERT_TRUE(first) << first.status.message(); + ASSERT_NE(first.adapter, nullptr); + lvh::GamepadStateAdapter moved {std::move(*first.adapter)}; + EXPECT_EQ(first.adapter->gamepad(), nullptr); + ASSERT_NE(moved.gamepad(), nullptr); + const auto first_device_id = moved.gamepad()->device_id(); + + auto second = lvh::GamepadStateAdapter::create(*runtime, second_options); + ASSERT_TRUE(second) << second.status.message(); + ASSERT_NE(second.adapter, nullptr); + moved = std::move(*second.adapter); + EXPECT_EQ(second.adapter->gamepad(), nullptr); + ASSERT_NE(moved.gamepad(), nullptr); + EXPECT_NE(moved.gamepad()->device_id(), first_device_id); + EXPECT_TRUE(moved.close().ok()); +}