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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ for the full design.
| `python3-dbus` (`dbus-python`) | serve the tray item and menu over D-Bus |
| `python3-gi` (PyGObject) | GLib main loop |

- *Optional, for the Bluetooth Xbox pad reporting product `0x02FD`:* `udev-hid-bpf`,
`clang`, `bpftool`, `libbpf-devel`, and a kernel with `CONFIG_HID_BPF` + BTF. `install.sh`
builds a HID-BPF descriptor fixup for that pad; without these it skips the fixup with a
warning and everything else installs normally (see
[the Xbox 0x02FD decision](docs/decisions/xbox-02fd-hid-bpf.md)).

## Installation

```bash
Expand Down Expand Up @@ -145,6 +151,7 @@ pads, over USB and Bluetooth.
- [Steam coexistence & lightbar ownership](docs/decisions/steam-coexistence.md)
- [Daemon-owned player numbers](docs/decisions/player-leds.md)
- [dbusmenu item model](docs/decisions/tray-menu-model.md)
- [Xbox 0x02FD HID-BPF descriptor fixup](docs/decisions/xbox-02fd-hid-bpf.md)
- [Troubleshooting / known issues](docs/troubleshooting.md)
- Runbooks:
- [Installation](runbooks/install.md)
Expand Down
27 changes: 23 additions & 4 deletions controller-manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,19 @@

# Optional nicer display names per (vendor, product); falls back to the kernel
# device name when unknown.
#
# 0x028E is deliberately absent: xpadneo rewrites the product id of the
# Bluetooth Xbox pads it binds to 0x028E ("pretending XB1S Windows wireless
# mode"), so both an Xbox One S (0x02E0) and a newer pad (0x02FD) present as
# 0x028E once connected — and 0x028E is also the real wired Xbox 360 id. Keying
# a name on it would mislabel every Bluetooth Xbox pad as "Xbox 360", so we let
# it fall back to the kernel name ("Xbox Wireless Controller"), which is
# accurate. Two such pads then share a base name and are disambiguated by
# player number (see _inst_label). Entries below are for USB-connected pads,
# whose ids xpadneo does not rewrite.
CONTROLLER_NAMES = {
(0x054c, 0x0ce6): "DualSense",
(0x054c, 0x0df2): "DualSense Edge",
(0x045e, 0x028e): "Xbox 360",
(0x045e, 0x02ea): "Xbox One",
(0x045e, 0x02fd): "Xbox One S",
(0x045e, 0x02e0): "Xbox One S (BT)",
Expand Down Expand Up @@ -1061,9 +1070,19 @@ def _semantic_items(self):
for inst in instances:
sem.append(("header", inst.ident,
self._inst_label(inst, instances)))
for mode in MODES_FOR_FAMILY.get(inst.family, []):
sem.append(("radio", inst.ident, mode,
MODE_LABELS[mode], inst.mode == mode))
modes = MODES_FOR_FAMILY.get(inst.family, [])
if len(modes) == 1:
# A single-choice family (e.g. Xbox: only native) has
# nothing to switch between. Show the mode as a static,
# non-interactive line rather than a lone radio that is
# always checked and does nothing when clicked. If a second
# mode is ever enabled for the family this reverts to radios
# automatically.
sem.append(("info", MODE_LABELS[modes[0]]))
else:
for mode in modes:
sem.append(("radio", inst.ident, mode,
MODE_LABELS[mode], inst.mode == mode))
if inst is not instances[-1]:
sem.append(("sep",))
sem.append(("sep",)) # final separator before Quit
Expand Down
97 changes: 97 additions & 0 deletions docs/decisions/xbox-02fd-hid-bpf.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Decision: HID-BPF fixup for the Bluetooth Xbox pad (product 0x02FD)

## Context

A Bluetooth Xbox Wireless Controller reporting USB product id `0x02FD` connects and pairs
normally — BlueZ shows it `Connected`, with the HID service — but it produces **no input
device at all**: no `/dev/input/event*`, no `js*`, no `hidraw*`. It is therefore invisible
to everything downstream: the tray never lists it, and no application (RetroArch, any SDL
title) can see it. Two structurally identical DualSense pads on the same host work fine, as
does a *different* Xbox pad reporting product `0x02E0`.

The cause is below this project entirely, in the kernel HID layer:

```
playstation/xpadneo 0005:045E:02FD.*: unbalanced collection at end of report description
... probe with driver <x> failed with error -22
```

The pad's firmware advertises a **306-byte HID report descriptor that is truncated
mid-item** inside the force-feedback output collection. It opens five `COLLECTION`s but
closes only three; the last bytes are a dangling `USAGE` / `LOGICAL_MINIMUM` with no Main
item and no closing `END_COLLECTION`. `hid_open_report()` rejects such a descriptor
outright, so **no driver can bind** — not `hid-generic`, not `xpadneo`. No bind means no
input node.

The working `0x02E0` sibling ships a well-formed 306-byte descriptor that ends in
`… 81 02 c0` (INPUT, END_COLLECTION) and binds cleanly. The only structural difference is
the two missing `END_COLLECTION` bytes. This is a per-firmware bug in the `0x02FD` unit;
Microsoft fixed it in later firmware.

## Options considered

1. **Update the controller firmware** — the real fix, but it is only reachable via the Xbox
Accessories app (Windows) or an Xbox console. Neither was available, and requiring either
defeats the point of a Linux-side tool.
2. **Patch xpadneo** — its `report_fixup` already massages these pads, but it does not add
the missing bytes, and it ships here as a packaged akmod. A downstream patch would have to
be re-applied against every package update.
3. **HID-BPF report-descriptor fixup** *(chosen)* — attach a small BPF program at device
probe that appends the two missing `END_COLLECTION` bytes, yielding exactly the balanced
descriptor the working pad already has. The kernel supports it (`CONFIG_HID_BPF`, BTF
present), Fedora packages the loader (`udev-hid-bpf`), and it needs no changes to the
kernel or to xpadneo. There is direct precedent: udev-hid-bpf already ships an equivalent
rdesc fixup for the Xbox Elite 2 (`0x0B22`) over Bluetooth.

## Decision

Ship `hid-bpf/0010-Microsoft__Xbox-One-S-02FD.bpf.c`, a `HID_BPF_RDESC_FIXUP` program built
and installed by `install.sh` via `udev-hid-bpf`. When the exact broken descriptor appears
(size 306 with the specific truncated tail `65 00 55 00 09 7c 15 00`), it writes two `0xC0`
bytes and returns the new size 308. The only content lost is the tail of one force-feedback
OUTPUT field the firmware had already truncated; every input field (buttons, sticks, D-pad)
lives in the well-formed leading section and is untouched. After the fixup the descriptor
parses, a driver binds, and the pad appears as an ordinary gamepad that `scan_controllers()`
adopts as the `xbox` family with no daemon changes.

Two implementation facts, learned the hard way and encoded in the source:

- **The device is matched as `0x028E`, not `0x02FD`.** xpadneo rewrites the product id
`0x02FD → 0x028E` inside its probe (“pretending XB1S Windows wireless mode”), and that
probe runs *before* the BPF program is attached, whether or not it ultimately fails. So by
the time the udev rule fires the device already reports `0x028E`. The program's
`HID_BPF_CONFIG` lists both ids (`0x028E` for the normal xpadneo case, `0x02FD` as a
fallback for a host without xpadneo), all scoped to `BUS_BLUETOOTH` so the identical
`0x028E` used by wired Xbox 360 pads (USB) and by this project's own virtual pad cannot
match.

- **The guard lives in the rdesc fixup, not in `probe()`.** Because the initial parse
failed, the report descriptor exposed to the `probe` syscall is empty (`rdesc_size` 0), so
probe cannot gate on it. The raw 306-byte descriptor is only visible inside the fixup, so
the size + tail check is there; `probe()` merely accepts the device-id match. On any other
descriptor the fixup is a no-op, so attaching to unrelated `0x028E` Bluetooth devices is
harmless.

With this pad, the driver that ultimately binds after the late BPF reprobe is `hid-generic`
(xpadneo's auto-load aliases key on `0x02FD`/`0x0B22`, not the rewritten `0x028E`, and it
does not re-grab on the reprobe). `hid-generic` exposes a complete, correctly-laid-out
gamepad — A/B/X/Y, both triggers, both sticks, hat D-pad — which is fully usable; xpadneo is
not required for this pad to work.

## Consequences

- **New build dependency, gated to opt-in.** Building the object needs `clang`, `bpftool`,
`libbpf-devel`, `udev-hid-bpf`, and a BTF-enabled kernel. Because the fixup only matters to
owners of the `0x02FD` pad, `install.sh` treats a missing prerequisite as a
skip-with-warning — a DualSense-only install is never blocked by it.
- **Built against the running kernel.** `hid-bpf/build.sh` regenerates `vmlinux.h` from
`/sys/kernel/btf/vmlinux` at install time rather than committing a large header or a
prebuilt binary; `build/` is git-ignored.
- **Licence boundary.** The BPF program and the vendored `hid-bpf/include/*` headers are
GPL-2.0-only (kernel-space code calling GPL BPF helpers), separate from the MIT userspace
daemon. See `hid-bpf/README.md`.
- **Self-healing on reconnect.** The udev rule re-attaches the program on every `add`, so a
Bluetooth reconnect comes back working with no manual step.
- **Narrow by construction.** A `0x02FD` pad on fixed firmware advertises a different
descriptor, so the size + tail guard leaves it alone; nothing else on the system is
affected.
30 changes: 30 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,36 @@ This is the Xbox→PlayStation direction, which is intentionally **not offered**
this reason — see [output protocol constraints](decisions/output-protocol-constraints.md).
The supported directions are PlayStation→Xbox and the two native modes.

## A Bluetooth controller is paired and connected but never appears in the tray

If a pad shows as `Connected` in `bluetoothctl` (with the HID service) yet is listed by no
game and never appears in the tray, first check whether it produced an input device at all:

```bash
ls /dev/input/js* # is there a js node for it?
for d in /sys/class/input/js*; do echo "$d $(cat $d/device/name)"; done
```

No node means no driver bound. A common cause on some Xbox pads is a **malformed HID report
descriptor** the kernel refuses to parse:

```bash
sudo dmesg | grep -i 'unbalanced collection'
# playstation/xpadneo 0005:045E:02FD.*: unbalanced collection at end of report description
# ... probe with driver <x> failed with error -22
```

For the specific Bluetooth Xbox pad reporting product `0x02FD` this is a firmware bug fixed
by the bundled HID-BPF descriptor fixup — make sure it installed (`install.sh` skips it with
a warning if the build toolchain is missing):

```bash
udev-hid-bpf list-devices | grep -i xbox # should show the pad matched to the fixup
```

See [the Xbox 0x02FD HID-BPF decision](decisions/xbox-02fd-hid-bpf.md) for the full story
and manual build/install steps.

## Buttons are mapped to the wrong positions

Some controllers expose a non-standard evdev button layout. The remapper carries a
Expand Down
1 change: 1 addition & 0 deletions hid-bpf/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
build/
121 changes: 121 additions & 0 deletions hid-bpf/0010-Microsoft__Xbox-One-S-02FD.bpf.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// SPDX-License-Identifier: GPL-2.0-only
/* Copyright (c) 2026 controller-manager contributors
*
* HID report-descriptor fixup for the Bluetooth Xbox Wireless Controller
* reporting product id 0x02FD (an "Xbox One S" era pad on newer firmware).
*
* This unit's firmware advertises a 306-byte HID report descriptor that is
* truncated mid-item inside the force-feedback output collection: it opens
* five COLLECTIONs but closes only three, so two collections are left open
* and the descriptor ends on a dangling USAGE / LOGICAL_MINIMUM with no Main
* item. hid_open_report() rejects such a descriptor outright, so NEITHER
* hid-generic NOR xpadneo can bind and no input node is ever created:
*
* playstation/xpadneo 0005:045E:02FD.*: unbalanced collection at end of
* report description
* ... probe with driver <x> failed with error -22
*
* A sibling pad reporting 0x02E0 ships a well-formed 306-byte descriptor that
* ends in "... 81 02 c0" (INPUT, END_COLLECTION) and binds cleanly. The only
* structural difference is the two missing END_COLLECTION bytes. Appending
* them here yields exactly the balanced structure the working pad already has;
* the sole content lost is the tail of one force-feedback OUTPUT field that
* the firmware truncated anyway. All input fields (buttons, sticks, D-pad)
* live in the well-formed leading section and are untouched.
*
* Microsoft fixed this in a later firmware, but that update is only reachable
* via the Xbox Accessories app (Windows) or an Xbox console. This fixup makes
* the pad usable on Linux without that. It is bound tightly to the exact
* broken descriptor (size == 306 and the specific truncated tail), so a pad on
* fixed firmware — which advertises a different descriptor — is left alone.
*
* A note on the product ids we match. The pad reports product 0x02FD, but
* xpadneo rewrites it to 0x028E ("pretending XB1S Windows wireless mode")
* during its probe — and that probe runs and rewrites the id BEFORE this
* program is attached, whether or not it ultimately fails. So by the time the
* udev rule fires and HID-BPF matches the device, the product is already
* 0x028E. We list both: 0x028E is what actually matches when xpadneo is
* present (the normal case), and 0x02FD covers a system without xpadneo where
* the id is never rewritten. BUS_BLUETOOTH keeps this away from the identical
* 0x028E id used by wired Xbox 360 pads (USB) and by our own virtual pad.
*
* We deliberately do NOT gate in probe(): for this device the parse failed, so
* the report descriptor exposed to the probe syscall is empty (rdesc_size 0).
* The real, raw 306-byte descriptor is only visible inside the rdesc fixup, so
* that is where the size + tail guard lives.
*/

