From fa6c880b8d587d8c34ce69751c70f7bac0c03100 Mon Sep 17 00:00:00 2001 From: NeroShooter Date: Mon, 26 Oct 2026 09:58:47 +0100 Subject: [PATCH 1/2] fix(macos): let GameController decode virtual Xbox controllers The macOS Xbox One/Series transport only sent wired GIP report 0x20, which Steam reads but Apple's GameController framework ignores, so streamed Xbox pads appeared with no inputs. Declare the Xbox Bluetooth gamepad collection alongside the GIP vendor collection and send a compact Bluetooth report 0x01 before each GIP report. Co-Authored-By: Claude Opus 5.5 --- src/platform/macos/macos_broker_client.cpp | 34 +++++---- src/platform/macos/macos_xbox_transport.hpp | 81 ++++++++++++++++----- tests/unit/test_macos_broker_protocol.cpp | 70 ++++++++++++++++++ 3 files changed, 153 insertions(+), 32 deletions(-) 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..d5bf2e5 100644 --- a/tests/unit/test_macos_broker_protocol.cpp +++ b/tests/unit/test_macos_broker_protocol.cpp @@ -6,8 +6,10 @@ #include "platform/macos/broker/io.hpp" #include "platform/macos/macos_xbox_transport.hpp" +#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 { From b1531cc25422d409e99b57887a12b23ccc30a7c0 Mon Sep 17 00:00:00 2001 From: NeroShooter Date: Wed, 28 Oct 2026 09:51:02 +0100 Subject: [PATCH 2/2] style(tests): sort macOS broker test includes Co-Authored-By: Claude Opus 5.5 --- tests/unit/test_macos_broker_protocol.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_macos_broker_protocol.cpp b/tests/unit/test_macos_broker_protocol.cpp index d5bf2e5..e687e8a 100644 --- a/tests/unit/test_macos_broker_protocol.cpp +++ b/tests/unit/test_macos_broker_protocol.cpp @@ -9,10 +9,10 @@ #include #include #include -#include #include #include #include +#include #include #include #include