Skip to content
Open
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
19 changes: 16 additions & 3 deletions docs/platform-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,20 @@ and control channels. Numbered control-channel output is normalized before
parsing, whether the kernel includes the report number in the payload or
provides it separately on the UHID event.

The default DualShock 4 and DualSense profiles use Bluetooth framing, avoiding
the parent-USB checks that can make virtual USB devices appear late in Steam.
Explicit USB and Bluetooth factories remain available for consumers that
require a particular transport. DualShock 4 Bluetooth input reports set the
HID-present header flag required by HIDAPI consumers and include the transport
CRC, so a running consumer can accept live input after hotplug. DualSense motion
packing preserves the public meters-per-second-squared and degrees-per-second
units while applying the same
raw sensor calibration used by Inputtino. Periodic PlayStation reports are
repacked at 100 Hz so their sequence number and sensor timestamp continue to
advance even when controller state is unchanged. Periodic and application
submissions are serialized so a repeated report cannot restore stale motion
state after a newer application report.

The backend opens `/dev/uhid` in nonblocking mode, matching the original
asynchronous gamepad registration path. Its event reader is active before
device registration begins, and creation does not report success until the
Expand All @@ -147,9 +161,8 @@ a controller before its kernel HID device has started.

On Linux, DualShock 4 and DualSense emit Sony's native `Wireless Controller`
product name for Steam HID discovery. The requested USB or Bluetooth bus,
descriptor, and report framing remain unchanged; in particular, the default
DualShock 4 profile stays on its USB report contract. This transport-only name
is confined to the Linux backend; public profile names, Windows names, and VHF
descriptor, and report framing remain unchanged. This transport-only name is
confined to the Linux backend; public profile names, Windows names, and VHF
behavior are unchanged.

Switch Pro keeps its Nintendo identity on the Linux uinput path. This follows
Expand Down
4 changes: 4 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,10 @@ Consumers may replace `DeviceProfile::name` before creating a gamepad, for
example, to prepend an application name while preserving the default controller
identity across platform backends.

`profiles::dualshock4()` and `profiles::dualsense()` select Bluetooth framing
for reliable native-controller discovery. Consumers can use the corresponding
`_usb()` or `_bluetooth()` factory when the transport must be explicit.

The platform-neutral Generic HID descriptor reports the D-pad as buttons 13
through 16 in the input report. Linux may still route that profile through
`uinput`, where the backend exposes those same logical directions through the
Expand Down
4 changes: 2 additions & 2 deletions src/core/profiles.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2095,7 +2095,7 @@ namespace lvh::profiles {
}

DeviceProfile dualshock4() {
return dualshock4_usb();
return dualshock4_bluetooth();
}

