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
28 changes: 28 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@

Short, rolling list of where this project stands and what comes next. Newest status on top.

## Status — 2026-07-15: phantom pad in a dual remap — root-caused, deferred

With two DualSense both in `ps5-xbox`, Steam lists a **fifth, phantom** PlayStation pad —
appearing the moment the *second* pad is emulated, always the second regardless of which
physical pad. **Root-caused on hardware and confirmed harmless:** the hidraw gate holds on
both pads (both nodes `000`, no holder); the phantom is the second pad's **evdev** node,
which Steam re-opens and lists during the second `ps5-xbox` transition's driver rebind. The
remapper's `EVIOCGRAB` still holds it, so it is **inert** — `evtest` reports the node
grabbed, no events leak, no double input.

`EVIOCGRAB` hides events, not enumeration, and no lightweight robust fix exists: the daemon
is a `--user` service sharing Steam's UID, so any evdev permission/ACL barrier that blocks
Steam also blocks the remapper's own grab. **Decision: document and defer** — the phantom is
cosmetic, and the only robust fix (born-gate the evdev node + a root helper that hands the
grabbed fd to the daemon via `SCM_RIGHTS` + a raw-read remapper path) expands a
security-sensitive privileged surface for a dead list entry. A game-side slot test is
pending; escalate only if the phantom proves functionally harmful (stolen player slot /
reordered controllers). Full analysis: `docs/decisions/evdev-enumeration-leak.md`;
symptom + verification: `docs/troubleshooting.md`.

## Status — 2026-07-06 (evening): full Steam ownership in native mode

The SDL ignore-vars workaround from the afternoon (below) is **superseded**: the
Expand Down Expand Up @@ -130,3 +150,11 @@ is reused while `inputN`-derived nodes (the `:rgb:indicator` LED) renumber. The
thread (worker/queue) with per-instance locking so the reconcile can't race the same pad.
Deferred deliberately: it is an invasive concurrency change with real regression risk and
needs hardware to verify, and the visible cost on a single-user desktop tray is small.
- **Phantom pad in a dual `ps5-xbox` remap (deferred, 2026-07-15):** Steam lists a fifth,
inert PlayStation pad once the second DualSense is emulated — its physical evdev node,
re-opened by Steam during the second transition's driver rebind. `EVIOCGRAB` keeps it
input-dead (no double input), so it is cosmetic. No lightweight robust fix exists (the
daemon shares Steam's UID, so an evdev permission barrier locks out the remapper too); the
robust fix is a root fd-passing grab with a born-gated evdev node. Deferred pending a
game-side slot test — escalate only on functional harm. Full write-up:
`docs/decisions/evdev-enumeration-leak.md`.
114 changes: 114 additions & 0 deletions docs/decisions/evdev-enumeration-leak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Decision: The evdev enumeration leak (phantom pad in a dual remap)

## Context

With two DualSense pads both in `ps5-xbox` (Xbox emulation), Steam's
*Settings → Controller* list shows **five** controllers instead of four: the two
native Xbox pads (Bluetooth), the two emulated virtual Xbox pads (USB), and one extra
**PlayStation ("PS5") controller**. The phantom appears the moment the *second* pad is
emulated and is **position-, not device-specific**: whichever pad is emulated first is
listed correctly; emulating a second one always adds the fifth entry. One pad emulated =
four controllers, clean.

This is not a hidraw-gate failure. Field verification (2026-07-15), both pads emulated:

- Both physical DualSense hidraw nodes are `c---------` (`MODE 0000`) with **no** fd
holder — the [hidraw gate](hidraw-gate.md) revoked Steam on both and holds. hidraw is
not the leak.
- The leak is the physical **evdev** node. `lsof` on the second-emulated pad's
`/dev/input/eventX` shows it held by **both** the daemon (its remapper) **and** `steam`;
the first-emulated pad's event node is held by the daemon only. Steam reads the pad's
VID/PID straight off that evdev node (`EVIOCGID` → `054c:0ce6`) and lists it as a
PlayStation controller.
- The phantom is **inert**. `evtest` on the leaked node reports *"This device is grabbed
by another process. No events are available"* — the remapper's `EVIOCGRAB` holds, so
the physical pad's input still goes only to the virtual Xbox pad. There is **no double
input**; the phantom is a dead list entry.

## Root cause

`EVIOCGRAB` gives the remapper exclusive access to the source's input **events**. It does
**not** remove the evdev node from the device tree or stop another process from *opening*
it: an open + `EVIOCGID` is enough for Steam to enumerate and list the pad, grabbed or not.

Why only the *second* pad leaks is a race the gate itself creates. A `ps5-xbox` transition
runs the gate's driver **rebind** (to revoke Steam's pre-open hidraw fd and reset the
lightbar latch — see [hidraw gate](hidraw-gate.md)). That rebind is device churn Steam
re-enumerates on: it re-opens every controller node it can, including the just-reborn evdev
node of the pad being emulated. For the *first* pad the remapper wins the open/grab race,
so Steam never keeps an fd on it. For the *second* pad, the second block's rebind triggers
a fresh full re-enumeration during which Steam re-opens the reborn evdev node before the
remapper grabs it — and because a grab does not evict an existing opener, Steam's fd sticks.
Hence: always the second, regardless of which physical pad it is.

## Why there is no lightweight robust fix

The obvious fix — extend the born-gated `MODE 0000` treatment from the hidraw node to the
evdev node — does not work here, for a structural reason:

