Beyond USB: Improving Virtual Controller Support in Linux Games
Recap#
I’ve been working on an open source project called inputtino: a library that allows you to create and use virtual input devices (ex: mouse, keyboard, joypads) on Linux.
In the first post, we have gone to the depts of SDL to understand why uinput
wasn’t
able to expose the advanced features of a DualSense controller.
Moving to the second post, we’ve managed to create a virtual PlayStation DualSense controller using UHID that supported some of the advanced features like Gyroscope, Accelerometer, and Touchpad.
Whilst the implementation seemed to work with Steam, users reported issues1 2 with the controller not being recognized by some games; in particular, the ones that should support natively the DualSense capabilities. On top of that, SDL2 seems incapable to expose some of the advanced features like LED, battery and touchpad. Since SDL is open source, and we are already familiar with the codebase, that’s the perfect candidate to start deep diving.
SDL2 and DualSense on Linux#
We’ve briefly mentioned in the first post that SDL2 uses different drivers to
communicate with joypads.
When using our virtual joypads SDL2 tries to use the hidapi
driver and fails, falling back to the sysjoystick
driver. This driver is a generic one that is lacking advanced features; for example: here’s the implementation
of LINUX_JoystickSetLED:
static int LINUX_JoystickSetLED(SDL_Joystick *joystick, Uint8 red, Uint8 green, Uint8 blue)
{
return SDL_Unsupported();
}
Ok, that explains why some of the features aren’t exposed by SDL when accessing our virtual joypad.
So, how can we make SDL2 use the hidapi
driver for our virtual joystick like it does for a real DualSense instead?
It’s time to dive back into the SDL2 source code!
The HIDAPI driver#
hidapi is a multiplatform library that allows applications to interface with USB and
Bluetooth HID devices. On linux there are two main backends: hidraw
and libusb
, we are going to focus on the first
one for reasons that will be clear later3.
hidraw
interfaces with the kernel’s HID
subsystem directly through /dev/hidraw*
devices. This is in contrast with
the previously mentioned sysjoistick
which instead works via the Linux input subsystem (
/dev/input/js*
or more recently /dev/input/event*
): a more generic interface that works with any kind of “device
that can be represented as a joystick”, but it lacks the ability to access advanced features.
By accessing to our virtual devices via hidraw
an application would be able to get full access to the UHID
reports
that we are sending (and receiving) to the kernel. The full implementation in SDL2 resides in
SDL_hidapi_ps5.c
Why hidraw doesn’t pick up our virtual DualSense?#
After debugging the SDL2 code I’ve found the place where hidraw
will fail to open our virtual
joypad: hid.c#L601-L620:
switch (bus_type) {
case BUS_USB:
/* The device pointed to by raw_dev contains information about
the hidraw device. In order to get information about the
USB device, get the parent device with the
subsystem/devtype pair of "usb"/"usb_device". This will
be several levels up the tree, but the function will find
it. */
usb_dev = udev_device_get_parent_with_subsystem_devtype(
raw_dev,
"usb",
"usb_device");
if (!usb_dev) {
/* Free this device */
// ...
/* Take it off the device list. */
It seems that for our device created via uhid
we don’t have a parent USB device (understandably so), the check fails
and our device is doomed to be opened by the fallback sysjoystick
driver.
💡 this seems to be specific to BUS_USB
but a DualSense joypad can also be connected via Bluetooth. What’s the code
doing there?
case BUS_BLUETOOTH:
/* Manufacturer and Product strings */
cur_dev->manufacturer_string = wcsdup(L"");
cur_dev->product_string = utf8_to_wchar_t(product_name_utf8);
break;
Well well well, that seems rather straightforward. Could it be that we just need to change our virtual joypad to be a Bluetooth one?
Implementing a virtual Bluetooth DualSense#
Luckily, most of the USB code can be reused for Bluetooth. The data exchanged via uhid
is slightly different but the
main structure and expected values are the same. For the most curious, here’s
the full PR that implements it.
There are a few notable additions to the code:
- The
UHID_CREATE2
event now usesBUS_BLUETOOTH
instead ofBUS_USB
and a slightly different report descriptor (always thanks to nondebug/dualsense) - I had to add a background thread that will keep sending the current joypad state to the kernel even if there’s no
change. For example, I’ve found that SDL
was timing out during initialization
if the joypad wasn’t sending any data after
16ms
- Bluetooth communications on a DualSense device involved a
CRC32
checksum of each message appended at the end. I wrote a short note about the implementation in here if you are interested in the details.
Does it work?#
I’ve tested it on my machine where Helldivers 2 wasn’t taking any input without Steam Input enabled before, and it’s now perfectly working with the Bluetooth variant. @hgaiser also reported that Horizon Zero Dawn Forbidden West is now working as expected.
We’ll see how this will work out in the wild for other users!
Ai posteri l’ardua sentenza.
Spoiler alert: since we are going to implement a virtual Bluetooth device,
libusb
is not an option for us. Onlyhidraw
supports both Bluetooth and USB devices. ↩︎