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..c42d4d7 --- /dev/null +++ b/tests/unit/test_gamepad_adapter.cpp @@ -0,0 +1,327 @@ +/** + * @file tests/unit/test_gamepad_adapter.cpp + * @brief Unit tests for platform-neutral gamepad adapter helpers. + */ + +// standard includes +#include +#include + +// 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 keyboard = lvh::profiles::keyboard(); + + 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); + + 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) { + 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_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()); + 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(), 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); + 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_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()); +}