#include "vmlinux.h"
#include "hid_bpf.h"
#include "hid_bpf_helpers.h"
#include <bpf/bpf_tracing.h>

#define VID_MICROSOFT 0x045e
#define PID_XBOX_RAW 0x02fd /* as the firmware reports it */
#define PID_XBOX_XPADNEO 0x028e /* what xpadneo rewrites it to */

HID_BPF_CONFIG(
HID_DEVICE(BUS_BLUETOOTH, HID_GROUP_GENERIC, VID_MICROSOFT, PID_XBOX_XPADNEO),
HID_DEVICE(BUS_BLUETOOTH, HID_GROUP_GENERIC, VID_MICROSOFT, PID_XBOX_RAW)
);

/* Size of the broken descriptor as advertised by the pad. */
#define BROKEN_RDESC_SIZE 306

/*
* Last 8 bytes of the broken descriptor. These are the dangling global/local
* items that trail off where the two END_COLLECTION bytes should be:
* 65 00 Unit (0)
* 55 00 Unit Exponent (0)
* 09 7c Usage (0x7c)
* 15 00 Logical Minimum (0)
* Matching this exact tail keeps the fixup from touching any other descriptor.
*/
static const __u8 broken_tail[] = {
0x65, 0x00, 0x55, 0x00, 0x09, 0x7c, 0x15, 0x00,
};

