Case study: 3Dconnexion SpaceNavigator and the Gamepad API
This article illustrates how to diagnose and correct a “quirky” input device on Linux. It will explore how input devices are handled throughout the software stack from web browsers down to USB HID report descriptors, highlighting the roles of udev, the kernel, and BPF. It will also evaluate previous attempts to address the issue and propose two fixes suitable for contributing upstream. This work resulted in the merge request udev-hid-bpf MR!181.
The intent is to give readers a tour of the various open-source projects composing the Linux input stack; to illustrate some of the tools, techniques, and resources one can use when diagnosing the behavior of input devices; and to provide an example of a situation in which contributing to udev-hid-bpf is a reasonable course of action.
Note
This investigation was performed in February 2025. Links to code repositories are pinned to contemporary revisions.
Introduction
3Dconnexion produces 3D input peripherals commonly used in CAD, 3D modeling, geospatial analysis, and medical diagnostics applications. The company provides a proprietary driver and SDK for Windows and macOS, but their official Linux support is basically useless. Fortunately, their USB devices follow the HID specifications for multi-axis controllers and are fully supported by Linux’s input subsystem via the evdev interface. The spacenav project has implemented open-source replacements for the 3Dconnexion driver daemon and SDK, allowing the devices to be used in native applications like Blender.
But web-based applications are not able to communicate with the driver daemon. While the spacenav project alludes to a proprietary websocket protocol being used by some web apps, a simpler solution is to leverage the W3C Gamepad API. On Windows, these controllers indeed show up as 6-axis “gamepads” in major browsers. But on Linux, my SpaceNavigator was not recognized by either Firefox or Chrome. Let’s walk through the diagnosis and explore potential fixes.
The web browser
We’ll start at the top of the stack and investigate how a web browser
(specifically, Firefox) decides which input devices to expose via the
gamepad API. Taking a look at
LinuxGamepad.cpp,
we find the relevant-sounding function IsDeviceGamepad()
:
bool LinuxGamepadService::IsDeviceGamepad(struct udev_device* aDev) {
if (!mUdev.udev_device_get_property_value(aDev, "ID_INPUT_JOYSTICK")) {
return false;
}
// ...
}
So we need our device to have the udev property ID_INPUT_JOYSTICK
set to 1
. We can query its properties using
udevadm info -q property -n /dev/input/by-id/usb-3Dconnexion_SpaceNavigator-event-*
to confirm that ID_INPUT_JOYSTICK
is currently not set. Next stop:
udev.
The device manager
udev is a userspace daemon that responds to devices being plugged into
or unplugged from a Linux computer. It is configured with a large
selection of rules that can match against and modify device properties.
It would be straightforward to add a rule (under /etc/udev/rules.d
)
that matches the SpaceNavigator vendor and product IDs (obtained from,
e.g., lsusb
) and marks the device as a joystick:
SUBSYSTEM=="input", ATTRS{idVendor}=="046D", ATTRS{idProduct}=="C626", ENV{ID_INPUT_JOYSTICK}="1"
If we do this, the SpaceNavigator will indeed show up in the Gamepad API
(after clicking one of its buttons—a prerequisite to compat browser
fingerprinting), but it will not report any of its axes. Furthermore,
other devices (like actual gamepads) don’t need this kind of special
treatment in udev rules—they are marked as joysticks automatically. The
logic used to determine whether or not a device is a joystick isn’t
obvious from udev’s default rules and hardware database (under
/usr/lib/udev/rules.d
and /usr/lib/udev/hwdb.d
), and that’s because it’s
handled by the input_id
builtin (invoked by 60-input-id.rules
).
udev is maintained as part of the systemd
project, and the logic we’re looking for is in
udev-builtin-input_id.c:
if (num_joystick_buttons > 0 || num_joystick_axes > 0)
is_joystick = true;
Reading the preceding code, a button is considered a “joystick button” if its event code (defined in the kernel’s linux/input-event-codes.h) is in certain ranges associated with triggers, d-pads, and other joystick/gamepad functionality. Similarly, an axis is considered a “joystick axis” if its event code represents absolute rotation or a number of other input types associated with simulators and games. It’s important to clarify that these event categories are only used as a heuristic for tagging a device with an appropriate default input type; consumers like web browsers will still be able to act on events outside of these ranges (for example, gamepads will often have translational axes as well as rotational ones; it’s just that udev does not consider the presence of an absolute translational axis to be sufficient to categorize a device as a “joystick”).
So what events codes are produced by the SpaceNavigator? Running
evemu-describe
shows the following relevant events:
# Event type 1 (EV_KEY)
# Event code 256 (BTN_0)
# Event code 257 (BTN_1)
# Event type 2 (EV_REL)
# Event code 0 (REL_X)
# Event code 1 (REL_Y)
# Event code 2 (REL_Z)
# Event code 3 (REL_RX)
# Event code 4 (REL_RY)
# Event code 5 (REL_RZ)
So we see two buttons, but they have a generic type, rather than
anything joystick-specific (which is no surprise). We also see our 6
axes, including 3 rotational axes (RX, RY, and RZ), but they are marked
as “relative” rather than “absolute”. This is why they don’t satisfy the
joystick heuristic, and it’s also why Firefox did not show any axes (but
did show buttons) even when we forced ID_INPUT_JOYSTICK
to be 1
.
Taking another look at Firefox’s code, we see it only cares about
absolute axes.
But are these event types appropriate? Unlike a mouse, which can only measure changes from its previous position (“relative” data), the SpaceNavigator directly senses the tilt and displacement of its “knob”. It would make more sense for Linux to treat these as absolute axes. So how were these event types determined?
The HID report descriptor
As a USB human interface device, the SpaceNavigator describes its
capabilities to its host computer using an HID report
descriptor. In Linux, we
can see its contents via the device’s report_descriptor
file under
sysfs. (Note: this convenient file does not report the raw descriptor as
sent by the device, but we can compare it with, say, a Wireshark capture
to show that it has not been tampered with. Spoiler: we will be
tampering with it soon.) The raw report for my device, formatted as
hexadecimal bytes, looks like this:
05 01 09 08 a1 01 a1 00 85 01 16 a2 fe 26 5e 01
36 88 fa 46 78 05 55 0c 65 11 09 30 09 31 09 32
75 10 95 03 81 06 c0 a1 00 85 02 09 33 09 34 09
35 75 10 95 03 81 06 c0 a1 02 85 03 05 01 05 09
19 01 29 02 15 00 25 01 35 00 45 01 75 01 95 02
81 02 95 0e 81 03 c0 a1 02 85 04 05 08 09 4b 15
00 25 01 95 01 75 01 91 02 95 01 75 07 91 03 c0
06 00 ff 09 01 a1 02 15 80 25 7f 75 08 09 3a a1
02 85 05 09 20 95 01 b1 02 c0 a1 02 85 06 09 21
95 01 b1 02 c0 a1 02 85 07 09 22 95 01 b1 02 c0
a1 02 85 08 09 23 95 07 b1 02 c0 a1 02 85 09 09
24 95 07 b1 02 c0 a1 02 85 0a 09 25 95 07 b1 02
c0 a1 02 85 0b 09 26 95 01 b1 02 c0 a1 02 85 13
09 2e 95 01 b1 02 c0 a1 02 85 19 09 31 95 04 b1
02 c0 c0 c0
Parsing the report with, e.g., hid-recorder, reveals these contents:
0x05, 0x01, // Usage Page (Generic Desktop Ctrls)
0x09, 0x08, // Usage (Multi-axis Controller)
0xA1, 0x01, // Collection (Application)
0xA1, 0x00, // Collection (Physical)
0x85, 0x01, // Report ID (1)
0x16, 0xA2, 0xFE, // Logical Minimum (-350)
0x26, 0x5E, 0x01, // Logical Maximum (350)
0x36, 0x88, 0xFA, // Physical Minimum (-1400)
0x46, 0x78, 0x05, // Physical Maximum (1400)
0x55, 0x0C, // Unit Exponent (-4)
0x65, 0x11, // Unit (System: SI Linear, Length: Centimeter)
0x09, 0x30, // Usage (X)
0x09, 0x31, // Usage (Y)
0x09, 0x32, // Usage (Z)
0x75, 0x10, // Report Size (16)
0x95, 0x03, // Report Count (3)
0x81, 0x06, // Input (Data,Var,Rel,No Wrap,Linear,Preferred State,No Null Position)
0xC0, // End Collection
0xA1, 0x00, // Collection (Physical)
0x85, 0x02, // Report ID (2)
0x09, 0x33, // Usage (Rx)
0x09, 0x34, // Usage (Ry)
0x09, 0x35, // Usage (Rz)
0x75, 0x10, // Report Size (16)
0x95, 0x03, // Report Count (3)
0x81, 0x06, // Input (Data,Var,Rel,No Wrap,Linear,Preferred State,No Null Position)
0xC0, // End Collection
// ...
0xC0, // End Collection
// 228 bytes
The relevant data is at offsets 36-37 (for the X, Y, and Z axes) and
53-54 (for the Rx, Ry, and Rz axes). The bytes 0x81 0x06
indicate a
relative input item, whereas we would expect 0x81 0x02
for an
absolute one (see Device class definition for
HID, page 30).
So the device is indeed reporting that its axes are relative rather than
absolute. This is arguably a firmware bug. (Note: a Stack Overflow
post
claims that the report descriptor has been fixed in newer products such
as the SpaceMouse Wireless.)
It’s certainly not the first device to have errors in its HID report descriptor, though. Linux drivers are full of code to work around similar device “quirks.” In fact, Linux already attempts to address this very issue!
The broken fix and a workaround
Linux 2.6.33 (released February 2010) contains commit 24985cf68612:
HID: support Logitech/3DConnexion SpaceTraveler and SpaceNavigator These devices wrongly report their axes as relative instead of absolute. Fix this in up report descriptor of the device before it enters the parser.
With that change, the Linux HID driver for Logitech devices recognizes
the SpaceNavigator product ID (0xC626
) and overwrites the first two
0x81 0x06
input items with 0x81 0x02
, as desired. Unfortunately,
it does so at offsets of 32 and 49, which are 4 bytes before they occur
in our unit’s report descriptor. Perhaps 3Dconnexion revised the
firmware in a subsequent production run (my SpaceNavigator was purchased
in 2016, and there is some
evidence that that the fix
worked for others around 2010—see comments in
spacenavig.c).
Regardless, this fix is ineffective for my device, and I’m not the
only one affected.
The relabsd project exists specifically to translate relative axes into absolute ones in order to use more devices as gamepads (they mention the SpaceNavigator as a candidate device, and SDL as a potential consumer). This project consists of a userspace daemon that reads events via evdev, translates them as necessary, and then injects them into a new virtual device. While their approach is flexible and effective, such runtime translation would not be necessary (at least for the SpaceNavigator) if the kernel fix were working as intended.
Patching the kernel to apply the fix to my variant of the SpaceNavigator should be straightforward and hopefully unobjectionable (the process for contributing such a patch upstream is currently beyond the scope of this article). A quick fix in hid-lg.c to handle both device variants could look something like this:
if (drv_data->quirks & LG_RDESC_REL_ABS) {
if (*rsize >= 51 &&
rdesc[32] == 0x81 && rdesc[33] == 0x06 &&
rdesc[49] == 0x81 && rdesc[50] == 0x06) {
hid_info(hdev,
"fixing up rel/abs in Logitech report descriptor\n");
rdesc[33] = rdesc[50] = 0x02;
} else if (*rsize >= 55 &&
rdesc[36] == 0x81 && rdesc[37] == 0x06 &&
rdesc[53] == 0x81 && rdesc[54] == 0x06) {
hid_info(hdev,
"fixing up rel/abs in Logitech report descriptor\n");
rdesc[37] = rdesc[54] = 0x02;
}
}
(A more robust fix would parse the descriptor in order to find the input items associated with the axes of interest, but that doesn’t seem to be the norm for fixups like this.) However, it will be a long time before such a fix is deployed broadly in professional work environments, which tend to run older kernels for many years. It would be nice if there were a way to apply such fixes without recompiling one’s kernel.
Fixing with a packet filter
It is possible to extend much of the Linux kernel’s functionality at runtime using eBPF, and the input subsystem is no exception. HID-BPF provides a way to fix report descriptors using eBPF, and the udev-hid-bpf project provides a framework for running such eBPF programs from udev rules.
Browsing the project’s existing BPF programs, applying device fixups is done very similarly to how they are applied in the kernel itself. Two differences are worth highlighting: first, the length of the report descriptor is typically checked against the expected value(s), and second, the descriptor is examined up front to determine whether the kernel has already applied the fix. This is especially courteous given how udev-hid-bpf works: the BPF program is loaded based on udev rules, and, before its fixup can be executed, the device is virtually disconnected and reconnected by the kernel. Avoiding redundant fixes thus also avoids unnecessary virtual disconnects.
Unfortunately, while I know the descriptor size for my device, I do not not know the size for the devices that are currently being successfully fixed up by the kernel. Determining this would improve the robustness of the BPF fix.
Surveying other people’s hardware
It would be great if we had a record of the HID report descriptor that was used to develop the original kernel fix back in 2009. But I had no luck finding such a record with Internet searches. What I did find, however, was a database of hardware details published by Linux users: linux-hardware.org.
Searching for computers with a SpaceNavigator attached returns 54
results. Data includes logs from lsusb
, which will tell us the
report descriptor’s length, but unfortunately they do not include the
report descriptor’s data (it is displayed as ** UNAVAILABLE **
if
the device is currently “bound”). Interestingly, at least three variants
of the SpaceNavigator are represented in the database:
Report descriptor length |
Instances |
---|---|
202 |
21 |
217 |
26 |
228 |
7 (plus me) |
Presumably at least one of those variants uses the offsets reflected in the original kernel fix, but it is unknown which offsets are used by the other variant. Without more information, and to avoid regressing the current fix, our program will try either set of offsets for devices with any of these three descriptor lengths.
Conclusion
This is enough information to open an
issue
in the udev-hid-bpf GitLab project and propose an improved fixup in a
merge request.
Since the project attempts to submit fixes for legitimate “quirks” to
the Linux kernel, it is important to adhere to Linux standards when
formatting code and commit messages; the command
/path/to/linux/scripts/checkpatch.pl -g HEAD
will flag most such
issues.
As always, test your work as broadly as you can, and be sure to test
every revision made during the code review process. A unique challenge
may come from the BPF verifier, which can sometimes complain about
compiler optimizations beyond your control. Unfortunately,
udev-hid-bpf
does not report detailed verifier errors, so you may
need to use a command like bpftool prog load
to see them.
In the end, in this case, simply applying the relative-to-absolute fix at the appropriate two offsets for my device variant was sufficient to make the SpaceNavigator usable via the Gamepad API in both Firefix and Chrome. But were we not that lucky, knowledge of udev, evdev (or libinput in other situations), and web browser internals, combined with the ability to rapidly iterate on kernel logic using BPF, would help us identify and address any remaining gaps in the chain.