diff --git a/src/platform/macos/macos_broker_client.cpp b/src/platform/macos/macos_broker_client.cpp index 36e2dd6..fc1ac91 100644 --- a/src/platform/macos/macos_broker_client.cpp +++ b/src/platform/macos/macos_broker_client.cpp @@ -66,21 +66,18 @@ namespace lvh::detail { } OperationStatus submit(const GamepadState &state, const std::vector &report) override { - std::vector xbox_report; - if (xbox_transport_) { - xbox_report = profile_.gamepad_kind == GamepadProfileKind::xbox_360 ? - macos::xbox_transport_input_report(state) : - macos::xbox_gip_transport_input_report(state, report, profile_.gamepad_kind == GamepadProfileKind::xbox_series); + if (!xbox_transport_) { + return submit_report(report); } - const auto &transport_report = xbox_transport_ ? xbox_report : report; - if (transport_report.empty() || transport_report.size() > macos_broker::max_report_size) { - return OperationStatus::failure(ErrorCode::invalid_argument, "macOS gamepad report exceeds broker limit"); + if (profile_.gamepad_kind == GamepadProfileKind::xbox_360) { + return submit_report(macos::xbox_transport_input_report(state)); } - macos_broker::Message request; - request.type = macos_broker::MessageType::submit; - request.size = static_cast(transport_report.size()); - std::ranges::copy(transport_report, request.data.begin()); - return call(request); + // GameController reads the Bluetooth report and Steam reads GIP input. + const bool series = profile_.gamepad_kind == GamepadProfileKind::xbox_series; + if (auto status = submit_report(macos::xbox_bluetooth_transport_input_report(state, report, series)); !status.ok()) { + return status; + } + return submit_report(macos::xbox_gip_transport_input_report(state, report, series)); } void set_output_callback(OutputCallback callback) override { @@ -153,6 +150,17 @@ namespace lvh::detail { } } + OperationStatus submit_report(const std::vector &report) { + if (report.empty() || report.size() > macos_broker::max_report_size) { + return OperationStatus::failure(ErrorCode::invalid_argument, "macOS gamepad report exceeds broker limit"); + } + macos_broker::Message request; + request.type = macos_broker::MessageType::submit; + request.size = static_cast(report.size()); + std::ranges::copy(report, request.data.begin()); + return call(request); + } + OperationStatus call(const macos_broker::Message &request) { std::lock_guard call_lock {call_mutex_}; if (fd_ < 0) { diff --git a/src/platform/macos/macos_xbox_transport.hpp b/src/platform/macos/macos_xbox_transport.hpp index bd78e2e..941ec56 100644 --- a/src/platform/macos/macos_xbox_transport.hpp +++ b/src/platform/macos/macos_xbox_transport.hpp @@ -1,10 +1,13 @@ /** * @file src/platform/macos/macos_xbox_transport.hpp - * @brief Steam-compatible macOS HID transport for Xbox gamepad profiles. + * @brief Steam- and GameController-compatible macOS HID transport for Xbox gamepad profiles. */ #pragma once +#include "platform/shared/xbox_bluetooth_transport.hpp" + #include +#include #include #include #include @@ -15,22 +18,29 @@ namespace lvh::detail::macos { inline constexpr std::uint8_t xbox_gip_input_command = 0x20; + inline constexpr std::size_t xbox_gip_input_report_size = 25; + /** + * @brief Build the macOS Xbox One and Series HID descriptor. + * + * IOHIDUserDevice exposes its transport as Virtual even when Bluetooth is + * requested, so Steam selects its wired GIP decoder for these IDs, while + * Apple's GameController framework decodes only the Bluetooth input report. + * The device therefore declares both: the Xbox Bluetooth gamepad collection + * and a vendor collection that carries GIP input and wired rumble output. + * + * @return HID report descriptor bytes. + */ inline std::vector xbox_gip_report_descriptor() { - // IOHIDUserDevice exposes its transport as Virtual even when Bluetooth is - // requested. Steam therefore selects its wired GIP decoder for these IDs. - return { - 0x05, - 0x01, // Usage Page (Generic Desktop) - 0x09, - 0x05, // Usage (Game Pad) - 0xA1, - 0x01, // Collection (Application) + auto descriptor = xbox_bluetooth::make_xbox_bluetooth_report_descriptor(false); + constexpr std::array gip_collection { 0x06, 0x00, 0xFF, // Usage Page (Vendor Defined) 0x09, 0x01, // Usage (Vendor Defined 1) + 0xA1, + 0x01, // Collection (Application) 0x15, 0x00, // Logical Minimum (0) 0x26, @@ -45,19 +55,15 @@ namespace lvh::detail::macos { 0x81, 0x02, // Input (Data, Variable, Absolute) 0x85, - 0x03, // Bluetooth-compatible rumble output - 0x95, - 0x08, - 0x91, - 0x02, // Output (Data, Variable, Absolute) - 0x85, 0x09, // Wired GIP rumble output 0x95, 0x0C, 0x91, - 0x02, - 0xC0, + 0x02, // Output (Data, Variable, Absolute) + 0xC0, // End Collection }; + descriptor.insert(descriptor.end(), gip_collection.begin(), gip_collection.end()); + return descriptor; } inline std::vector xbox_gip_transport_input_report( @@ -73,7 +79,7 @@ namespace lvh::detail::macos { const auto pressed = [&state](GamepadButton button, unsigned int bit) { return state.buttons.test(button) ? (1U << bit) : 0U; }; - std::vector report(25U); + std::vector report(xbox_gip_input_report_size); report[0] = xbox_gip_input_command; report[3] = 0x10; // Sixteen-byte GIP state payload. report[4] = static_cast(pressed(start, 2) | pressed(back, 3) | pressed(a, 4) | pressed(b, 5) | pressed(x, 6) | pressed(y, 7)); @@ -94,6 +100,43 @@ namespace lvh::detail::macos { return report; } + /** + * @brief Build the Xbox Bluetooth input report sent alongside GIP input. + * + * GameController decodes buttons in the compact firmware 5 order (A, B, X, + * Y, bumpers, View, Menu, then stick clicks) rather than the gapped layout + * Linux exposes. The broker accepts one fixed input size per device, so the + * 17-byte report is zero-padded to the GIP report size; HID consumers read + * only the fields declared for report ID 1. + * + * @param state Gamepad state. + * @param packed Input report packed for the requested Xbox profile. + * @param series Whether the requested profile has the Series Share button. + * @return Padded Bluetooth input report, or empty when `packed` is too short. + */ + inline std::vector xbox_bluetooth_transport_input_report( + const GamepadState &state, + std::span packed, + bool series + ) { + if (packed.size() < 15U) { + return {}; + } + + using enum GamepadButton; + const auto pressed = [&state](GamepadButton button, unsigned int bit) { + return state.buttons.test(button) ? (1U << bit) : 0U; + }; + std::vector report(xbox_gip_input_report_size); + report[0] = xbox_bluetooth::xbox_bluetooth_input_report_id; + std::copy_n(packed.begin(), 12, report.begin() + 1); // Sticks and triggers. + report[13] = packed[14]; // Hat switch. + report[14] = static_cast(pressed(a, 0) | pressed(b, 1) | pressed(x, 2) | pressed(y, 3) | pressed(left_shoulder, 4) | pressed(right_shoulder, 5) | pressed(back, 6) | pressed(start, 7)); + report[15] = static_cast(pressed(left_stick, 0) | pressed(right_stick, 1) | pressed(guide, 2)); + report[16] = series && state.buttons.test(misc1) ? 0x01 : 0x00; // Record usage. + return report; + } + inline std::vector xbox_transport_output_reports( const DeviceProfile &profile, const std::vector &report diff --git a/tests/unit/test_macos_broker_protocol.cpp b/tests/unit/test_macos_broker_protocol.cpp index 744ba97..e687e8a 100644 --- a/tests/unit/test_macos_broker_protocol.cpp +++ b/tests/unit/test_macos_broker_protocol.cpp @@ -6,11 +6,13 @@ #include "platform/macos/broker/io.hpp" #include "platform/macos/macos_xbox_transport.hpp" +#include #include #include #include #include #include +#include #include #include #include @@ -97,6 +99,10 @@ TEST(MacosBrokerProtocolTest, XboxOneAndSeriesUseDistinctIdentitiesAndGipInput) EXPECT_EQ(transport.input_report_size, 25U); EXPECT_EQ(transport.output_report_size, 9U); EXPECT_NE(transport.report_descriptor, requested.report_descriptor); + for (const std::uint8_t report_id : {0x01, 0x03, 0x09, 0x20}) { + const std::array report_id_item {0x85, report_id}; + EXPECT_FALSE(std::ranges::search(transport.report_descriptor, report_id_item).empty()) << int {report_id}; + } lvh::GamepadState state; state.buttons.set(lvh::GamepadButton::a); @@ -164,6 +170,70 @@ TEST(MacosBrokerProtocolTest, XboxGipTransportMapsEveryButtonAndGuide) { } } +TEST(MacosBrokerProtocolTest, XboxBluetoothTransportUsesCompactGameControllerLayout) { + using enum lvh::GamepadButton; + + struct ButtonCase { + lvh::GamepadButton button; + std::size_t index; + std::uint8_t mask; + }; + + const std::array buttons { + ButtonCase {a, 14, 0x01}, + ButtonCase {b, 14, 0x02}, + ButtonCase {x, 14, 0x04}, + ButtonCase {y, 14, 0x08}, + ButtonCase {left_shoulder, 14, 0x10}, + ButtonCase {right_shoulder, 14, 0x20}, + ButtonCase {back, 14, 0x40}, + ButtonCase {start, 14, 0x80}, + ButtonCase {left_stick, 15, 0x01}, + ButtonCase {right_stick, 15, 0x02}, + ButtonCase {guide, 15, 0x04}, + ButtonCase {misc1, 16, 0x01}, + }; + for (const auto &item : buttons) { + lvh::GamepadState state; + state.buttons.set(item.button); + const auto packed = lvh::reports::pack_input_report(lvh::profiles::xbox_series(), state); + const auto report = lvh::detail::macos::xbox_bluetooth_transport_input_report(state, packed, true); + ASSERT_EQ(report.size(), 25U); + EXPECT_EQ(report[0], 0x01U); + EXPECT_EQ(report[item.index], item.mask); + EXPECT_EQ(std::bitset<8> {report[14]}.count() + std::bitset<8> {report[15]}.count() + std::bitset<8> {report[16]}.count(), 1U); + } + + lvh::GamepadState share; + share.buttons.set(misc1); + const auto xbox_one = lvh::detail::macos::xbox_bluetooth_transport_input_report( + share, + lvh::reports::pack_input_report(lvh::profiles::xbox_one(), share), + false + ); + ASSERT_EQ(xbox_one.size(), 25U); + EXPECT_EQ(xbox_one[16], 0U); +} + +TEST(MacosBrokerProtocolTest, XboxBluetoothTransportCopiesAxesAndHat) { + lvh::GamepadState state; + state.left_stick = {1.0F, 1.0F}; + state.right_stick = {-1.0F, 0.0F}; + state.left_trigger = 1.0F; + state.right_trigger = 0.5F; + state.buttons.set(lvh::GamepadButton::dpad_left); + const auto packed = lvh::reports::pack_input_report(lvh::profiles::xbox_series(), state); + const auto report = lvh::detail::macos::xbox_bluetooth_transport_input_report(state, packed, true); + ASSERT_EQ(report.size(), 25U); + EXPECT_TRUE(std::ranges::equal(std::span {report}.subspan(1, 12), std::span {packed}.first(12))); + EXPECT_EQ(report[13], packed[14]); + EXPECT_NE(report[13], 0U); + EXPECT_TRUE(std::ranges::all_of(std::span {report}.subspan(17), [](auto value) { + return value == 0U; + })); + EXPECT_TRUE(lvh::detail::macos::xbox_bluetooth_transport_input_report(state, std::span {packed}.first(14), true).empty()); +} + TEST(MacosBrokerProtocolTest, XboxGipRumbleUsesFourMotorOutputDecoder) { const auto profile = lvh::detail::macos::xbox_transport_profile(lvh::profiles::xbox_series()); const std::vector wired {