#define TAIL_OFFSET (BROKEN_RDESC_SIZE - sizeof(broken_tail))

SEC(HID_BPF_RDESC_FIXUP)
int BPF_PROG(hid_fix_rdesc, struct hid_bpf_ctx *hctx)
{
__u8 *data = hid_bpf_get_data(hctx, 0 /* offset */, 4096 /* size */);

if (!data)
return 0; /* EPERM check */

/* Only the exact broken descriptor; leave anything else as-is. */
if (hctx->size != BROKEN_RDESC_SIZE)
return 0;

if (__builtin_memcmp(data + TAIL_OFFSET, broken_tail, sizeof(broken_tail)))
return 0;

/* Close the two collections the firmware left open. */
data[BROKEN_RDESC_SIZE + 0] = 0xc0; /* End Collection */
data[BROKEN_RDESC_SIZE + 1] = 0xc0; /* End Collection */

/* Positive return value = new descriptor size. */
return BROKEN_RDESC_SIZE + 2;
}

HID_BPF_OPS(xbox_one_s_02fd) = {
.hid_rdesc_fixup = (void *)hid_fix_rdesc,
};

SEC("syscall")
int probe(struct hid_bpf_probe_args *ctx)
{
/*
* Attach whenever the device id matches. We cannot gate on the
* descriptor here: the failed parse leaves the probe's report
* descriptor empty (rdesc_size 0). The actual guard (exact size + tail)
* lives in hid_fix_rdesc, which is the only place the raw descriptor is
* visible; on any non-matching descriptor it is a no-op.
*/
ctx->retval = 0;
return 0;
}