- **The daemon is a `systemd --user` service; it and Steam run under the same UID**
(`admin`). Its only privilege is NOPASSWD access to two scoped helpers
(`controller-hidraw-gate`, `controller-led`).
- Steam's access to the evdev node comes from the **`uaccess` seat ACL** (`TAGS=…:uaccess:`
→ logind grants the active session user rw). The daemon is the *same* session user, so
that identical ACL is also what lets the **remapper** open the node to grab it.
- Therefore any permission, group, or ACL barrier that denies Steam the evdev node denies
the remapper too. Born-gating the node `MODE 0000` would lock out the daemon's own grab.
A user service cannot be granted a supplementary group Steam lacks (same UID inherits the
same group set), so no group split exists either.

Two lighter levers were considered and rejected:

- **SDL ignore-vars** (`SDL_GAMECONTROLLER_IGNORE_DEVICES=0x054c/0x0ce6`) would suppress the
phantom but is already rejected project-wide: scoped to Steam it strips native DualSense
features from Steam-launched games; see [Steam coexistence](steam-coexistence.md).
- **Stripping `ID_INPUT_JOYSTICK`** from the gated node via udev is unreliable: SDL falls
back to reading the device's capability bitmaps from sysfs when the property is absent and
re-derives "joystick" from `BTN_GAMEPAD`/`ABS_*`, so the classification returns anyway.

The only robust fix keeps the evdev node openable by the daemon while denying Steam — which,
given the same-UID constraint, requires a privileged path:

1. udev: born-gate the gated pad's **input/event** node as well (`SUBSYSTEM=="input"`,
`uaccess` removed, `MODE 0000`), so Steam cannot open it.
2. a new NOPASSWD helper branch: open the evdev node as root, `EVIOCGRAB` it, and hand the
fd to the daemon over a unix socket (`SCM_RIGHTS`). The grab is a property of the open
file description, so it survives the helper's exit as long as the daemon holds the fd.
3. remapper: take the source fd from the helper instead of `evdev.InputDevice` (which only
opens by path — it cannot adopt an fd), read raw `input_event` structs from it, and read
the source capabilities from sysfs (`/sys/class/input/eventX/device/capabilities/*`,
world-readable) to build the virtual pad.

## Decision

**Document the phantom as a known limitation and defer the robust fix.** The phantom is
inert — no double input, no feature broken — so it is a cosmetic list entry, and the only
robust fix expands a security-sensitive privileged surface (a helper that hands out grabbed
input fds of controllers) and rewrites the remapper's read path. That trade-off is not
justified by a purely cosmetic symptom.

**Escalation trigger.** Build the fd-passing fix (above) only if a real game shows
*functional* harm — the phantom stealing a player slot or shifting controller ordering in
local multiplayer. A game-side slot test is pending (planned, not yet run as of
2026-07-15). If the phantom is ignored in-game, documentation stands.

## Consequences

- No runtime behaviour changes; the daemon is untouched. Existing gate, remapper, and
lightbar guarantees are unaffected.
- The full root cause, its verification evidence, and the fix design are captured so the
deferred work can start from the decision, not a re-investigation — mirroring how the
deferred concurrency item in the [roadmap](../../ROADMAP.md) is handled.
- User-facing symptom and the "is it inert?" verification live in
[troubleshooting](../troubleshooting.md).

## Rejected alternatives

- **Best-effort race-retry** (after `apply_mode`, if Steam holds the evdev node, rebind +
re-grab until Steam loses the open race): rejected. Each extra rebind renumbers nodes,
churns the lightbar, and triggers *another* Steam re-enumeration — i.e. the same race
again — with no guarantee of convergence, adding fragility to the most delicate part of
the daemon for a cosmetic gain.
- **Running the daemon as root** (system service) so it can open a born-gated `MODE 0000`
node while Steam cannot: rejected. It is a session/tray app on the D-Bus session bus, and
running it as root is a far larger security downgrade than one scoped fd-passing helper.
30 changes: 30 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,36 @@ The exact open permissions of a native node come from the distribution's control
rules (usually `0660`/`0666` plus a seat `uaccess` ACL, shown as a trailing `+`); what
matters is gated = `000` with no ACL vs. native = readable with the seat ACL.

## A fifth (phantom) controller appears when two DualSense are both emulated

Symptom: with two DualSense both in `ps5-xbox`, Steam's *Settings → Controller* lists
**five** controllers — the two native Xbox pads, the two emulated virtual Xbox pads, and
one extra **PlayStation** pad. It appears only once the *second* pad is emulated (one
emulated = four, clean) and is always the second, whichever physical pad that is.

This is a **known, inert limitation**, not a gate failure. The hidraw gate holds on both
pads; the phantom is the second pad's **evdev** node, which Steam re-opens and lists during
the driver rebind of the second `ps5-xbox` transition. The remapper's `EVIOCGRAB` still
holds it, so the phantom produces **no input** (no double input) — it is a dead list entry.
Full analysis and why there is no lightweight fix:
[the evdev enumeration leak](decisions/evdev-enumeration-leak.md).

Confirm it is the harmless case — both hidraw nodes gated, the phantom grabbed and inert:

```bash
# both physical DualSense hidraw nodes must be 000 (gate holds on both):
for d in /sys/bus/hid/devices/*:054C:*; do
sed -n 's/^HID_UNIQ=/uniq /p' "$d/uevent"
for h in "$d"/hidraw/hidraw*; do ls -l "/dev/$(basename "$h")"; done
done

# the phantom's evdev node is grabbed → no events leak to Steam:
evtest /dev/input/eventN # prints "This device is grabbed by another process"
```

If instead the phantom *reacts* to input in a game (double input) or steals a player slot,
that is the functional case the decision doc gates its fix on — worth reporting.

## The DualSense lightbar shows the wrong colour after a reconnect

Symptom: the pad is correctly remapped (e.g. output as Xbox, raw HID gated) but the lightbar
Expand Down
Loading