diff --git a/ROADMAP.md b/ROADMAP.md index 1b4ea34..02a525b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 @@ -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`. diff --git a/docs/decisions/evdev-enumeration-leak.md b/docs/decisions/evdev-enumeration-leak.md new file mode 100644 index 0000000..51d8bc1 --- /dev/null +++ b/docs/decisions/evdev-enumeration-leak.md @@ -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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8e17307..7946d7e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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