DeviceProfile dualshock4_usb() {
Expand All @@ -2107,7 +2107,7 @@ namespace lvh::profiles {
}

DeviceProfile dualsense() {
return dualsense_usb();
return dualsense_bluetooth();
}

DeviceProfile dualsense_usb() {
Expand Down
38 changes: 32 additions & 6 deletions src/core/report.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
// standard includes
#include <algorithm>
#include <array>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <numbers>
#include <optional>
#include <span>
#include <utility>
Expand All @@ -31,6 +33,8 @@ namespace lvh::reports {

constexpr auto dualshock4_bt_input_report_id = std::byte {0x11};

constexpr auto dualshock4_bt_input_hid_present = std::byte {0x80};

constexpr auto dualshock4_bt_output_report_id = std::byte {0x11};

constexpr auto dualshock4_output_hwctl_crc32 = std::byte {0x40};
Expand Down Expand Up @@ -61,6 +65,10 @@ namespace lvh::reports {

constexpr auto dualsense_flag2_compatible_vibration = std::byte {0x04};

constexpr auto dualsense_acceleration_scale = 9.80665F * 100.0F;

constexpr auto dualsense_gyroscope_scale = 1145.0F * std::numbers::pi_v<float> / 180.0F;

constexpr std::uint8_t switch_rumble_and_subcommand_output_report_id = 0x01;

constexpr std::uint8_t switch_rumble_only_output_report_id = 0x10;
Expand Down Expand Up @@ -613,6 +621,19 @@ namespace lvh::reports {
return static_cast<std::uint16_t>((static_cast<std::uint64_t>(elapsed) * 3U) / 16U);
}

std::uint8_t dualsense_sequence_number() {
static std::atomic_uint32_t sequence_number = 0;
return static_cast<std::uint8_t>((sequence_number.fetch_add(1U, std::memory_order_relaxed) + 1U) % 255U);
}

std::uint32_t dualsense_sensor_timestamp() {
const auto elapsed = std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now().time_since_epoch()
)
.count();
return static_cast<std::uint32_t>(static_cast<std::uint64_t>(elapsed) / 333U);
}

std::vector<std::uint8_t> pack_dualshock4_input_report(const DeviceProfile &profile, const GamepadState &state) {
const auto is_bluetooth = profile.bus_type == BusType::bluetooth;
const auto payload_offset = is_bluetooth ? 3U : 1U;
Expand All @@ -627,6 +648,9 @@ namespace lvh::reports {

ByteReport report(profile.input_report_size, zero_byte);
report[0] = is_bluetooth ? dualshock4_bt_input_report_id : to_byte(profile.report_id);
if (is_bluetooth) {
report[1] = dualshock4_bt_input_hid_present;
}

report[payload_offset + 0U] = to_byte(normalize_u8_axis(normalized.left_stick.x));
report[payload_offset + 1U] = to_byte(normalize_u8_axis(-normalized.left_stick.y));
Expand Down Expand Up @@ -722,6 +746,7 @@ namespace lvh::reports {
report[payload_offset + 3U] = to_byte(normalize_u8_axis(-normalized.right_stick.y));
report[payload_offset + 4U] = to_byte(normalize_trigger(normalized.left_trigger));
report[payload_offset + 5U] = to_byte(normalize_trigger(normalized.right_trigger));
report[payload_offset + 6U] = to_byte(dualsense_sequence_number());
report[payload_offset + 7U] = to_byte(hat_from_buttons(normalized.buttons));

if (normalized.buttons.test(GamepadButton::x)) {
Expand Down Expand Up @@ -773,15 +798,16 @@ namespace lvh::reports {
}

if (normalized.gyroscope) {
write_i16(report, payload_offset + 15U, scale_i16(normalized.gyroscope->x, 1145.0F));
write_i16(report, payload_offset + 17U, scale_i16(normalized.gyroscope->y, 1145.0F));
write_i16(report, payload_offset + 19U, scale_i16(normalized.gyroscope->z, 1145.0F));
write_i16(report, payload_offset + 15U, scale_i16(normalized.gyroscope->x, dualsense_gyroscope_scale));
write_i16(report, payload_offset + 17U, scale_i16(normalized.gyroscope->y, dualsense_gyroscope_scale));
write_i16(report, payload_offset + 19U, scale_i16(normalized.gyroscope->z, dualsense_gyroscope_scale));
}
if (normalized.acceleration) {
write_i16(report, payload_offset + 21U, scale_i16(normalized.acceleration->x, 100.0F));
write_i16(report, payload_offset + 23U, scale_i16(normalized.acceleration->y, 100.0F));
write_i16(report, payload_offset + 25U, scale_i16(normalized.acceleration->z, 100.0F));
write_i16(report, payload_offset + 21U, scale_i16(normalized.acceleration->x, dualsense_acceleration_scale));
write_i16(report, payload_offset + 23U, scale_i16(normalized.acceleration->y, dualsense_acceleration_scale));
write_i16(report, payload_offset + 25U, scale_i16(normalized.acceleration->z, dualsense_acceleration_scale));
}
write_u32(report, payload_offset + 27U, dualsense_sensor_timestamp());

write_dualsense_touch_contact(report, payload_offset + 32U, normalized.touchpad_contacts[0]);
write_dualsense_touch_contact(report, payload_offset + 36U, normalized.touchpad_contacts[1]);
Expand Down
12 changes: 10 additions & 2 deletions src/include/libvirtualhid/profiles.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ namespace lvh::profiles {
/**
* @brief Create the PlayStation DualShock 4-compatible gamepad profile.
*
* @return Default DualShock 4-compatible device profile.
* The default uses Bluetooth framing because Linux native-controller
* consumers discover virtual DualShock 4 devices more reliably through that
* transport. Use `dualshock4_usb()` when USB framing is explicitly required.
*
* @return Bluetooth DualShock 4-compatible device profile.
*/
DeviceProfile dualshock4();

Expand All @@ -65,7 +69,11 @@ namespace lvh::profiles {
/**
* @brief Create the PlayStation DualSense-compatible gamepad profile.
*
* @return Default DualSense-compatible device profile.
* The default uses Bluetooth framing because Linux native-controller
* consumers discover virtual DualSense devices more reliably through that
* transport. Use `dualsense_usb()` when USB framing is explicitly required.
*
* @return Bluetooth DualSense-compatible device profile.
*/
DeviceProfile dualsense();

Expand Down
53 changes: 27 additions & 26 deletions src/platform/linux/uhid_backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2815,8 +2815,8 @@ namespace lvh::detail {
std::memcpy(request.rd_data, options.profile.report_descriptor.data(), options.profile.report_descriptor.size());
profile_ = options.profile;
{
std::lock_guard lock {report_mutex_};
last_report_ = reports::pack_input_report(profile_, {});
std::lock_guard lock {state_mutex_};
last_state_ = {};
}

{
Expand Down Expand Up @@ -2848,25 +2848,13 @@ namespace lvh::detail {
}

OperationStatus submit(
const GamepadState & /*state*/,
const GamepadState &state,
const std::vector<std::uint8_t> &report
) override {
if (!open_) {
return OperationStatus::failure(ErrorCode::device_closed, "UHID gamepad is closed");
}

uhid_event event {};
if (report.size() > sizeof(event.u.input2.data)) {
return OperationStatus::failure(ErrorCode::invalid_argument, "HID input report is too large for UHID");
}

event.type = UHID_INPUT2;
event.u.input2.size = static_cast<std::uint16_t>(report.size());
std::memcpy(event.u.input2.data, report.data(), report.size());
auto status = write_event(event);
std::lock_guard lock {state_mutex_};
auto status = write_input_report(report);
if (status.ok()) {
std::lock_guard lock {report_mutex_};
last_report_ = report;
last_state_ = state;
}
return status;
}
Expand Down Expand Up @@ -2922,6 +2910,22 @@ namespace lvh::detail {
}

private:
OperationStatus write_input_report(const std::vector<std::uint8_t> &report) {
if (!open_) {
return OperationStatus::failure(ErrorCode::device_closed, "UHID gamepad is closed");
}

uhid_event event {};
if (report.size() > sizeof(event.u.input2.data)) {
return OperationStatus::failure(ErrorCode::invalid_argument, "HID input report is too large for UHID");
}

event.type = UHID_INPUT2;
event.u.input2.size = static_cast<std::uint16_t>(report.size());
std::memcpy(event.u.input2.data, report.data(), report.size());
return write_event(event);
}

OperationStatus write_event(const uhid_event &event) {
using enum ErrorCode;

Expand Down Expand Up @@ -3042,13 +3046,10 @@ namespace lvh::detail {
break;
}

std::vector<std::uint8_t> report;
{
std::lock_guard lock {report_mutex_};
report = last_report_;
}
std::lock_guard lock {state_mutex_};
const auto report = reports::pack_input_report(profile_, last_state_);
if (!report.empty()) {
static_cast<void>(submit({}, report));
static_cast<void>(write_input_report(report));
}
}
}
Expand Down Expand Up @@ -3173,7 +3174,7 @@ namespace lvh::detail {
std::string physical_id_;
std::string unique_id_;
std::array<std::uint8_t, 6> playstation_mac_address_ {};
std::vector<std::uint8_t> last_report_;
GamepadState last_state_;
std::atomic_bool open_ = true;
std::atomic_bool running_ = false;
std::jthread reader_;
Expand All @@ -3183,7 +3184,7 @@ namespace lvh::detail {
bool started_ = false;
bool reader_exited_ = false;
std::mutex write_mutex_;
std::mutex report_mutex_;
std::mutex state_mutex_;
std::mutex callback_mutex_;
OutputCallback output_callback_;
};
Expand Down
4 changes: 2 additions & 2 deletions tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -212,9 +212,9 @@ namespace lvh::detail::test {
bool saw_dualshock4_feature_crc = false;

/**
* @brief Whether the peer observed a Bluetooth-framed DualSense input report.
* @brief Whether periodic Bluetooth DualSense reports preserved motion and advanced sensor metadata.
*/
bool saw_dualsense_bluetooth_input = false;
bool saw_dualsense_bluetooth_input_with_live_sensor_metadata = false;

/**
* @brief Whether the peer observed a Bluetooth-framed DualShock 4 input report.
Expand Down
38 changes: 35 additions & 3 deletions tests/fixtures/linux_backend_test_hooks.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1698,13 +1698,42 @@ namespace lvh::detail::test {
UhidGamepad gamepad {descriptors[0]};
auto event = create_started_profile_uhid_gamepad(gamepad, 9, options, descriptors[1], BUS_BLUETOOTH, result);

GamepadState motion_state;
motion_state.acceleration = Vector3 {.x = 1.0F, .y = 2.0F, .z = 3.0F};
motion_state.gyroscope = Vector3 {.x = 4.0F, .y = 5.0F, .z = 6.0F};
const auto submitted_report = reports::pack_input_report(options.profile, motion_state);
result.submit_status = gamepad.submit(motion_state, submitted_report);

bool first_input_report_valid = false;
std::uint8_t first_sequence = 0;
std::uint32_t first_sensor_timestamp = 0;
std::array<std::uint8_t, 12> first_sensor_values {};
if (read_uhid_event_type(descriptors[1], UHID_INPUT2, event)) {
const auto report_size = static_cast<std::size_t>(event.u.input2.size);
if (report_size == options.profile.input_report_size && event.u.input2.data[0] == 0x31) {
const auto crc_offset = report_size - 4U;
const auto expected_crc = crc32(std::span<const std::uint8_t> {event.u.input2.data, crc_offset}, playstation_crc_seed(0xA1));
const auto actual_crc = read_u32_le(event.u.input2.data + crc_offset);
result.saw_dualsense_bluetooth_input = expected_crc == actual_crc;
std::copy_n(event.u.input2.data + 17U, first_sensor_values.size(), first_sensor_values.begin());
first_input_report_valid = expected_crc == actual_crc && std::equal(
first_sensor_values.begin(),
first_sensor_values.end(),
submitted_report.begin() + 17
);
first_sequence = event.u.input2.data[8];
first_sensor_timestamp = read_u32_le(event.u.input2.data + 29U);
}
}

if (read_uhid_event_type(descriptors[1], UHID_INPUT2, event)) {
const auto report_size = static_cast<std::size_t>(event.u.input2.size);
if (report_size == options.profile.input_report_size && event.u.input2.data[0] == 0x31) {
const auto crc_offset = report_size - 4U;
const auto expected_crc = crc32(std::span<const std::uint8_t> {event.u.input2.data, crc_offset}, playstation_crc_seed(0xA1));
const auto actual_crc = read_u32_le(event.u.input2.data + crc_offset);
result.saw_dualsense_bluetooth_input_with_live_sensor_metadata =
first_input_report_valid && expected_crc == actual_crc && event.u.input2.data[8] != first_sequence &&
read_u32_le(event.u.input2.data + 29U) != first_sensor_timestamp && std::equal(first_sensor_values.begin(), first_sensor_values.end(), event.u.input2.data + 17U);
}
}

Expand Down Expand Up @@ -1732,7 +1761,6 @@ namespace lvh::detail::test {

result.close_status = gamepad.close();
static_cast<void>(::close(descriptors[1]));
result.submit_status = OperationStatus::success();
return result;
}

Expand Down Expand Up @@ -1840,7 +1868,11 @@ namespace lvh::detail::test {

if (read_uhid_event_type(descriptors[1], UHID_INPUT2, event)) {
const auto report_size = static_cast<std::size_t>(event.u.input2.size);
if (report_size == options.profile.input_report_size && event.u.input2.data[0] == 0x11) {
if (
report_size == options.profile.input_report_size &&
event.u.input2.data[0] == 0x11 &&
(event.u.input2.data[1] & 0x80U) != 0U
) {
const auto crc_offset = report_size - 4U;
const auto expected_crc = crc32(std::span<const std::uint8_t> {event.u.input2.data, crc_offset}, playstation_crc_seed(0xA1));
const auto actual_crc = read_u32_le(event.u.input2.data + crc_offset);
Expand Down
3 changes: 2 additions & 1 deletion tests/unit/test_linux_backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -822,11 +822,12 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualSenseRepliesToFeatureReports) {
TEST_F(LinuxBackendTest, SocketpairBackedDualSenseBluetoothFramesReports) {
const auto result = lvh::detail::test::linux_dualsense_bluetooth_uhid_socketpair_reports();
EXPECT_TRUE(result.create_status.ok()) << result.create_status.message();
EXPECT_TRUE(result.submit_status.ok()) << result.submit_status.message();
EXPECT_TRUE(result.close_status.ok()) << result.close_status.message();
EXPECT_TRUE(result.creation.saw_create);
EXPECT_TRUE(result.creation.waited_for_start);
EXPECT_EQ(result.creation.name, "Wireless Controller");
EXPECT_TRUE(result.saw_dualsense_bluetooth_input);
EXPECT_TRUE(result.saw_dualsense_bluetooth_input_with_live_sensor_metadata);
EXPECT_TRUE(result.saw_dualsense_pairing);
EXPECT_TRUE(result.saw_dualsense_feature_crc);
}
Expand Down
3 changes: 1 addition & 2 deletions tests/unit/test_linux_consumers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -886,7 +886,7 @@ TEST_F(LinuxConsumerTest, SdlSeesDualShock4UsbControllerBehavior) {
});
}

TEST_F(LinuxConsumerTest, SdlSeesDualShock4BluetoothControllerDiscovery) {
TEST_F(LinuxConsumerTest, SdlSeesDualShock4BluetoothControllerBehavior) {
ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid"));

run_sdl_playstation_controller_test({
Expand All @@ -895,7 +895,6 @@ TEST_F(LinuxConsumerTest, SdlSeesDualShock4BluetoothControllerDiscovery) {
.stable_id = "02:00:00:00:00:04",
.minimum_buttons = 10,
.minimum_axes = 4,
.expect_live_input = false,
});
}

Expand Down
Loading
Loading