Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 21 additions & 13 deletions src/platform/macos/macos_broker_client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -66,21 +66,18 @@ namespace lvh::detail {
}

OperationStatus submit(const GamepadState &state, const std::vector<std::uint8_t> &report) override {
std::vector<std::uint8_t> 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<std::uint32_t>(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 {
Expand Down Expand Up @@ -153,6 +150,17 @@ namespace lvh::detail {
}
}

OperationStatus submit_report(const std::vector<std::uint8_t> &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<std::uint32_t>(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) {
Expand Down
81 changes: 62 additions & 19 deletions src/platform/macos/macos_xbox_transport.hpp
Original file line number Diff line number Diff line change
@@ -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 <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
#include <libvirtualhid/profiles.hpp>
Expand All @@ -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<std::uint8_t> 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<std::uint8_t, 27> 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,
Expand All @@ -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<std::uint8_t> xbox_gip_transport_input_report(
Expand All @@ -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<std::uint8_t> report(25U);
std::vector<std::uint8_t> report(xbox_gip_input_report_size);
report[0] = xbox_gip_input_command;
report[3] = 0x10; // Sixteen-byte GIP state payload.
report[4] = static_cast<std::uint8_t>(pressed(start, 2) | pressed(back, 3) | pressed(a, 4) | pressed(b, 5) | pressed(x, 6) | pressed(y, 7));
Expand All @@ -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<std::uint8_t> xbox_bluetooth_transport_input_report(
const GamepadState &state,
std::span<const std::uint8_t> 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<std::uint8_t> 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<std::uint8_t>(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<std::uint8_t>(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<GamepadOutput> xbox_transport_output_reports(
const DeviceProfile &profile,
const std::vector<std::uint8_t> &report
Expand Down
70 changes: 70 additions & 0 deletions tests/unit/test_macos_broker_protocol.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
#include "platform/macos/broker/io.hpp"
#include "platform/macos/macos_xbox_transport.hpp"

#include <algorithm>
#include <array>
#include <bitset>
#include <gtest/gtest.h>
#include <libvirtualhid/profiles.hpp>
#include <libvirtualhid/report.hpp>
#include <span>
#include <sys/socket.h>
#include <thread>
#include <unistd.h>
Expand Down Expand Up @@ -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<std::uint8_t, 2> 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);
Expand Down Expand Up @@ -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<std::uint8_t> wired {
Expand Down
Loading