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 in97c67b26/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:
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
Reference in New Issue
Block a user