char _license[] SEC("license") = "GPL";
44 changes: 44 additions & 0 deletions hid-bpf/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# hid-bpf — kernel-side report-descriptor fixups

This directory holds HID-BPF programs that patch a controller's HID report descriptor in
the kernel before it is parsed. They exist to make otherwise-unusable pads work; the
userspace daemon (`../controller-manager.py`) needs no knowledge of them.

## Contents

- `0010-Microsoft__Xbox-One-S-02FD.bpf.c` — appends the two missing `END_COLLECTION` bytes
to the truncated HID descriptor of the Bluetooth Xbox pad reporting product `0x02FD`,
which otherwise fails to parse and produces no input device at all. Full rationale in
[../docs/decisions/xbox-02fd-hid-bpf.md](../docs/decisions/xbox-02fd-hid-bpf.md).
- `include/` — the minimal vendored HID-BPF headers (`hid_bpf.h`, `hid_bpf_helpers.h`) the
program includes, copied verbatim from upstream `udev-hid-bpf`.
- `build.sh` — builds the program(s) against the running kernel's BTF into `build/`
(git-ignored). `vmlinux.h` is generated at build time, not committed.

## Requirements

- A kernel with `CONFIG_HID_BPF=y` and BTF (`/sys/kernel/btf/vmlinux`).
- `clang`, `bpftool`, `libbpf-devel` (for `bpf/bpf_helpers.h`), and `udev-hid-bpf`.
On Fedora: `sudo dnf install udev-hid-bpf clang bpftool libbpf-devel`.

## Build and install

`../install.sh` does this automatically (skipping with a warning if the toolchain is
absent). To do it by hand:

```sh
./build.sh # -> build/<name>.bpf.o
sudo udev-hid-bpf install --force build/<name>.bpf.o # -> /etc/udev-hid-bpf + udev rule
sudo udevadm control --reload
sudo udevadm trigger --action=add --subsystem-match=hid # attach to an already-connected pad
```

Inspect a built object with `udev-hid-bpf inspect build/<name>.bpf.o`. To remove a fixup,
delete its files under `/etc/udev-hid-bpf/` and `/etc/udev/rules.d/99-hid-bpf-*.rules` and
reload udev (the `udev-hid-bpf install` output prints the exact paths).

## Licence

Unlike the MIT-licensed userspace daemon, the code here is **GPL-2.0-only**: BPF programs
that call kernel BPF helpers must be GPL, and the vendored `include/*` headers are
GPL-2.0-only upstream. Each file carries its own SPDX identifier.
Loading
Loading