A controller that reaches the host by USB passthrough — VirtualHere and friends, or simply a pad plugged into the host — arrives there twice: once as the real device, once as the virtual pad the client builds from the same pair of hands. Games read both, so a stick drifts against the centred second pad and menus take every input twice.
What
A new per-client setting, "Forward controllers", default on (today's behaviour). It is tier-P, so a settings profile can decline what another profile forwards.
On Linux and Windows it is deliberately stronger than "send nothing". Opening a controller is what claims it — SDL's HIDAPI drivers take the device node — and a claimed device is one a passthrough tool cannot bind. So with this off the session opens no slot at all, and never enables the Valve HIDAPI drivers.
Menu navigation is untouched: the launcher still opens the active pad, and a session supersedes menu mode whether it forwards or not, so the pad is free for the whole time a stream is up. The consequence, documented at both the setting and the chord: the controller escape chord is read off forwarded pads, so it is unavailable there — Ctrl+Alt+Shift+D and the client's own UI still end a session.
Apple and Android claim nothing, so those clients keep their slots and their chords and only gate the wire sends. Losing tvOS's only controller way out of a stream would have been the worse bug. Android does stop its DualSense and Steam Controller 2 USB captures, which really do claim the device.
Surfaces
Client
Where
Linux (GTK)
Controllers page
Windows (WinUI)
Controllers page
Console / Gaming Mode
Settings → Controller
Apple (iOS/iPadOS/macOS/tvOS)
Settings → Controllers, and the gamepad settings screen
Android (phone + TV)
Settings → Controllers, and the gamepad settings screen
Decky
Stream settings
Everywhere, the "which pad" and "pad type" rows grey out while it is off; Decky hides them.
Both the Rust and Kotlin gates were proven non-vacuous by planting a deliberate error in an edited file and confirming it was caught.
Not verified
clients/windows is UNCOMPILED — both Windows boxes (.133, .173) were offline. Its edits were reviewed by hand against setting_toggle / described_overridable signatures and the OverrideFlags shape; every SettingsOverlay literal in that file already uses ..Default::default(), so the added field is safe. Wants a CI run or a box before merge.
No on-glass test — in particular the claim this is really about: that with forwarding off, VirtualHere can bind a pad the client would otherwise be holding.
The Apple build was macOS-only; no hunk sits inside an #if os(iOS) block, but the iOS/tvOS triples were not compiled.
## Why
A controller that reaches the host by USB passthrough — [VirtualHere](https://www.virtualhere.com/) and friends, or simply a pad plugged into the host — arrives there **twice**: once as the real device, once as the virtual pad the client builds from the same pair of hands. Games read both, so a stick drifts against the centred second pad and menus take every input twice.
## What
A new per-client setting, **"Forward controllers"**, default **on** (today's behaviour). It is tier-P, so a settings profile can decline what another profile forwards.
**On Linux and Windows it is deliberately stronger than "send nothing".** Opening a controller is what *claims* it — SDL's HIDAPI drivers take the device node — and a claimed device is one a passthrough tool cannot bind. So with this off the session opens no slot at all, and never enables the Valve HIDAPI drivers.
Menu navigation is untouched: the launcher still opens the active pad, and a session supersedes menu mode whether it forwards or not, so the pad is free for the whole time a stream is up. The consequence, documented at both the setting and the chord: **the controller escape chord is read off forwarded pads, so it is unavailable there** — Ctrl+Alt+Shift+D and the client's own UI still end a session.
**Apple and Android claim nothing**, so those clients keep their slots and their chords and only gate the wire sends. Losing tvOS's only controller way out of a stream would have been the worse bug. Android *does* stop its DualSense and Steam Controller 2 USB captures, which really do claim the device.
## Surfaces
| Client | Where |
|---|---|
| Linux (GTK) | Controllers page |
| Windows (WinUI) | Controllers page |
| Console / Gaming Mode | Settings → Controller |
| Apple (iOS/iPadOS/macOS/tvOS) | Settings → Controllers, and the gamepad settings screen |
| Android (phone + TV) | Settings → Controllers, and the gamepad settings screen |
| Decky | Stream settings |
Everywhere, the "which pad" and "pad type" rows grey out while it is off; Decky hides them.
## Verification
| Gate | Result |
|---|---|
| `cargo clippy --all-targets -- -D warnings` — `pf-client-core`, `pf-console-ui`, `punktfunk-client-session`, `punktfunk-client-linux` (linux/amd64 container) | clean |
| `cargo test -p pf-client-core` | 79 passed, incl. a new `gamepad_forwarding_overrides_off_and_resets_back` |
| `cargo fmt --all --check` | clean |
| `swift build` (Apple, macOS) | clean |
| Android `:app:` + `:kit:compileDebugKotlin`, `:app:testDebugUnitTest` | clean, 49 tests |
| Decky `tsc --noEmit` | clean |
Both the Rust and Kotlin gates were **proven non-vacuous** by planting a deliberate error in an edited file and confirming it was caught.
## Not verified
- **`clients/windows` is UNCOMPILED** — both Windows boxes (.133, .173) were offline. Its edits were reviewed by hand against `setting_toggle` / `described_overridable` signatures and the `OverrideFlags` shape; every `SettingsOverlay` literal in that file already uses `..Default::default()`, so the added field is safe. Wants a CI run or a box before merge.
- **No on-glass test** — in particular the claim this is really about: that with forwarding off, VirtualHere can bind a pad the client would otherwise be holding.
- The Apple build was macOS-only; no hunk sits inside an `#if os(iOS)` block, but the iOS/tvOS triples were not compiled.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
A controller that reaches the host by USB passthrough — VirtualHere and friends, or simply a
pad plugged into the host — arrived there twice: once as the real device, once as the virtual
pad this client built from the same hands. Games read both, so a stick drifts against the
centred second pad and menus take every input twice.
New per-client setting, "Forward controllers", default on (today's behaviour). It is tier-P,
so a profile can decline what another profile forwards.
On Linux and Windows it is deliberately stronger than "send nothing". Opening a controller is
what CLAIMS it — SDL's HIDAPI drivers take the device node — and a claimed device is one a
passthrough tool cannot bind, so with this off the session opens no slot at all and never
enables the Valve HIDAPI drivers. Menu navigation is untouched: the launcher still opens the
active pad, and a session supersedes menu mode whether it forwards or not, so the pad is free
for the whole time a stream is up. The consequence, documented at both the setting and the
chord: the controller escape chord is read off forwarded pads, so it is unavailable there.
The Apple and Android input stacks claim nothing, so those clients keep their slots and their
chords and only gate the wire sends — losing tvOS's only controller way out of a stream would
have been the worse bug. Android does stop its DualSense and Steam Controller 2 USB captures,
which do claim the device.
Surfaces: GTK, WinUI, the console settings screen, Apple's touch and gamepad settings, the
Android touch and gamepad settings, and Decky (which also hides the rows that now have nothing
to act on). Everywhere the "which pad" and "pad type" rows grey out while it is off.
Verified: cargo clippy --all-targets -D warnings + 79 tests on pf-client-core, pf-console-ui,
punktfunk-client-session and punktfunk-client-linux (linux/amd64 container, gate proven
non-vacuous with a planted error); swift build for the Apple clients; gradle compile + 49 unit
tests for Android (likewise proven); tsc for Decky. clients/windows is UNCOMPILED — both
Windows boxes were offline; its edits were reviewed against the helper signatures by hand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
enricobuehler
marked the pull request as ready for review 2026-08-02 21:37:54 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Why
A controller that reaches the host by USB passthrough — VirtualHere and friends, or simply a pad plugged into the host — arrives there twice: once as the real device, once as the virtual pad the client builds from the same pair of hands. Games read both, so a stick drifts against the centred second pad and menus take every input twice.
What
A new per-client setting, "Forward controllers", default on (today's behaviour). It is tier-P, so a settings profile can decline what another profile forwards.
On Linux and Windows it is deliberately stronger than "send nothing". Opening a controller is what claims it — SDL's HIDAPI drivers take the device node — and a claimed device is one a passthrough tool cannot bind. So with this off the session opens no slot at all, and never enables the Valve HIDAPI drivers.
Menu navigation is untouched: the launcher still opens the active pad, and a session supersedes menu mode whether it forwards or not, so the pad is free for the whole time a stream is up. The consequence, documented at both the setting and the chord: the controller escape chord is read off forwarded pads, so it is unavailable there — Ctrl+Alt+Shift+D and the client's own UI still end a session.
Apple and Android claim nothing, so those clients keep their slots and their chords and only gate the wire sends. Losing tvOS's only controller way out of a stream would have been the worse bug. Android does stop its DualSense and Steam Controller 2 USB captures, which really do claim the device.
Surfaces
Everywhere, the "which pad" and "pad type" rows grey out while it is off; Decky hides them.
Verification
cargo clippy --all-targets -- -D warnings—pf-client-core,pf-console-ui,punktfunk-client-session,punktfunk-client-linux(linux/amd64 container)cargo test -p pf-client-coregamepad_forwarding_overrides_off_and_resets_backcargo fmt --all --checkswift build(Apple, macOS):app:+:kit:compileDebugKotlin,:app:testDebugUnitTesttsc --noEmitBoth the Rust and Kotlin gates were proven non-vacuous by planting a deliberate error in an edited file and confirming it was caught.
Not verified
clients/windowsis UNCOMPILED — both Windows boxes (.133, .173) were offline. Its edits were reviewed by hand againstsetting_toggle/described_overridablesignatures and theOverrideFlagsshape; everySettingsOverlayliteral in that file already uses..Default::default(), so the added field is safe. Wants a CI run or a box before merge.#if os(iOS)block, but the iOS/tvOS triples were not compiled.🤖 Generated with Claude Code