fix(client/gamepad): honor an explicit controller type — the per-pad arrival was re-declaring the physical pad

The "Controller type" setting reached the Hello and stopped there. Every pad then
opened with a GamepadArrival carrying its DETECTED kind, and the host builds each
virtual device from that arrival — the session default is only the fallback for a
pad that never declares one. So "emulate my DualSense as a DualShock 4" put a
DualSense on the host the instant the controller connected, and no amount of
reconnecting helped. Apple and the native clients both; Android already applied
the setting per pad.

The setting now rides the declaration too: explicit wins for every slot, Automatic
keeps per-pad detection so a mixed session stays honest. The physical kind still
drives the LOCAL feedback paths — a DualSense emulated as a DualShock 4 keeps its
lightbar and adaptive triggers, and a Deck keeps its rumble keep-alive.

Regressed in 97c67b26 / 76be4c3e (multi-controller support); ships in 0.19.x.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 16:56:44 +02:00
co-authored by Claude Opus 5
parent 8362d57001
commit 6edd700910
6 changed files with 156 additions and 12 deletions
@@ -990,7 +990,9 @@ struct ContentView: View {
rawValue: UInt32(clamping: gamepadType)) ?? .auto) rawValue: UInt32(clamping: gamepadType)) ?? .auto)
if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_REMOTE_GAMEPAD"], if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_REMOTE_GAMEPAD"],
let g = PunktfunkConnection.GamepadType(name: name) { let g = PunktfunkConnection.GamepadType(name: name) {
pad = g // Back through resolveType so the lever is adopted as the session's setting: the
// per-pad arrivals declare it too, which is what the host actually builds from.
pad = GamepadManager.shared.resolveType(setting: g)
} }
var bitrate = UInt32(clamping: bitrateKbps) var bitrate = UInt32(clamping: bitrateKbps)
if let kbps = ProcessInfo.processInfo.environment["PUNKTFUNK_BITRATE_KBPS"], if let kbps = ProcessInfo.processInfo.environment["PUNKTFUNK_BITRATE_KBPS"],
@@ -55,7 +55,11 @@ public final class GamepadCapture {
/// Wire pad index (GamepadManager's stable lowest-free assignment), threaded onto every /// Wire pad index (GamepadManager's stable lowest-free assignment), threaded onto every
/// event this controller sends the low byte of `flags`. /// event this controller sends the low byte of `flags`.
let pad: UInt32 let pad: UInt32
/// The controller KIND declared to the host (GamepadArrival) when the slot opened. /// The controller KIND declared to the host (GamepadArrival) when the slot opened the
/// user's explicit "Controller type" setting when they picked one, else the detected
/// kind (`GamepadManager.declaredKind(for:)`). NOT the physical pad's kind: local
/// feedback keys off the live `GCController` subclass instead, so an emulated type never
/// costs a DualSense its lightbar or adaptive triggers.
let pref: PunktfunkConnection.GamepadType let pref: PunktfunkConnection.GamepadType
var buttons: UInt32 = 0 var buttons: UInt32 = 0
var axes: [Int32] = [0, 0, 0, 0, 0, 0] var axes: [Int32] = [0, 0, 0, 0, 0, 0]
@@ -166,7 +170,7 @@ public final class GamepadCapture {
private func openSlot(_ dc: GamepadManager.DiscoveredController) { private func openSlot(_ dc: GamepadManager.DiscoveredController) {
guard let pad = manager.padIndex(for: dc), let ext = dc.controller.extendedGamepad else { return } guard let pad = manager.padIndex(for: dc), let ext = dc.controller.extendedGamepad else { return }
let c = dc.controller let c = dc.controller
let slot = Slot(controller: c, pad: UInt32(pad), pref: dc.kind) let slot = Slot(controller: c, pad: UInt32(pad), pref: manager.declaredKind(for: dc))
slots.append(slot) slots.append(slot)
ext.valueChangedHandler = { [weak self, weak slot] g, _ in ext.valueChangedHandler = { [weak self, weak slot] g, _ in
@@ -192,9 +196,12 @@ public final class GamepadCapture {
} }
} }
// Declare this pad's controller KIND before any of its input, so the host builds a // Declare this pad's controller KIND before any of its input, so the host builds a
// matching virtual device (mixed types pad 0 a DualSense, pad 1 an Xbox pad). The core // matching virtual device the user's chosen type when they picked one, else per-pad
// re-sends it a few times against datagram loss; an older host ignores it and uses the // detection (mixed types pad 0 a DualSense, pad 1 an Xbox pad). This declaration is
// session-default kind. Then wake the host pad (pads are created lazily from the first // what the host actually builds from, so it MUST carry an explicit setting; the
// handshake's session default is only the fallback for a pad that never declares. The
// core re-sends it a few times against datagram loss; an older host ignores it and uses
// the session-default kind. Then wake the host pad (pads are created lazily from the first
// event; a DualSense's UHID handshake + initial lightbar write only start then). // event; a DualSense's UHID handshake + initial lightbar write only start then).
connection.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad)) connection.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad)) connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
@@ -131,13 +131,40 @@ public final class GamepadManager: ObservableObject {
GCController.stopWirelessControllerDiscovery() GCController.stopWirelessControllerDiscovery()
} }
/// The user's controller-type choice AS CHOSEN (not resolved) for the session being dialed
/// adopted by `resolveType` and read back by `declaredKind(for:)`. `.auto` = detect per pad.
public private(set) var typeSetting: PunktfunkConnection.GamepadType = .auto
/// The kind to DECLARE to the host for one forwarded controller (its `GamepadArrival`).
/// An explicit setting wins for every pad the handshake's session default alone does NOT
/// stick, because a current host honors the per-pad arrival over it (punktfunk-host's
/// `Pads::set_kind`), so a client that declared only the detected kind here would silently
/// undo the user's choice. `.auto` keeps per-pad detection, which is what makes a mixed
/// session (pad 0 a DualSense, pad 1 an Xbox pad) honest.
public func declaredKind(
for controller: DiscoveredController
) -> PunktfunkConnection.GamepadType {
Self.declaredKind(setting: typeSetting, detected: controller.kind)
}
/// The pure fold behind `declaredKind(for:)` (pf-client-core's `declared_kind`).
nonisolated static func declaredKind(
setting: PunktfunkConnection.GamepadType,
detected: PunktfunkConnection.GamepadType
) -> PunktfunkConnection.GamepadType {
setting == .auto ? detected : setting
}
/// Connect-time resolution of the user's controller-type setting: an explicit choice /// Connect-time resolution of the user's controller-type setting: an explicit choice
/// wins; `.auto` matches the virtual pad to the active physical controller (DualSense /// wins; `.auto` matches the virtual pad to the active physical controller (DualSense
/// DualSense, DualShock 4 DualShock 4, an Xbox pad Xbox One, anything else Xbox /// DualSense, DualShock 4 DualShock 4, an Xbox pad Xbox One, anything else Xbox
/// 360); no controller at all defers to the host. /// 360); no controller at all defers to the host. Called once per dial with the RAW setting,
/// which it also adopts for `declaredKind(for:)` so the handshake default and every pad's
/// arrival can never disagree about an explicit choice.
public func resolveType( public func resolveType(
setting: PunktfunkConnection.GamepadType setting: PunktfunkConnection.GamepadType
) -> PunktfunkConnection.GamepadType { ) -> PunktfunkConnection.GamepadType {
typeSetting = setting
guard setting == .auto else { return setting } guard setting == .auto else { return setting }
// Refresh from the LIVE controller list first. `active` is otherwise only populated by the // Refresh from the LIVE controller list first. `active` is otherwise only populated by the
// async `.GCControllerDidConnect` notification, so at connect time it can still be nil even // async `.GCControllerDidConnect` notification, so at connect time it can still be nil even
@@ -119,6 +119,25 @@ final class GamepadWireTests: XCTestCase {
XCTAssertEqual(GamepadWire.maxPads, 16) XCTAssertEqual(GamepadWire.maxPads, 16)
} }
func testAnExplicitControllerTypeIsWhatEveryPadDeclares() {
// The regression this pins: the "Controller type" setting reached the Hello only, and each
// pad's arrival then re-declared the DETECTED kind which the host honors over the
// session default, so "emulate my DualSense as a DualShock 4" produced a DualSense.
// (pf-client-core's `declared_kind` test is the same table.)
XCTAssertEqual(
GamepadManager.declaredKind(setting: .dualShock4, detected: .dualSense), .dualShock4)
// Every physical pad in a mixed session follows the one explicit choice.
for detected: PunktfunkConnection.GamepadType in [
.dualSense, .xbox360, .switchPro, .dualSenseEdge,
] {
XCTAssertEqual(
GamepadManager.declaredKind(setting: .xbox360, detected: detected), .xbox360)
}
// Automatic keeps per-pad detection otherwise a mixed session collapses to one type.
XCTAssertEqual(GamepadManager.declaredKind(setting: .auto, detected: .dualSense), .dualSense)
XCTAssertEqual(GamepadManager.declaredKind(setting: .auto, detected: .switchPro), .switchPro)
}
func testTouchpadConversionCorners() { func testTouchpadConversionCorners() {
// GC ±1 with +y up wire 0...65535 with origin top-left, +y down. // GC ±1 with +y up wire 0...65535 with origin top-left, +y down.
let topLeft = GamepadWire.touchpad(x: -1, y: 1) let topLeft = GamepadWire.touchpad(x: -1, y: 1)
+11 -3
View File
@@ -204,9 +204,17 @@ mod session_main {
mode, mode,
compositor: CompositorPref::from_name(&settings.compositor) compositor: CompositorPref::from_name(&settings.compositor)
.unwrap_or(CompositorPref::Auto), .unwrap_or(CompositorPref::Auto),
gamepad: match GamepadPref::from_name(&settings.gamepad) { gamepad: {
Some(GamepadPref::Auto) | None => gamepad.auto_pref(), // The setting AS CHOSEN goes to the pad service too, not just the Hello: the host
Some(explicit) => explicit, // builds each virtual pad from that pad's arrival and only falls back to this
// session default for a pad that never declares one, so an explicit choice that
// stopped here would be undone the moment a controller connected.
let chosen = GamepadPref::from_name(&settings.gamepad).unwrap_or(GamepadPref::Auto);
gamepad.set_kind_override(chosen);
match chosen {
GamepadPref::Auto => gamepad.auto_pref(),
explicit => explicit,
}
}, },
bitrate_kbps: settings.bitrate_kbps, bitrate_kbps: settings.bitrate_kbps,
audio_channels: settings.audio_channels, audio_channels: settings.audio_channels,
+83 -2
View File
@@ -299,6 +299,23 @@ fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
} }
} }
/// The kind a slot DECLARES to the host ([`InputKind::GamepadArrival`]) given the user's
/// controller-type `setting` and the pad's `physical` kind: an explicit setting emulates that pad
/// for every slot, `Auto` keeps per-pad detection (what makes a mixed session honest).
///
/// This has to be applied per pad and not just in the Hello: the host builds each virtual device
/// from that pad's arrival and only falls back to the session default for a pad that never
/// declares one, so a client that always declared the detected kind would silently undo the
/// setting the moment a controller connected. The physical kind is still what the LOCAL feedback
/// paths use (DualSense raw effects, the Deck rumble keep-alive) — those talk to the controller in
/// the user's hands, not the one the host is pretending to have.
fn declared_kind(setting: GamepadPref, physical: GamepadPref) -> GamepadPref {
match setting {
GamepadPref::Auto => physical,
explicit => explicit,
}
}
/// Best-effort "this machine is a Steam Deck". The Gaming-Mode env short-circuits; desktop /// Best-effort "this machine is a Steam Deck". The Gaming-Mode env short-circuits; desktop
/// mode falls back to DMI (Valve board, Jupiter = LCD / Galileo = OLED — readable inside the /// mode falls back to DMI (Valve board, Jupiter = LCD / Galileo = OLED — readable inside the
/// flatpak sandbox). Cached: the answer can't change while we run. /// flatpak sandbox). Cached: the answer can't change while we run.
@@ -318,6 +335,7 @@ enum Ctl {
Attach(Arc<NativeClient>), Attach(Arc<NativeClient>),
Detach, Detach,
Pin(Option<String>), Pin(Option<String>),
KindOverride(GamepadPref),
MenuMode(bool), MenuMode(bool),
MenuRumble(MenuPulse), MenuRumble(MenuPulse),
} }
@@ -452,6 +470,18 @@ impl GamepadService {
let _ = self.ctl.send(Ctl::Pin(key)); let _ = self.ctl.send(Ctl::Pin(key));
} }
/// Adopt the user's explicit controller-type setting for the session about to start
/// (`GamepadPref::Auto` = detect per pad, the default).
///
/// This is NOT redundant with the session default in the Hello: a current host honors a pad's
/// [`InputKind::GamepadArrival`] over the session default, so a client that declared only the
/// detected kind would silently undo the setting the moment a controller connected. Call it
/// before [`Self::attach`] — slots declare their kind at open time and the host does not
/// hot-swap a device that already exists.
pub fn set_kind_override(&self, pref: GamepadPref) {
let _ = self.ctl.send(Ctl::KindOverride(pref));
}
pub fn attach(&self, connector: Arc<NativeClient>) { pub fn attach(&self, connector: Arc<NativeClient>) {
let _ = self.ctl.send(Ctl::Attach(connector)); let _ = self.ctl.send(Ctl::Attach(connector));
} }
@@ -691,6 +721,10 @@ struct Worker {
/// connected pads, so it survives restarts and disconnects. A pin forwards ONLY that pad /// connected pads, so it survives restarts and disconnects. A pin forwards ONLY that pad
/// (an explicit single-player choice); Automatic forwards every real controller. /// (an explicit single-player choice); Automatic forwards every real controller.
pinned: Option<String>, pinned: Option<String>,
/// The user's explicit "controller type" setting ([`GamepadService::set_kind_override`]);
/// `Auto` = per-pad detection. Applied at slot open to the kind DECLARED to the host, never
/// to [`Slot::pref`] — the local feedback paths must keep reading the physical pad.
kind_override: GamepadPref,
attached: Option<Arc<NativeClient>>, attached: Option<Arc<NativeClient>>,
/// Raises the UI escape signal; the escape chord fires it once per press. /// Raises the UI escape signal; the escape chord fires it once per press.
escape_tx: async_channel::Sender<()>, escape_tx: async_channel::Sender<()>,
@@ -886,6 +920,7 @@ impl Worker {
Some(p) => p.pref, Some(p) => p.pref,
None => GamepadPref::Xbox360, None => GamepadPref::Xbox360,
}; };
let declared = declared_kind(self.kind_override, pref);
match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) { match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) {
Ok(pad) => { Ok(pad) => {
let mut slot = Slot::new(id, index, pref, pad); let mut slot = Slot::new(id, index, pref, pad);
@@ -895,7 +930,13 @@ impl Worker {
// re-sends it a few times against datagram loss; an older host ignores it and // re-sends it a few times against datagram loss; an older host ignores it and
// uses the session-default kind. // uses the session-default kind.
if let Some(c) = &self.attached { if let Some(c) = &self.attached {
send(c, InputKind::GamepadArrival, pref.to_u8() as u32, 0, index); send(
c,
InputKind::GamepadArrival,
declared.to_u8() as u32,
0,
index,
);
// Declare the actuator's quirks to the shared rumble policy engine. ALWAYS // Declare the actuator's quirks to the shared rumble policy engine. ALWAYS
// set (defaults for a well-behaved pad): wire indices are reused within a // set (defaults for a well-behaved pad): wire indices are reused within a
// connection, so a Deck slot that closes must not leave its keepalive quirk // connection, so a Deck slot that closes must not leave its keepalive quirk
@@ -911,7 +952,13 @@ impl Worker {
}; };
c.set_rumble_quirks(index as u16, quirks); c.set_rumble_quirks(index as u16, quirks);
} }
tracing::info!(id, index, pref = ?pref, "gamepad forwarding (slot opened)"); tracing::info!(
id,
index,
pref = ?pref,
declared = ?declared,
"gamepad forwarding (slot opened)"
);
self.slots.push(slot); self.slots.push(slot);
} }
Err(e) => tracing::warn!(id, error = %e, "gamepad open failed"), Err(e) => tracing::warn!(id, error = %e, "gamepad open failed"),
@@ -1219,6 +1266,7 @@ impl Worker {
self.pinned = key; self.pinned = key;
self.refresh_active(); self.refresh_active();
} }
Ok(Ctl::KindOverride(pref)) => self.kind_override = pref,
Ok(Ctl::MenuMode(on)) => { Ok(Ctl::MenuMode(on)) => {
self.menu_mode = on; self.menu_mode = on;
if on { if on {
@@ -1558,6 +1606,7 @@ impl Worker {
menu_open: None, menu_open: None,
order: Vec::new(), order: Vec::new(),
pinned: None, pinned: None,
kind_override: GamepadPref::Auto,
attached: None, attached: None,
escape_tx, escape_tx,
disconnect_tx, disconnect_tx,
@@ -1823,6 +1872,38 @@ mod slot_tests {
assert_eq!(lowest_free_index(&but_seven), Some(7)); assert_eq!(lowest_free_index(&but_seven), Some(7));
} }
#[test]
fn an_explicit_setting_is_what_every_pad_declares() {
// The regression this pins: the setting used to reach the Hello only, and each pad's
// arrival then re-declared the DETECTED kind — which the host honors over the session
// default, so "emulate my DualSense as a DualShock 4" produced a DualSense.
assert_eq!(
declared_kind(GamepadPref::DualShock4, GamepadPref::DualSense),
GamepadPref::DualShock4
);
// Every physical pad in a mixed session follows the one explicit choice.
for physical in [
GamepadPref::DualSense,
GamepadPref::Xbox360,
GamepadPref::SwitchPro,
GamepadPref::SteamDeck,
] {
assert_eq!(
declared_kind(GamepadPref::Xbox360, physical),
GamepadPref::Xbox360
);
}
// Automatic keeps per-pad detection — otherwise a mixed session collapses to one type.
assert_eq!(
declared_kind(GamepadPref::Auto, GamepadPref::DualSense),
GamepadPref::DualSense
);
assert_eq!(
declared_kind(GamepadPref::Auto, GamepadPref::SteamDeck),
GamepadPref::SteamDeck
);
}
#[test] #[test]
fn hidout_pad_reads_every_variant() { fn hidout_pad_reads_every_variant() {
assert_eq!( assert_eq!(