Merge branch 'feat/gamepad-new-types': DualSense Edge + Switch Pro + classic SC virtual pads
windows-drivers / probe-and-proto (push) Successful in 41s
ci / rust (push) Failing after 1m2s
ci / docs-site (push) Successful in 1m0s
ci / web (push) Successful in 1m8s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
decky / build-publish (push) Successful in 29s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 44s
windows-drivers / driver-build (push) Failing after 1m31s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 48s
apple / swift (push) Successful in 4m29s
ci / bench (push) Successful in 6m29s
docker / deploy-docs (push) Successful in 27s
arch / build-publish (push) Has been cancelled
deb / build-publish (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
windows-drivers / probe-and-proto (push) Successful in 41s
ci / rust (push) Failing after 1m2s
ci / docs-site (push) Successful in 1m0s
ci / web (push) Successful in 1m8s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
decky / build-publish (push) Successful in 29s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 44s
windows-drivers / driver-build (push) Failing after 1m31s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 48s
apple / swift (push) Successful in 4m29s
ci / bench (push) Successful in 6m29s
docker / deploy-docs (push) Successful in 27s
arch / build-publish (push) Has been cancelled
deb / build-publish (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
gamepad-new-types plan, all phases (7 commits, hashes preserved): Phase 0 wire bytes 7/8; N1 DualSense Edge (Linux UHID + Windows UMDF device_type 2 — all four wire paddles on native back/Fn slots); N2 Switch Pro (Linux UHID, full hid-nintendo probe conversation canned); N3 classic Steam Controller (reserved slot 5 live, UHID); N4 Windows-Deck spike -> NO-GO documented (Steam wants interface 2, a software devnode reads as 0); SDL/Apple/Android kind pickers. Verified per commit: .21 clippy -D warnings + 304/0 host tests + headless bind/probe/evdev smokes for all three new backends; .133 clippy -D warnings + WDK driver-workspace check. On-glass on .173: Windows Edge identity confirmed live by Steam (HIDAPI claim as 054c:0df2) with the rebuilt signed driver (9.9.0714.1141) staged. Remaining physical-pad debts tracked in the plan doc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -387,6 +387,8 @@ private fun prefLabel(pref: Int): String = when (pref) {
|
|||||||
Gamepad.PREF_DUALSHOCK4 -> "DualShock 4"
|
Gamepad.PREF_DUALSHOCK4 -> "DualShock 4"
|
||||||
Gamepad.PREF_STEAMCONTROLLER -> "Steam Controller"
|
Gamepad.PREF_STEAMCONTROLLER -> "Steam Controller"
|
||||||
Gamepad.PREF_STEAMDECK -> "Steam Deck"
|
Gamepad.PREF_STEAMDECK -> "Steam Deck"
|
||||||
|
Gamepad.PREF_DUALSENSEEDGE -> "DualSense Edge"
|
||||||
|
Gamepad.PREF_SWITCHPRO -> "Switch Pro"
|
||||||
else -> "Automatic"
|
else -> "Automatic"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ object Gamepad {
|
|||||||
const val PREF_DUALSHOCK4 = 4
|
const val PREF_DUALSHOCK4 = 4
|
||||||
const val PREF_STEAMCONTROLLER = 5
|
const val PREF_STEAMCONTROLLER = 5
|
||||||
const val PREF_STEAMDECK = 6
|
const val PREF_STEAMDECK = 6
|
||||||
|
const val PREF_DUALSENSEEDGE = 7
|
||||||
|
const val PREF_SWITCHPRO = 8
|
||||||
|
|
||||||
// USB vendor ids of the controllers we can identify by VID/PID.
|
// USB vendor ids of the controllers we can identify by VID/PID.
|
||||||
private const val VID_SONY = 0x054C
|
private const val VID_SONY = 0x054C
|
||||||
@@ -59,10 +61,17 @@ object Gamepad {
|
|||||||
private const val VID_VALVE = 0x28DE
|
private const val VID_VALVE = 0x28DE
|
||||||
private const val VID_NINTENDO = 0x057E
|
private const val VID_NINTENDO = 0x057E
|
||||||
|
|
||||||
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
|
// Sony product ids. DualSense (PS5), DualSense Edge, and DualShock 4 (PS4) map to distinct
|
||||||
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
|
// host pad types — the Edge's back paddles get native slots on the virtual Edge (Android
|
||||||
|
// forwards no paddle input yet, but the identity + rich planes match the physical pad).
|
||||||
|
private val PID_DUALSENSE = setOf(0x0CE6)
|
||||||
|
private val PID_DUALSENSEEDGE = setOf(0x0DF2)
|
||||||
private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC)
|
private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC)
|
||||||
|
|
||||||
|
// Nintendo: Switch Pro Controller — the host builds the virtual hid-nintendo pad (correct
|
||||||
|
// glyphs + positional layout).
|
||||||
|
private val PID_SWITCHPRO = setOf(0x2009)
|
||||||
|
|
||||||
// Valve: Steam Deck built-in controller (0x1205); classic Steam Controller wired (0x1102) /
|
// Valve: Steam Deck built-in controller (0x1205); classic Steam Controller wired (0x1102) /
|
||||||
// dongle (0x1142). The host builds the virtual hid-steam pad; rich-input capture (paddles /
|
// dongle (0x1142). The host builds the virtual hid-steam pad; rich-input capture (paddles /
|
||||||
// trackpads / gyro) is out of scope on Android (no rich-input plane yet), so only the standard
|
// trackpads / gyro) is out of scope on Android (no rich-input plane yet), so only the standard
|
||||||
@@ -91,10 +100,12 @@ object Gamepad {
|
|||||||
val pid = dev.productId
|
val pid = dev.productId
|
||||||
return when {
|
return when {
|
||||||
vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE
|
vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE
|
||||||
|
vid == VID_SONY && pid in PID_DUALSENSEEDGE -> PREF_DUALSENSEEDGE
|
||||||
vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
|
vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
|
||||||
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
|
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
|
||||||
vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK
|
vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK
|
||||||
vid == VID_VALVE && pid in PID_STEAMCONTROLLER -> PREF_STEAMCONTROLLER
|
vid == VID_VALVE && pid in PID_STEAMCONTROLLER -> PREF_STEAMCONTROLLER
|
||||||
|
vid == VID_NINTENDO && pid in PID_SWITCHPRO -> PREF_SWITCHPRO
|
||||||
else -> PREF_XBOX360
|
else -> PREF_XBOX360
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,6 +188,14 @@ public final class PunktfunkConnection {
|
|||||||
// exist so the resolved type round-trips and name parsing matches the host.
|
// exist so the resolved type round-trips and name parsing matches the host.
|
||||||
case steamController = 5
|
case steamController = 5
|
||||||
case steamDeck = 6
|
case steamDeck = 6
|
||||||
|
/// DualSense Edge (Linux UHID / Windows UMDF hosts): the DualSense plus native back/Fn
|
||||||
|
/// buttons. GameController exposes the Edge as a `GCDualSenseGamepad` with its own
|
||||||
|
/// product category; paddle CAPTURE is still gated on G22, but the declared identity +
|
||||||
|
/// rich planes match the physical pad.
|
||||||
|
case dualSenseEdge = 7
|
||||||
|
/// Nintendo Switch Pro Controller (Linux UHID hid-nintendo hosts): correct Nintendo
|
||||||
|
/// glyphs + positional layout on the host side.
|
||||||
|
case switchPro = 8
|
||||||
|
|
||||||
/// Loose name parsing for env/dev hooks, mirroring the host's
|
/// Loose name parsing for env/dev hooks, mirroring the host's
|
||||||
/// `GamepadPref::from_name`.
|
/// `GamepadPref::from_name`.
|
||||||
@@ -200,6 +208,9 @@ public final class PunktfunkConnection {
|
|||||||
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
|
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
|
||||||
case "steamdeck", "steam-deck", "deck": self = .steamDeck
|
case "steamdeck", "steam-deck", "deck": self = .steamDeck
|
||||||
case "steamcontroller", "steam-controller", "steamcon": self = .steamController
|
case "steamcontroller", "steam-controller", "steamcon": self = .steamController
|
||||||
|
case "dualsenseedge", "dualsense-edge", "edge", "dsedge": self = .dualSenseEdge
|
||||||
|
case "switchpro", "switch-pro", "switch", "procontroller", "pro-controller":
|
||||||
|
self = .switchPro
|
||||||
default: return nil
|
default: return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,13 +42,14 @@ public final class GamepadManager: ObservableObject {
|
|||||||
public let hasHaptics: Bool
|
public let hasHaptics: Bool
|
||||||
public let hasMotion: Bool
|
public let hasMotion: Bool
|
||||||
public let hasAdaptiveTriggers: Bool
|
public let hasAdaptiveTriggers: Bool
|
||||||
/// Specifically a DualSense — gates the DualSense-only feedback (adaptive triggers,
|
/// Specifically a DualSense (incl. the Edge — same feedback surface) — gates the
|
||||||
/// player LEDs) and the PlayStation glyph in Settings.
|
/// DualSense-only feedback (adaptive triggers, player LEDs) and the PlayStation glyph
|
||||||
public var isDualSense: Bool { kind == .dualSense }
|
/// in Settings.
|
||||||
/// A PlayStation pad with a touchpad + motion (DualSense OR DualShock 4) — gates
|
public var isDualSense: Bool { kind == .dualSense || kind == .dualSenseEdge }
|
||||||
|
/// A PlayStation pad with a touchpad + motion (DualSense family OR DualShock 4) — gates
|
||||||
/// rich-input CAPTURE (touchpad contacts + gyro/accel on plane 0xCC).
|
/// rich-input CAPTURE (touchpad contacts + gyro/accel on plane 0xCC).
|
||||||
public var hasTouchpadAndMotion: Bool {
|
public var hasTouchpadAndMotion: Bool {
|
||||||
kind == .dualSense || kind == .dualShock4
|
kind == .dualSense || kind == .dualSenseEdge || kind == .dualShock4
|
||||||
}
|
}
|
||||||
/// 0...1, nil when the controller doesn't report a battery (e.g. wired).
|
/// 0...1, nil when the controller doesn't report a battery (e.g. wired).
|
||||||
public let batteryLevel: Float?
|
public let batteryLevel: Float?
|
||||||
@@ -227,7 +228,7 @@ public final class GamepadManager: ObservableObject {
|
|||||||
|
|
||||||
private static func describe(_ c: GCController, id: String) -> DiscoveredController {
|
private static func describe(_ c: GCController, id: String) -> DiscoveredController {
|
||||||
let extended = c.extendedGamepad
|
let extended = c.extendedGamepad
|
||||||
let kind = padKind(extended)
|
let kind = padKind(extended, productCategory: c.productCategory)
|
||||||
return DiscoveredController(
|
return DiscoveredController(
|
||||||
id: id,
|
id: id,
|
||||||
name: c.vendorName ?? c.productCategory,
|
name: c.vendorName ?? c.productCategory,
|
||||||
@@ -237,28 +238,40 @@ public final class GamepadManager: ObservableObject {
|
|||||||
hasLight: c.light != nil,
|
hasLight: c.light != nil,
|
||||||
hasHaptics: c.haptics != nil,
|
hasHaptics: c.haptics != nil,
|
||||||
hasMotion: c.motion != nil,
|
hasMotion: c.motion != nil,
|
||||||
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration; the
|
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration (the
|
||||||
// DualShock 4 has none.
|
// Edge included); the DualShock 4 has none.
|
||||||
hasAdaptiveTriggers: kind == .dualSense,
|
hasAdaptiveTriggers: kind == .dualSense || kind == .dualSenseEdge,
|
||||||
batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil },
|
batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil },
|
||||||
isCharging: c.battery?.batteryState == .charging,
|
isCharging: c.battery?.batteryState == .charging,
|
||||||
controller: c)
|
controller: c)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a physical controller's matching virtual-pad type from its GameController
|
/// Resolve a physical controller's matching virtual-pad type from its GameController
|
||||||
/// subclass. Detection order (all are `: GCExtendedGamepad`): DualSense first, then
|
/// subclass (+ the product-category string where the subclass is shared). Detection order
|
||||||
/// DualShock 4, then any Xbox pad, else fall back to Xbox 360. A non-extended / absent
|
/// (all are `: GCExtendedGamepad`): DualSense family first (the Edge is a
|
||||||
/// profile also falls back to `.xbox360` (it's never forwarded anyway).
|
/// `GCDualSenseGamepad` too — its distinct product category splits it out), then
|
||||||
|
/// DualShock 4, any Xbox pad, then Nintendo Switch pads by category (GameController has no
|
||||||
|
/// dedicated subclass for them). A non-extended / absent profile falls back to `.xbox360`
|
||||||
|
/// (it's never forwarded anyway).
|
||||||
private static func padKind(
|
private static func padKind(
|
||||||
_ extended: GCExtendedGamepad?
|
_ extended: GCExtendedGamepad?,
|
||||||
|
productCategory: String
|
||||||
) -> PunktfunkConnection.GamepadType {
|
) -> PunktfunkConnection.GamepadType {
|
||||||
guard let extended else { return .xbox360 }
|
guard let extended else { return .xbox360 }
|
||||||
|
let category = productCategory.lowercased()
|
||||||
// Deployment floor (macOS 14 / iOS 17 / tvOS 17) clears every introduction version
|
// Deployment floor (macOS 14 / iOS 17 / tvOS 17) clears every introduction version
|
||||||
// here, so no `@available` guard is needed — matching the unguarded
|
// here, so no `@available` guard is needed — matching the unguarded
|
||||||
// `GCDualSenseGamepad` use elsewhere in the package.
|
// `GCDualSenseGamepad` use elsewhere in the package.
|
||||||
if extended is GCDualSenseGamepad { return .dualSense }
|
if extended is GCDualSenseGamepad {
|
||||||
|
return category.contains("edge") ? .dualSenseEdge : .dualSense
|
||||||
|
}
|
||||||
if extended is GCDualShockGamepad { return .dualShock4 }
|
if extended is GCDualShockGamepad { return .dualShock4 }
|
||||||
if extended is GCXboxGamepad { return .xboxOne }
|
if extended is GCXboxGamepad { return .xboxOne }
|
||||||
|
// Nintendo Switch Pro Controller / a paired Joy-Con set (a full pad surface). Single
|
||||||
|
// Joy-Cons ("Joy-Con (L)" / "(R)") stay on the Xbox 360 fallback — half a pad.
|
||||||
|
if category.contains("switch pro") || category.contains("joy-con (l/r)") {
|
||||||
|
return .switchPro
|
||||||
|
}
|
||||||
return .xbox360
|
return .xbox360
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,9 +264,12 @@ impl PadInfo {
|
|||||||
pub fn kind_label(&self) -> &'static str {
|
pub fn kind_label(&self) -> &'static str {
|
||||||
match self.pref {
|
match self.pref {
|
||||||
GamepadPref::DualSense => "DualSense",
|
GamepadPref::DualSense => "DualSense",
|
||||||
|
GamepadPref::DualSenseEdge => "DualSense Edge",
|
||||||
GamepadPref::DualShock4 => "DualShock 4",
|
GamepadPref::DualShock4 => "DualShock 4",
|
||||||
GamepadPref::XboxOne => "Xbox One",
|
GamepadPref::XboxOne => "Xbox One",
|
||||||
GamepadPref::SteamDeck => "Steam Deck",
|
GamepadPref::SteamDeck => "Steam Deck",
|
||||||
|
GamepadPref::SteamController => "Steam Controller",
|
||||||
|
GamepadPref::SwitchPro => "Switch Pro",
|
||||||
_ => "",
|
_ => "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,6 +300,9 @@ fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
|
|||||||
T::PS5 => GamepadPref::DualSense,
|
T::PS5 => GamepadPref::DualSense,
|
||||||
T::PS4 => GamepadPref::DualShock4,
|
T::PS4 => GamepadPref::DualShock4,
|
||||||
T::XboxOne => GamepadPref::XboxOne,
|
T::XboxOne => GamepadPref::XboxOne,
|
||||||
|
// A paired Joy-Con set exposes the full Pro button surface through SDL, so it rides
|
||||||
|
// the same virtual pad; single Joy-Cons stay on the Xbox 360 fallback (half a pad).
|
||||||
|
T::NintendoSwitchPro | T::NintendoSwitchJoyconPair => GamepadPref::SwitchPro,
|
||||||
_ => GamepadPref::Xbox360,
|
_ => GamepadPref::Xbox360,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -778,11 +784,20 @@ impl Worker {
|
|||||||
self.subsystem.product_for_id(jid).unwrap_or(0),
|
self.subsystem.product_for_id(jid).unwrap_or(0),
|
||||||
);
|
);
|
||||||
// There is no SDL gamepad type for the Steam Deck / Steam Controller, so detect Valve by
|
// There is no SDL gamepad type for the Steam Deck / Steam Controller, so detect Valve by
|
||||||
// VID/PID (Deck 0x1205, SC wired 0x1102, SC dongle 0x1142) — the host then builds the virtual
|
// VID/PID — the host then builds the matching virtual hid-steam pad (grips + trackpads +
|
||||||
// hid-steam pad with the back grips + dual trackpads and the right glyph identity.
|
// the right glyph identity): Deck 0x1205; classic SC wired 0x1102 / dongle 0x1142.
|
||||||
if vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) {
|
if vid == 0x28DE && pid == 0x1205 {
|
||||||
pref = GamepadPref::SteamDeck;
|
pref = GamepadPref::SteamDeck;
|
||||||
}
|
}
|
||||||
|
if vid == 0x28DE && matches!(pid, 0x1102 | 0x1142) {
|
||||||
|
pref = GamepadPref::SteamController;
|
||||||
|
}
|
||||||
|
// The DualSense Edge has no distinct SDL gamepad type either (it reports PS5) — detect by
|
||||||
|
// VID/PID so the host builds the virtual Edge and this pad's back paddles land on native
|
||||||
|
// slots instead of the fold/drop policy.
|
||||||
|
if vid == 0x054C && pid == 0x0DF2 {
|
||||||
|
pref = GamepadPref::DualSenseEdge;
|
||||||
|
}
|
||||||
let name = self
|
let name = self
|
||||||
.subsystem
|
.subsystem
|
||||||
.name_for_id(jid)
|
.name_for_id(jid)
|
||||||
@@ -1556,7 +1571,12 @@ impl Worker {
|
|||||||
let Some(slot) = self.slots.iter_mut().find(|s| s.index == idx) else {
|
let Some(slot) = self.slots.iter_mut().find(|s| s.index == idx) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let is_ds = slot.pref == GamepadPref::DualSense;
|
// A physical Edge takes the same raw DS5 effects packets (SDL's DS5EffectsState_t
|
||||||
|
// layout is shared; SDL keys the enhanced path off the Edge PID itself).
|
||||||
|
let is_ds = matches!(
|
||||||
|
slot.pref,
|
||||||
|
GamepadPref::DualSense | GamepadPref::DualSenseEdge
|
||||||
|
);
|
||||||
match hid {
|
match hid {
|
||||||
HidOutput::Led { r, g, b, .. } if is_ds => {
|
HidOutput::Led { r, g, b, .. } if is_ds => {
|
||||||
let _ = slot.pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b));
|
let _ = slot.pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b));
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ pub(crate) enum GlyphStyle {
|
|||||||
impl GlyphStyle {
|
impl GlyphStyle {
|
||||||
pub(crate) fn from_pref(pref: Option<GamepadPref>) -> GlyphStyle {
|
pub(crate) fn from_pref(pref: Option<GamepadPref>) -> GlyphStyle {
|
||||||
match pref {
|
match pref {
|
||||||
Some(GamepadPref::DualSense | GamepadPref::DualShock4) => GlyphStyle::Shapes,
|
Some(
|
||||||
|
GamepadPref::DualSense | GamepadPref::DualSenseEdge | GamepadPref::DualShock4,
|
||||||
|
) => GlyphStyle::Shapes,
|
||||||
Some(_) => GlyphStyle::Letters,
|
Some(_) => GlyphStyle::Letters,
|
||||||
None => GlyphStyle::Keyboard,
|
None => GlyphStyle::Keyboard,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -531,10 +531,17 @@ pub mod gamepad {
|
|||||||
pub const PAD_MAGIC: u32 = 0x5046_4453;
|
pub const PAD_MAGIC: u32 = 0x5046_4453;
|
||||||
|
|
||||||
/// `device_type` selector the `pf_dualsense` driver reads to pick its HID identity. The section is
|
/// `device_type` selector the `pf_dualsense` driver reads to pick its HID identity. The section is
|
||||||
/// zeroed, so `0` = DualSense is the default; one driver serves either identity.
|
/// zeroed, so `0` = DualSense is the default; one driver serves every identity.
|
||||||
pub const DEVTYPE_DUALSENSE: u8 = 0;
|
pub const DEVTYPE_DUALSENSE: u8 = 0;
|
||||||
/// `device_type` = DualShock 4 (`VID_054C&PID_09CC` HID identity).
|
/// `device_type` = DualShock 4 (`VID_054C&PID_09CC` HID identity).
|
||||||
pub const DEVTYPE_DUALSHOCK4: u8 = 1;
|
pub const DEVTYPE_DUALSHOCK4: u8 = 1;
|
||||||
|
/// `device_type` = DualSense Edge (`VID_054C&PID_0DF2` HID identity — the DualSense report
|
||||||
|
/// codec plus the four native back/Fn button bits).
|
||||||
|
pub const DEVTYPE_DUALSENSE_EDGE: u8 = 2;
|
||||||
|
/// `device_type` = **N4-spike** Steam Deck identity (`VID_28DE&PID_1205`). Exists only for
|
||||||
|
/// the `deck-windows-spike` go/no-go probe (does Steam Input on Windows promote a
|
||||||
|
/// software-devnode HID Deck?) — never stamped by a session.
|
||||||
|
pub const DEVTYPE_STEAMDECK_SPIKE: u8 = 3;
|
||||||
|
|
||||||
/// The value a gamepad driver writes into its section's `driver_proto` field once it attaches —
|
/// The value a gamepad driver writes into its section's `driver_proto` field once it attaches —
|
||||||
/// the host's positive "driver is alive on this section" signal (health check + version audit).
|
/// the host's positive "driver is alive on this section" signal (health check + version audit).
|
||||||
|
|||||||
@@ -882,13 +882,19 @@ pub const PUNKTFUNK_GAMEPAD_XBOXONE: u32 = 3;
|
|||||||
/// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux
|
/// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux
|
||||||
/// hosts); otherwise the host falls back to X-Box 360.
|
/// hosts); otherwise the host falls back to X-Box 360.
|
||||||
pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
|
pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
|
||||||
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): dual trackpads, gyro,
|
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual
|
||||||
/// two grip paddles. Reserved — currently folds to `XBOX360` until its backend lands.
|
/// trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360.
|
||||||
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5;
|
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5;
|
||||||
/// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`): full Deck gamepad incl. the
|
/// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`): full Deck gamepad incl. the
|
||||||
/// four back grips, a right trackpad, and the IMU; re-grabbed by Steam Input with native glyphs when
|
/// four back grips, a right trackpad, and the IMU; re-grabbed by Steam Input with native glyphs when
|
||||||
/// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360.
|
/// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360.
|
||||||
pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6;
|
pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6;
|
||||||
|
/// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a
|
||||||
|
/// client's back paddles land on native slots. Folds to `DUALSENSE` until its backend lands.
|
||||||
|
pub const PUNKTFUNK_GAMEPAD_DUALSENSEEDGE: u32 = 7;
|
||||||
|
/// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo`): Nintendo glyphs +
|
||||||
|
/// positional layout, gyro/accel, HD rumble. Folds to `XBOX360` until its backend lands.
|
||||||
|
pub const PUNKTFUNK_GAMEPAD_SWITCHPRO: u32 = 8;
|
||||||
|
|
||||||
/// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips
|
/// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips
|
||||||
/// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1–P4) + the misc/capture button, in Moonlight's
|
/// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1–P4) + the misc/capture button, in Moonlight's
|
||||||
@@ -945,6 +951,8 @@ const _: () = {
|
|||||||
assert!(PUNKTFUNK_GAMEPAD_DUALSHOCK4 == GamepadPref::DualShock4.to_u8() as u32);
|
assert!(PUNKTFUNK_GAMEPAD_DUALSHOCK4 == GamepadPref::DualShock4.to_u8() as u32);
|
||||||
assert!(PUNKTFUNK_GAMEPAD_STEAMCONTROLLER == GamepadPref::SteamController.to_u8() as u32);
|
assert!(PUNKTFUNK_GAMEPAD_STEAMCONTROLLER == GamepadPref::SteamController.to_u8() as u32);
|
||||||
assert!(PUNKTFUNK_GAMEPAD_STEAMDECK == GamepadPref::SteamDeck.to_u8() as u32);
|
assert!(PUNKTFUNK_GAMEPAD_STEAMDECK == GamepadPref::SteamDeck.to_u8() as u32);
|
||||||
|
assert!(PUNKTFUNK_GAMEPAD_DUALSENSEEDGE == GamepadPref::DualSenseEdge.to_u8() as u32);
|
||||||
|
assert!(PUNKTFUNK_GAMEPAD_SWITCHPRO == GamepadPref::SwitchPro.to_u8() as u32);
|
||||||
// Extended button bits mirror the wire `input::gamepad` constants.
|
// Extended button bits mirror the wire `input::gamepad` constants.
|
||||||
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE1 == g::BTN_PADDLE1);
|
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE1 == g::BTN_PADDLE1);
|
||||||
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE2 == g::BTN_PADDLE2);
|
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE2 == g::BTN_PADDLE2);
|
||||||
|
|||||||
@@ -138,8 +138,8 @@ impl CompositorPref {
|
|||||||
/// honored only if that backend is available on the host (DualSense / DualShock 4 need Linux UHID);
|
/// honored only if that backend is available on the host (DualSense / DualShock 4 need Linux UHID);
|
||||||
/// otherwise the host falls back and reports the real choice in `Welcome`. The wire form is a single
|
/// otherwise the host falls back and reports the real choice in `Welcome`. The wire form is a single
|
||||||
/// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
|
/// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
|
||||||
/// `5 = SteamController`, `6 = SteamDeck`), appended to `Hello`/`Welcome` — older peers simply
|
/// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`), appended to
|
||||||
/// omit/ignore it (an unknown byte degrades to `Auto`).
|
/// `Hello`/`Welcome` — older peers simply omit/ignore it (an unknown byte degrades to `Auto`).
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||||
pub enum GamepadPref {
|
pub enum GamepadPref {
|
||||||
/// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360).
|
/// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360).
|
||||||
@@ -156,19 +156,26 @@ pub enum GamepadPref {
|
|||||||
/// UHID DualShock 4 (kernel `hid-playstation`, ≥ 6.2) — lightbar, touchpad, motion, rumble. Like
|
/// UHID DualShock 4 (kernel `hid-playstation`, ≥ 6.2) — lightbar, touchpad, motion, rumble. Like
|
||||||
/// `DualSense` minus adaptive triggers / player LEDs / mute. Needs Linux UHID on the host.
|
/// `DualSense` minus adaptive triggers / player LEDs / mute. Needs Linux UHID on the host.
|
||||||
DualShock4,
|
DualShock4,
|
||||||
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`) — dual trackpads, gyro,
|
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`) — one stick + dual
|
||||||
/// two grip paddles, trackpad-only haptics. Needs Linux UHID. *(Reserved; its backend is not yet
|
/// trackpads + two grip paddles. The wire right stick drives the right pad; a left-pad contact
|
||||||
/// built — currently folds to `Xbox360`; the Deck identity below is the implemented one.)*
|
/// shadows the stick (hardware multiplex). Needs Linux UHID.
|
||||||
SteamController,
|
SteamController,
|
||||||
/// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`) — full Deck gamepad incl.
|
/// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`) — full Deck gamepad incl.
|
||||||
/// the four back grips (L4/L5/R4/R5), a right trackpad, and the IMU; re-grabbed by Steam Input
|
/// the four back grips (L4/L5/R4/R5), a right trackpad, and the IMU; re-grabbed by Steam Input
|
||||||
/// with native glyphs when Steam runs on the host. Needs Linux UHID.
|
/// with native glyphs when Steam runs on the host. Needs Linux UHID.
|
||||||
SteamDeck,
|
SteamDeck,
|
||||||
|
/// DualSense Edge (Sony `054C:0DF2`, kernel `hid-playstation` ≥ 6.3 / Windows UMDF) — the
|
||||||
|
/// DualSense plus two back buttons + two Fn buttons, so a client's back paddles (Deck grips,
|
||||||
|
/// Elite P1–P4) land on a native slot instead of the fold/drop policy.
|
||||||
|
DualSenseEdge,
|
||||||
|
/// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo` ≥ 5.16) —
|
||||||
|
/// correct Nintendo glyphs + positional layout, gyro/accel, HD rumble back. Needs Linux UHID.
|
||||||
|
SwitchPro,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GamepadPref {
|
impl GamepadPref {
|
||||||
/// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
|
/// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
|
||||||
/// `5 = SteamController`, `6 = SteamDeck`.
|
/// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`.
|
||||||
pub const fn to_u8(self) -> u8 {
|
pub const fn to_u8(self) -> u8 {
|
||||||
match self {
|
match self {
|
||||||
GamepadPref::Auto => 0,
|
GamepadPref::Auto => 0,
|
||||||
@@ -178,6 +185,8 @@ impl GamepadPref {
|
|||||||
GamepadPref::DualShock4 => 4,
|
GamepadPref::DualShock4 => 4,
|
||||||
GamepadPref::SteamController => 5,
|
GamepadPref::SteamController => 5,
|
||||||
GamepadPref::SteamDeck => 6,
|
GamepadPref::SteamDeck => 6,
|
||||||
|
GamepadPref::DualSenseEdge => 7,
|
||||||
|
GamepadPref::SwitchPro => 8,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +200,8 @@ impl GamepadPref {
|
|||||||
4 => GamepadPref::DualShock4,
|
4 => GamepadPref::DualShock4,
|
||||||
5 => GamepadPref::SteamController,
|
5 => GamepadPref::SteamController,
|
||||||
6 => GamepadPref::SteamDeck,
|
6 => GamepadPref::SteamDeck,
|
||||||
|
7 => GamepadPref::DualSenseEdge,
|
||||||
|
8 => GamepadPref::SwitchPro,
|
||||||
_ => GamepadPref::Auto,
|
_ => GamepadPref::Auto,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,12 +219,16 @@ impl GamepadPref {
|
|||||||
"dualshock4" | "dualshock" | "ds4" | "ps4" => GamepadPref::DualShock4,
|
"dualshock4" | "dualshock" | "ds4" | "ps4" => GamepadPref::DualShock4,
|
||||||
"steamdeck" | "steam-deck" | "deck" => GamepadPref::SteamDeck,
|
"steamdeck" | "steam-deck" | "deck" => GamepadPref::SteamDeck,
|
||||||
"steamcontroller" | "steam-controller" | "steamcon" => GamepadPref::SteamController,
|
"steamcontroller" | "steam-controller" | "steamcon" => GamepadPref::SteamController,
|
||||||
|
"dualsenseedge" | "dualsense-edge" | "edge" | "dsedge" => GamepadPref::DualSenseEdge,
|
||||||
|
"switchpro" | "switch-pro" | "switch" | "procontroller" | "pro-controller" => {
|
||||||
|
GamepadPref::SwitchPro
|
||||||
|
}
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`,
|
/// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`,
|
||||||
/// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`).
|
/// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`, `"dualsenseedge"`, `"switchpro"`).
|
||||||
pub fn as_str(self) -> &'static str {
|
pub fn as_str(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
GamepadPref::Auto => "auto",
|
GamepadPref::Auto => "auto",
|
||||||
@@ -223,6 +238,8 @@ impl GamepadPref {
|
|||||||
GamepadPref::DualShock4 => "dualshock4",
|
GamepadPref::DualShock4 => "dualshock4",
|
||||||
GamepadPref::SteamController => "steamcontroller",
|
GamepadPref::SteamController => "steamcontroller",
|
||||||
GamepadPref::SteamDeck => "steamdeck",
|
GamepadPref::SteamDeck => "steamdeck",
|
||||||
|
GamepadPref::DualSenseEdge => "dualsenseedge",
|
||||||
|
GamepadPref::SwitchPro => "switchpro",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,18 +275,45 @@ fn gamepad_pref_wire_and_names() {
|
|||||||
GamepadPref::DualSense,
|
GamepadPref::DualSense,
|
||||||
GamepadPref::XboxOne,
|
GamepadPref::XboxOne,
|
||||||
GamepadPref::DualShock4,
|
GamepadPref::DualShock4,
|
||||||
|
GamepadPref::SteamController,
|
||||||
|
GamepadPref::SteamDeck,
|
||||||
|
GamepadPref::DualSenseEdge,
|
||||||
|
GamepadPref::SwitchPro,
|
||||||
] {
|
] {
|
||||||
assert_eq!(GamepadPref::from_u8(p.to_u8()), p);
|
assert_eq!(GamepadPref::from_u8(p.to_u8()), p);
|
||||||
assert_eq!(GamepadPref::from_name(p.as_str()), Some(p));
|
assert_eq!(GamepadPref::from_name(p.as_str()), Some(p));
|
||||||
}
|
}
|
||||||
// Distinct wire bytes (forward-compat with peers that only know 0..=2).
|
// Every wire byte 0..=8 is assigned, distinct, and pinned (forward-compat with peers
|
||||||
assert_eq!(GamepadPref::XboxOne.to_u8(), 3);
|
// that only know a prefix of the range).
|
||||||
assert_eq!(GamepadPref::DualShock4.to_u8(), 4);
|
for (v, p) in [
|
||||||
|
(0, GamepadPref::Auto),
|
||||||
|
(1, GamepadPref::Xbox360),
|
||||||
|
(2, GamepadPref::DualSense),
|
||||||
|
(3, GamepadPref::XboxOne),
|
||||||
|
(4, GamepadPref::DualShock4),
|
||||||
|
(5, GamepadPref::SteamController),
|
||||||
|
(6, GamepadPref::SteamDeck),
|
||||||
|
(7, GamepadPref::DualSenseEdge),
|
||||||
|
(8, GamepadPref::SwitchPro),
|
||||||
|
] {
|
||||||
|
assert_eq!(p.to_u8(), v);
|
||||||
|
assert_eq!(GamepadPref::from_u8(v), p);
|
||||||
|
}
|
||||||
|
// The next unassigned byte degrades to Auto today; assigning it later must update this.
|
||||||
|
assert_eq!(GamepadPref::from_u8(9), GamepadPref::Auto);
|
||||||
// Aliases + unknowns.
|
// Aliases + unknowns.
|
||||||
assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense));
|
assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense));
|
||||||
assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360));
|
assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360));
|
||||||
assert_eq!(GamepadPref::from_name("ps4"), Some(GamepadPref::DualShock4));
|
assert_eq!(GamepadPref::from_name("ps4"), Some(GamepadPref::DualShock4));
|
||||||
assert_eq!(GamepadPref::from_name("DS4"), Some(GamepadPref::DualShock4));
|
assert_eq!(GamepadPref::from_name("DS4"), Some(GamepadPref::DualShock4));
|
||||||
|
assert_eq!(
|
||||||
|
GamepadPref::from_name("edge"),
|
||||||
|
Some(GamepadPref::DualSenseEdge)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
GamepadPref::from_name("Switch-Pro"),
|
||||||
|
Some(GamepadPref::SwitchPro)
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
GamepadPref::from_name("xbox-one"),
|
GamepadPref::from_name("xbox-one"),
|
||||||
Some(GamepadPref::XboxOne)
|
Some(GamepadPref::XboxOne)
|
||||||
|
|||||||
@@ -482,6 +482,11 @@ pub mod dualsense_proto;
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
#[path = "inject/windows/dualsense_windows.rs"]
|
#[path = "inject/windows/dualsense_windows.rs"]
|
||||||
pub mod dualsense_windows;
|
pub mod dualsense_windows;
|
||||||
|
/// Windows: virtual DualSense **Edge** via the same UMDF minidriver + shared-memory channel
|
||||||
|
/// (device-type 2) — the wire back grips land on the Edge's native back/Fn buttons.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "inject/windows/dualsense_edge_windows.rs"]
|
||||||
|
pub mod dualsense_edge_windows;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "inject/linux/dualshock4.rs"]
|
#[path = "inject/linux/dualshock4.rs"]
|
||||||
pub mod dualshock4;
|
pub mod dualshock4;
|
||||||
@@ -522,6 +527,15 @@ pub mod pad_slots;
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "inject/linux/steam_controller.rs"]
|
#[path = "inject/linux/steam_controller.rs"]
|
||||||
pub mod steam_controller;
|
pub mod steam_controller;
|
||||||
|
/// Linux: virtual Nintendo Switch Pro Controller via UHID (kernel `hid-nintendo`).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[path = "inject/linux/switch_pro.rs"]
|
||||||
|
pub mod switch_pro;
|
||||||
|
/// Transport-independent Switch Pro Controller codec + the canned `hid-nintendo` handshake
|
||||||
|
/// replies, used by the Linux UHID backend ([`switch_pro`]).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[path = "inject/proto/switch_proto.rs"]
|
||||||
|
pub mod switch_proto;
|
||||||
/// Linux: virtual Steam Deck via the USB gadget subsystem (`raw_gadget` + `dummy_hcd`) — the only
|
/// Linux: virtual Steam Deck via the USB gadget subsystem (`raw_gadget` + `dummy_hcd`) — the only
|
||||||
/// virtual-Deck transport Steam Input promotes (presents the controller on USB interface 2).
|
/// virtual-Deck transport Steam Input promotes (presents the controller on USB interface 2).
|
||||||
/// SteamOS-host only (needs `dummy_hcd` + `raw_gadget`).
|
/// SteamOS-host only (needs `dummy_hcd` + `raw_gadget`).
|
||||||
|
|||||||
@@ -13,9 +13,10 @@
|
|||||||
//! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it.
|
//! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it.
|
||||||
|
|
||||||
use super::dualsense_proto::{
|
use super::dualsense_proto::{
|
||||||
parse_ds_output, serialize_state, DsFeedback, DsState, DS_FEATURE_CALIBRATION,
|
edge_paddle_bits, parse_ds_output, serialize_state, DsFeedback, DsState,
|
||||||
DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING, DS_INPUT_REPORT_LEN, DS_PRODUCT, DS_TOUCH_H,
|
DS_EDGE_PRODUCT, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING,
|
||||||
DS_TOUCH_W, DS_VENDOR, DUALSENSE_RDESC,
|
DS_INPUT_REPORT_LEN, DS_PRODUCT, DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC,
|
||||||
|
DUALSENSE_RDESC,
|
||||||
};
|
};
|
||||||
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -43,9 +44,45 @@ fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
|||||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A virtual DualSense backed by `/dev/uhid` (hand-rolled codec — no bindgen, mirroring the
|
/// The UHID identity a [`DualSensePad`] is created with — the plain DualSense or the Edge (same
|
||||||
/// uinput pad's style). Dropping it destroys the device (the kernel tears down the bound
|
/// driver, same report codec; the Edge differs by PID + descriptor and carries the four extra
|
||||||
/// `hid-playstation` interface).
|
/// `buttons[2]` bits). Mirrors the uinput pad's `PadIdentity` shape.
|
||||||
|
pub struct DsUhidIdentity {
|
||||||
|
product: u32,
|
||||||
|
rdesc: &'static [u8],
|
||||||
|
/// Device name prefix ("Punktfunk <name> <index>").
|
||||||
|
name: &'static str,
|
||||||
|
/// Path token for the phys string ("punktfunk/<phys>/<index>").
|
||||||
|
phys: &'static str,
|
||||||
|
/// Short slug for the uniq string ("punktfunk-<slug>-<index>").
|
||||||
|
slug: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DsUhidIdentity {
|
||||||
|
pub const fn dualsense() -> DsUhidIdentity {
|
||||||
|
DsUhidIdentity {
|
||||||
|
product: DS_PRODUCT,
|
||||||
|
rdesc: DUALSENSE_RDESC,
|
||||||
|
name: "DualSense",
|
||||||
|
phys: "dualsense",
|
||||||
|
slug: "ds",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn dualsense_edge() -> DsUhidIdentity {
|
||||||
|
DsUhidIdentity {
|
||||||
|
product: DS_EDGE_PRODUCT,
|
||||||
|
rdesc: DUALSENSE_EDGE_RDESC,
|
||||||
|
name: "DualSense Edge",
|
||||||
|
phys: "dualsense-edge",
|
||||||
|
slug: "dsedge",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A virtual DualSense / DualSense Edge backed by `/dev/uhid` (hand-rolled codec — no bindgen,
|
||||||
|
/// mirroring the uinput pad's style). Dropping it destroys the device (the kernel tears down the
|
||||||
|
/// bound `hid-playstation` interface).
|
||||||
pub struct DualSensePad {
|
pub struct DualSensePad {
|
||||||
fd: File,
|
fd: File,
|
||||||
seq: u8,
|
seq: u8,
|
||||||
@@ -53,8 +90,9 @@ pub struct DualSensePad {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DualSensePad {
|
impl DualSensePad {
|
||||||
/// Create the UHID DualSense for pad `index` (used only to make the device name/uniq unique).
|
/// Create the UHID pad for wire index `index` under `id`'s identity (`index` is used only to
|
||||||
pub fn open(index: u8) -> Result<DualSensePad> {
|
/// make the device name/uniq unique).
|
||||||
|
pub fn open(index: u8, id: &DsUhidIdentity) -> Result<DualSensePad> {
|
||||||
let fd = OpenOptions::new()
|
let fd = OpenOptions::new()
|
||||||
.read(true)
|
.read(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
@@ -64,24 +102,24 @@ impl DualSensePad {
|
|||||||
format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)")
|
format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)")
|
||||||
})?;
|
})?;
|
||||||
let mut ds = DualSensePad { fd, seq: 0, ts: 0 };
|
let mut ds = DualSensePad { fd, seq: 0, ts: 0 };
|
||||||
ds.send_create2(index).context("UHID_CREATE2 DualSense")?;
|
ds.send_create2(index, id).context("UHID_CREATE2 DualSense")?;
|
||||||
Ok(ds)
|
Ok(ds)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_create2(&mut self, index: u8) -> Result<()> {
|
fn send_create2(&mut self, index: u8, id: &DsUhidIdentity) -> Result<()> {
|
||||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||||
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
|
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
|
||||||
// union (uhid_create2_req) starts at byte 4.
|
// union (uhid_create2_req) starts at byte 4.
|
||||||
put_cstr(&mut ev, 4, 128, &format!("Punktfunk DualSense {index}")); // name[128]
|
put_cstr(&mut ev, 4, 128, &format!("Punktfunk {} {index}", id.name)); // name[128]
|
||||||
put_cstr(&mut ev, 132, 64, &format!("punktfunk/dualsense/{index}")); // phys[64]
|
put_cstr(&mut ev, 132, 64, &format!("punktfunk/{}/{index}", id.phys)); // phys[64]
|
||||||
put_cstr(&mut ev, 196, 64, &format!("punktfunk-ds-{index}")); // uniq[64]
|
put_cstr(&mut ev, 196, 64, &format!("punktfunk-{}-{index}", id.slug)); // uniq[64]
|
||||||
ev[260..262].copy_from_slice(&(DUALSENSE_RDESC.len() as u16).to_ne_bytes()); // rd_size
|
ev[260..262].copy_from_slice(&(id.rdesc.len() as u16).to_ne_bytes()); // rd_size
|
||||||
ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus
|
ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus
|
||||||
ev[264..268].copy_from_slice(&DS_VENDOR.to_ne_bytes());
|
ev[264..268].copy_from_slice(&DS_VENDOR.to_ne_bytes());
|
||||||
ev[268..272].copy_from_slice(&DS_PRODUCT.to_ne_bytes());
|
ev[268..272].copy_from_slice(&id.product.to_ne_bytes());
|
||||||
ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version
|
ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version
|
||||||
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
|
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
|
||||||
ev[280..280 + DUALSENSE_RDESC.len()].copy_from_slice(DUALSENSE_RDESC); // rd_data
|
ev[280..280 + id.rdesc.len()].copy_from_slice(id.rdesc); // rd_data
|
||||||
self.fd.write_all(&ev).context("write UHID_CREATE2")?;
|
self.fd.write_all(&ev).context("write UHID_CREATE2")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -186,7 +224,7 @@ impl PadProto for DsLinuxProto {
|
|||||||
const CREATE_HINT: &'static str = "";
|
const CREATE_HINT: &'static str = "";
|
||||||
|
|
||||||
fn open(&mut self, idx: u8) -> Result<DualSensePad> {
|
fn open(&mut self, idx: u8) -> Result<DualSensePad> {
|
||||||
let p = DualSensePad::open(idx)?;
|
let p = DualSensePad::open(idx, &DsUhidIdentity::dualsense())?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
index = idx,
|
index = idx,
|
||||||
"virtual DualSense created (UHID hid-playstation)"
|
"virtual DualSense created (UHID hid-playstation)"
|
||||||
@@ -251,3 +289,83 @@ impl PadProto for DsLinuxProto {
|
|||||||
/// source changes, and heartbeats it through input silence (a real DualSense streams report `0x01`
|
/// source changes, and heartbeats it through input silence (a real DualSense streams report `0x01`
|
||||||
/// continuously — `hid-playstation`/Proton/SDL treat a multi-second gap as an unplug).
|
/// continuously — `hid-playstation`/Proton/SDL treat a multi-second gap as an unplug).
|
||||||
pub type DualSenseManager = UhidManager<DsLinuxProto>;
|
pub type DualSenseManager = UhidManager<DsLinuxProto>;
|
||||||
|
|
||||||
|
/// The DualSense **Edge** half of the shared stateful manager: the plain-DualSense transport and
|
||||||
|
/// report codec under the Edge USB identity (`054C:0DF2` + the Edge descriptor), with the four
|
||||||
|
/// wire back-grip bits mapped onto the Edge's native `buttons[2]` slots instead of the
|
||||||
|
/// fold/drop policy — the whole point of this backend (a client's Deck grips / Elite paddles
|
||||||
|
/// stop vanishing). No remap config: every paddle has a native home.
|
||||||
|
///
|
||||||
|
/// Kernel note: `hid-playstation` binds the Edge PID since 6.1 (forced vibration-v2 output), but
|
||||||
|
/// only kernels ≥ 7.2 surface the Fn/back bits as evdev keys (`BTN_TRIGGER_HAPPY1..4`); SDL /
|
||||||
|
/// Steam Input read the report off hidraw and see them on any kernel.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct DsEdgeLinuxProto;
|
||||||
|
|
||||||
|
impl PadProto for DsEdgeLinuxProto {
|
||||||
|
type Pad = DualSensePad;
|
||||||
|
type State = DsState;
|
||||||
|
const LABEL: &'static str = "DualSense Edge";
|
||||||
|
const DEVICE: &'static str = "DualSense Edge";
|
||||||
|
const CREATE_HINT: &'static str = "";
|
||||||
|
|
||||||
|
fn open(&mut self, idx: u8) -> Result<DualSensePad> {
|
||||||
|
let p = DualSensePad::open(idx, &DsUhidIdentity::dualsense_edge())?;
|
||||||
|
tracing::info!(
|
||||||
|
index = idx,
|
||||||
|
"virtual DualSense Edge created (UHID hid-playstation)"
|
||||||
|
);
|
||||||
|
Ok(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn neutral(&self) -> DsState {
|
||||||
|
DsState::neutral()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields — like the
|
||||||
|
/// plain DualSense, EXCEPT the wire paddles are not folded away: they land on the Edge's own
|
||||||
|
/// `buttons[2]` bits (rebuilt from every button frame, so no extra persistence).
|
||||||
|
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
|
||||||
|
let mut s = DsState::from_gamepad(
|
||||||
|
f.buttons,
|
||||||
|
f.ls_x,
|
||||||
|
f.ls_y,
|
||||||
|
f.rs_x,
|
||||||
|
f.rs_y,
|
||||||
|
f.left_trigger,
|
||||||
|
f.right_trigger,
|
||||||
|
);
|
||||||
|
s.buttons[2] |= edge_paddle_bits(f.buttons);
|
||||||
|
s.touch = prev.touch;
|
||||||
|
s.gyro = prev.gyro;
|
||||||
|
s.accel = prev.accel;
|
||||||
|
s.touch_click = prev.touch_click;
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
|
||||||
|
/// split the one touchpad left/right, pad clicks ride touch_click.
|
||||||
|
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
|
||||||
|
st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_state(&self, pad: &mut DualSensePad, st: &DsState) {
|
||||||
|
let _ = pad.write_state(st);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same kernel handshake + feedback parse as the plain DualSense — the Edge's GET_REPORT set
|
||||||
|
/// (calibration 0x05 / pairing 0x09 / firmware 0x20) and output report 0x02 are identical
|
||||||
|
/// (the Edge's rumble arrives via the vibration-v2 valid_flag2 bit, which
|
||||||
|
/// [`parse_ds_output`] already handles).
|
||||||
|
fn service(&self, pad: &mut DualSensePad, idx: u8) -> PadFeedback {
|
||||||
|
let fb = pad.service(idx);
|
||||||
|
PadFeedback {
|
||||||
|
rumble: fb.rumble,
|
||||||
|
hidout: fb.hidout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All virtual DualSense Edge pads of a session — `PUNKTFUNK_GAMEPAD=edge`, or the per-pad kind a
|
||||||
|
/// client declares for a paddle-bearing physical controller.
|
||||||
|
pub type DualSenseEdgeManager = UhidManager<DsEdgeLinuxProto>;
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
//! command (the DualSense backend only services GET_REPORT + OUTPUT).
|
//! command (the DualSense backend only services GET_REPORT + OUTPUT).
|
||||||
|
|
||||||
use super::steam_proto::{
|
use super::steam_proto::{
|
||||||
btn, parse_steam_output, serial_reply, serialize_deck_state, SteamState, STEAMDECK_PRODUCT,
|
btn, parse_steam_output, sc_from_gamepad, serial_reply, serialize_deck_state,
|
||||||
STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR,
|
serialize_sc_state, SteamModel, SteamState, STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR,
|
||||||
};
|
};
|
||||||
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -77,10 +77,12 @@ fn try_clear_lizard_mode() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A virtual Steam Deck backed by `/dev/uhid`. Dropping it destroys the device (the kernel tears
|
/// A virtual Steam Deck **or classic Steam Controller** backed by `/dev/uhid` (same driver, two
|
||||||
/// down the bound `hid-steam` interface + both evdevs).
|
/// identities/report layouts — see [`SteamModel`]). Dropping it destroys the device (the kernel
|
||||||
|
/// tears down the bound `hid-steam` interface + its evdevs).
|
||||||
pub struct SteamDeckPad {
|
pub struct SteamDeckPad {
|
||||||
fd: File,
|
fd: File,
|
||||||
|
model: SteamModel,
|
||||||
seq: u32,
|
seq: u32,
|
||||||
created: Instant,
|
created: Instant,
|
||||||
/// When `b9.6` started being continuously held in our OUTPUT (anti-toggle guard); `None` = not.
|
/// When `b9.6` started being continuously held in our OUTPUT (anti-toggle guard); `None` = not.
|
||||||
@@ -89,7 +91,16 @@ pub struct SteamDeckPad {
|
|||||||
|
|
||||||
impl SteamDeckPad {
|
impl SteamDeckPad {
|
||||||
pub fn open(index: u8) -> Result<SteamDeckPad> {
|
pub fn open(index: u8) -> Result<SteamDeckPad> {
|
||||||
try_clear_lizard_mode();
|
SteamDeckPad::open_model(index, SteamModel::Deck)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open under a specific Steam identity. The classic Controller's `ID_CONTROLLER_STATE` path
|
||||||
|
/// has NO `gamepad_mode` gate in the kernel (only the Deck's parser early-returns under
|
||||||
|
/// lizard mode), so the SC skips the whole mode-entry machinery.
|
||||||
|
pub fn open_model(index: u8, model: SteamModel) -> Result<SteamDeckPad> {
|
||||||
|
if model == SteamModel::Deck {
|
||||||
|
try_clear_lizard_mode();
|
||||||
|
}
|
||||||
let fd = OpenOptions::new()
|
let fd = OpenOptions::new()
|
||||||
.read(true)
|
.read(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
@@ -100,24 +111,29 @@ impl SteamDeckPad {
|
|||||||
})?;
|
})?;
|
||||||
let mut pad = SteamDeckPad {
|
let mut pad = SteamDeckPad {
|
||||||
fd,
|
fd,
|
||||||
|
model,
|
||||||
seq: 0,
|
seq: 0,
|
||||||
created: Instant::now(),
|
created: Instant::now(),
|
||||||
menu_hold_since: None,
|
menu_hold_since: None,
|
||||||
};
|
};
|
||||||
pad.send_create2(index).context("UHID_CREATE2 Steam Deck")?;
|
pad.send_create2(index).context("UHID_CREATE2 Steam pad")?;
|
||||||
Ok(pad)
|
Ok(pad)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_create2(&mut self, index: u8) -> Result<()> {
|
fn send_create2(&mut self, index: u8) -> Result<()> {
|
||||||
|
let (name, phys, uniq) = match self.model {
|
||||||
|
SteamModel::Deck => ("Steam Deck", "steam", "steam"),
|
||||||
|
SteamModel::Controller => ("Steam Controller", "steamctrl", "steamctrl"),
|
||||||
|
};
|
||||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||||
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
|
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
|
||||||
put_cstr(&mut ev, 4, 128, &format!("Punktfunk Steam Deck {index}")); // name[128]
|
put_cstr(&mut ev, 4, 128, &format!("Punktfunk {name} {index}")); // name[128]
|
||||||
put_cstr(&mut ev, 132, 64, &format!("punktfunk/steam/{index}")); // phys[64]
|
put_cstr(&mut ev, 132, 64, &format!("punktfunk/{phys}/{index}")); // phys[64]
|
||||||
put_cstr(&mut ev, 196, 64, &format!("punktfunk-steam-{index}")); // uniq[64]
|
put_cstr(&mut ev, 196, 64, &format!("punktfunk-{uniq}-{index}")); // uniq[64]
|
||||||
ev[260..262].copy_from_slice(&(STEAMDECK_RDESC.len() as u16).to_ne_bytes()); // rd_size
|
ev[260..262].copy_from_slice(&(STEAMDECK_RDESC.len() as u16).to_ne_bytes()); // rd_size
|
||||||
ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus
|
ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus
|
||||||
ev[264..268].copy_from_slice(&STEAM_VENDOR.to_ne_bytes());
|
ev[264..268].copy_from_slice(&STEAM_VENDOR.to_ne_bytes());
|
||||||
ev[268..272].copy_from_slice(&STEAMDECK_PRODUCT.to_ne_bytes());
|
ev[268..272].copy_from_slice(&self.model.product().to_ne_bytes());
|
||||||
ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version
|
ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version
|
||||||
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
|
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
|
||||||
ev[280..280 + STEAMDECK_RDESC.len()].copy_from_slice(STEAMDECK_RDESC);
|
ev[280..280 + STEAMDECK_RDESC.len()].copy_from_slice(STEAMDECK_RDESC);
|
||||||
@@ -125,13 +141,19 @@ impl SteamDeckPad {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize `st` (with the gamepad-mode entry overlay + anti-toggle guard applied) and write it.
|
/// Serialize `st` under this pad's model (Deck reports get the gamepad-mode entry overlay +
|
||||||
|
/// anti-toggle guard applied) and write it.
|
||||||
pub fn write_state(&mut self, st: &SteamState) -> Result<()> {
|
pub fn write_state(&mut self, st: &SteamState) -> Result<()> {
|
||||||
self.seq = self.seq.wrapping_add(1);
|
self.seq = self.seq.wrapping_add(1);
|
||||||
let mut s = *st;
|
|
||||||
s.buttons = self.effective_buttons(st.buttons);
|
|
||||||
let mut r = [0u8; STEAM_REPORT_LEN];
|
let mut r = [0u8; STEAM_REPORT_LEN];
|
||||||
serialize_deck_state(&mut r, &s, self.seq);
|
match self.model {
|
||||||
|
SteamModel::Deck => {
|
||||||
|
let mut s = *st;
|
||||||
|
s.buttons = self.effective_buttons(st.buttons);
|
||||||
|
serialize_deck_state(&mut r, &s, self.seq);
|
||||||
|
}
|
||||||
|
SteamModel::Controller => serialize_sc_state(&mut r, st, self.seq),
|
||||||
|
}
|
||||||
|
|
||||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||||
ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
|
ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
|
||||||
@@ -142,8 +164,9 @@ impl SteamDeckPad {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// True while still pulsing the mode-switch at creation (the caller force-writes during this).
|
/// True while still pulsing the mode-switch at creation (the caller force-writes during this).
|
||||||
|
/// Deck-only — the SC's kernel parser has no mode gate.
|
||||||
fn in_mode_entry(&self) -> bool {
|
fn in_mode_entry(&self) -> bool {
|
||||||
self.created.elapsed() < MODE_ENTER
|
self.model == SteamModel::Deck && self.created.elapsed() < MODE_ENTER
|
||||||
}
|
}
|
||||||
|
|
||||||
/// During mode entry, force `b9.6` held (override). Afterwards, pass the real buttons through but
|
/// During mode entry, force `b9.6` held (override). Afterwards, pass the real buttons through but
|
||||||
@@ -438,6 +461,113 @@ impl PadProto for SteamProto {
|
|||||||
/// the pad alive (and drives the mode-entry pulse) — all from the shared [`UhidManager`].
|
/// the pad alive (and drives the mode-entry pulse) — all from the shared [`UhidManager`].
|
||||||
pub type SteamControllerManager = UhidManager<SteamProto>;
|
pub type SteamControllerManager = UhidManager<SteamProto>;
|
||||||
|
|
||||||
|
/// The **classic Steam Controller** half of the shared stateful manager: the same `hid-steam`
|
||||||
|
/// driver under the wired-SC identity (`28DE:1102`, `ID_CONTROLLER_STATE`), UHID-only in v1 —
|
||||||
|
/// the usbip/gadget transports present the Deck's captured 3-interface USB device, and the SC's
|
||||||
|
/// wired interface layout hasn't been captured, so there is no Steam-Input promotion (the same
|
||||||
|
/// degraded-but-working state the Deck had pre-usbip; acceptable for discontinued hardware).
|
||||||
|
///
|
||||||
|
/// Deltas vs the Deck (see [`sc_from_gamepad`]/[`serialize_sc_state`]): one stick + two pads +
|
||||||
|
/// two grips — the wire right stick drives the right pad, a left-pad contact shadows the left
|
||||||
|
/// stick, wire PADDLE1/2 land on the two grips (3/4 fold via the remap policy), and the kernel
|
||||||
|
/// registers neither FF rumble nor a sensors evdev for this model (feedback stays empty).
|
||||||
|
pub struct ScProto {
|
||||||
|
/// Fallback policy for the wire paddles beyond the SC's two grips (PADDLE3/4).
|
||||||
|
remap: crate::inject::steam_remap::RemapConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ScProto {
|
||||||
|
fn default() -> ScProto {
|
||||||
|
ScProto {
|
||||||
|
remap: crate::inject::steam_remap::RemapConfig::from_env(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PadProto for ScProto {
|
||||||
|
type Pad = SteamDeckPad;
|
||||||
|
type State = SteamState;
|
||||||
|
const LABEL: &'static str = "Steam Controller";
|
||||||
|
const DEVICE: &'static str = "Steam Controller";
|
||||||
|
const CREATE_HINT: &'static str = "";
|
||||||
|
|
||||||
|
fn open(&mut self, idx: u8) -> Result<SteamDeckPad> {
|
||||||
|
let p = SteamDeckPad::open_model(idx, SteamModel::Controller)?;
|
||||||
|
tracing::info!(
|
||||||
|
index = idx,
|
||||||
|
"virtual Steam Controller created (UHID hid-steam)"
|
||||||
|
);
|
||||||
|
Ok(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn neutral(&self) -> SteamState {
|
||||||
|
SteamState::neutral()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge buttons/sticks/triggers, preserving the rich-plane fields. PADDLE1/2 map natively to
|
||||||
|
/// the SC's two grips inside [`sc_from_gamepad`]; only 3/4 go through the fold policy — mask
|
||||||
|
/// the native pair out of the fold input so the policy can't double-fire them.
|
||||||
|
fn merge_frame(
|
||||||
|
&self,
|
||||||
|
prev: &SteamState,
|
||||||
|
f: &crate::gamestream::gamepad::GamepadFrame,
|
||||||
|
) -> SteamState {
|
||||||
|
use punktfunk_core::input::gamepad as gs;
|
||||||
|
let native = f.buttons & (gs::BTN_PADDLE1 | gs::BTN_PADDLE2);
|
||||||
|
let folded = crate::inject::steam_remap::fold_paddles(
|
||||||
|
f.buttons & !(gs::BTN_PADDLE1 | gs::BTN_PADDLE2),
|
||||||
|
self.remap.paddles,
|
||||||
|
);
|
||||||
|
let mut s = sc_from_gamepad(
|
||||||
|
folded | native,
|
||||||
|
f.ls_x,
|
||||||
|
f.ls_y,
|
||||||
|
f.rs_x,
|
||||||
|
f.rs_y,
|
||||||
|
f.left_trigger,
|
||||||
|
f.right_trigger,
|
||||||
|
);
|
||||||
|
s.lpad_x = prev.lpad_x;
|
||||||
|
s.lpad_y = prev.lpad_y;
|
||||||
|
s.gyro = prev.gyro;
|
||||||
|
s.accel = prev.accel;
|
||||||
|
s.buttons |= prev.buttons & btn::LPAD_TOUCH;
|
||||||
|
s.lpad_click = prev.lpad_click;
|
||||||
|
// The right pad carries the wire right stick each frame; a rich right-pad contact
|
||||||
|
// (TouchpadEx surface 2) overrides it only while the stick is centered — the stick is
|
||||||
|
// the primary camera surface on this mapping.
|
||||||
|
if f.rs_x == 0 && f.rs_y == 0 {
|
||||||
|
s.rpad_x = prev.rpad_x;
|
||||||
|
s.rpad_y = prev.rpad_y;
|
||||||
|
s.buttons |= prev.buttons & btn::RPAD_TOUCH;
|
||||||
|
s.rpad_click = prev.rpad_click;
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_rich(&self, st: &mut SteamState, rich: RichInput) {
|
||||||
|
st.apply_rich(rich);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_state(&self, pad: &mut SteamDeckPad, st: &SteamState) {
|
||||||
|
let _ = pad.write_state(st);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answer the kernel handshake (serial GET_REPORT + settings SET_REPORTs). The kernel
|
||||||
|
/// registers no FF device for the classic SC, so rumble feedback can only arrive from a
|
||||||
|
/// hidraw client (`0xEB`) — surfaced if it ever does.
|
||||||
|
fn service(&self, pad: &mut SteamDeckPad, _idx: u8) -> PadFeedback {
|
||||||
|
PadFeedback {
|
||||||
|
rumble: pad.service(),
|
||||||
|
hidout: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All virtual classic Steam Controllers of a session — `PUNKTFUNK_GAMEPAD=steamcontroller`, or
|
||||||
|
/// the per-pad kind a client declares for a physical SC.
|
||||||
|
pub type SteamCtrlManager = UhidManager<ScProto>;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -547,4 +677,40 @@ mod tests {
|
|||||||
"device not torn down on drop"
|
"device not torn down on drop"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// On-box smoke for the classic-SC identity: binds `hid-steam` as `28DE:1102`, input flows
|
||||||
|
/// with NO mode-entry pulse (the SC parser has no gamepad_mode gate), a held A + right-stick
|
||||||
|
/// deflection land on the evdev (BTN_A + ABS_RX — the right PAD surface), and a grip lands
|
||||||
|
/// on BTN_GRIPR (0x2c5? — kernel BTN_GRIPR = 0x2c5 on new kernels / check via bitmap).
|
||||||
|
#[test]
|
||||||
|
#[ignore = "creates a real /dev/uhid device; needs hid-steam + the input group"]
|
||||||
|
fn sc_backend_binds_and_input_flows() {
|
||||||
|
use punktfunk_core::input::gamepad as gs;
|
||||||
|
const BTN_A: u16 = 0x130;
|
||||||
|
const ABS_RX: u16 = 0x03;
|
||||||
|
let mut pad = SteamDeckPad::open_model(0, SteamModel::Controller)
|
||||||
|
.expect("open SC pad (/dev/uhid + input group?)");
|
||||||
|
let st = sc_from_gamepad(gs::BTN_A | gs::BTN_PADDLE1, 0, 0, 9000, 0, 0, 0);
|
||||||
|
let start = Instant::now();
|
||||||
|
while start.elapsed() < Duration::from_millis(900) {
|
||||||
|
let _ = pad.service();
|
||||||
|
pad.write_state(&st).expect("write_state");
|
||||||
|
std::thread::sleep(Duration::from_millis(4));
|
||||||
|
}
|
||||||
|
let devs = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
devs.contains("Steam Controller"),
|
||||||
|
"SC gamepad evdev not created"
|
||||||
|
);
|
||||||
|
let node = find_node("Steam Controller").expect("SC evdev node");
|
||||||
|
assert!(
|
||||||
|
key_is_down(&node, BTN_A),
|
||||||
|
"BTN_A not down — SC serialize failed (no mode gate should apply)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
abs_value(&node, ABS_RX),
|
||||||
|
Some(9000),
|
||||||
|
"wire right stick did not land on the right pad (ABS_RX)"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
//! Virtual Nintendo Switch Pro Controller via UHID — bound by the kernel's `hid-nintendo`
|
||||||
|
//! (≥ 5.16), so a Nintendo-family client pad gets correct glyphs + positional layout, live
|
||||||
|
//! gyro/accel, and HD-rumble feedback, instead of folding to the Xbox 360 pad (mirrored A/B
|
||||||
|
//! + X/Y, no motion).
|
||||||
|
//!
|
||||||
|
//! Unlike `hid-playstation` (whose init is three GET_REPORTs), `hid-nintendo` runs a real
|
||||||
|
//! PROBE CONVERSATION against the device: the `0x80`-family USB commands, then ~a dozen
|
||||||
|
//! subcommands (device info, SPI-flash calibration reads, IMU/vibration enable, input mode,
|
||||||
|
//! player lights) — each a blocking send that must see its reply (input report `0x81`/`0x21`)
|
||||||
|
//! within 1–2 s or probe aborts and NO input devices appear. The whole codec + the canned
|
||||||
|
//! replies live in [`super::switch_proto`]; this module is the `/dev/uhid` plumbing that
|
||||||
|
//! answers them from the [`UhidManager`]'s frequent `service` pass (the same cadence that
|
||||||
|
//! already completes the DualSense handshake).
|
||||||
|
//!
|
||||||
|
//! Post-probe, the driver stalls every LED/rumble write for up to 250 ms unless input reports
|
||||||
|
//! are flowing — the shared manager's 8 ms silence heartbeat provides exactly that steady
|
||||||
|
//! `0x30` stream. On host suspend/resume the driver re-runs the whole init; the service pass
|
||||||
|
//! answers it identically (nothing probe-specific is latched).
|
||||||
|
|
||||||
|
use super::switch_proto::{
|
||||||
|
build_subcmd_reply, build_usb_ack, device_info_payload, parse_output, player_leds_bits,
|
||||||
|
serialize_report_0x30, spi_flash_read, switch_mac, SwitchOutput, SwitchState, PROCON_RDESC,
|
||||||
|
SWITCH_PRODUCT, SWITCH_REPORT_LEN, SWITCH_VENDOR,
|
||||||
|
};
|
||||||
|
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||||
|
use std::fs::{File, OpenOptions};
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
|
||||||
|
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
|
||||||
|
const UHID_PATH: &str = "/dev/uhid";
|
||||||
|
const UHID_DESTROY: u32 = 1;
|
||||||
|
const UHID_OUTPUT: u32 = 6;
|
||||||
|
const UHID_GET_REPORT: u32 = 9;
|
||||||
|
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||||
|
const UHID_CREATE2: u32 = 11;
|
||||||
|
const UHID_INPUT2: u32 = 12;
|
||||||
|
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||||
|
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
||||||
|
const BUS_USB: u16 = 0x03;
|
||||||
|
|
||||||
|
/// Copy a NUL-padded C string field into the event buffer.
|
||||||
|
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||||
|
let n = s.len().min(cap - 1);
|
||||||
|
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A virtual Pro Controller backed by `/dev/uhid`. Dropping it destroys the device (the kernel
|
||||||
|
/// tears down the bound `hid-nintendo` interface).
|
||||||
|
pub struct SwitchProPad {
|
||||||
|
fd: File,
|
||||||
|
index: u8,
|
||||||
|
/// Rolling report timer (byte 1 of every input report).
|
||||||
|
timer: u8,
|
||||||
|
/// The last written state — subcommand replies embed the current input-state header, so the
|
||||||
|
/// probe conversation always reports coherent (neutral, at first) controller state.
|
||||||
|
state: SwitchState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SwitchProPad {
|
||||||
|
/// Create the UHID Pro Controller for pad `index` (used for the name/uniq + the virtual MAC).
|
||||||
|
pub fn open(index: u8) -> Result<SwitchProPad> {
|
||||||
|
let fd = OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.custom_flags(libc::O_NONBLOCK)
|
||||||
|
.open(UHID_PATH)
|
||||||
|
.with_context(|| {
|
||||||
|
format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)")
|
||||||
|
})?;
|
||||||
|
let mut pad = SwitchProPad {
|
||||||
|
fd,
|
||||||
|
index,
|
||||||
|
timer: 0,
|
||||||
|
state: SwitchState::neutral(),
|
||||||
|
};
|
||||||
|
pad.send_create2(index).context("UHID_CREATE2 Switch Pro")?;
|
||||||
|
Ok(pad)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_create2(&mut self, index: u8) -> Result<()> {
|
||||||
|
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||||
|
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
|
||||||
|
// union (uhid_create2_req) starts at byte 4.
|
||||||
|
put_cstr(&mut ev, 4, 128, &format!("Punktfunk Switch Pro Controller {index}")); // name[128]
|
||||||
|
put_cstr(&mut ev, 132, 64, &format!("punktfunk/switchpro/{index}")); // phys[64]
|
||||||
|
put_cstr(&mut ev, 196, 64, &format!("punktfunk-swpro-{index}")); // uniq[64]
|
||||||
|
ev[260..262].copy_from_slice(&(PROCON_RDESC.len() as u16).to_ne_bytes()); // rd_size
|
||||||
|
ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus (selects the driver's USB init path)
|
||||||
|
ev[264..268].copy_from_slice(&SWITCH_VENDOR.to_ne_bytes());
|
||||||
|
ev[268..272].copy_from_slice(&SWITCH_PRODUCT.to_ne_bytes());
|
||||||
|
ev[272..276].copy_from_slice(&0x0200u32.to_ne_bytes()); // version (bcdDevice 2.00)
|
||||||
|
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
|
||||||
|
ev[280..280 + PROCON_RDESC.len()].copy_from_slice(PROCON_RDESC); // rd_data
|
||||||
|
self.fd.write_all(&ev).context("write UHID_CREATE2")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write one full input report to the kernel (UHID_INPUT2).
|
||||||
|
fn write_report(&mut self, r: &[u8; SWITCH_REPORT_LEN]) -> Result<()> {
|
||||||
|
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||||
|
ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
|
||||||
|
ev[4..6].copy_from_slice(&(r.len() as u16).to_ne_bytes()); // input2.size
|
||||||
|
ev[6..6 + r.len()].copy_from_slice(r); // input2.data
|
||||||
|
self.fd.write_all(&ev).context("write UHID_INPUT2")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize the state into the standard `0x30` report and stream it.
|
||||||
|
pub fn write_state(&mut self, st: &SwitchState) -> Result<()> {
|
||||||
|
self.state = *st;
|
||||||
|
self.timer = self.timer.wrapping_add(1);
|
||||||
|
let r = serialize_report_0x30(st, self.timer);
|
||||||
|
self.write_report(&r)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answer one subcommand from the driver with its canned `0x21` reply.
|
||||||
|
fn answer_subcmd(&mut self, id: u8, args: &[u8]) {
|
||||||
|
self.timer = self.timer.wrapping_add(1);
|
||||||
|
let st = self.state;
|
||||||
|
let reply = match id {
|
||||||
|
// Device info — the fatal one (probe aborts without it): type = Pro Controller +
|
||||||
|
// this pad's virtual MAC. Real hardware acks it with 0x82.
|
||||||
|
0x02 => build_subcmd_reply(&st, self.timer, 0x82, id, &device_info_payload(&switch_mac(self.index))),
|
||||||
|
// SPI flash read: echoed addr + len + the canned calibration bytes. An unmapped
|
||||||
|
// range answers zeroes (echoed header, zero data) — the driver then warns and uses
|
||||||
|
// its defaults instead of stalling through 2 × 1 s timeouts.
|
||||||
|
0x10 => {
|
||||||
|
let addr = args
|
||||||
|
.get(..4)
|
||||||
|
.map(|a| u32::from_le_bytes([a[0], a[1], a[2], a[3]]))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let len = args.get(4).copied().unwrap_or(0);
|
||||||
|
let payload = spi_flash_read(addr, len).unwrap_or_else(|| {
|
||||||
|
tracing::debug!(addr = format!("{addr:#x}"), len, "unmapped SPI read — zero fill");
|
||||||
|
let mut p = Vec::with_capacity(5 + len as usize);
|
||||||
|
p.extend_from_slice(&addr.to_le_bytes());
|
||||||
|
p.push(len);
|
||||||
|
p.extend(std::iter::repeat_n(0u8, len as usize));
|
||||||
|
p
|
||||||
|
});
|
||||||
|
build_subcmd_reply(&st, self.timer, 0x90, id, &payload)
|
||||||
|
}
|
||||||
|
// Everything else the driver sends (input mode 0x03, IMU 0x40, vibration 0x48,
|
||||||
|
// player lights 0x30, home light 0x38, …) just needs the ack + echoed id.
|
||||||
|
_ => build_subcmd_reply(&st, self.timer, 0x80, id, &[]),
|
||||||
|
};
|
||||||
|
let _ = self.write_report(&reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service the device, non-blocking: answer the driver's probe conversation (USB commands +
|
||||||
|
/// subcommands) and surface a game's rumble / player-lights feedback for pad `pad`. Call
|
||||||
|
/// frequently — each probe step blocks the driver until answered.
|
||||||
|
pub fn service(&mut self, pad: u8) -> PadFeedback {
|
||||||
|
let mut fb = PadFeedback::default();
|
||||||
|
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||||
|
while let Ok(n) = self.fd.read(&mut ev) {
|
||||||
|
if n < UHID_EVENT_SIZE {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match u32::from_ne_bytes([ev[0], ev[1], ev[2], ev[3]]) {
|
||||||
|
UHID_OUTPUT => {
|
||||||
|
// uhid_output_req: data[4096] at [4..4100], size u16 at [4100..4102].
|
||||||
|
let size = u16::from_ne_bytes([ev[4100], ev[4101]]) as usize;
|
||||||
|
let end = 4 + size.min(HID_MAX_DESCRIPTOR_SIZE);
|
||||||
|
match parse_output(&ev[4..end]) {
|
||||||
|
Some(SwitchOutput::UsbCmd(cmd)) => {
|
||||||
|
// Ack every 0x80 command, incl. no-timeout (0x04) — the driver
|
||||||
|
// ignores that ack but replying skips its 2 × 100 ms wait.
|
||||||
|
let _ = self.write_report(&build_usb_ack(cmd));
|
||||||
|
}
|
||||||
|
Some(SwitchOutput::Subcmd { id, args, rumble }) => {
|
||||||
|
fb.rumble = Some(rumble);
|
||||||
|
if id == 0x30 {
|
||||||
|
// Player lights ride the subcommand itself; still ack it.
|
||||||
|
if let Some(&arg) = args.first() {
|
||||||
|
fb.hidout.push(HidOutput::PlayerLeds {
|
||||||
|
pad,
|
||||||
|
bits: player_leds_bits(arg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.answer_subcmd(id, &args);
|
||||||
|
}
|
||||||
|
Some(SwitchOutput::Rumble(r)) => fb.rumble = Some(r),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UHID_GET_REPORT => {
|
||||||
|
// hid-nintendo never GET_REPORTs; answer EIO so nothing ever blocks on us.
|
||||||
|
let req_id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||||
|
let _ = self.reply_get_report_err(req_id);
|
||||||
|
}
|
||||||
|
_ => {} // Start/Stop/Open/Close/SetReport — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fb
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply_get_report_err(&mut self, id: u32) -> Result<()> {
|
||||||
|
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||||
|
ev[0..4].copy_from_slice(&UHID_GET_REPORT_REPLY.to_ne_bytes());
|
||||||
|
// uhid_get_report_reply_req: id u32 [4..8], err u16 [8..10], size u16 [10..12].
|
||||||
|
ev[4..8].copy_from_slice(&id.to_ne_bytes());
|
||||||
|
ev[8..10].copy_from_slice(&5u16.to_ne_bytes()); // EIO
|
||||||
|
self.fd
|
||||||
|
.write_all(&ev)
|
||||||
|
.context("write UHID_GET_REPORT_REPLY")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for SwitchProPad {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||||
|
ev[0..4].copy_from_slice(&UHID_DESTROY.to_ne_bytes());
|
||||||
|
let _ = self.fd.write_all(&ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Switch-Pro-specific half of the shared stateful manager (see [`PadProto`]): UHID
|
||||||
|
/// transport open, the [`SwitchState`] mappers, and the probe-conversation service pass.
|
||||||
|
/// Lifecycle (slot table, unplug sweep, heartbeat, dedup) lives in [`UhidManager`].
|
||||||
|
pub struct SwitchProProto {
|
||||||
|
/// Fallback policy for the Steam back grips a client may send (a Pro Controller has no
|
||||||
|
/// back-button slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
|
||||||
|
remap: crate::inject::steam_remap::RemapConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SwitchProProto {
|
||||||
|
fn default() -> SwitchProProto {
|
||||||
|
SwitchProProto {
|
||||||
|
remap: crate::inject::steam_remap::RemapConfig::from_env(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PadProto for SwitchProProto {
|
||||||
|
type Pad = SwitchProPad;
|
||||||
|
type State = SwitchState;
|
||||||
|
const LABEL: &'static str = "Switch Pro";
|
||||||
|
const DEVICE: &'static str = "Switch Pro Controller";
|
||||||
|
const CREATE_HINT: &'static str = "";
|
||||||
|
|
||||||
|
fn open(&mut self, idx: u8) -> Result<SwitchProPad> {
|
||||||
|
let p = SwitchProPad::open(idx)?;
|
||||||
|
tracing::info!(
|
||||||
|
index = idx,
|
||||||
|
"virtual Switch Pro Controller created (UHID hid-nintendo)"
|
||||||
|
);
|
||||||
|
Ok(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn neutral(&self) -> SwitchState {
|
||||||
|
SwitchState::neutral()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge buttons/sticks/triggers from the frame, preserving motion (it arrives on the rich
|
||||||
|
/// plane and must survive a button-only frame). Paddles fold via the configured policy.
|
||||||
|
fn merge_frame(
|
||||||
|
&self,
|
||||||
|
prev: &SwitchState,
|
||||||
|
f: &crate::gamestream::gamepad::GamepadFrame,
|
||||||
|
) -> SwitchState {
|
||||||
|
let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
||||||
|
let mut s = SwitchState::from_gamepad(
|
||||||
|
buttons,
|
||||||
|
f.ls_x,
|
||||||
|
f.ls_y,
|
||||||
|
f.rs_x,
|
||||||
|
f.rs_y,
|
||||||
|
f.left_trigger,
|
||||||
|
f.right_trigger,
|
||||||
|
);
|
||||||
|
s.gyro = prev.gyro;
|
||||||
|
s.accel = prev.accel;
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Motion lands on the IMU sample frames; a Pro Controller has no touchpad, so touch events
|
||||||
|
/// are dropped (the client folds trackpads into stick/mouse modes itself).
|
||||||
|
fn apply_rich(&self, st: &mut SwitchState, rich: RichInput) {
|
||||||
|
if let RichInput::Motion { gyro, accel, .. } = rich {
|
||||||
|
st.apply_motion(gyro, accel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_state(&self, pad: &mut SwitchProPad, st: &SwitchState) {
|
||||||
|
let _ = pad.write_state(st);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answer the driver's probe conversation (it blocks `hid-nintendo` init until every step is
|
||||||
|
/// answered — call frequently) and surface a game's feedback: HD-rumble amplitude on the
|
||||||
|
/// universal 0xCA plane, player lights on the 0xCD plane.
|
||||||
|
fn service(&self, pad: &mut SwitchProPad, idx: u8) -> PadFeedback {
|
||||||
|
pad.service(idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All virtual Switch Pro Controllers of a session — `PUNKTFUNK_GAMEPAD=switchpro`, or the
|
||||||
|
/// per-pad kind a client declares for a Nintendo-family physical pad.
|
||||||
|
pub type SwitchProManager = UhidManager<SwitchProProto>;
|
||||||
@@ -66,8 +66,45 @@ pub const DUALSENSE_RDESC: &[u8] = &[
|
|||||||
0xC0,
|
0xC0,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Sony DualSense **Edge** USB HID report descriptor (389 bytes) — a verbatim real-device
|
||||||
|
/// capture (hid-recorder, hhd-dev/hwinfo `devices/ds5_edge`, cross-checked byte-for-byte against
|
||||||
|
/// the raw usbmon pcap in the same repo and the descriptor Handheld Daemon ships for ITS virtual
|
||||||
|
/// UHID Edge). vs the plain DS5 descriptor: output report `0x02` grows 47→63 bytes, feature
|
||||||
|
/// `0xF2` 15→52, and 19 vendor feature reports (`0x60..=0x7B`, the Edge profile slots) are
|
||||||
|
/// appended — input report `0x01` is bit-identical (the Edge's Fn/back buttons ride previously
|
||||||
|
/// reserved bits of `buttons[2]`, see [`btn2`]).
|
||||||
|
#[rustfmt::skip]
|
||||||
|
pub const DUALSENSE_EDGE_RDESC: &[u8] = &[
|
||||||
|
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x35,
|
||||||
|
0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x06, 0x81, 0x02, 0x06,
|
||||||
|
0x00, 0xFF, 0x09, 0x20, 0x95, 0x01, 0x81, 0x02, 0x05, 0x01, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07,
|
||||||
|
0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00, 0x05,
|
||||||
|
0x09, 0x19, 0x01, 0x29, 0x0F, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0F, 0x81, 0x02, 0x06,
|
||||||
|
0x00, 0xFF, 0x09, 0x21, 0x95, 0x0D, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x22, 0x15, 0x00, 0x26,
|
||||||
|
0xFF, 0x00, 0x75, 0x08, 0x95, 0x34, 0x81, 0x02, 0x85, 0x02, 0x09, 0x23, 0x95, 0x3F, 0x91, 0x02,
|
||||||
|
0x85, 0x05, 0x09, 0x33, 0x95, 0x28, 0xB1, 0x02, 0x85, 0x08, 0x09, 0x34, 0x95, 0x2F, 0xB1, 0x02,
|
||||||
|
0x85, 0x09, 0x09, 0x24, 0x95, 0x13, 0xB1, 0x02, 0x85, 0x0A, 0x09, 0x25, 0x95, 0x1A, 0xB1, 0x02,
|
||||||
|
0x85, 0x20, 0x09, 0x26, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x21, 0x09, 0x27, 0x95, 0x04, 0xB1, 0x02,
|
||||||
|
0x85, 0x22, 0x09, 0x40, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x80, 0x09, 0x28, 0x95, 0x3F, 0xB1, 0x02,
|
||||||
|
0x85, 0x81, 0x09, 0x29, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x2A, 0x95, 0x09, 0xB1, 0x02,
|
||||||
|
0x85, 0x83, 0x09, 0x2B, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x84, 0x09, 0x2C, 0x95, 0x3F, 0xB1, 0x02,
|
||||||
|
0x85, 0x85, 0x09, 0x2D, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xA0, 0x09, 0x2E, 0x95, 0x01, 0xB1, 0x02,
|
||||||
|
0x85, 0xE0, 0x09, 0x2F, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x30, 0x95, 0x3F, 0xB1, 0x02,
|
||||||
|
0x85, 0xF1, 0x09, 0x31, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2, 0x09, 0x32, 0x95, 0x34, 0xB1, 0x02,
|
||||||
|
0x85, 0xF4, 0x09, 0x35, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF5, 0x09, 0x36, 0x95, 0x03, 0xB1, 0x02,
|
||||||
|
0x85, 0x60, 0x09, 0x41, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x61, 0x09, 0x42, 0xB1, 0x02, 0x85, 0x62,
|
||||||
|
0x09, 0x43, 0xB1, 0x02, 0x85, 0x63, 0x09, 0x44, 0xB1, 0x02, 0x85, 0x64, 0x09, 0x45, 0xB1, 0x02,
|
||||||
|
0x85, 0x65, 0x09, 0x46, 0xB1, 0x02, 0x85, 0x68, 0x09, 0x47, 0xB1, 0x02, 0x85, 0x70, 0x09, 0x48,
|
||||||
|
0xB1, 0x02, 0x85, 0x71, 0x09, 0x49, 0xB1, 0x02, 0x85, 0x72, 0x09, 0x4A, 0xB1, 0x02, 0x85, 0x73,
|
||||||
|
0x09, 0x4B, 0xB1, 0x02, 0x85, 0x74, 0x09, 0x4C, 0xB1, 0x02, 0x85, 0x75, 0x09, 0x4D, 0xB1, 0x02,
|
||||||
|
0x85, 0x76, 0x09, 0x4E, 0xB1, 0x02, 0x85, 0x77, 0x09, 0x4F, 0xB1, 0x02, 0x85, 0x78, 0x09, 0x50,
|
||||||
|
0xB1, 0x02, 0x85, 0x79, 0x09, 0x51, 0xB1, 0x02, 0x85, 0x7A, 0x09, 0x52, 0xB1, 0x02, 0x85, 0x7B,
|
||||||
|
0x09, 0x53, 0xB1, 0x02, 0xC0,
|
||||||
|
];
|
||||||
|
|
||||||
pub const DS_VENDOR: u32 = 0x054C; // Sony Interactive Entertainment
|
pub const DS_VENDOR: u32 = 0x054C; // Sony Interactive Entertainment
|
||||||
pub const DS_PRODUCT: u32 = 0x0CE6; // DualSense Wireless Controller
|
pub const DS_PRODUCT: u32 = 0x0CE6; // DualSense Wireless Controller
|
||||||
|
pub const DS_EDGE_PRODUCT: u32 = 0x0DF2; // DualSense Edge Wireless Controller
|
||||||
/// USB input report `0x01` is 64 bytes total (report id + 63-byte body).
|
/// USB input report `0x01` is 64 bytes total (report id + 63-byte body).
|
||||||
pub const DS_INPUT_REPORT_LEN: usize = 64;
|
pub const DS_INPUT_REPORT_LEN: usize = 64;
|
||||||
/// The DualSense touchpad's reported resolution (the kernel exposes it as ABS_MT 0..1920/1080).
|
/// The DualSense touchpad's reported resolution (the kernel exposes it as ABS_MT 0..1920/1080).
|
||||||
@@ -92,12 +129,47 @@ pub mod btn1 {
|
|||||||
pub const L3: u8 = 0x40;
|
pub const L3: u8 = 0x40;
|
||||||
pub const R3: u8 = 0x80;
|
pub const R3: u8 = 0x80;
|
||||||
}
|
}
|
||||||
/// `buttons[2]`: PS, touchpad click, mute (+ a rolling counter in the high bits).
|
/// `buttons[2]`: PS, touchpad click, mute — plus, on the DualSense **Edge**, the two Fn and two
|
||||||
|
/// back buttons in bits 4–7 (kernel `DS_EDGE_BUTTONS_*` / SDL `SDL_GAMEPAD_BUTTON_PS5_*`; the
|
||||||
|
/// plain DS5 leaves those bits reserved). The kernel maps them to `BTN_TRIGGER_HAPPY1..4`
|
||||||
|
/// (Fn-L, Fn-R, back-L, back-R) since 7.2; SDL/Steam read them off hidraw on any kernel.
|
||||||
pub mod btn2 {
|
pub mod btn2 {
|
||||||
pub const PS: u8 = 0x01;
|
pub const PS: u8 = 0x01;
|
||||||
pub const TOUCHPAD: u8 = 0x02;
|
pub const TOUCHPAD: u8 = 0x02;
|
||||||
/// Mic-mute / capture button — set from the wire `BTN_MISC1` in `DsState::from_gamepad`.
|
/// Mic-mute / capture button — set from the wire `BTN_MISC1` in `DsState::from_gamepad`.
|
||||||
pub const MUTE: u8 = 0x04;
|
pub const MUTE: u8 = 0x04;
|
||||||
|
/// Edge left Fn button (below the left stick).
|
||||||
|
pub const EDGE_FN_LEFT: u8 = 0x10;
|
||||||
|
/// Edge right Fn button.
|
||||||
|
pub const EDGE_FN_RIGHT: u8 = 0x20;
|
||||||
|
/// Edge left back button (rear paddle).
|
||||||
|
pub const EDGE_BACK_LEFT: u8 = 0x40;
|
||||||
|
/// Edge right back button (rear paddle).
|
||||||
|
pub const EDGE_BACK_RIGHT: u8 = 0x80;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map the wire back-grip bits onto the DualSense Edge's `buttons[2]` bits — the reason the Edge
|
||||||
|
/// backend exists: all four client paddles (Deck grips L4/L5/R4/R5, Elite P1–P4) land on native
|
||||||
|
/// slots instead of the fold/drop policy. Wire PADDLE1/2 = R4/L4 (the primary pair, Steam
|
||||||
|
/// convention) → the Edge's right/left BACK buttons; PADDLE3/4 = R5/L5 → the right/left Fn
|
||||||
|
/// buttons (real-HW Fn is profile-switch chrome, but on a virtual pad the bits reach consumers
|
||||||
|
/// as ordinary buttons — kernel `BTN_TRIGGER_HAPPY1/2`, SDL `LEFT/RIGHT_FUNCTION`).
|
||||||
|
pub fn edge_paddle_bits(buttons: u32) -> u8 {
|
||||||
|
use punktfunk_core::input::gamepad as gs;
|
||||||
|
let mut b = 0;
|
||||||
|
if buttons & gs::BTN_PADDLE1 != 0 {
|
||||||
|
b |= btn2::EDGE_BACK_RIGHT; // R4
|
||||||
|
}
|
||||||
|
if buttons & gs::BTN_PADDLE2 != 0 {
|
||||||
|
b |= btn2::EDGE_BACK_LEFT; // L4
|
||||||
|
}
|
||||||
|
if buttons & gs::BTN_PADDLE3 != 0 {
|
||||||
|
b |= btn2::EDGE_FN_RIGHT; // R5
|
||||||
|
}
|
||||||
|
if buttons & gs::BTN_PADDLE4 != 0 {
|
||||||
|
b |= btn2::EDGE_FN_LEFT; // L5
|
||||||
|
}
|
||||||
|
b
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One touchpad contact for the report.
|
/// One touchpad contact for the report.
|
||||||
@@ -798,6 +870,51 @@ mod tests {
|
|||||||
assert_eq!(s.buttons[2], 0);
|
assert_eq!(s.buttons[2], 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Edge paddle map, pinned against hid-playstation's `DS_EDGE_BUTTONS_*` masks (bits
|
||||||
|
/// 4–7 of `buttons[2]`) and SDL's `SDL_GAMEPAD_BUTTON_PS5_*` (same byte off hidraw):
|
||||||
|
/// PADDLE1/2 (R4/L4) → right/left BACK, PADDLE3/4 (R5/L5) → right/left Fn — and the mapped
|
||||||
|
/// bits land in the serialized report's byte 10 next to the ordinary buttons[2] bits.
|
||||||
|
#[test]
|
||||||
|
fn edge_paddles_map_to_native_bits() {
|
||||||
|
use punktfunk_core::input::gamepad as gs;
|
||||||
|
assert_eq!(edge_paddle_bits(0), 0);
|
||||||
|
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE1), btn2::EDGE_BACK_RIGHT);
|
||||||
|
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE2), btn2::EDGE_BACK_LEFT);
|
||||||
|
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE3), btn2::EDGE_FN_RIGHT);
|
||||||
|
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE4), btn2::EDGE_FN_LEFT);
|
||||||
|
// Exact kernel/SDL bit values (a one-bit slip ships dead paddles).
|
||||||
|
assert_eq!(btn2::EDGE_FN_LEFT, 0x10);
|
||||||
|
assert_eq!(btn2::EDGE_FN_RIGHT, 0x20);
|
||||||
|
assert_eq!(btn2::EDGE_BACK_LEFT, 0x40);
|
||||||
|
assert_eq!(btn2::EDGE_BACK_RIGHT, 0x80);
|
||||||
|
// All four + a non-paddle bit: paddles map, the rest is ignored here.
|
||||||
|
let all = gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4 | gs::BTN_A;
|
||||||
|
assert_eq!(edge_paddle_bits(all), 0xF0);
|
||||||
|
// Serialized: the Edge merge ORs into buttons[2]; byte 10 carries both the paddles and
|
||||||
|
// the ordinary bits (e.g. a simultaneous PS press).
|
||||||
|
let mut s = DsState::from_gamepad(gs::BTN_GUIDE, 0, 0, 0, 0, 0, 0);
|
||||||
|
s.buttons[2] |= edge_paddle_bits(gs::BTN_PADDLE2 | gs::BTN_PADDLE3);
|
||||||
|
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||||
|
serialize_state(&mut r, &s, 0, 0);
|
||||||
|
assert_eq!(r[10], btn2::PS | btn2::EDGE_BACK_LEFT | btn2::EDGE_FN_RIGHT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Edge descriptor is the real-device capture: exact length, the three deltas vs the
|
||||||
|
/// plain DS5 descriptor (output 0x02 count 63, feature 0xF2 count 52, the appended profile
|
||||||
|
/// feature reports), and an unchanged input-report prefix (report 0x01 is bit-identical —
|
||||||
|
/// the serializer needs no Edge variant).
|
||||||
|
#[test]
|
||||||
|
fn edge_descriptor_shape() {
|
||||||
|
assert_eq!(DUALSENSE_RDESC.len(), 273);
|
||||||
|
assert_eq!(DUALSENSE_EDGE_RDESC.len(), 389);
|
||||||
|
// Identical through the input-report + output-report-id prefix; the first delta is the
|
||||||
|
// output report 0x02's Report Count at offset 109 (47 → 63 bytes of payload).
|
||||||
|
assert_eq!(DUALSENSE_EDGE_RDESC[..109], DUALSENSE_RDESC[..109]);
|
||||||
|
assert_eq!(DUALSENSE_RDESC[109], 0x2F);
|
||||||
|
assert_eq!(DUALSENSE_EDGE_RDESC[109], 0x3F);
|
||||||
|
assert_eq!(*DUALSENSE_EDGE_RDESC.last().unwrap(), 0xC0);
|
||||||
|
}
|
||||||
|
|
||||||
/// A short / wrong-id report yields nothing.
|
/// A short / wrong-id report yields nothing.
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_output_rejects_garbage() {
|
fn parse_output_rejects_garbage() {
|
||||||
|
|||||||
@@ -341,6 +341,129 @@ pub fn serialize_deck_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq
|
|||||||
r[58..60].copy_from_slice(&st.rpad_pressure.to_le_bytes());
|
r[58..60].copy_from_slice(&st.rpad_pressure.to_le_bytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Map an `XInput`/GameStream pad frame into **classic Steam Controller** state. The SC's 24-bit
|
||||||
|
/// button field (report bytes 8..10) shares its low-bit layout with the Deck's (face/shoulder/
|
||||||
|
/// trigger-full byte 8; dpad/View/Steam/Menu byte 9 bits 0–6), so this reuses the [`btn`] masks —
|
||||||
|
/// with the SC-specific tail per the kernel's `ID_CONTROLLER_STATE` table:
|
||||||
|
/// - `9.7`/`10.0` are the SC's TWO grips (the bit positions the Deck calls L5/R5): wire
|
||||||
|
/// `BTN_PADDLE2`/`BTN_PADDLE1` (L4/R4, the primary pair) land there; fold PADDLE3/4 via
|
||||||
|
/// [`super::steam_remap`] BEFORE calling this.
|
||||||
|
/// - `10.2` = right-pad clicked (the SC has no right stick): wire `BTN_RS_CLICK` and the
|
||||||
|
/// DualSense `BTN_TOUCHPAD` click both land there.
|
||||||
|
/// - `10.6` = joystick clicked = wire `BTN_LS_CLICK` (the same bit the Deck calls L3).
|
||||||
|
/// - No QAM/misc slot — `BTN_MISC1` is dropped (fold it upstream if a policy wants it).
|
||||||
|
///
|
||||||
|
/// The wire right STICK drives the right-pad coordinates (`rpad_x/y` + the `10.4` touched bit
|
||||||
|
/// while deflected) — the SC's camera surface; the loss of a true second stick is inherent to
|
||||||
|
/// the hardware. The left stick rides the joystick fields; a left-pad `TouchpadEx` contact
|
||||||
|
/// (via [`SteamState::apply_rich`]) SHADOWS the joystick while touched (the report multiplexes
|
||||||
|
/// them at bytes 16..20, exactly like real hardware's `lpad_touched` flag).
|
||||||
|
pub fn sc_from_gamepad(
|
||||||
|
buttons: u32,
|
||||||
|
lx: i16,
|
||||||
|
ly: i16,
|
||||||
|
rx: i16,
|
||||||
|
ry: i16,
|
||||||
|
lt: u8,
|
||||||
|
rt: u8,
|
||||||
|
) -> SteamState {
|
||||||
|
let on = |bit: u32| buttons & bit != 0;
|
||||||
|
let mut s = SteamState {
|
||||||
|
lx,
|
||||||
|
ly,
|
||||||
|
rx: 0,
|
||||||
|
ry: 0,
|
||||||
|
lt: (lt as u16) * 128,
|
||||||
|
rt: (rt as u16) * 128,
|
||||||
|
// The wire right stick becomes a right-pad contact (see the doc above).
|
||||||
|
rpad_x: rx,
|
||||||
|
rpad_y: ry,
|
||||||
|
..SteamState::neutral()
|
||||||
|
};
|
||||||
|
let mut b = 0u64;
|
||||||
|
let set = |b: &mut u64, on: bool, m: u64| {
|
||||||
|
if on {
|
||||||
|
*b |= m;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
set(&mut b, on(gs::BTN_A), btn::A);
|
||||||
|
set(&mut b, on(gs::BTN_B), btn::B);
|
||||||
|
set(&mut b, on(gs::BTN_X), btn::X);
|
||||||
|
set(&mut b, on(gs::BTN_Y), btn::Y);
|
||||||
|
set(&mut b, on(gs::BTN_LB), btn::LB);
|
||||||
|
set(&mut b, on(gs::BTN_RB), btn::RB);
|
||||||
|
set(&mut b, lt > 0, btn::LT_FULL);
|
||||||
|
set(&mut b, rt > 0, btn::RT_FULL);
|
||||||
|
set(&mut b, on(gs::BTN_BACK), btn::VIEW);
|
||||||
|
set(&mut b, on(gs::BTN_START), btn::MENU);
|
||||||
|
set(&mut b, on(gs::BTN_GUIDE), btn::STEAM);
|
||||||
|
set(&mut b, on(gs::BTN_DPAD_UP), btn::DPAD_UP);
|
||||||
|
set(&mut b, on(gs::BTN_DPAD_DOWN), btn::DPAD_DOWN);
|
||||||
|
set(&mut b, on(gs::BTN_DPAD_LEFT), btn::DPAD_LEFT);
|
||||||
|
set(&mut b, on(gs::BTN_DPAD_RIGHT), btn::DPAD_RIGHT);
|
||||||
|
// SC grips at the Deck's L5/R5 bit positions (9.7 / 10.0): the wire primary pair L4/R4.
|
||||||
|
set(&mut b, on(gs::BTN_PADDLE2), btn::L5); // left grip
|
||||||
|
set(&mut b, on(gs::BTN_PADDLE1), btn::R5); // right grip
|
||||||
|
// Joystick click (10.6 — the bit the Deck calls L3) + right-pad click (10.2).
|
||||||
|
set(&mut b, on(gs::BTN_LS_CLICK), btn::L3);
|
||||||
|
set(
|
||||||
|
&mut b,
|
||||||
|
on(gs::BTN_RS_CLICK) || on(gs::BTN_TOUCHPAD),
|
||||||
|
btn::RPAD_CLICK,
|
||||||
|
);
|
||||||
|
// Right-pad touched (10.4) while the wire stick is deflected — the coords are live then.
|
||||||
|
set(&mut b, rx != 0 || ry != 0, btn::RPAD_TOUCH);
|
||||||
|
s.buttons = b;
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize the classic Steam Controller input report (`ID_CONTROLLER_STATE`) into the 64-byte
|
||||||
|
/// unnumbered frame `steam_do_input_event` parses. Byte-exact against the kernel's message
|
||||||
|
/// table: 24-bit buttons at 8..11, **u8** triggers at 11/12 (the Deck uses u16 at 44/46),
|
||||||
|
/// the joystick/left-pad MULTIPLEX at 16..20 (left-pad coords shadow the joystick while the
|
||||||
|
/// `10.3` touched bit is set), the right pad at 20..24, and the (kernel-ignored, hidraw-visible)
|
||||||
|
/// accel/gyro at 28..39. The kernel negates both Y axes on top of these raw values.
|
||||||
|
pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq: u32) {
|
||||||
|
r.fill(0);
|
||||||
|
r[0] = 0x01;
|
||||||
|
r[1] = 0x00;
|
||||||
|
r[2] = ID_CONTROLLER_STATE;
|
||||||
|
r[3] = 0x3C;
|
||||||
|
r[4..8].copy_from_slice(&seq.to_le_bytes());
|
||||||
|
// Rich-plane pad clicks merge like the Deck path: left-pad clicked = 10.1 (hidraw-only —
|
||||||
|
// the kernel maps no key to it), right-pad clicked = 10.2.
|
||||||
|
let mut buttons = st.buttons;
|
||||||
|
if st.lpad_click {
|
||||||
|
buttons |= btn::LPAD_CLICK;
|
||||||
|
}
|
||||||
|
if st.rpad_click {
|
||||||
|
buttons |= btn::RPAD_CLICK;
|
||||||
|
}
|
||||||
|
r[8] = (buttons & 0xFF) as u8;
|
||||||
|
r[9] = ((buttons >> 8) & 0xFF) as u8;
|
||||||
|
r[10] = ((buttons >> 16) & 0xFF) as u8;
|
||||||
|
r[11] = (st.lt >> 7).min(255) as u8; // left trigger, u8
|
||||||
|
r[12] = (st.rt >> 7).min(255) as u8; // right trigger, u8
|
||||||
|
// Bytes 16..20 carry EITHER the joystick OR the left pad, per the 10.3 touched bit.
|
||||||
|
let (x, y) = if buttons & btn::LPAD_TOUCH != 0 {
|
||||||
|
(st.lpad_x, st.lpad_y)
|
||||||
|
} else {
|
||||||
|
(st.lx, st.ly)
|
||||||
|
};
|
||||||
|
r[16..18].copy_from_slice(&x.to_le_bytes());
|
||||||
|
r[18..20].copy_from_slice(&y.to_le_bytes());
|
||||||
|
r[20..22].copy_from_slice(&st.rpad_x.to_le_bytes());
|
||||||
|
r[22..24].copy_from_slice(&st.rpad_y.to_le_bytes());
|
||||||
|
// IMU: present in the frame (28..39) for hidraw readers, but the kernel maps none of it
|
||||||
|
// ("accelerator/gyro is disabled by default" — no sensors evdev for the SC).
|
||||||
|
r[28..30].copy_from_slice(&st.accel[0].to_le_bytes());
|
||||||
|
r[30..32].copy_from_slice(&st.accel[1].to_le_bytes());
|
||||||
|
r[32..34].copy_from_slice(&st.accel[2].to_le_bytes());
|
||||||
|
r[34..36].copy_from_slice(&st.gyro[0].to_le_bytes());
|
||||||
|
r[36..38].copy_from_slice(&st.gyro[1].to_le_bytes());
|
||||||
|
r[38..40].copy_from_slice(&st.gyro[2].to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the `steam_get_serial` GET_REPORT reply. The Steam feature path is report-id-0 with a
|
/// Build the `steam_get_serial` GET_REPORT reply. The Steam feature path is report-id-0 with a
|
||||||
/// leading report-id byte the kernel strips (`steam_recv_report` does `memcpy(data, buf+1, …)`), so
|
/// leading report-id byte the kernel strips (`steam_recv_report` does `memcpy(data, buf+1, …)`), so
|
||||||
/// the wire is `[0x00, 0xAE, len, 0x01, ascii…]`; the kernel then validates `reply[0]==0xAE`,
|
/// the wire is `[0x00, 0xAE, len, 0x01, ascii…]`; the kernel then validates `reply[0]==0xAE`,
|
||||||
@@ -693,6 +816,72 @@ mod tests {
|
|||||||
assert_ne!(serialized & btn::LPAD_CLICK, 0); // click lands in the report despite the rebuild
|
assert_ne!(serialized & btn::LPAD_CLICK, 0); // click lands in the report despite the rebuild
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The classic-SC frame, byte-exact against the kernel's `ID_CONTROLLER_STATE` table: 24-bit
|
||||||
|
/// buttons at 8..11, u8 triggers at 11/12, the joystick/left-pad multiplex at 16..20, right
|
||||||
|
/// pad at 20..24 — and the SC-specific button tail (grips at 9.7/10.0, right-pad click at
|
||||||
|
/// 10.2, joystick click at 10.6).
|
||||||
|
#[test]
|
||||||
|
fn sc_serialize_and_mapping() {
|
||||||
|
// Full mapping: face + grips + clicks + a deflected right stick.
|
||||||
|
let s = sc_from_gamepad(
|
||||||
|
gs::BTN_A
|
||||||
|
| gs::BTN_PADDLE1
|
||||||
|
| gs::BTN_PADDLE2
|
||||||
|
| gs::BTN_LS_CLICK
|
||||||
|
| gs::BTN_RS_CLICK,
|
||||||
|
1000,
|
||||||
|
-2000,
|
||||||
|
3000,
|
||||||
|
-4000,
|
||||||
|
255,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert_ne!(s.buttons & btn::A, 0);
|
||||||
|
assert_ne!(s.buttons & btn::R5, 0); // PADDLE1 → right grip (10.0)
|
||||||
|
assert_ne!(s.buttons & btn::L5, 0); // PADDLE2 → left grip (9.7)
|
||||||
|
assert_ne!(s.buttons & btn::L3, 0); // LS click → joystick clicked (10.6)
|
||||||
|
assert_ne!(s.buttons & btn::RPAD_CLICK, 0); // RS click → right-pad clicked (10.2)
|
||||||
|
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0); // deflected stick = touched pad (10.4)
|
||||||
|
assert_eq!((s.rpad_x, s.rpad_y), (3000, -4000)); // right stick rides the right pad
|
||||||
|
assert_eq!((s.rx, s.ry), (0, 0));
|
||||||
|
|
||||||
|
let mut r = [0u8; STEAM_REPORT_LEN];
|
||||||
|
serialize_sc_state(&mut r, &s, 0x0102_0304);
|
||||||
|
assert_eq!(&r[0..4], &[0x01, 0x00, 0x01, 0x3C]); // ID_CONTROLLER_STATE
|
||||||
|
assert_eq!(&r[4..8], &[0x04, 0x03, 0x02, 0x01]);
|
||||||
|
assert_eq!(r[8] & 0x80, 0x80); // A = 8.7
|
||||||
|
assert_eq!(r[9] & 0x80, 0x80); // left grip = 9.7
|
||||||
|
assert_eq!(r[10] & 0x01, 0x01); // right grip = 10.0
|
||||||
|
assert_eq!(r[10] & 0x04, 0x04); // right-pad clicked = 10.2
|
||||||
|
assert_eq!(r[10] & 0x40, 0x40); // joystick clicked = 10.6
|
||||||
|
assert_eq!(r[11], 255); // left trigger u8
|
||||||
|
assert_eq!(r[12], 0); // right trigger u8
|
||||||
|
assert_eq!(&r[16..18], &1000i16.to_le_bytes()); // joystick X (lpad untouched)
|
||||||
|
assert_eq!(&r[18..20], &(-2000i16).to_le_bytes());
|
||||||
|
assert_eq!(&r[20..22], &3000i16.to_le_bytes()); // right pad X
|
||||||
|
assert_eq!(&r[22..24], &(-4000i16).to_le_bytes());
|
||||||
|
|
||||||
|
// Left-pad multiplex: a TouchpadEx surface-1 contact shadows the joystick at 16..20
|
||||||
|
// and sets the 10.3 touched bit (+ the 10.1 click bit from the rich field).
|
||||||
|
let mut s = sc_from_gamepad(0, 1234, 0, 0, 0, 0, 0);
|
||||||
|
s.apply_rich(RichInput::TouchpadEx {
|
||||||
|
pad: 0,
|
||||||
|
surface: 1,
|
||||||
|
finger: 0,
|
||||||
|
touch: true,
|
||||||
|
click: true,
|
||||||
|
x: -5000,
|
||||||
|
y: 6000,
|
||||||
|
pressure: 0,
|
||||||
|
});
|
||||||
|
let mut r = [0u8; STEAM_REPORT_LEN];
|
||||||
|
serialize_sc_state(&mut r, &s, 0);
|
||||||
|
assert_eq!(r[10] & 0x08, 0x08); // left-pad touched = 10.3
|
||||||
|
assert_eq!(r[10] & 0x02, 0x02); // left-pad clicked = 10.1 (rich click merged)
|
||||||
|
assert_eq!(&r[16..18], &(-5000i16).to_le_bytes()); // lpad coords shadow the joystick
|
||||||
|
assert_eq!(&r[18..20], &(-6000i16).to_le_bytes()); // screen +down → raw +up (flip)
|
||||||
|
}
|
||||||
|
|
||||||
/// The serial reply carries the leading report-id byte the kernel strips, so the *stripped*
|
/// The serial reply carries the leading report-id byte the kernel strips, so the *stripped*
|
||||||
/// view (`reply[1..]`) is what `steam_get_serial` validates: `[0xAE, len, 0x01, ascii…]`.
|
/// view (`reply[1..]`) is what `steam_get_serial` validates: `[0xAE, len, 0x01, ascii…]`.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,654 @@
|
|||||||
|
//! Transport-independent Nintendo Switch Pro Controller contract — the report codec + canned
|
||||||
|
//! handshake replies the Linux UHID backend ([`super::switch_pro`]) drives `hid-nintendo` with.
|
||||||
|
//!
|
||||||
|
//! Everything here is pinned against the kernel driver source (drivers/hid/hid-nintendo.c —
|
||||||
|
//! the ONE consumer of these bytes; a virtual pad must answer its probe exactly or no input
|
||||||
|
//! devices appear):
|
||||||
|
//!
|
||||||
|
//! - **USB handshake**: 2-byte output reports `0x80 <cmd>` (handshake / baudrate / no-timeout),
|
||||||
|
//! each ACKed with an input report `0x81 <cmd>` (`joycon_send_usb` matches only those two
|
||||||
|
//! bytes).
|
||||||
|
//! - **Subcommands**: output report `0x01` (packet counter + 8 rumble bytes + subcommand id +
|
||||||
|
//! args), ACKed with input report `0x21` — a 12-byte input-state header, then ack byte /
|
||||||
|
//! echoed subcommand id / payload. The driver matches ONLY the echoed id (byte 14) and
|
||||||
|
//! requires ≥ 49 bytes; real hardware sends 64.
|
||||||
|
//! - **SPI flash reads** (subcommand `0x10`): the driver reads the user-calibration magics
|
||||||
|
//! (absent here → `0xFF 0xFF`, so it takes the factory path), the factory stick calibrations
|
||||||
|
//! (9-byte packed 12-bit triples — max/center/min order DIFFERS left vs right), and the
|
||||||
|
//! 24-byte factory IMU calibration. We serve blobs chosen so the math is clean: sticks
|
||||||
|
//! centered at [`STICK_CENTER`] ± [`STICK_RANGE`], IMU offsets 0 with the driver's default
|
||||||
|
//! scales (accel 16384, gyro 13371) so raw units pass through 1:1.
|
||||||
|
//! - **Input report `0x30`**: 3 button bytes (bit layout per `JC_BTN_*`), two packed 12-bit
|
||||||
|
//! stick triples, battery/connection, and 3 IMU sample frames (accel then gyro, i16 LE).
|
||||||
|
//! - **Rumble**: 4 encoded bytes per side in every `0x01`/`0x10` output; we decode the
|
||||||
|
//! amplitude through the driver's own `joycon_rumble_amplitudes` table (inverted) back to the
|
||||||
|
//! 0..=0xFFFF wire magnitudes it was scaled from (left = strong/low, right = weak/high).
|
||||||
|
//!
|
||||||
|
//! Wire-mapping subtleties (see the plan doc, gamepad-new-types §4):
|
||||||
|
//! - **Positional swap.** Wire `BTN_A` is the SOUTH button (GameStream convention); on a Switch
|
||||||
|
//! pad SOUTH is `B`. `from_gamepad` maps wire-south → the report's B bit (and X/Y likewise),
|
||||||
|
//! so the physical-position ↔ glyph relationship stays correct end-to-end.
|
||||||
|
//! - **Units.** Wire motion is DualSense-convention (20 LSB/°·s, 10000 LSB/g); the report wants
|
||||||
|
//! real-Pro-Controller raw units (≈14.247 LSB/°·s per `JC_IMU_GYRO_RES_PER_DPS`, 4096 LSB/g
|
||||||
|
//! per `JC_IMU_ACCEL_RES_PER_G`), which our calibration blobs make the driver consume 1:1.
|
||||||
|
|
||||||
|
use punktfunk_core::input::gamepad as gs;
|
||||||
|
|
||||||
|
pub const SWITCH_VENDOR: u32 = 0x057E; // Nintendo Co., Ltd
|
||||||
|
pub const SWITCH_PRODUCT: u32 = 0x2009; // Pro Controller
|
||||||
|
|
||||||
|
/// Nintendo Switch Pro Controller **USB** HID report descriptor (203 bytes) — a verbatim
|
||||||
|
/// real-device capture (usbhid-dump off a wired Pro Controller; three independent public
|
||||||
|
/// captures agree byte-for-byte: mzyy94's usbhid-dump, ToadKing's full USB capture, and
|
||||||
|
/// spacemeowx2's annotated dump). Declares exactly the report ids `hid-nintendo` exchanges
|
||||||
|
/// wired (inputs 0x30/0x21/0x81, outputs 0x01/0x10/0x80/0x82); the driver reads raw events,
|
||||||
|
/// so the descriptor only has to `hid_parse()` — but this is what real hardware presents.
|
||||||
|
/// NOT the Bluetooth descriptor (that one is ~170 bytes with a different report set).
|
||||||
|
#[rustfmt::skip]
|
||||||
|
pub const PROCON_RDESC: &[u8] = &[
|
||||||
|
0x05, 0x01, 0x15, 0x00, 0x09, 0x04, 0xA1, 0x01, 0x85, 0x30, 0x05, 0x01, 0x05, 0x09, 0x19, 0x01,
|
||||||
|
0x29, 0x0A, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0A, 0x55, 0x00, 0x65, 0x00, 0x81, 0x02,
|
||||||
|
0x05, 0x09, 0x19, 0x0B, 0x29, 0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x04, 0x81, 0x02,
|
||||||
|
0x75, 0x01, 0x95, 0x02, 0x81, 0x03, 0x0B, 0x01, 0x00, 0x01, 0x00, 0xA1, 0x00, 0x0B, 0x30, 0x00,
|
||||||
|
0x01, 0x00, 0x0B, 0x31, 0x00, 0x01, 0x00, 0x0B, 0x32, 0x00, 0x01, 0x00, 0x0B, 0x35, 0x00, 0x01,
|
||||||
|
0x00, 0x15, 0x00, 0x27, 0xFF, 0xFF, 0x00, 0x00, 0x75, 0x10, 0x95, 0x04, 0x81, 0x02, 0xC0, 0x0B,
|
||||||
|
0x39, 0x00, 0x01, 0x00, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75,
|
||||||
|
0x04, 0x95, 0x01, 0x81, 0x02, 0x05, 0x09, 0x19, 0x0F, 0x29, 0x12, 0x15, 0x00, 0x25, 0x01, 0x75,
|
||||||
|
0x01, 0x95, 0x04, 0x81, 0x02, 0x75, 0x08, 0x95, 0x34, 0x81, 0x03, 0x06, 0x00, 0xFF, 0x85, 0x21,
|
||||||
|
0x09, 0x01, 0x75, 0x08, 0x95, 0x3F, 0x81, 0x03, 0x85, 0x81, 0x09, 0x02, 0x75, 0x08, 0x95, 0x3F,
|
||||||
|
0x81, 0x03, 0x85, 0x01, 0x09, 0x03, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0x85, 0x10, 0x09, 0x04,
|
||||||
|
0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0x85, 0x80, 0x09, 0x05, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83,
|
||||||
|
0x85, 0x82, 0x09, 0x06, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0xC0,
|
||||||
|
];
|
||||||
|
/// Every input report we emit is the full USB size (the driver requires ≥ 49 for `0x21`).
|
||||||
|
pub const SWITCH_REPORT_LEN: usize = 64;
|
||||||
|
|
||||||
|
/// Stick raw center + full-deflection range of OUR virtual pad's calibration (12-bit axis).
|
||||||
|
/// The factory blobs below advertise exactly this, so the driver maps
|
||||||
|
/// `center ± range → ∓/± 32767` — one clean linear scale from the wire values.
|
||||||
|
pub const STICK_CENTER: u16 = 2048;
|
||||||
|
pub const STICK_RANGE: u16 = 1400;
|
||||||
|
|
||||||
|
/// `battery and connection info` byte (report byte 2): high 3 bits = level (4 = full),
|
||||||
|
/// BIT(4) = charging, BIT(0) = host powered — "full + charging + wired", so no low-battery
|
||||||
|
/// warnings ever.
|
||||||
|
pub const BAT_CON_FULL_WIRED: u8 = 0x91;
|
||||||
|
/// `vibrator_report` (report byte 12): must be non-zero or the driver stops pumping its rumble
|
||||||
|
/// queue (`joycon_ctlr_read_handler` gates on it). Real hardware sends 0x70-ish.
|
||||||
|
pub const VIBRATOR_READY: u8 = 0x70;
|
||||||
|
|
||||||
|
// Button bits of the 24-bit little-endian button field (report bytes 3..6), per the kernel's
|
||||||
|
// JC_BTN_* defines.
|
||||||
|
pub mod btn {
|
||||||
|
pub const Y: u32 = 1 << 0;
|
||||||
|
pub const X: u32 = 1 << 1;
|
||||||
|
pub const B: u32 = 1 << 2;
|
||||||
|
pub const A: u32 = 1 << 3;
|
||||||
|
pub const R: u32 = 1 << 6;
|
||||||
|
pub const ZR: u32 = 1 << 7;
|
||||||
|
pub const MINUS: u32 = 1 << 8;
|
||||||
|
pub const PLUS: u32 = 1 << 9;
|
||||||
|
pub const RSTICK: u32 = 1 << 10;
|
||||||
|
pub const LSTICK: u32 = 1 << 11;
|
||||||
|
pub const HOME: u32 = 1 << 12;
|
||||||
|
pub const CAPTURE: u32 = 1 << 13;
|
||||||
|
pub const DOWN: u32 = 1 << 16;
|
||||||
|
pub const UP: u32 = 1 << 17;
|
||||||
|
pub const RIGHT: u32 = 1 << 18;
|
||||||
|
pub const LEFT: u32 = 1 << 19;
|
||||||
|
pub const L: u32 = 1 << 22;
|
||||||
|
pub const ZL: u32 = 1 << 23;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full Pro Controller state serialized into report `0x30` (and the `0x21` reply headers).
|
||||||
|
/// Sticks are the RAW 12-bit values ([`STICK_CENTER`]-centered); motion is raw IMU units.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct SwitchState {
|
||||||
|
/// 24-bit `JC_BTN_*` field.
|
||||||
|
pub buttons: u32,
|
||||||
|
pub lx: u16,
|
||||||
|
pub ly: u16,
|
||||||
|
pub rx: u16,
|
||||||
|
pub ry: u16,
|
||||||
|
/// Raw gyro (≈14.247 LSB/°·s) and accel (4096 LSB/g), driver axis order x/y/z.
|
||||||
|
pub gyro: [i16; 3],
|
||||||
|
pub accel: [i16; 3],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SwitchState {
|
||||||
|
/// Centered sticks, nothing pressed, flat at rest (1 g on +Z — a pad lying on the desk, so
|
||||||
|
/// SDL/games don't see a free-falling controller).
|
||||||
|
pub fn neutral() -> SwitchState {
|
||||||
|
SwitchState {
|
||||||
|
buttons: 0,
|
||||||
|
lx: STICK_CENTER,
|
||||||
|
ly: STICK_CENTER,
|
||||||
|
rx: STICK_CENTER,
|
||||||
|
ry: STICK_CENTER,
|
||||||
|
gyro: [0; 3],
|
||||||
|
accel: [0, 0, 4096],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a GameStream/XInput pad frame into Pro Controller state. Face buttons are mapped
|
||||||
|
/// **positionally** (wire A = south → Switch B, etc. — see the module doc); triggers are
|
||||||
|
/// digital on a Pro Controller, so any analog pull presses ZL/ZR. The wire paddles have no
|
||||||
|
/// Switch slot — fold them via [`super::steam_remap`] BEFORE calling this (like the
|
||||||
|
/// DualSense-family backends do).
|
||||||
|
pub fn from_gamepad(
|
||||||
|
buttons: u32,
|
||||||
|
lx: i16,
|
||||||
|
ly: i16,
|
||||||
|
rx: i16,
|
||||||
|
ry: i16,
|
||||||
|
lt: u8,
|
||||||
|
rt: u8,
|
||||||
|
) -> SwitchState {
|
||||||
|
let on = |bit: u32| buttons & bit != 0;
|
||||||
|
let mut b = 0u32;
|
||||||
|
// Positional: wire south/east/west/north → the Switch button at that position.
|
||||||
|
if on(gs::BTN_A) {
|
||||||
|
b |= btn::B; // south
|
||||||
|
}
|
||||||
|
if on(gs::BTN_B) {
|
||||||
|
b |= btn::A; // east
|
||||||
|
}
|
||||||
|
if on(gs::BTN_X) {
|
||||||
|
b |= btn::Y; // west
|
||||||
|
}
|
||||||
|
if on(gs::BTN_Y) {
|
||||||
|
b |= btn::X; // north
|
||||||
|
}
|
||||||
|
if on(gs::BTN_LB) {
|
||||||
|
b |= btn::L;
|
||||||
|
}
|
||||||
|
if on(gs::BTN_RB) {
|
||||||
|
b |= btn::R;
|
||||||
|
}
|
||||||
|
if lt > 0 {
|
||||||
|
b |= btn::ZL;
|
||||||
|
}
|
||||||
|
if rt > 0 {
|
||||||
|
b |= btn::ZR;
|
||||||
|
}
|
||||||
|
if on(gs::BTN_BACK) {
|
||||||
|
b |= btn::MINUS;
|
||||||
|
}
|
||||||
|
if on(gs::BTN_START) {
|
||||||
|
b |= btn::PLUS;
|
||||||
|
}
|
||||||
|
if on(gs::BTN_LS_CLICK) {
|
||||||
|
b |= btn::LSTICK;
|
||||||
|
}
|
||||||
|
if on(gs::BTN_RS_CLICK) {
|
||||||
|
b |= btn::RSTICK;
|
||||||
|
}
|
||||||
|
if on(gs::BTN_GUIDE) {
|
||||||
|
b |= btn::HOME;
|
||||||
|
}
|
||||||
|
if on(gs::BTN_MISC1) {
|
||||||
|
b |= btn::CAPTURE;
|
||||||
|
}
|
||||||
|
if on(gs::BTN_DPAD_UP) {
|
||||||
|
b |= btn::UP;
|
||||||
|
}
|
||||||
|
if on(gs::BTN_DPAD_DOWN) {
|
||||||
|
b |= btn::DOWN;
|
||||||
|
}
|
||||||
|
if on(gs::BTN_DPAD_LEFT) {
|
||||||
|
b |= btn::LEFT;
|
||||||
|
}
|
||||||
|
if on(gs::BTN_DPAD_RIGHT) {
|
||||||
|
b |= btn::RIGHT;
|
||||||
|
}
|
||||||
|
SwitchState {
|
||||||
|
buttons: b,
|
||||||
|
lx: stick_raw(lx),
|
||||||
|
ly: stick_raw(ly),
|
||||||
|
rx: stick_raw(rx),
|
||||||
|
ry: stick_raw(ry),
|
||||||
|
..SwitchState::neutral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a wire motion sample (DualSense-convention units) as raw IMU values. No axis flip:
|
||||||
|
/// both conventions are x-toward-triggers / z-up for a Pro Controller held like a DualSense,
|
||||||
|
/// and the driver applies no negation for the Pro (only the right Joy-Con negates).
|
||||||
|
pub fn apply_motion(&mut self, gyro: [i16; 3], accel: [i16; 3]) {
|
||||||
|
// gyro: wire 20 LSB/°·s → raw 14.247 LSB/°·s; accel: wire 10000 LSB/g → raw 4096 LSB/g.
|
||||||
|
self.gyro = gyro.map(|v| ((v as i32 * 14247) / 20000) as i16);
|
||||||
|
self.accel = accel.map(|v| ((v as i32 * 4096) / 10000) as i16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire stick value (i16, +32767 = right/up) → raw 12-bit axis. The driver Y-negates BOTH the
|
||||||
|
/// wire's and evdev's conventions away: it computes `evdev_y = -scale(raw_y)`, and evdev's
|
||||||
|
/// gamepad convention is negative-up — so wire +y (up) maps to raw above-center, exactly like x.
|
||||||
|
pub fn stick_raw(v: i16) -> u16 {
|
||||||
|
let raw = STICK_CENTER as i32 + (v as i32 * STICK_RANGE as i32) / 32767;
|
||||||
|
raw.clamp(0, 0xFFF) as u16
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pack two 12-bit values into the 3-byte stick / calibration wire form
|
||||||
|
/// (`hid_field_extract` little-endian bitfield order).
|
||||||
|
pub fn pack12(a: u16, b: u16) -> [u8; 3] {
|
||||||
|
[
|
||||||
|
(a & 0xFF) as u8,
|
||||||
|
((a >> 8) & 0x0F) as u8 | ((b & 0x0F) << 4) as u8,
|
||||||
|
((b >> 4) & 0xFF) as u8,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the shared 13-byte input-state header (report id .. `vibrator_report`) that both the
|
||||||
|
/// `0x30` stream and every `0x21` subcommand reply carry.
|
||||||
|
fn write_header(r: &mut [u8; SWITCH_REPORT_LEN], id: u8, st: &SwitchState, timer: u8) {
|
||||||
|
r[0] = id;
|
||||||
|
r[1] = timer;
|
||||||
|
r[2] = BAT_CON_FULL_WIRED;
|
||||||
|
r[3] = (st.buttons & 0xFF) as u8;
|
||||||
|
r[4] = ((st.buttons >> 8) & 0xFF) as u8;
|
||||||
|
r[5] = ((st.buttons >> 16) & 0xFF) as u8;
|
||||||
|
r[6..9].copy_from_slice(&pack12(st.lx, st.ly));
|
||||||
|
r[9..12].copy_from_slice(&pack12(st.rx, st.ry));
|
||||||
|
r[12] = VIBRATOR_READY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize the full/standard input report `0x30`: state header + 3 IMU sample frames
|
||||||
|
/// (accel x/y/z then gyro x/y/z, i16 LE — `struct joycon_imu_data`). We repeat the current
|
||||||
|
/// sample across all three 5 ms sub-frames (we sample per report, not per sub-frame).
|
||||||
|
pub fn serialize_report_0x30(st: &SwitchState, timer: u8) -> [u8; SWITCH_REPORT_LEN] {
|
||||||
|
let mut r = [0u8; SWITCH_REPORT_LEN];
|
||||||
|
write_header(&mut r, 0x30, st, timer);
|
||||||
|
for frame in 0..3 {
|
||||||
|
let off = 13 + frame * 12;
|
||||||
|
for (i, v) in st.accel.iter().enumerate() {
|
||||||
|
r[off + i * 2..off + i * 2 + 2].copy_from_slice(&v.to_le_bytes());
|
||||||
|
}
|
||||||
|
for (i, v) in st.gyro.iter().enumerate() {
|
||||||
|
r[off + 6 + i * 2..off + 6 + i * 2 + 2].copy_from_slice(&v.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the `0x81 <cmd>` input report acknowledging a USB `0x80 <cmd>` command
|
||||||
|
/// (`joycon_send_usb` matches exactly those two bytes).
|
||||||
|
pub fn build_usb_ack(cmd: u8) -> [u8; SWITCH_REPORT_LEN] {
|
||||||
|
let mut r = [0u8; SWITCH_REPORT_LEN];
|
||||||
|
r[0] = 0x81;
|
||||||
|
r[1] = cmd;
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `0x21` subcommand reply: state header, then ack / echoed subcommand id / payload.
|
||||||
|
/// The driver matches on the echoed id only; the MSB-set ack byte mirrors real hardware
|
||||||
|
/// (`0x80` plain ack, `0x80 | data-type` when a payload follows).
|
||||||
|
pub fn build_subcmd_reply(
|
||||||
|
st: &SwitchState,
|
||||||
|
timer: u8,
|
||||||
|
ack: u8,
|
||||||
|
subcmd: u8,
|
||||||
|
payload: &[u8],
|
||||||
|
) -> [u8; SWITCH_REPORT_LEN] {
|
||||||
|
let mut r = [0u8; SWITCH_REPORT_LEN];
|
||||||
|
write_header(&mut r, 0x21, st, timer);
|
||||||
|
r[13] = ack;
|
||||||
|
r[14] = subcmd;
|
||||||
|
let n = payload.len().min(SWITCH_REPORT_LEN - 15);
|
||||||
|
r[15..15 + n].copy_from_slice(&payload[..n]);
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The device-info payload (subcommand `0x02`): firmware 4.33, type `0x03` = **Pro Controller**
|
||||||
|
/// (`ctlr_type` — the value that selects the Pro button/stick/IMU paths), `0x02`, the 6-byte
|
||||||
|
/// MAC (parsed into `ctlr->mac_addr`, printed + used as the input devices' `uniq`), `0x01`,
|
||||||
|
/// and `0x01` = "colors in SPI" (not read by the driver).
|
||||||
|
pub fn device_info_payload(mac: &[u8; 6]) -> [u8; 12] {
|
||||||
|
let mut p = [0u8; 12];
|
||||||
|
p[0] = 0x04;
|
||||||
|
p[1] = 0x21;
|
||||||
|
p[2] = 0x03; // JOYCON_CTLR_TYPE_PRO
|
||||||
|
p[3] = 0x02;
|
||||||
|
p[4..10].copy_from_slice(mac);
|
||||||
|
p[10] = 0x01;
|
||||||
|
p[11] = 0x01;
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stable per-pad virtual MAC (Nintendo OUI + our index) — the driver requires one from
|
||||||
|
/// device info and keys the input devices' `uniq` off it.
|
||||||
|
pub fn switch_mac(index: u8) -> [u8; 6] {
|
||||||
|
[0x7C, 0xBB, 0x8A, 0xDF, 0x00, index]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The canned SPI-flash contents (subcommand `0x10`): reply payload = echoed LE address +
|
||||||
|
/// echoed length + the flash bytes. `None` for an unmapped range (the caller then replies with
|
||||||
|
/// zeroes — the driver falls back to defaults rather than aborting).
|
||||||
|
///
|
||||||
|
/// Served ranges:
|
||||||
|
/// - `0x8010`/`0x801B`/`0x8026` (user-cal magics, 2 B): NOT `0xB2 0xA1` → user cal absent, the
|
||||||
|
/// driver takes the factory path.
|
||||||
|
/// - `0x603D`/`0x6046` (factory stick cal, 9 B): [`STICK_CENTER`] ± [`STICK_RANGE`] on every
|
||||||
|
/// axis. **Byte order differs**: left = max-above ++ center ++ min-below; right = center ++
|
||||||
|
/// min-below ++ max-above (`joycon_read_stick_calibration`).
|
||||||
|
/// - `0x6020` (factory IMU cal, 24 B): offsets 0, accel scale 16384, gyro scale 13371 — the
|
||||||
|
/// driver's own defaults, making its per-sample math the identity (accel) / ×1000 (gyro).
|
||||||
|
pub fn spi_flash_read(addr: u32, len: u8) -> Option<Vec<u8>> {
|
||||||
|
let cal_pair = pack12(STICK_RANGE, STICK_RANGE);
|
||||||
|
let center_pair = pack12(STICK_CENTER, STICK_CENTER);
|
||||||
|
let data: Vec<u8> = match (addr, len) {
|
||||||
|
(0x8010 | 0x801B | 0x8026, 2) => vec![0xFF, 0xFF],
|
||||||
|
(0x603D, 9) => [cal_pair, center_pair, cal_pair].concat(),
|
||||||
|
(0x6046, 9) => [center_pair, cal_pair, cal_pair].concat(),
|
||||||
|
(0x6020, 24) => {
|
||||||
|
let mut v = Vec::with_capacity(24);
|
||||||
|
v.extend_from_slice(&[0u8; 6]); // accel offsets = 0
|
||||||
|
for _ in 0..3 {
|
||||||
|
v.extend_from_slice(&16384u16.to_le_bytes()); // accel scale (driver default)
|
||||||
|
}
|
||||||
|
v.extend_from_slice(&[0u8; 6]); // gyro offsets = 0
|
||||||
|
for _ in 0..3 {
|
||||||
|
v.extend_from_slice(&13371u16.to_le_bytes()); // gyro scale (driver default)
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let mut payload = Vec::with_capacity(5 + data.len());
|
||||||
|
payload.extend_from_slice(&addr.to_le_bytes());
|
||||||
|
payload.push(len);
|
||||||
|
payload.extend_from_slice(&data);
|
||||||
|
Some(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One decoded host-bound output report from the driver.
|
||||||
|
pub enum SwitchOutput {
|
||||||
|
/// `0x80 <cmd>` USB command — answer with [`build_usb_ack`].
|
||||||
|
UsbCmd(u8),
|
||||||
|
/// `0x01` subcommand (with its rumble bytes) — answer with a `0x21` reply.
|
||||||
|
Subcmd {
|
||||||
|
id: u8,
|
||||||
|
/// Subcommand argument bytes (report bytes 11..).
|
||||||
|
args: Vec<u8>,
|
||||||
|
/// Decoded rumble `(low, high)` magnitudes.
|
||||||
|
rumble: (u16, u16),
|
||||||
|
},
|
||||||
|
/// `0x10` rumble-only report — no reply expected.
|
||||||
|
Rumble((u16, u16)),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse one output report from the driver. Returns `None` for anything unrecognized/short.
|
||||||
|
pub fn parse_output(data: &[u8]) -> Option<SwitchOutput> {
|
||||||
|
match *data.first()? {
|
||||||
|
0x80 => Some(SwitchOutput::UsbCmd(*data.get(1)?)),
|
||||||
|
0x01 if data.len() >= 11 => Some(SwitchOutput::Subcmd {
|
||||||
|
id: data[10],
|
||||||
|
args: data.get(11..).map(|s| s.to_vec()).unwrap_or_default(),
|
||||||
|
rumble: decode_rumble(&data[2..10]),
|
||||||
|
}),
|
||||||
|
0x10 if data.len() >= 10 => Some(SwitchOutput::Rumble(decode_rumble(&data[2..10]))),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The driver's `joycon_rumble_amplitudes` table, amplitude column only, indexed by
|
||||||
|
/// `amp_high / 2` (the encoded high-band amplitude byte is always even). Copied verbatim from
|
||||||
|
/// hid-nintendo.c; last entry = `joycon_max_rumble_amp` (1003).
|
||||||
|
#[rustfmt::skip]
|
||||||
|
const RUMBLE_AMPS: [u16; 101] = [
|
||||||
|
0, 10, 12, 14, 17, 20, 24, 28, 33, 40,
|
||||||
|
47, 56, 67, 80, 95, 112, 117, 123, 128, 134,
|
||||||
|
140, 146, 152, 159, 166, 173, 181, 189, 198, 206,
|
||||||
|
215, 225, 230, 235, 240, 245, 251, 256, 262, 268,
|
||||||
|
273, 279, 286, 292, 298, 305, 311, 318, 325, 332,
|
||||||
|
340, 347, 355, 362, 370, 378, 387, 395, 404, 413,
|
||||||
|
422, 431, 440, 450, 460, 470, 480, 491, 501, 512,
|
||||||
|
524, 535, 547, 559, 571, 584, 596, 609, 623, 636,
|
||||||
|
650, 665, 679, 694, 709, 725, 741, 757, 773, 790,
|
||||||
|
808, 825, 843, 862, 881, 900, 920, 940, 960, 981,
|
||||||
|
1003,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Invert the driver's per-side rumble encoding back to the 0..=0xFFFF magnitude it scaled
|
||||||
|
/// from: byte1's even bits carry the amplitude-table index × 2 (`data[1] = freq_high_lo +
|
||||||
|
/// amp.high`, where the freq contribution is only ever bit 0).
|
||||||
|
fn side_amplitude(side: &[u8]) -> u16 {
|
||||||
|
let idx = ((side[1] & 0xFE) / 2) as usize;
|
||||||
|
let amp = RUMBLE_AMPS[idx.min(RUMBLE_AMPS.len() - 1)] as u32;
|
||||||
|
// Driver: amp = magnitude * 1003 / 65535 — invert, saturating at full scale.
|
||||||
|
((amp * 65535) / 1003).min(65535) as u16
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode the 8 rumble bytes (left side = strong → wire `low`, right side = weak → wire
|
||||||
|
/// `high`, per `joycon_play_effect`).
|
||||||
|
pub fn decode_rumble(bytes: &[u8]) -> (u16, u16) {
|
||||||
|
if bytes.len() < 8 {
|
||||||
|
return (0, 0);
|
||||||
|
}
|
||||||
|
(side_amplitude(&bytes[..4]), side_amplitude(&bytes[4..8]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a player-lights subcommand payload (`(flash << 4) | on`, one bit per LED) into the
|
||||||
|
/// wire `PlayerLeds` bits: a flashing LED counts as on.
|
||||||
|
pub fn player_leds_bits(arg: u8) -> u8 {
|
||||||
|
(arg & 0x0F) | (arg >> 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The positional swap, pinned: wire south/east/west/north land on the Switch B/A/Y/X bits
|
||||||
|
/// (the driver then maps them back to BTN_SOUTH/EAST/WEST/NORTH — position-correct
|
||||||
|
/// end-to-end), and the rest of the buttons land on their JC_BTN_* bits.
|
||||||
|
#[test]
|
||||||
|
fn positional_swap_and_button_bits() {
|
||||||
|
let st = SwitchState::from_gamepad(gs::BTN_A, 0, 0, 0, 0, 0, 0);
|
||||||
|
assert_eq!(st.buttons, btn::B);
|
||||||
|
let st = SwitchState::from_gamepad(gs::BTN_B, 0, 0, 0, 0, 0, 0);
|
||||||
|
assert_eq!(st.buttons, btn::A);
|
||||||
|
let st = SwitchState::from_gamepad(gs::BTN_X, 0, 0, 0, 0, 0, 0);
|
||||||
|
assert_eq!(st.buttons, btn::Y);
|
||||||
|
let st = SwitchState::from_gamepad(gs::BTN_Y, 0, 0, 0, 0, 0, 0);
|
||||||
|
assert_eq!(st.buttons, btn::X);
|
||||||
|
// Shoulders / sticks / meta / dpad / triggers-as-digital.
|
||||||
|
let st = SwitchState::from_gamepad(
|
||||||
|
gs::BTN_LB | gs::BTN_RB | gs::BTN_BACK | gs::BTN_START | gs::BTN_GUIDE | gs::BTN_MISC1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
255,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
st.buttons,
|
||||||
|
btn::L | btn::R | btn::MINUS | btn::PLUS | btn::HOME | btn::CAPTURE | btn::ZL | btn::ZR
|
||||||
|
);
|
||||||
|
let st = SwitchState::from_gamepad(gs::BTN_DPAD_UP | gs::BTN_DPAD_LEFT, 0, 0, 0, 0, 0, 0);
|
||||||
|
assert_eq!(st.buttons, btn::UP | btn::LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sticks: wire full deflection → center ± range on the raw 12-bit axis, both axes the same
|
||||||
|
/// direction (the driver's own Y negation restores evdev's negative-up).
|
||||||
|
#[test]
|
||||||
|
fn stick_scaling() {
|
||||||
|
assert_eq!(stick_raw(0), STICK_CENTER);
|
||||||
|
assert_eq!(stick_raw(32767), STICK_CENTER + STICK_RANGE);
|
||||||
|
assert_eq!(stick_raw(-32767), STICK_CENTER - STICK_RANGE);
|
||||||
|
// Extreme min doesn't underflow past the 12-bit range.
|
||||||
|
assert!(stick_raw(i16::MIN) <= 0xFFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The 3-byte 12-bit packing matches `hid_field_extract`'s little-endian bitfield order:
|
||||||
|
/// value A at bit 0, value B at bit 12.
|
||||||
|
#[test]
|
||||||
|
fn pack12_layout() {
|
||||||
|
assert_eq!(pack12(0x578, 0x578), [0x78, 0x85, 0x57]); // 1400/1400 (the cal pair)
|
||||||
|
assert_eq!(pack12(0x800, 0x800), [0x00, 0x08, 0x80]); // 2048/2048 (the center pair)
|
||||||
|
// Extract back: a = b0 | (b1 & 0xF) << 8; b = (b1 >> 4) | b2 << 4.
|
||||||
|
let p = pack12(0xABC, 0x123);
|
||||||
|
let a = p[0] as u16 | ((p[1] as u16 & 0xF) << 8);
|
||||||
|
let b = ((p[1] as u16) >> 4) | ((p[2] as u16) << 4);
|
||||||
|
assert_eq!((a, b), (0xABC, 0x123));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report 0x30 layout, pinned against `struct joycon_input_report` + `joycon_imu_data`:
|
||||||
|
/// header bytes, packed sticks, and the 3 × 12-byte IMU frames (accel then gyro, LE).
|
||||||
|
#[test]
|
||||||
|
fn report_0x30_layout() {
|
||||||
|
let mut st = SwitchState::neutral();
|
||||||
|
st.buttons = btn::B | btn::MINUS | btn::ZL;
|
||||||
|
st.gyro = [0x1122, -2, 3];
|
||||||
|
st.accel = [-1, 0x3344, 5];
|
||||||
|
let r = serialize_report_0x30(&st, 7);
|
||||||
|
assert_eq!(r[0], 0x30);
|
||||||
|
assert_eq!(r[1], 7);
|
||||||
|
assert_eq!(r[2], BAT_CON_FULL_WIRED);
|
||||||
|
assert_eq!(r[3], 0x04); // B = bit 2
|
||||||
|
assert_eq!(r[4], 0x01); // MINUS = bit 8
|
||||||
|
assert_eq!(r[5], 0x80); // ZL = bit 23
|
||||||
|
assert_eq!(&r[6..9], &pack12(STICK_CENTER, STICK_CENTER));
|
||||||
|
assert_eq!(&r[9..12], &pack12(STICK_CENTER, STICK_CENTER));
|
||||||
|
assert_eq!(r[12], VIBRATOR_READY);
|
||||||
|
// Frame 0 at byte 13: accel x/y/z then gyro x/y/z, i16 LE.
|
||||||
|
assert_eq!(&r[13..15], &(-1i16).to_le_bytes());
|
||||||
|
assert_eq!(&r[15..17], &0x3344u16.to_le_bytes());
|
||||||
|
assert_eq!(&r[19..21], &0x1122u16.to_le_bytes());
|
||||||
|
// Frames repeat identically at +12 and +24.
|
||||||
|
assert_eq!(&r[13..25], &r[25..37]);
|
||||||
|
assert_eq!(&r[13..25], &r[37..49]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subcommand replies: ≥ 49 bytes (we send 64), ack at byte 13, echoed id at byte 14 (the
|
||||||
|
/// ONLY byte the driver's matcher checks), payload from byte 15.
|
||||||
|
#[test]
|
||||||
|
fn subcmd_reply_layout() {
|
||||||
|
let st = SwitchState::neutral();
|
||||||
|
let r = build_subcmd_reply(&st, 3, 0x90, 0x10, &[0xAA, 0xBB]);
|
||||||
|
assert_eq!(r.len(), SWITCH_REPORT_LEN);
|
||||||
|
assert_eq!(r[0], 0x21);
|
||||||
|
assert_eq!(r[13], 0x90);
|
||||||
|
assert_eq!(r[14], 0x10);
|
||||||
|
assert_eq!(&r[15..17], &[0xAA, 0xBB]);
|
||||||
|
// USB ack: exactly the two bytes joycon_send_usb matches.
|
||||||
|
let a = build_usb_ack(0x02);
|
||||||
|
assert_eq!((a[0], a[1]), (0x81, 0x02));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SPI blobs: magics read as ABSENT (≠ B2 A1); the stick blobs put center strictly between
|
||||||
|
/// min and max on both axes in the driver's per-side byte order; the reply echoes addr+len.
|
||||||
|
#[test]
|
||||||
|
fn spi_blobs_valid() {
|
||||||
|
for addr in [0x8010u32, 0x801B, 0x8026] {
|
||||||
|
let p = spi_flash_read(addr, 2).unwrap();
|
||||||
|
assert_eq!(&p[..4], &addr.to_le_bytes());
|
||||||
|
assert_eq!(p[4], 2);
|
||||||
|
assert!(!(p[5] == 0xB2 && p[6] == 0xA1));
|
||||||
|
}
|
||||||
|
let unpack = |b: &[u8]| -> (u16, u16) {
|
||||||
|
let a = b[0] as u16 | ((b[1] as u16 & 0xF) << 8);
|
||||||
|
let y = ((b[1] as u16) >> 4) | ((b[2] as u16) << 4);
|
||||||
|
(a, y)
|
||||||
|
};
|
||||||
|
// Left: max-above ++ center ++ min-below.
|
||||||
|
let l = spi_flash_read(0x603D, 9).unwrap();
|
||||||
|
let (data, hdr) = (&l[5..], &l[..5]);
|
||||||
|
assert_eq!(hdr, &[0x3D, 0x60, 0, 0, 9]);
|
||||||
|
let (max_above, _) = unpack(&data[0..3]);
|
||||||
|
let (center, _) = unpack(&data[3..6]);
|
||||||
|
let (min_below, _) = unpack(&data[6..9]);
|
||||||
|
assert_eq!(center, STICK_CENTER);
|
||||||
|
assert!(center - min_below < center && center < center + max_above);
|
||||||
|
// Right: center ++ min-below ++ max-above.
|
||||||
|
let r = spi_flash_read(0x6046, 9).unwrap();
|
||||||
|
let (rc, _) = unpack(&r[5..8]);
|
||||||
|
assert_eq!(rc, STICK_CENTER);
|
||||||
|
// IMU: offsets 0, driver-default scales — the identity calibration.
|
||||||
|
let imu = spi_flash_read(0x6020, 24).unwrap();
|
||||||
|
let d = &imu[5..];
|
||||||
|
assert_eq!(&d[0..6], &[0; 6]);
|
||||||
|
assert_eq!(&d[6..8], &16384u16.to_le_bytes());
|
||||||
|
assert_eq!(&d[12..18], &[0; 6]);
|
||||||
|
assert_eq!(&d[18..20], &13371u16.to_le_bytes());
|
||||||
|
// Unmapped range → None.
|
||||||
|
assert!(spi_flash_read(0x6050, 12).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Motion unit conversion: wire (20 LSB/°·s, 10000 LSB/g) → raw (14.247 LSB/°·s, 4096 LSB/g).
|
||||||
|
#[test]
|
||||||
|
fn motion_units() {
|
||||||
|
let mut st = SwitchState::neutral();
|
||||||
|
// 100 °/s = wire 2000 → raw ≈ 1424; 1 g = wire 10000 → raw 4096.
|
||||||
|
st.apply_motion([2000, 0, -2000], [10000, -10000, 0]);
|
||||||
|
assert_eq!(st.gyro, [1424, 0, -1424]);
|
||||||
|
assert_eq!(st.accel, [4096, -4096, 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rumble decode inverts the driver's encoder: a neutral packet decodes to silence; the
|
||||||
|
/// max-amplitude packet decodes to full scale; left = low/strong, right = high/weak.
|
||||||
|
#[test]
|
||||||
|
fn rumble_decode() {
|
||||||
|
// Neutral per the driver's tables: freq defaults + amp 0.
|
||||||
|
let neutral = [0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40];
|
||||||
|
assert_eq!(decode_rumble(&neutral), (0, 0));
|
||||||
|
// Max amp (0xC8 → index 100 → 1003 → 65535) on the LEFT only → (low=full, high=0).
|
||||||
|
let left_max = [0x00, 0xC8, 0x40, 0x72, 0x00, 0x01, 0x40, 0x40];
|
||||||
|
assert_eq!(decode_rumble(&left_max), (65535, 0));
|
||||||
|
// Mid-table on the right: amp_high 0x20 → index 16 → 117 → 117*65535/1003 = 7644.
|
||||||
|
let right_mid = [0x00, 0x01, 0x40, 0x40, 0x00, 0x20, 0x48, 0x40];
|
||||||
|
assert_eq!(decode_rumble(&right_mid), (0, 7644));
|
||||||
|
// The freq bit riding data[1] bit0 must not disturb the amplitude index.
|
||||||
|
let with_freq_bit = [0x00, 0x21, 0x48, 0x40, 0x00, 0x01, 0x40, 0x40];
|
||||||
|
assert_eq!(decode_rumble(&with_freq_bit).0, 7644);
|
||||||
|
// Short slice → silence, not a panic.
|
||||||
|
assert_eq!(decode_rumble(&[0x10; 4]), (0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output-report parse: the three shapes the driver sends.
|
||||||
|
#[test]
|
||||||
|
fn parse_output_shapes() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse_output(&[0x80, 0x02]),
|
||||||
|
Some(SwitchOutput::UsbCmd(0x02))
|
||||||
|
));
|
||||||
|
let mut sub = vec![0x01, 0x05];
|
||||||
|
sub.extend_from_slice(&[0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40]);
|
||||||
|
sub.push(0x10); // subcmd id
|
||||||
|
sub.extend_from_slice(&[0x3D, 0x60, 0x00, 0x00, 0x09]); // SPI addr+len args
|
||||||
|
match parse_output(&sub) {
|
||||||
|
Some(SwitchOutput::Subcmd { id, args, rumble }) => {
|
||||||
|
assert_eq!(id, 0x10);
|
||||||
|
assert_eq!(&args[..5], &[0x3D, 0x60, 0x00, 0x00, 0x09]);
|
||||||
|
assert_eq!(rumble, (0, 0));
|
||||||
|
}
|
||||||
|
_ => panic!("expected subcmd"),
|
||||||
|
}
|
||||||
|
let mut rum = vec![0x10, 0x06];
|
||||||
|
rum.extend_from_slice(&[0x00, 0xC8, 0x40, 0x72, 0x00, 0x01, 0x40, 0x40]);
|
||||||
|
assert!(matches!(
|
||||||
|
parse_output(&rum),
|
||||||
|
Some(SwitchOutput::Rumble((65535, 0)))
|
||||||
|
));
|
||||||
|
assert!(parse_output(&[0x21]).is_none());
|
||||||
|
assert!(parse_output(&[]).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Player lights: solid + flashing nibbles both count as lit.
|
||||||
|
#[test]
|
||||||
|
fn player_lights() {
|
||||||
|
assert_eq!(player_leds_bits(0x01), 0b0001);
|
||||||
|
assert_eq!(player_leds_bits(0x10), 0b0001); // flashing LED 1
|
||||||
|
assert_eq!(player_leds_bits(0x23), 0b0011 | 0b0010);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Device info: type byte 0x03 (Pro Controller) at payload[2], MAC at [4..10].
|
||||||
|
#[test]
|
||||||
|
fn device_info_shape() {
|
||||||
|
let mac = switch_mac(3);
|
||||||
|
let p = device_info_payload(&mac);
|
||||||
|
assert_eq!(p[2], 0x03);
|
||||||
|
assert_eq!(&p[4..10], &mac);
|
||||||
|
assert_eq!(mac[5], 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
//! Virtual Sony DualSense **Edge** on Windows via the UMDF minidriver — the Edge sibling of
|
||||||
|
//! [`super::dualsense_windows`]. Same transport ([`DsWinPad`]: a per-session `SwDeviceCreate`
|
||||||
|
//! devnode + the sealed shared-memory channel), same report codec ([`super::dualsense_proto`]);
|
||||||
|
//! the host stamps `device_type = 2` so the one UMDF driver serves the Edge descriptor /
|
||||||
|
//! `VID_054C&PID_0DF2` attributes, and the wire back-grip bits map onto the Edge's native
|
||||||
|
//! `buttons[2]` slots instead of the fold/drop policy — a client's Deck grips / Elite paddles
|
||||||
|
//! reach games as real buttons. Feedback is identical to the plain DualSense (rumble arrives with
|
||||||
|
//! the vibration-v2 flag, which [`parse_ds_output`](super::dualsense_proto::parse_ds_output)
|
||||||
|
//! already handles).
|
||||||
|
|
||||||
|
use super::dualsense_proto::{edge_paddle_bits, DsState, DS_TOUCH_H, DS_TOUCH_W};
|
||||||
|
use super::dualsense_windows::{DsWinPad, WinDsIdentity};
|
||||||
|
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||||
|
use anyhow::Result;
|
||||||
|
use punktfunk_core::quic::RichInput;
|
||||||
|
|
||||||
|
/// The Windows-Edge half of the shared stateful manager (see [`PadProto`]): the shared
|
||||||
|
/// [`DsWinPad`] transport under the Edge identity, with the Edge paddle mapping in `merge_frame`.
|
||||||
|
/// No remap config — every wire paddle has a native slot.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct DsEdgeWinProto;
|
||||||
|
|
||||||
|
impl PadProto for DsEdgeWinProto {
|
||||||
|
type Pad = DsWinPad;
|
||||||
|
type State = DsState;
|
||||||
|
const LABEL: &'static str = "DualSense Edge/Windows";
|
||||||
|
const DEVICE: &'static str = "DualSense Edge";
|
||||||
|
const CREATE_HINT: &'static str =
|
||||||
|
" (install/repair: punktfunk-host.exe driver install --gamepad)";
|
||||||
|
|
||||||
|
fn open(&mut self, idx: u8) -> Result<DsWinPad> {
|
||||||
|
let p = DsWinPad::open(idx, &WinDsIdentity::dualsense_edge())?;
|
||||||
|
tracing::info!(
|
||||||
|
index = idx,
|
||||||
|
"virtual DualSense Edge created (Windows UMDF shm channel)"
|
||||||
|
);
|
||||||
|
Ok(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn neutral(&self) -> DsState {
|
||||||
|
DsState::neutral()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields — like the
|
||||||
|
/// plain DualSense, EXCEPT the wire paddles land on the Edge's own `buttons[2]` bits
|
||||||
|
/// (rebuilt from every button frame, so no extra persistence).
|
||||||
|
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
|
||||||
|
let mut s = DsState::from_gamepad(
|
||||||
|
f.buttons,
|
||||||
|
f.ls_x,
|
||||||
|
f.ls_y,
|
||||||
|
f.rs_x,
|
||||||
|
f.rs_y,
|
||||||
|
f.left_trigger,
|
||||||
|
f.right_trigger,
|
||||||
|
);
|
||||||
|
s.buttons[2] |= edge_paddle_bits(f.buttons);
|
||||||
|
s.touch = prev.touch;
|
||||||
|
s.gyro = prev.gyro;
|
||||||
|
s.accel = prev.accel;
|
||||||
|
s.touch_click = prev.touch_click;
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
|
||||||
|
/// split the one touchpad left/right, pad clicks ride touch_click.
|
||||||
|
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
|
||||||
|
st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_state(&self, pad: &mut DsWinPad, st: &DsState) {
|
||||||
|
pad.write_state(st);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll the section for a game's feedback: motor rumble on the universal 0xCA plane, the rich
|
||||||
|
/// lightbar/player-LED/trigger events on the 0xCD plane.
|
||||||
|
fn service(&self, pad: &mut DsWinPad, idx: u8) -> PadFeedback {
|
||||||
|
let fb = pad.service(idx);
|
||||||
|
PadFeedback {
|
||||||
|
rumble: fb.rumble,
|
||||||
|
hidout: fb.hidout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All virtual DualSense Edge pads of a session — the Windows analogue of
|
||||||
|
/// [`DualSenseEdgeManager`](crate::inject::dualsense::DualSenseEdgeManager), with the same method
|
||||||
|
/// surface (via the shared [`UhidManager`]) as the other Windows pad managers.
|
||||||
|
pub type DualSenseEdgeWindowsManager = UhidManager<DsEdgeWinProto>;
|
||||||
@@ -55,6 +55,7 @@ pub(super) const OFF_DRIVER_PROTO: usize =
|
|||||||
pub(super) const OFF_PAD_INDEX: usize =
|
pub(super) const OFF_PAD_INDEX: usize =
|
||||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, pad_index);
|
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, pad_index);
|
||||||
pub(super) const DEVTYPE_DUALSHOCK4: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4;
|
pub(super) const DEVTYPE_DUALSHOCK4: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4;
|
||||||
|
pub(super) const DEVTYPE_DUALSENSE_EDGE: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSENSE_EDGE;
|
||||||
|
|
||||||
/// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_<index>` software devnode (the driver
|
/// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_<index>` software devnode (the driver
|
||||||
/// loads on it and the HID DualSense appears to games) plus the sealed shared-memory channel.
|
/// loads on it and the HID DualSense appears to games) plus the sealed shared-memory channel.
|
||||||
@@ -228,20 +229,57 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
|
|||||||
Ok((hsw, ctx.instance_id()))
|
Ok((hsw, ctx.instance_id()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The identity a [`DsWinPad`] enumerates with — the plain DualSense or the Edge share the whole
|
||||||
|
/// transport (section layout, input report shape, output parse); only the `device_type` stamp and
|
||||||
|
/// the PnP identity differ. The DS4 differs in report codec too, so it keeps its own pad type.
|
||||||
|
pub(super) struct WinDsIdentity {
|
||||||
|
/// `device_type` stamped into the section (the driver picks its HID identity off it).
|
||||||
|
pub devtype: u8,
|
||||||
|
/// PnP instance-id prefix (`pf_pad` / `pf_edge`) — distinct namespaces per type.
|
||||||
|
pub instance_prefix: &'static str,
|
||||||
|
/// The INF-matched hardware id.
|
||||||
|
pub hwid: &'static str,
|
||||||
|
/// The USB VID&PID token for the synthesized bus identity.
|
||||||
|
pub usb_vid_pid: &'static str,
|
||||||
|
/// Device Manager description.
|
||||||
|
pub description: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WinDsIdentity {
|
||||||
|
pub(super) const fn dualsense() -> WinDsIdentity {
|
||||||
|
WinDsIdentity {
|
||||||
|
devtype: 0,
|
||||||
|
instance_prefix: "pf_pad",
|
||||||
|
hwid: "pf_dualsense",
|
||||||
|
usb_vid_pid: "VID_054C&PID_0CE6",
|
||||||
|
description: "punktfunk Virtual DualSense",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) const fn dualsense_edge() -> WinDsIdentity {
|
||||||
|
WinDsIdentity {
|
||||||
|
devtype: DEVTYPE_DUALSENSE_EDGE,
|
||||||
|
instance_prefix: "pf_edge",
|
||||||
|
hwid: "pf_dualsenseedge",
|
||||||
|
usb_vid_pid: "VID_054C&PID_0DF2",
|
||||||
|
description: "punktfunk Virtual DualSense Edge",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl DsWinPad {
|
impl DsWinPad {
|
||||||
/// Create the sealed channel (unnamed DATA section + `Global\pfds-boot-<index>` mailbox), stamp
|
/// Create the sealed channel (unnamed DATA section + `Global\pfds-boot-<index>` mailbox), stamp
|
||||||
/// the pad index + neutral report + the magic LAST, then spawn the `pf_pad_<index>` devnode (the
|
/// the device type FIRST (so it's visible the moment magic is) + the pad index + a neutral
|
||||||
/// driver loads on it and receives the DATA handle over the bootstrap). The devnode lives for the
|
/// report + the magic LAST, then spawn the devnode (the driver loads on it and receives the
|
||||||
/// pad's lifetime — dropping the pad removes it (`SwDeviceClose`).
|
/// DATA handle over the bootstrap). The devnode lives for the pad's lifetime — dropping the pad
|
||||||
fn open(index: u8) -> Result<DsWinPad> {
|
/// removes it (`SwDeviceClose`).
|
||||||
|
pub(super) fn open(index: u8, id: &WinDsIdentity) -> Result<DsWinPad> {
|
||||||
let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
|
let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
|
||||||
let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
|
let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
|
||||||
let base = channel.data_base();
|
let base = channel.data_base();
|
||||||
// Stamp the pad index (the driver validates it on attach) + the neutral input report, then
|
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range.
|
||||||
// the magic LAST (the driver only accepts the section once magic is set). The device-type
|
|
||||||
// stays 0 (DualSense — the section arrives zeroed).
|
|
||||||
// SAFETY: base points at SHM_SIZE writable bytes; OFF_PAD_INDEX/OFF_INPUT are in range.
|
|
||||||
unsafe {
|
unsafe {
|
||||||
|
*base.add(OFF_DEVTYPE) = id.devtype;
|
||||||
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
||||||
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], {
|
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], {
|
||||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||||
@@ -251,19 +289,19 @@ impl DsWinPad {
|
|||||||
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||||
}
|
}
|
||||||
// Spawn the per-session devnode via SwDeviceCreate; `SwDeviceClose` removes it on drop. On the
|
// Spawn the per-session devnode via SwDeviceCreate; `SwDeviceClose` removes it on drop. On the
|
||||||
// rare failure we keep the section + data plane and fall back to an out-of-band `pf_dualsense`
|
// rare failure we keep the section + data plane and fall back to an out-of-band devnode
|
||||||
// devnode (installer / dev-box devgen) — its persistent driver polls the same mailbox name.
|
// (installer / dev-box devgen) — its persistent driver polls the same mailbox name.
|
||||||
let inst = format!("pf_pad_{index}");
|
let inst = format!("{}_{index}", id.instance_prefix);
|
||||||
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
||||||
instance: &inst,
|
instance: &inst,
|
||||||
container_index: index,
|
container_index: index,
|
||||||
hwid: "pf_dualsense",
|
hwid: id.hwid,
|
||||||
usb_vid_pid: "VID_054C&PID_0CE6",
|
usb_vid_pid: id.usb_vid_pid,
|
||||||
description: "punktfunk Virtual DualSense",
|
description: id.description,
|
||||||
}) {
|
}) {
|
||||||
Ok((h, id)) => (Some(h), id),
|
Ok((h, i)) => (Some(h), i),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; falling back to an out-of-band pf_dualsense devnode");
|
tracing::warn!(error = %format!("{e:#}"), hwid = id.hwid, "SwDeviceCreate failed; falling back to an out-of-band devnode");
|
||||||
(None, None)
|
(None, None)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -275,8 +313,8 @@ impl DsWinPad {
|
|||||||
_sw,
|
_sw,
|
||||||
channel,
|
channel,
|
||||||
attach: super::gamepad_raii::DriverAttach::new(
|
attach: super::gamepad_raii::DriverAttach::new(
|
||||||
"pf_dualsense",
|
id.hwid,
|
||||||
"pf_dualsense.inf",
|
"pf_dualsense.inf", // one driver package serves every PS identity
|
||||||
"C:\\Users\\Public\\pfds-driver.log",
|
"C:\\Users\\Public\\pfds-driver.log",
|
||||||
boot_name,
|
boot_name,
|
||||||
instance_id,
|
instance_id,
|
||||||
@@ -288,7 +326,7 @@ impl DsWinPad {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize `st` into report `0x01` and publish it to the section's input slot.
|
/// Serialize `st` into report `0x01` and publish it to the section's input slot.
|
||||||
fn write_state(&mut self, st: &DsState) {
|
pub(super) fn write_state(&mut self, st: &DsState) {
|
||||||
self.seq = self.seq.wrapping_add(1);
|
self.seq = self.seq.wrapping_add(1);
|
||||||
self.ts = self.ts.wrapping_add(1);
|
self.ts = self.ts.wrapping_add(1);
|
||||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||||
@@ -318,7 +356,7 @@ impl DsWinPad {
|
|||||||
/// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything
|
/// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything
|
||||||
/// new. Also ticks the sealed-channel delivery and feeds the driver-attach health watcher (the
|
/// new. Also ticks the sealed-channel delivery and feeds the driver-attach health watcher (the
|
||||||
/// driver's ~125 Hz timer stamps `driver_proto` while it has the section mapped).
|
/// driver's ~125 Hz timer stamps `driver_proto` while it has the section mapped).
|
||||||
fn service(&mut self, pad: u8) -> DsFeedback {
|
pub(super) fn service(&mut self, pad: u8) -> DsFeedback {
|
||||||
self.channel.pump();
|
self.channel.pump();
|
||||||
let mut fb = DsFeedback::default();
|
let mut fb = DsFeedback::default();
|
||||||
// SAFETY: base points at SHM_SIZE bytes.
|
// SAFETY: base points at SHM_SIZE bytes.
|
||||||
@@ -378,7 +416,7 @@ impl PadProto for DsWinProto {
|
|||||||
" (install/repair: punktfunk-host.exe driver install --gamepad)";
|
" (install/repair: punktfunk-host.exe driver install --gamepad)";
|
||||||
|
|
||||||
fn open(&mut self, idx: u8) -> Result<DsWinPad> {
|
fn open(&mut self, idx: u8) -> Result<DsWinPad> {
|
||||||
let p = DsWinPad::open(idx)?;
|
let p = DsWinPad::open(idx, &WinDsIdentity::dualsense())?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
index = idx,
|
index = idx,
|
||||||
"virtual DualSense created (Windows UMDF shm channel)"
|
"virtual DualSense created (Windows UMDF shm channel)"
|
||||||
@@ -433,6 +471,72 @@ impl PadProto for DsWinProto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **N4 spike** (gamepad-new-types §6, timeboxed): create a software-devnode HID **Steam Deck**
|
||||||
|
/// (`device_type = 3`, `VID_28DE&PID_1205`) and hold it for `secs`, streaming the neutral Deck
|
||||||
|
/// frame, so the go/no-go question — does Steam Input on Windows promote a software-devnode HID
|
||||||
|
/// Deck, or does it require a real USB bus identity (the documented GameInput instance-path
|
||||||
|
/// gap)? — can be answered by watching Steam's `logs/controller.txt` / controller settings
|
||||||
|
/// while this holds. Never used by a session; wired to the `deck-windows-spike` subcommand.
|
||||||
|
pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
|
||||||
|
let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
|
||||||
|
let mut channel = PadChannel::create(boot_name, SHM_SIZE)?;
|
||||||
|
let base = channel.data_base();
|
||||||
|
// Neutral Deck input frame: [0x01, 0x00, ID_CONTROLLER_DECK_STATE=0x09, 0x3C], all released.
|
||||||
|
let mut neutral = [0u8; 64];
|
||||||
|
(neutral[0], neutral[2], neutral[3]) = (0x01, 0x09, 0x3C);
|
||||||
|
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range. Device-type
|
||||||
|
// FIRST, magic LAST — the same publish order the session pads use.
|
||||||
|
unsafe {
|
||||||
|
*base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK_SPIKE;
|
||||||
|
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
||||||
|
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; 64], neutral);
|
||||||
|
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||||
|
}
|
||||||
|
let inst = format!("pf_deckspike_{index}");
|
||||||
|
let (hsw, _) = create_swdevice(&SwDeviceProfile {
|
||||||
|
instance: &inst,
|
||||||
|
container_index: index,
|
||||||
|
hwid: "pf_steamdeck",
|
||||||
|
usb_vid_pid: "VID_28DE&PID_1205",
|
||||||
|
description: "punktfunk Virtual Steam Deck (spike)",
|
||||||
|
})?;
|
||||||
|
let _sw = super::gamepad_raii::SwDevice::new(hsw);
|
||||||
|
channel.deliver_eager(std::time::Duration::from_millis(1500));
|
||||||
|
println!(
|
||||||
|
"virtual Steam Deck devnode up (28DE:1205, device_type 3) — holding {secs}s.\n\
|
||||||
|
Observe: Get-PnpDevice -PresentOnly | findstr 1205; Steam logs\\controller.txt for a\n\
|
||||||
|
detect/promote line; Steam Settings > Controller for a 'Steam Deck' entry.\n\
|
||||||
|
GO = Steam lists/promotes it; NO-GO = it never appears (the Linux `Interface: -1` gap\n\
|
||||||
|
applies verbatim — document and keep the SteamDeck->DualSense Windows fold)."
|
||||||
|
);
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(secs);
|
||||||
|
let mut last_out_seq = 0u32;
|
||||||
|
while std::time::Instant::now() < deadline {
|
||||||
|
channel.pump();
|
||||||
|
// Log any feature/output traffic Steam sends — each one is spike evidence.
|
||||||
|
// SAFETY: base points at SHM_SIZE bytes; OFF_OUT_SEQ is in range.
|
||||||
|
let seq = unsafe {
|
||||||
|
std::ptr::read_unaligned(channel.data_base().add(OFF_OUT_SEQ) as *const u32)
|
||||||
|
};
|
||||||
|
if seq != last_out_seq {
|
||||||
|
last_out_seq = seq;
|
||||||
|
let mut out = [0u8; 16];
|
||||||
|
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
|
||||||
|
unsafe {
|
||||||
|
std::ptr::copy_nonoverlapping(
|
||||||
|
channel.data_base().add(OFF_OUTPUT),
|
||||||
|
out.as_mut_ptr(),
|
||||||
|
16,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
println!(" output report from a client (Steam?): {out:02x?}");
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
println!("deck-windows-spike: done (devnode removed on exit)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// All virtual DualSense pads of a session — the Windows analogue of
|
/// All virtual DualSense pads of a session — the Windows analogue of
|
||||||
/// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface (via the shared
|
/// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface (via the shared
|
||||||
/// [`UhidManager`]) so the session input thread drives either backend identically. The heartbeat
|
/// [`UhidManager`]) so the session input thread drives either backend identically. The heartbeat
|
||||||
|
|||||||
@@ -255,19 +255,28 @@ fn real_main() -> Result<()> {
|
|||||||
// Create a virtual DualSense via UHID and exercise it (validation, no streaming session):
|
// Create a virtual DualSense via UHID and exercise it (validation, no streaming session):
|
||||||
// toggles the Cross button, sweeps the left stick, and prints any HID output the kernel
|
// toggles the Cross button, sweeps the left stick, and prints any HID output the kernel
|
||||||
// sends back. Verify with `evtest` / `ls /dev/input/by-id/*Punktfunk*` / `wpctl status`.
|
// sends back. Verify with `evtest` / `ls /dev/input/by-id/*Punktfunk*` / `wpctl status`.
|
||||||
|
// `--edge` creates a DualSense **Edge** (054C:0DF2) instead and additionally cycles the
|
||||||
|
// four back/Fn buttons (kernel ≥ 7.2 exposes them as BTN_TRIGGER_HAPPY1..4; on older
|
||||||
|
// kernels verify the bind + `hidraw` byte 10 instead).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
Some("dualsense-test") => {
|
Some("dualsense-test") => {
|
||||||
use inject::dualsense::DualSensePad;
|
use inject::dualsense::{DsUhidIdentity, DualSensePad};
|
||||||
use inject::dualsense_proto::DsState;
|
use inject::dualsense_proto::{edge_paddle_bits, DsState};
|
||||||
let secs: u64 = args
|
let secs: u64 = args
|
||||||
.iter()
|
.iter()
|
||||||
.skip_while(|a| *a != "--seconds")
|
.skip_while(|a| *a != "--seconds")
|
||||||
.nth(1)
|
.nth(1)
|
||||||
.and_then(|s| s.parse().ok())
|
.and_then(|s| s.parse().ok())
|
||||||
.unwrap_or(20);
|
.unwrap_or(20);
|
||||||
|
let edge = args.iter().any(|a| a == "--edge");
|
||||||
|
let (identity, label) = if edge {
|
||||||
|
(DsUhidIdentity::dualsense_edge(), "DualSense Edge")
|
||||||
|
} else {
|
||||||
|
(DsUhidIdentity::dualsense(), "DualSense")
|
||||||
|
};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
let mut pad =
|
let mut pad = DualSensePad::open(0, &identity)
|
||||||
DualSensePad::open(0).context("create virtual DualSense via /dev/uhid")?;
|
.with_context(|| format!("create virtual {label} via /dev/uhid"))?;
|
||||||
// Answer the kernel's init GET_REPORTs promptly so hid-playstation creates the input
|
// Answer the kernel's init GET_REPORTs promptly so hid-playstation creates the input
|
||||||
// devices before we start streaming state.
|
// devices before we start streaming state.
|
||||||
let init = Instant::now() + Duration::from_millis(800);
|
let init = Instant::now() + Duration::from_millis(800);
|
||||||
@@ -276,7 +285,7 @@ fn real_main() -> Result<()> {
|
|||||||
std::thread::sleep(Duration::from_millis(10));
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
}
|
}
|
||||||
println!(
|
println!(
|
||||||
"virtual DualSense created — check `evtest`, `ls /dev/input/by-id/*Punktfunk*`, \
|
"virtual {label} created — check `evtest`, `ls /dev/input/by-id/*Punktfunk*`, \
|
||||||
`ls /sys/class/leds/`. Cycling Cross + sweeping LS for {secs}s."
|
`ls /sys/class/leds/`. Cycling Cross + sweeping LS for {secs}s."
|
||||||
);
|
);
|
||||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||||
@@ -292,20 +301,106 @@ fn real_main() -> Result<()> {
|
|||||||
if last_write.elapsed() >= Duration::from_millis(300) {
|
if last_write.elapsed() >= Duration::from_millis(300) {
|
||||||
last_write = Instant::now();
|
last_write = Instant::now();
|
||||||
i += 1;
|
i += 1;
|
||||||
let buttons = if i % 2 == 0 {
|
let mut buttons = if i % 2 == 0 {
|
||||||
punktfunk_core::input::gamepad::BTN_A
|
punktfunk_core::input::gamepad::BTN_A
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
|
if edge {
|
||||||
|
// Cycle one paddle per beat (R4 → L4 → R5 → L5) so all four Edge slots
|
||||||
|
// are visible in evtest / hidraw.
|
||||||
|
buttons |= punktfunk_core::input::gamepad::BTN_PADDLE1 << (i % 4);
|
||||||
|
}
|
||||||
let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
|
let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
|
||||||
let st = DsState::from_gamepad(buttons, lx, 0, 0, 0, 0, 0);
|
let mut st = DsState::from_gamepad(buttons, lx, 0, 0, 0, 0, 0);
|
||||||
pad.write_state(&st).context("write DualSense report")?;
|
if edge {
|
||||||
|
st.buttons[2] |= edge_paddle_bits(buttons);
|
||||||
|
}
|
||||||
|
pad.write_state(&st).context("write report")?;
|
||||||
}
|
}
|
||||||
std::thread::sleep(Duration::from_millis(15));
|
std::thread::sleep(Duration::from_millis(15));
|
||||||
}
|
}
|
||||||
println!("dualsense-test: done");
|
println!("dualsense-test: done");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
// Create a virtual Switch Pro Controller via UHID and exercise it (validation, no
|
||||||
|
// streaming session): answers the full hid-nintendo probe conversation, then cycles the
|
||||||
|
// A/B buttons (positionally swapped) + sweeps the left stick, printing rumble / player-
|
||||||
|
// light feedback. Verify with `evtest` (hid-nintendo input devices), `dmesg | grep
|
||||||
|
// nintendo`, SDL identifying a "Nintendo Switch Pro Controller".
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
Some("switchpro-test") => {
|
||||||
|
use inject::switch_pro::SwitchProPad;
|
||||||
|
use inject::switch_proto::SwitchState;
|
||||||
|
let secs: u64 = args
|
||||||
|
.iter()
|
||||||
|
.skip_while(|a| *a != "--seconds")
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(20);
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
let mut pad = SwitchProPad::open(0)
|
||||||
|
.context("create virtual Switch Pro Controller via /dev/uhid")?;
|
||||||
|
// Answer the driver's probe conversation promptly — every step blocks hid-nintendo
|
||||||
|
// init until its reply lands; also stream neutral 0x30 reports like real hardware.
|
||||||
|
println!("virtual Switch Pro created — servicing the hid-nintendo probe…");
|
||||||
|
let init = Instant::now() + Duration::from_millis(2500);
|
||||||
|
let mut hb = Instant::now();
|
||||||
|
while Instant::now() < init {
|
||||||
|
let fb = pad.service(0);
|
||||||
|
for o in fb.hidout {
|
||||||
|
println!(" probe feedback: {o:?}");
|
||||||
|
}
|
||||||
|
if hb.elapsed() >= Duration::from_millis(15) {
|
||||||
|
hb = Instant::now();
|
||||||
|
let _ = pad.write_state(&SwitchState::neutral());
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(2));
|
||||||
|
}
|
||||||
|
println!("probe window over — cycling buttons + stick for {secs}s (check evtest)");
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||||
|
let (mut i, mut last_write) = (0i32, Instant::now());
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
let fb = pad.service(0);
|
||||||
|
if let Some((low, high)) = fb.rumble {
|
||||||
|
println!(" rumble from kernel/game: low={low} high={high}");
|
||||||
|
}
|
||||||
|
for o in fb.hidout {
|
||||||
|
println!(" hid output from kernel/game: {o:?}");
|
||||||
|
}
|
||||||
|
// ~15 ms cadence = the real controller's report rate (also keeps the driver's
|
||||||
|
// post-probe subcommand rate limiter fed).
|
||||||
|
if last_write.elapsed() >= Duration::from_millis(15) {
|
||||||
|
last_write = Instant::now();
|
||||||
|
i += 1;
|
||||||
|
let step = i / 20; // change the pressed button every ~300 ms
|
||||||
|
let buttons = if step % 2 == 0 {
|
||||||
|
punktfunk_core::input::gamepad::BTN_A
|
||||||
|
} else {
|
||||||
|
punktfunk_core::input::gamepad::BTN_B
|
||||||
|
};
|
||||||
|
let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
|
||||||
|
let st = SwitchState::from_gamepad(buttons, lx, 0, 0, 0, 0, 0);
|
||||||
|
pad.write_state(&st).context("write Switch Pro report")?;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(2));
|
||||||
|
}
|
||||||
|
println!("switchpro-test: done");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
// Windows N4 SPIKE (gamepad-new-types §6): hold a software-devnode HID Steam Deck
|
||||||
|
// (28DE:1205 via device_type 3) and watch whether Steam Input promotes it. Needs the
|
||||||
|
// updated signed driver installed + Steam running. `--seconds N` (default 120).
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
Some("deck-windows-spike") => {
|
||||||
|
let secs: u64 = args
|
||||||
|
.iter()
|
||||||
|
.skip_while(|a| *a != "--seconds")
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(120);
|
||||||
|
inject::dualsense_windows::deck_spike_hold(0, secs)
|
||||||
|
}
|
||||||
// Windows: create a virtual DualSense via the UMDF driver (SwDeviceCreate per-session devnode
|
// Windows: create a virtual DualSense via the UMDF driver (SwDeviceCreate per-session devnode
|
||||||
// + the shared-memory channel) and hold it, pushing one fixed frame (Cross + LS-right). Drives
|
// + the shared-memory channel) and hold it, pushing one fixed frame (Cross + LS-right). Drives
|
||||||
// the real DualSenseWindowsManager, so it validates the device lifecycle end to end. Verify
|
// the real DualSenseWindowsManager, so it validates the device lifecycle end to end. Verify
|
||||||
@@ -332,6 +427,15 @@ fn real_main() -> Result<()> {
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let ds4 = args.iter().any(|a| a == "--ds4");
|
let ds4 = args.iter().any(|a| a == "--ds4");
|
||||||
let xbox = args.iter().any(|a| a == "--xbox");
|
let xbox = args.iter().any(|a| a == "--xbox");
|
||||||
|
// `--edge` drives the DualSense Edge backend (device_type 2) and additionally holds
|
||||||
|
// the R4/L4 paddles on the pressed beats, so a HID read shows the Edge bits in
|
||||||
|
// report byte 10 (0x80|0x40) next to Cross.
|
||||||
|
let edge = args.iter().any(|a| a == "--edge");
|
||||||
|
let extra_buttons: u32 = if edge {
|
||||||
|
punktfunk_core::input::gamepad::BTN_PADDLE1 | punktfunk_core::input::gamepad::BTN_PADDLE2
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
// Same drive loop for either backend (identical method surface): Arrival creates the pad,
|
// Same drive loop for either backend (identical method surface): Arrival creates the pad,
|
||||||
// State pushes a cycling report, pump surfaces a game's rumble/lightbar feedback.
|
// State pushes a cycling report, pump surfaces a game's rumble/lightbar feedback.
|
||||||
macro_rules! drive {
|
macro_rules! drive {
|
||||||
@@ -360,7 +464,7 @@ fn real_main() -> Result<()> {
|
|||||||
last = Instant::now();
|
last = Instant::now();
|
||||||
i += 1;
|
i += 1;
|
||||||
let buttons = if i % 2 == 0 {
|
let buttons = if i % 2 == 0 {
|
||||||
punktfunk_core::input::gamepad::BTN_A // Cross
|
punktfunk_core::input::gamepad::BTN_A | extra_buttons // Cross (+ Edge paddles)
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
@@ -425,6 +529,11 @@ fn real_main() -> Result<()> {
|
|||||||
inject::dualshock4_windows::DualShock4WindowsManager::new(),
|
inject::dualshock4_windows::DualShock4WindowsManager::new(),
|
||||||
"DualShock 4"
|
"DualShock 4"
|
||||||
);
|
);
|
||||||
|
} else if edge {
|
||||||
|
drive!(
|
||||||
|
inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new(),
|
||||||
|
"DualSense Edge"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
drive!(
|
drive!(
|
||||||
inject::dualsense_windows::DualSenseWindowsManager::new(),
|
inject::dualsense_windows::DualSenseWindowsManager::new(),
|
||||||
|
|||||||
@@ -1752,7 +1752,8 @@ const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_s
|
|||||||
///
|
///
|
||||||
/// - Xbox 360 / One — uinput on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager),
|
/// - Xbox 360 / One — uinput on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager),
|
||||||
/// two identities), the XUSB companion driver (classic XInput) on Windows.
|
/// two identities), the XUSB companion driver (classic XInput) on Windows.
|
||||||
/// - DualSense / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF minidriver.
|
/// - DualSense / DualSense Edge / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF
|
||||||
|
/// minidriver (device-type 0/2/1).
|
||||||
/// - Steam Deck — Linux UHID `hid-steam`.
|
/// - Steam Deck — Linux UHID `hid-steam`.
|
||||||
///
|
///
|
||||||
/// [`resolve_pad_kind`] folds any kind a platform can't build into one it can, so this never
|
/// [`resolve_pad_kind`] folds any kind a platform can't build into one it can, so this never
|
||||||
@@ -1771,12 +1772,20 @@ struct Pads {
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
dualsense: Option<crate::inject::dualsense::DualSenseManager>,
|
dualsense: Option<crate::inject::dualsense::DualSenseManager>,
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
dualsense_edge: Option<crate::inject::dualsense::DualSenseEdgeManager>,
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
dualshock4: Option<crate::inject::dualshock4::DualShock4Manager>,
|
dualshock4: Option<crate::inject::dualshock4::DualShock4Manager>,
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
steamdeck: Option<crate::inject::steam_controller::SteamControllerManager>,
|
steamdeck: Option<crate::inject::steam_controller::SteamControllerManager>,
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
switchpro: Option<crate::inject::switch_pro::SwitchProManager>,
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
steamctrl: Option<crate::inject::steam_controller::SteamCtrlManager>,
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>,
|
dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>,
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
dualsense_edge_win: Option<crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager>,
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>,
|
dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1798,12 +1807,20 @@ impl Pads {
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
dualsense: None,
|
dualsense: None,
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
dualsense_edge: None,
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
dualshock4: None,
|
dualshock4: None,
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
steamdeck: None,
|
steamdeck: None,
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
switchpro: None,
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
steamctrl: None,
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
dualsense_win: None,
|
dualsense_win: None,
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
dualsense_edge_win: None,
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
dualshock4_win: None,
|
dualshock4_win: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1855,6 +1872,11 @@ impl Pads {
|
|||||||
.get_or_insert_with(crate::inject::dualsense::DualSenseManager::new)
|
.get_or_insert_with(crate::inject::dualsense::DualSenseManager::new)
|
||||||
.handle(ev),
|
.handle(ev),
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
GamepadPref::DualSenseEdge => self
|
||||||
|
.dualsense_edge
|
||||||
|
.get_or_insert_with(crate::inject::dualsense::DualSenseEdgeManager::new)
|
||||||
|
.handle(ev),
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
GamepadPref::DualShock4 => self
|
GamepadPref::DualShock4 => self
|
||||||
.dualshock4
|
.dualshock4
|
||||||
.get_or_insert_with(crate::inject::dualshock4::DualShock4Manager::new)
|
.get_or_insert_with(crate::inject::dualshock4::DualShock4Manager::new)
|
||||||
@@ -1865,6 +1887,16 @@ impl Pads {
|
|||||||
.get_or_insert_with(crate::inject::steam_controller::SteamControllerManager::new)
|
.get_or_insert_with(crate::inject::steam_controller::SteamControllerManager::new)
|
||||||
.handle(ev),
|
.handle(ev),
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
GamepadPref::SwitchPro => self
|
||||||
|
.switchpro
|
||||||
|
.get_or_insert_with(crate::inject::switch_pro::SwitchProManager::new)
|
||||||
|
.handle(ev),
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
GamepadPref::SteamController => self
|
||||||
|
.steamctrl
|
||||||
|
.get_or_insert_with(crate::inject::steam_controller::SteamCtrlManager::new)
|
||||||
|
.handle(ev),
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
GamepadPref::XboxOne => self
|
GamepadPref::XboxOne => self
|
||||||
.xboxone
|
.xboxone
|
||||||
.get_or_insert_with(|| {
|
.get_or_insert_with(|| {
|
||||||
@@ -1879,6 +1911,13 @@ impl Pads {
|
|||||||
.get_or_insert_with(crate::inject::dualsense_windows::DualSenseWindowsManager::new)
|
.get_or_insert_with(crate::inject::dualsense_windows::DualSenseWindowsManager::new)
|
||||||
.handle(ev),
|
.handle(ev),
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
GamepadPref::DualSenseEdge => self
|
||||||
|
.dualsense_edge_win
|
||||||
|
.get_or_insert_with(
|
||||||
|
crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new,
|
||||||
|
)
|
||||||
|
.handle(ev),
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
GamepadPref::DualShock4 => self
|
GamepadPref::DualShock4 => self
|
||||||
.dualshock4_win
|
.dualshock4_win
|
||||||
.get_or_insert_with(
|
.get_or_insert_with(
|
||||||
@@ -1920,6 +1959,12 @@ impl Pads {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
GamepadPref::DualSenseEdge => {
|
||||||
|
if let Some(m) = &mut self.dualsense_edge {
|
||||||
|
m.apply_rich(rich)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
GamepadPref::DualShock4 => {
|
GamepadPref::DualShock4 => {
|
||||||
if let Some(m) = &mut self.dualshock4 {
|
if let Some(m) = &mut self.dualshock4 {
|
||||||
m.apply_rich(rich)
|
m.apply_rich(rich)
|
||||||
@@ -1931,6 +1976,18 @@ impl Pads {
|
|||||||
m.apply_rich(rich)
|
m.apply_rich(rich)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
GamepadPref::SwitchPro => {
|
||||||
|
if let Some(m) = &mut self.switchpro {
|
||||||
|
m.apply_rich(rich)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
GamepadPref::SteamController => {
|
||||||
|
if let Some(m) = &mut self.steamctrl {
|
||||||
|
m.apply_rich(rich)
|
||||||
|
}
|
||||||
|
}
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
GamepadPref::DualSense => {
|
GamepadPref::DualSense => {
|
||||||
if let Some(m) = &mut self.dualsense_win {
|
if let Some(m) = &mut self.dualsense_win {
|
||||||
@@ -1938,6 +1995,12 @@ impl Pads {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
GamepadPref::DualSenseEdge => {
|
||||||
|
if let Some(m) = &mut self.dualsense_edge_win {
|
||||||
|
m.apply_rich(rich)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
GamepadPref::DualShock4 => {
|
GamepadPref::DualShock4 => {
|
||||||
if let Some(m) = &mut self.dualshock4_win {
|
if let Some(m) = &mut self.dualshock4_win {
|
||||||
m.apply_rich(rich)
|
m.apply_rich(rich)
|
||||||
@@ -1967,18 +2030,30 @@ impl Pads {
|
|||||||
if let Some(m) = &mut self.dualsense {
|
if let Some(m) = &mut self.dualsense {
|
||||||
m.pump(&mut rumble, &mut hidout);
|
m.pump(&mut rumble, &mut hidout);
|
||||||
}
|
}
|
||||||
|
if let Some(m) = &mut self.dualsense_edge {
|
||||||
|
m.pump(&mut rumble, &mut hidout);
|
||||||
|
}
|
||||||
if let Some(m) = &mut self.dualshock4 {
|
if let Some(m) = &mut self.dualshock4 {
|
||||||
m.pump(&mut rumble, &mut hidout);
|
m.pump(&mut rumble, &mut hidout);
|
||||||
}
|
}
|
||||||
if let Some(m) = &mut self.steamdeck {
|
if let Some(m) = &mut self.steamdeck {
|
||||||
m.pump(&mut rumble, &mut hidout);
|
m.pump(&mut rumble, &mut hidout);
|
||||||
}
|
}
|
||||||
|
if let Some(m) = &mut self.switchpro {
|
||||||
|
m.pump(&mut rumble, &mut hidout);
|
||||||
|
}
|
||||||
|
if let Some(m) = &mut self.steamctrl {
|
||||||
|
m.pump(&mut rumble, &mut hidout);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
if let Some(m) = &mut self.dualsense_win {
|
if let Some(m) = &mut self.dualsense_win {
|
||||||
m.pump(&mut rumble, &mut hidout);
|
m.pump(&mut rumble, &mut hidout);
|
||||||
}
|
}
|
||||||
|
if let Some(m) = &mut self.dualsense_edge_win {
|
||||||
|
m.pump(&mut rumble, &mut hidout);
|
||||||
|
}
|
||||||
if let Some(m) = &mut self.dualshock4_win {
|
if let Some(m) = &mut self.dualshock4_win {
|
||||||
m.pump(&mut rumble, &mut hidout);
|
m.pump(&mut rumble, &mut hidout);
|
||||||
}
|
}
|
||||||
@@ -1996,12 +2071,21 @@ impl Pads {
|
|||||||
if let Some(m) = &mut self.dualsense {
|
if let Some(m) = &mut self.dualsense {
|
||||||
m.heartbeat(gap);
|
m.heartbeat(gap);
|
||||||
}
|
}
|
||||||
|
if let Some(m) = &mut self.dualsense_edge {
|
||||||
|
m.heartbeat(gap);
|
||||||
|
}
|
||||||
if let Some(m) = &mut self.dualshock4 {
|
if let Some(m) = &mut self.dualshock4 {
|
||||||
m.heartbeat(gap);
|
m.heartbeat(gap);
|
||||||
}
|
}
|
||||||
if let Some(m) = &mut self.steamdeck {
|
if let Some(m) = &mut self.steamdeck {
|
||||||
m.heartbeat(gap);
|
m.heartbeat(gap);
|
||||||
}
|
}
|
||||||
|
if let Some(m) = &mut self.switchpro {
|
||||||
|
m.heartbeat(gap);
|
||||||
|
}
|
||||||
|
if let Some(m) = &mut self.steamctrl {
|
||||||
|
m.heartbeat(gap);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
@@ -2009,6 +2093,9 @@ impl Pads {
|
|||||||
if let Some(m) = &mut self.dualsense_win {
|
if let Some(m) = &mut self.dualsense_win {
|
||||||
m.heartbeat(gap);
|
m.heartbeat(gap);
|
||||||
}
|
}
|
||||||
|
if let Some(m) = &mut self.dualsense_edge_win {
|
||||||
|
m.heartbeat(gap);
|
||||||
|
}
|
||||||
if let Some(m) = &mut self.dualshock4_win {
|
if let Some(m) = &mut self.dualshock4_win {
|
||||||
m.heartbeat(gap);
|
m.heartbeat(gap);
|
||||||
}
|
}
|
||||||
@@ -2692,14 +2779,22 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
|
|||||||
// One/Series: a real, distinct uinput identity on Linux; folded into the 360 backend on
|
// One/Series: a real, distinct uinput identity on Linux; folded into the 360 backend on
|
||||||
// Windows (XInput can't tell them apart anyway).
|
// Windows (XInput can't tell them apart anyway).
|
||||||
GamepadPref::XboxOne if linux => GamepadPref::XboxOne,
|
GamepadPref::XboxOne if linux => GamepadPref::XboxOne,
|
||||||
// Steam Deck: Linux UHID hid-steam. The classic Steam Controller's backend isn't built yet,
|
// Steam Deck / classic Steam Controller: Linux UHID hid-steam (Windows Steam devices
|
||||||
// so it folds to Xbox360 for now (Windows Steam devices are M7).
|
// are the N4 spike).
|
||||||
GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck,
|
GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck,
|
||||||
|
GamepadPref::SteamController if linux => GamepadPref::SteamController,
|
||||||
// No virtual Deck on Windows (M7) — fold to DualSense, the closest rich pad: its
|
// No virtual Deck on Windows (M7) — fold to DualSense, the closest rich pad: its
|
||||||
// backend keeps gyro + trackpads + pad-click alive (the Deck's dual pads split the
|
// backend keeps gyro + trackpads + pad-click alive (the Deck's dual pads split the
|
||||||
// DualSense touchpad left/right per DsState::apply_rich). Folding to Xbox360 dropped
|
// DualSense touchpad left/right per DsState::apply_rich). Folding to Xbox360 dropped
|
||||||
// all of that silently.
|
// all of that silently.
|
||||||
GamepadPref::SteamDeck if windows => GamepadPref::DualSense,
|
GamepadPref::SteamDeck if windows => GamepadPref::DualSense,
|
||||||
|
// DualSense Edge: Linux UHID hid-playstation / Windows UMDF (device-type 2) — the plain
|
||||||
|
// DualSense plus native back/Fn buttons, so the wire paddles stop hitting the fold/drop
|
||||||
|
// policy. Degrades to Xbox360 elsewhere like its siblings.
|
||||||
|
GamepadPref::DualSenseEdge if linux || windows => GamepadPref::DualSenseEdge,
|
||||||
|
// Switch Pro: Linux UHID hid-nintendo (≥ 5.16) — correct Nintendo glyphs + positional
|
||||||
|
// layout + gyro + HD rumble. No Windows backend; folds to Xbox360 there.
|
||||||
|
GamepadPref::SwitchPro if linux => GamepadPref::SwitchPro,
|
||||||
_ => GamepadPref::Xbox360,
|
_ => GamepadPref::Xbox360,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2712,7 +2807,12 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
|
|||||||
fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
|
fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
|
||||||
let needs_uhid = matches!(
|
let needs_uhid = matches!(
|
||||||
chosen,
|
chosen,
|
||||||
GamepadPref::DualSense | GamepadPref::DualShock4 | GamepadPref::SteamDeck
|
GamepadPref::DualSense
|
||||||
|
| GamepadPref::DualSenseEdge
|
||||||
|
| GamepadPref::DualShock4
|
||||||
|
| GamepadPref::SteamDeck
|
||||||
|
| GamepadPref::SteamController
|
||||||
|
| GamepadPref::SwitchPro
|
||||||
);
|
);
|
||||||
if needs_uhid
|
if needs_uhid
|
||||||
&& std::fs::OpenOptions::new()
|
&& std::fs::OpenOptions::new()
|
||||||
@@ -5279,6 +5379,38 @@ mod tests {
|
|||||||
assert_eq!(pick_gamepad(SteamDeck, None, false, true), DualSense);
|
assert_eq!(pick_gamepad(SteamDeck, None, false, true), DualSense);
|
||||||
assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), DualSense);
|
assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), DualSense);
|
||||||
assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360);
|
assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360);
|
||||||
|
// Classic Steam Controller: native on Linux (UHID hid-steam); Xbox360 elsewhere.
|
||||||
|
assert_eq!(
|
||||||
|
pick_gamepad(SteamController, None, true, false),
|
||||||
|
SteamController
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pick_gamepad(Auto, Some("steamcontroller"), true, false),
|
||||||
|
SteamController
|
||||||
|
);
|
||||||
|
assert_eq!(pick_gamepad(SteamController, None, false, true), Xbox360);
|
||||||
|
|
||||||
|
// DualSense Edge: native on Linux (UHID) AND Windows (UMDF device-type 2); Xbox360
|
||||||
|
// elsewhere.
|
||||||
|
assert_eq!(
|
||||||
|
pick_gamepad(DualSenseEdge, None, true, false),
|
||||||
|
DualSenseEdge
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pick_gamepad(DualSenseEdge, None, false, true),
|
||||||
|
DualSenseEdge
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pick_gamepad(Auto, Some("edge"), true, false),
|
||||||
|
DualSenseEdge
|
||||||
|
);
|
||||||
|
assert_eq!(pick_gamepad(DualSenseEdge, None, false, false), Xbox360);
|
||||||
|
// Switch Pro: native on Linux (UHID hid-nintendo); Xbox360 on Windows and elsewhere.
|
||||||
|
assert_eq!(pick_gamepad(SwitchPro, None, true, false), SwitchPro);
|
||||||
|
assert_eq!(pick_gamepad(Auto, Some("switchpro"), true, false), SwitchPro);
|
||||||
|
assert_eq!(pick_gamepad(Auto, Some("switch"), true, false), SwitchPro);
|
||||||
|
assert_eq!(pick_gamepad(SwitchPro, None, false, true), Xbox360);
|
||||||
|
assert_eq!(pick_gamepad(SwitchPro, None, false, false), Xbox360);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
|||||||
|
|
||||||
| Setting | Values | Meaning |
|
| Setting | Values | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualshock4` · `steamdeck` · `steamcontroller` (aliases: `ps5`, `ps4`, `deck`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. DualSense/DualShock 4/Steam Deck need Linux UHID; unsupported choices fold to Xbox 360. |
|
| `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualsenseedge` · `dualshock4` · `steamdeck` · `switchpro` · `steamcontroller` (aliases: `ps5`, `edge`, `ps4`, `deck`, `switch`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. `dualsenseedge` gives the client's back paddles native buttons; `switchpro` gives Nintendo-family pads correct glyphs/layout + gyro. DualSense (Edge)/DualShock 4/Steam Deck/Switch Pro need Linux UHID; unsupported choices fold to Xbox 360. |
|
||||||
| `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. |
|
| `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. |
|
||||||
|
|
||||||
## Audio / microphone
|
## Audio / microphone
|
||||||
|
|||||||
@@ -121,6 +121,14 @@
|
|||||||
// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360.
|
// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360.
|
||||||
#define PUNKTFUNK_GAMEPAD_STEAMDECK 6
|
#define PUNKTFUNK_GAMEPAD_STEAMDECK 6
|
||||||
|
|
||||||
|
// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a
|
||||||
|
// client's back paddles land on native slots. Folds to `DUALSENSE` until its backend lands.
|
||||||
|
#define PUNKTFUNK_GAMEPAD_DUALSENSEEDGE 7
|
||||||
|
|
||||||
|
// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo`): Nintendo glyphs +
|
||||||
|
// positional layout, gyro/accel, HD rumble. Folds to `XBOX360` until its backend lands.
|
||||||
|
#define PUNKTFUNK_GAMEPAD_SWITCHPRO 8
|
||||||
|
|
||||||
// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips
|
// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips
|
||||||
// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1–P4) + the misc/capture button, in Moonlight's
|
// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1–P4) + the misc/capture button, in Moonlight's
|
||||||
// `buttonFlags2 << 16` namespace. Mirror `input::gamepad::BTN_PADDLE1..4` / `BTN_MISC1`.
|
// `buttonFlags2 << 16` namespace. Mirror `input::gamepad::BTN_PADDLE1..4` / `BTN_MISC1`.
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ pf_dualsense.dll=1
|
|||||||
[pf.NT$ARCH$.10.0...22000]
|
[pf.NT$ARCH$.10.0...22000]
|
||||||
; Hardware ids: `root\pf_dualsense` for a root-enumerated devnode (devgen/devcon tests); `pf_dualsense`
|
; Hardware ids: `root\pf_dualsense` for a root-enumerated devnode (devgen/devcon tests); `pf_dualsense`
|
||||||
; for the host's SwDeviceCreate'd DualSense (the `root\` prefix is reserved for root enumeration, so
|
; for the host's SwDeviceCreate'd DualSense (the `root\` prefix is reserved for root enumeration, so
|
||||||
; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` for the host's virtual DualShock 4 — the
|
; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` / `pf_dualsenseedge` for the host's
|
||||||
; same driver binds both and serves the DualSense or DS4 identity per the device_type byte the host
|
; virtual DualShock 4 / DualSense Edge — the same driver binds all of them and serves the matching
|
||||||
; stamps into shared memory.
|
; identity per the device_type byte the host stamps into shared memory.
|
||||||
%DeviceDesc%=pfDualSense, root\pf_dualsense, pf_dualsense, pf_dualshock4
|
%DeviceDesc%=pfDualSense, root\pf_dualsense, pf_dualsense, pf_dualshock4, pf_dualsenseedge, pf_steamdeck
|
||||||
|
|
||||||
[pfDualSense.NT]
|
[pfDualSense.NT]
|
||||||
CopyFiles=UMDriverCopy
|
CopyFiles=UMDriverCopy
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// punktfunk virtual DualSense / DualShock 4 — UMDF2 HID minidriver.
|
// punktfunk virtual DualSense / DualShock 4 / DualSense Edge — UMDF2 HID minidriver.
|
||||||
//
|
//
|
||||||
// A Rust port of the WDK `vhidmini2` UMDF2 sample, reconfigured to present a Sony DualSense
|
// A Rust port of the WDK `vhidmini2` UMDF2 sample, reconfigured to present a Sony DualSense
|
||||||
// (VID 054C / PID 0CE6) or DualShock 4 (device_type=1) using the inputtino report descriptor +
|
// (VID 054C / PID 0CE6), DualShock 4 (device_type=1) or DualSense Edge (device_type=2) using the
|
||||||
// feature blobs punktfunk already ships in `inject/{dualsense,dualshock4}.rs`. Games see a genuine
|
// report descriptors + feature blobs punktfunk already ships in `inject/`. Games see a genuine
|
||||||
// HID PS controller; the host streams input in / reads output (rumble/lightbar/triggers) back.
|
// HID PS controller; the host streams input in / reads output (rumble/lightbar/triggers) back.
|
||||||
//
|
//
|
||||||
// No WDF object contexts: this is a singleton virtual device, so per-device state lives in statics.
|
// No WDF object contexts: this is a singleton virtual device, so per-device state lives in statics.
|
||||||
@@ -63,6 +63,14 @@ const DS_PID: u16 = 0x0CE6;
|
|||||||
const DS_VER: u16 = 0x0100;
|
const DS_VER: u16 = 0x0100;
|
||||||
/// DualShock 4 v2 product id — served (same VID/version) when the host stamps device_type=1.
|
/// DualShock 4 v2 product id — served (same VID/version) when the host stamps device_type=1.
|
||||||
const DS4_PID: u16 = 0x09CC;
|
const DS4_PID: u16 = 0x09CC;
|
||||||
|
/// DualSense Edge product id — served (same VID/version) when the host stamps device_type=2.
|
||||||
|
const DS_EDGE_PID: u16 = 0x0DF2;
|
||||||
|
/// **N4 spike** (gamepad-new-types §6): the Steam Deck controller identity (Valve 28DE:1205),
|
||||||
|
/// served when the host stamps device_type=3. Exists ONLY to answer the go/no-go question "does
|
||||||
|
/// Steam Input on Windows promote a software-devnode HID Deck?" — the host never stamps 3
|
||||||
|
/// outside the `deck-windows-spike` subcommand.
|
||||||
|
const DECK_VID: u16 = 0x28DE;
|
||||||
|
const DECK_PID: u16 = 0x1205;
|
||||||
|
|
||||||
// Sony DualSense USB HID report descriptor (273 bytes), verbatim from inputtino (== inject/dualsense.rs).
|
// Sony DualSense USB HID report descriptor (273 bytes), verbatim from inputtino (== inject/dualsense.rs).
|
||||||
// NOTE: inject/dualsense.rs comments this as "232 bytes" — that comment is wrong; it is 273.
|
// NOTE: inject/dualsense.rs comments this as "232 bytes" — that comment is wrong; it is 273.
|
||||||
@@ -175,18 +183,72 @@ static DS4_FEATURE_FIRMWARE: [u8; 49] = [ // 0xa3 firmware/build info
|
|||||||
0x00,
|
0x00,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ---- DualSense Edge assets (served when the host stamps device_type=2) ----
|
||||||
|
// Sony DualSense Edge USB HID report descriptor (389 bytes), verbatim from
|
||||||
|
// inject/proto/dualsense_proto.rs (a real-device capture; see the provenance note there). Input
|
||||||
|
// report 0x01 is bit-identical to the plain DualSense — the Edge's Fn/back buttons ride reserved
|
||||||
|
// bits of buttons[2]; output report 0x02 grows to 63 bytes and 19 profile feature reports are added.
|
||||||
|
#[rustfmt::skip]
|
||||||
|
static DS_EDGE_RDESC: [u8; 389] = [
|
||||||
|
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x35,
|
||||||
|
0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x06, 0x81, 0x02, 0x06,
|
||||||
|
0x00, 0xFF, 0x09, 0x20, 0x95, 0x01, 0x81, 0x02, 0x05, 0x01, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07,
|
||||||
|
0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00, 0x05,
|
||||||
|
0x09, 0x19, 0x01, 0x29, 0x0F, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0F, 0x81, 0x02, 0x06,
|
||||||
|
0x00, 0xFF, 0x09, 0x21, 0x95, 0x0D, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x22, 0x15, 0x00, 0x26,
|
||||||
|
0xFF, 0x00, 0x75, 0x08, 0x95, 0x34, 0x81, 0x02, 0x85, 0x02, 0x09, 0x23, 0x95, 0x3F, 0x91, 0x02,
|
||||||
|
0x85, 0x05, 0x09, 0x33, 0x95, 0x28, 0xB1, 0x02, 0x85, 0x08, 0x09, 0x34, 0x95, 0x2F, 0xB1, 0x02,
|
||||||
|
0x85, 0x09, 0x09, 0x24, 0x95, 0x13, 0xB1, 0x02, 0x85, 0x0A, 0x09, 0x25, 0x95, 0x1A, 0xB1, 0x02,
|
||||||
|
0x85, 0x20, 0x09, 0x26, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x21, 0x09, 0x27, 0x95, 0x04, 0xB1, 0x02,
|
||||||
|
0x85, 0x22, 0x09, 0x40, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x80, 0x09, 0x28, 0x95, 0x3F, 0xB1, 0x02,
|
||||||
|
0x85, 0x81, 0x09, 0x29, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x2A, 0x95, 0x09, 0xB1, 0x02,
|
||||||
|
0x85, 0x83, 0x09, 0x2B, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x84, 0x09, 0x2C, 0x95, 0x3F, 0xB1, 0x02,
|
||||||
|
0x85, 0x85, 0x09, 0x2D, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xA0, 0x09, 0x2E, 0x95, 0x01, 0xB1, 0x02,
|
||||||
|
0x85, 0xE0, 0x09, 0x2F, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x30, 0x95, 0x3F, 0xB1, 0x02,
|
||||||
|
0x85, 0xF1, 0x09, 0x31, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2, 0x09, 0x32, 0x95, 0x34, 0xB1, 0x02,
|
||||||
|
0x85, 0xF4, 0x09, 0x35, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF5, 0x09, 0x36, 0x95, 0x03, 0xB1, 0x02,
|
||||||
|
0x85, 0x60, 0x09, 0x41, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x61, 0x09, 0x42, 0xB1, 0x02, 0x85, 0x62,
|
||||||
|
0x09, 0x43, 0xB1, 0x02, 0x85, 0x63, 0x09, 0x44, 0xB1, 0x02, 0x85, 0x64, 0x09, 0x45, 0xB1, 0x02,
|
||||||
|
0x85, 0x65, 0x09, 0x46, 0xB1, 0x02, 0x85, 0x68, 0x09, 0x47, 0xB1, 0x02, 0x85, 0x70, 0x09, 0x48,
|
||||||
|
0xB1, 0x02, 0x85, 0x71, 0x09, 0x49, 0xB1, 0x02, 0x85, 0x72, 0x09, 0x4A, 0xB1, 0x02, 0x85, 0x73,
|
||||||
|
0x09, 0x4B, 0xB1, 0x02, 0x85, 0x74, 0x09, 0x4C, 0xB1, 0x02, 0x85, 0x75, 0x09, 0x4D, 0xB1, 0x02,
|
||||||
|
0x85, 0x76, 0x09, 0x4E, 0xB1, 0x02, 0x85, 0x77, 0x09, 0x4F, 0xB1, 0x02, 0x85, 0x78, 0x09, 0x50,
|
||||||
|
0xB1, 0x02, 0x85, 0x79, 0x09, 0x51, 0xB1, 0x02, 0x85, 0x7A, 0x09, 0x52, 0xB1, 0x02, 0x85, 0x7B,
|
||||||
|
0x09, 0x53, 0xB1, 0x02, 0xC0,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---- N4-spike Steam Deck assets (served when the host stamps device_type=3) ----
|
||||||
|
// The Deck's captured CONTROLLER-interface report descriptor (38 bytes, interface 2 of a real
|
||||||
|
// 28DE:1205 — verbatim from inject/proto/steam_proto.rs RDESC_DECK_CTRL): one vendor-defined
|
||||||
|
// (page 0xFFFF) collection with a 64-byte input + 64-byte feature report.
|
||||||
|
#[rustfmt::skip]
|
||||||
|
static DECK_RDESC: [u8; 38] = [
|
||||||
|
0x06, 0xff, 0xff, 0x09, 0x01, 0xa1, 0x01, 0x09, 0x02, 0x09, 0x03, 0x15, 0x00, 0x26, 0xff, 0x00,
|
||||||
|
0x75, 0x08, 0x95, 0x40, 0x81, 0x02, 0x09, 0x06, 0x09, 0x07, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75,
|
||||||
|
0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
|
||||||
|
];
|
||||||
|
|
||||||
// HID descriptor (9 bytes, packed): len, type=0x21, bcdHID=0x0100, country=0, numDesc=1, then
|
// HID descriptor (9 bytes, packed): len, type=0x21, bcdHID=0x0100, country=0, numDesc=1, then
|
||||||
// {reportType=0x22, wReportLength}. DualSense = 273 (0x0111); DualShock 4 = 507 (0x01FB).
|
// {reportType=0x22, wReportLength}. DualSense = 273 (0x0111); DualShock 4 = 507 (0x01FB);
|
||||||
|
// DualSense Edge = 389 (0x0185).
|
||||||
static HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x11, 0x01];
|
static HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x11, 0x01];
|
||||||
static DS4_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0xFB, 0x01];
|
static DS4_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0xFB, 0x01];
|
||||||
|
static EDGE_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x85, 0x01];
|
||||||
|
static DECK_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x26, 0x00]; // 38 bytes
|
||||||
|
|
||||||
// HID_DEVICE_ATTRIBUTES (32 bytes): Size(u32)=32, VendorID, ProductID, VersionNumber, Reserved[11].
|
// HID_DEVICE_ATTRIBUTES (32 bytes): Size(u32)=32, VendorID, ProductID, VersionNumber, Reserved[11].
|
||||||
// `ds4` selects the DualShock 4 product id (same VID/version).
|
// `devtype` selects the identity: PS family (same Sony VID/version) or the N4-spike Deck.
|
||||||
fn hid_attrs(ds4: bool) -> [u8; 32] {
|
fn hid_attrs(devtype: u8) -> [u8; 32] {
|
||||||
|
let (vid, pid) = match devtype {
|
||||||
|
1 => (DS_VID, DS4_PID),
|
||||||
|
2 => (DS_VID, DS_EDGE_PID),
|
||||||
|
3 => (DECK_VID, DECK_PID),
|
||||||
|
_ => (DS_VID, DS_PID),
|
||||||
|
};
|
||||||
let mut a = [0u8; 32];
|
let mut a = [0u8; 32];
|
||||||
a[0..4].copy_from_slice(&32u32.to_le_bytes());
|
a[0..4].copy_from_slice(&32u32.to_le_bytes());
|
||||||
a[4..6].copy_from_slice(&DS_VID.to_le_bytes());
|
a[4..6].copy_from_slice(&vid.to_le_bytes());
|
||||||
a[6..8].copy_from_slice(&(if ds4 { DS4_PID } else { DS_PID }).to_le_bytes());
|
a[6..8].copy_from_slice(&pid.to_le_bytes());
|
||||||
a[8..10].copy_from_slice(&DS_VER.to_le_bytes());
|
a[8..10].copy_from_slice(&DS_VER.to_le_bytes());
|
||||||
a
|
a
|
||||||
}
|
}
|
||||||
@@ -215,11 +277,20 @@ const DS4_NEUTRAL_REPORT: [u8; 64] = {
|
|||||||
r[5] = 0x08; // buttons[0]: low nibble = dpad hat (8 = neutral), high nibble = face buttons (0)
|
r[5] = 0x08; // buttons[0]: low nibble = dpad hat (8 = neutral), high nibble = face buttons (0)
|
||||||
r
|
r
|
||||||
};
|
};
|
||||||
fn neutral_report(ds4: bool) -> [u8; 64] {
|
// Neutral Steam Deck input frame (unnumbered): header [0x01, 0x00, ID_CONTROLLER_DECK_STATE=0x09,
|
||||||
if ds4 {
|
// payload-len 0x3C], everything released.
|
||||||
DS4_NEUTRAL_REPORT
|
const DECK_NEUTRAL_REPORT: [u8; 64] = {
|
||||||
} else {
|
let mut r = [0u8; 64];
|
||||||
NEUTRAL_REPORT
|
r[0] = 0x01;
|
||||||
|
r[2] = 0x09;
|
||||||
|
r[3] = 0x3C;
|
||||||
|
r
|
||||||
|
};
|
||||||
|
fn neutral_report(devtype: u8) -> [u8; 64] {
|
||||||
|
match devtype {
|
||||||
|
1 => DS4_NEUTRAL_REPORT,
|
||||||
|
3 => DECK_NEUTRAL_REPORT,
|
||||||
|
_ => NEUTRAL_REPORT, // DualSense and Edge share the report 0x01 shape
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +322,8 @@ const OFF_PAD_INDEX: usize = core::mem::offset_of!(PadShm, pad_index);
|
|||||||
/// The sealed-channel client (per-pad: `ProcessSharingDisabled` gives each pad its own WUDFHost, so
|
/// The sealed-channel client (per-pad: `ProcessSharingDisabled` gives each pad its own WUDFHost, so
|
||||||
/// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`.
|
/// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`.
|
||||||
static CHANNEL: ChannelClient = ChannelClient::new();
|
static CHANNEL: ChannelClient = ChannelClient::new();
|
||||||
/// The last observed `device_type` (0 = DualSense, 1 = DualShock 4) — the neutral-report shape when
|
/// The last observed `device_type` (0 = DualSense, 1 = DualShock 4, 2 = DualSense Edge) — the
|
||||||
|
/// neutral-report shape when
|
||||||
/// the channel detaches, and the fallback identity while unattached.
|
/// the channel detaches, and the fallback identity while unattached.
|
||||||
static LAST_DEVTYPE: AtomicU32 = AtomicU32::new(0);
|
static LAST_DEVTYPE: AtomicU32 = AtomicU32::new(0);
|
||||||
/// device_type()'s bounded first-read wait fires at most once (see its docs).
|
/// device_type()'s bounded first-read wait fires at most once (see its docs).
|
||||||
@@ -480,27 +552,26 @@ extern "C" fn evt_io_device_control(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let status: NTSTATUS = match ioctl {
|
let status: NTSTATUS = match ioctl {
|
||||||
IOCTL_HID_GET_DEVICE_DESCRIPTOR => request.copy_to_output(if device_type() == 1 {
|
IOCTL_HID_GET_DEVICE_DESCRIPTOR => request.copy_to_output(match device_type() {
|
||||||
&DS4_HID_DESC
|
1 => &DS4_HID_DESC,
|
||||||
} else {
|
2 => &EDGE_HID_DESC,
|
||||||
&HID_DESC
|
3 => &DECK_HID_DESC,
|
||||||
|
_ => &HID_DESC,
|
||||||
}),
|
}),
|
||||||
IOCTL_HID_GET_DEVICE_ATTRIBUTES => request.copy_to_output(&hid_attrs(device_type() == 1)),
|
IOCTL_HID_GET_DEVICE_ATTRIBUTES => request.copy_to_output(&hid_attrs(device_type())),
|
||||||
IOCTL_HID_GET_REPORT_DESCRIPTOR => request.copy_to_output(if device_type() == 1 {
|
IOCTL_HID_GET_REPORT_DESCRIPTOR => request.copy_to_output(match device_type() {
|
||||||
&DS4_RDESC[..]
|
1 => &DS4_RDESC[..],
|
||||||
} else {
|
2 => &DS_EDGE_RDESC[..],
|
||||||
&DUALSENSE_RDESC[..]
|
3 => &DECK_RDESC[..],
|
||||||
|
_ => &DUALSENSE_RDESC[..],
|
||||||
}),
|
}),
|
||||||
IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => {
|
IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => {
|
||||||
on_output_report(&request, ioctl)
|
on_output_report(&request, ioctl)
|
||||||
}
|
}
|
||||||
IOCTL_UMDF_HID_SET_FEATURE => {
|
IOCTL_UMDF_HID_SET_FEATURE => on_set_feature(&request),
|
||||||
log("[pf-ds] SET_FEATURE (stub ok)");
|
|
||||||
STATUS_SUCCESS
|
|
||||||
}
|
|
||||||
IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(&request),
|
IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(&request),
|
||||||
IOCTL_UMDF_HID_GET_INPUT_REPORT => {
|
IOCTL_UMDF_HID_GET_INPUT_REPORT => {
|
||||||
request.copy_to_output(&neutral_report(device_type() == 1))
|
request.copy_to_output(&neutral_report(device_type()))
|
||||||
}
|
}
|
||||||
IOCTL_HID_GET_STRING => on_get_string(&request),
|
IOCTL_HID_GET_STRING => on_get_string(&request),
|
||||||
_ => STATUS_NOT_IMPLEMENTED,
|
_ => STATUS_NOT_IMPLEMENTED,
|
||||||
@@ -545,8 +616,88 @@ fn on_output_report(request: &Request, ioctl: ULONG) -> NTSTATUS {
|
|||||||
STATUS_SUCCESS
|
STATUS_SUCCESS
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET_FEATURE: report id from the input buffer; reply with the matching DualSense/DualShock 4 blob.
|
/// N4 spike: the last SET_FEATURE payload (the Steam command byte + args, minus the report-id
|
||||||
|
/// prefix). Steam's Deck contract is command-in-SET_FEATURE → answer-in-GET_FEATURE on the one
|
||||||
|
/// unnumbered feature report; the PS identities ignore this (their SET_FEATUREs are fire-and-
|
||||||
|
/// forget) — acking them is all they need.
|
||||||
|
static LAST_SET_FEATURE: std::sync::Mutex<[u8; 64]> = std::sync::Mutex::new([0; 64]);
|
||||||
|
|
||||||
|
// SET_FEATURE: ack (the PS identities' contract), and latch the payload for the Deck's
|
||||||
|
// GET_FEATURE answer. Per the UMDF marshalling convention the report data is the input buffer.
|
||||||
|
fn on_set_feature(request: &Request) -> NTSTATUS {
|
||||||
|
if let Ok((bytes, _)) = request.input_bytes(64) {
|
||||||
|
// The wire carries [report-id 0, cmd, …] for the unnumbered Steam report; store the
|
||||||
|
// command-first view. (PS set-features carry their own report id first — harmless.)
|
||||||
|
let src: &[u8] = if bytes.first() == Some(&0x00) && bytes.len() > 1 {
|
||||||
|
&bytes[1..]
|
||||||
|
} else {
|
||||||
|
&bytes
|
||||||
|
};
|
||||||
|
if let Ok(mut g) = LAST_SET_FEATURE.lock() {
|
||||||
|
g.fill(0);
|
||||||
|
let n = src.len().min(64);
|
||||||
|
g[..n].copy_from_slice(&src[..n]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dbglog!("[pf-ds] SET_FEATURE (acked, latched for GET)");
|
||||||
|
STATUS_SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// N4 spike: build the Deck's GET_FEATURE reply from the latched SET_FEATURE command — the
|
||||||
|
/// 0x83 GET_ATTRIBUTES 9-attribute blob (unit id keyed per pad) or the 0xAE unit serial, both
|
||||||
|
/// captured from a physical Deck (see inject/proto/steam_proto.rs feature_reply, the source of
|
||||||
|
/// truth this mirrors). Anything else echoes the latched command.
|
||||||
|
fn deck_feature_reply() -> [u8; 64] {
|
||||||
|
let last = LAST_SET_FEATURE
|
||||||
|
.lock()
|
||||||
|
.map(|g| *g)
|
||||||
|
.unwrap_or([0u8; 64]);
|
||||||
|
let unit_id: u32 = 0x5046_0003; // "PF" + the spike's scratch index
|
||||||
|
let serial = b"PFDK50460003";
|
||||||
|
let mut r = [0u8; 64];
|
||||||
|
match last[0] {
|
||||||
|
0x83 => {
|
||||||
|
// GET_ATTRIBUTES_VALUES: [0x83, 0x2d, then 9x (attr-id, value u32-LE)].
|
||||||
|
r[0] = 0x83;
|
||||||
|
r[1] = 0x2D;
|
||||||
|
let attrs: [(u8, u32); 9] = [
|
||||||
|
(0x01, 0x1205),
|
||||||
|
(0x02, 0),
|
||||||
|
(0x0A, unit_id),
|
||||||
|
(0x04, unit_id ^ 0x5555_5555),
|
||||||
|
(0x09, 0x2E),
|
||||||
|
(0x0B, 0x0FA0),
|
||||||
|
(0x0D, 0),
|
||||||
|
(0x0C, 0),
|
||||||
|
(0x0E, 0),
|
||||||
|
];
|
||||||
|
let mut o = 2;
|
||||||
|
for (id, val) in attrs {
|
||||||
|
r[o] = id;
|
||||||
|
r[o + 1..o + 5].copy_from_slice(&val.to_le_bytes());
|
||||||
|
o += 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0xAE => {
|
||||||
|
// GET_STRING_ATTRIBUTE: [0xAE, len, attr, ascii…].
|
||||||
|
let attr = if last[2] != 0 { last[2] } else { 0x01 };
|
||||||
|
r[0] = 0xAE;
|
||||||
|
r[1] = serial.len() as u8;
|
||||||
|
r[2] = attr;
|
||||||
|
r[3..3 + serial.len()].copy_from_slice(serial);
|
||||||
|
}
|
||||||
|
_ => r.copy_from_slice(&last),
|
||||||
|
}
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET_FEATURE: report id from the input buffer; reply with the matching DualSense/DualShock 4 blob
|
||||||
|
// (the Deck identity instead answers the latched Steam command — its one feature report is
|
||||||
|
// unnumbered).
|
||||||
fn on_get_feature(request: &Request) -> NTSTATUS {
|
fn on_get_feature(request: &Request) -> NTSTATUS {
|
||||||
|
if device_type() == 3 {
|
||||||
|
return request.copy_to_output(&deck_feature_reply());
|
||||||
|
}
|
||||||
let (bytes, _) = match request.input_bytes(1) {
|
let (bytes, _) = match request.input_bytes(1) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(st) => return st,
|
Err(st) => return st,
|
||||||
@@ -554,14 +705,16 @@ fn on_get_feature(request: &Request) -> NTSTATUS {
|
|||||||
let Some(&report_id) = bytes.first() else {
|
let Some(&report_id) = bytes.first() else {
|
||||||
return STATUS_INVALID_PARAMETER;
|
return STATUS_INVALID_PARAMETER;
|
||||||
};
|
};
|
||||||
// DualSense uses feature ids 0x05/0x09/0x20; DualShock 4 uses 0x02/0x12/0xa3.
|
// DualSense + Edge use feature ids 0x05/0x09/0x20 (same blobs — SDL forces enhanced-rumble
|
||||||
let blob: &[u8] = match (device_type() == 1, report_id) {
|
// for the Edge PID regardless of the firmware version at 0x20[44..46]); DualShock 4 uses
|
||||||
(false, 0x05) => &DS_FEATURE_CALIBRATION,
|
// 0x02/0x12/0xa3.
|
||||||
(false, 0x09) => &DS_FEATURE_PAIRING,
|
let blob: &[u8] = match (device_type(), report_id) {
|
||||||
(false, 0x20) => &DS_FEATURE_FIRMWARE,
|
(0 | 2, 0x05) => &DS_FEATURE_CALIBRATION,
|
||||||
(true, 0x02) => &DS4_FEATURE_CALIBRATION,
|
(0 | 2, 0x09) => &DS_FEATURE_PAIRING,
|
||||||
(true, 0x12) => &DS4_FEATURE_PAIRING,
|
(0 | 2, 0x20) => &DS_FEATURE_FIRMWARE,
|
||||||
(true, 0xA3) => &DS4_FEATURE_FIRMWARE,
|
(1, 0x02) => &DS4_FEATURE_CALIBRATION,
|
||||||
|
(1, 0x12) => &DS4_FEATURE_PAIRING,
|
||||||
|
(1, 0xA3) => &DS4_FEATURE_FIRMWARE,
|
||||||
(_, other) => {
|
(_, other) => {
|
||||||
dbglog!("[pf-ds] GET_FEATURE unknown report id 0x{other:02x}");
|
dbglog!("[pf-ds] GET_FEATURE unknown report id 0x{other:02x}");
|
||||||
return STATUS_INVALID_PARAMETER;
|
return STATUS_INVALID_PARAMETER;
|
||||||
@@ -586,30 +739,26 @@ fn on_get_string(request: &Request) -> NTSTATUS {
|
|||||||
0
|
0
|
||||||
};
|
};
|
||||||
let string_id = id_val & 0xFFFF;
|
let string_id = id_val & 0xFFFF;
|
||||||
let ds4 = device_type() == 1;
|
let devtype = device_type();
|
||||||
dbglog!("[pf-ds] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) ds4={ds4}");
|
dbglog!("[pf-ds] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) devtype={devtype}");
|
||||||
let s: &str = match string_id {
|
let s: &str = match string_id {
|
||||||
0 | 0x000e => {
|
0 | 0x000e => match devtype {
|
||||||
if ds4 {
|
1 => "Sony Computer Entertainment",
|
||||||
"Sony Computer Entertainment"
|
3 => "Valve Software",
|
||||||
} else {
|
_ => "Sony Interactive Entertainment",
|
||||||
"Sony Interactive Entertainment"
|
},
|
||||||
}
|
2 | 0x0010 => match devtype {
|
||||||
}
|
1 => "DEADBEEF0001",
|
||||||
2 | 0x0010 => {
|
2 => "35533AD6E775",
|
||||||
if ds4 {
|
3 => "PFDK50460003",
|
||||||
"DEADBEEF0001"
|
_ => "35533AD6E774",
|
||||||
} else {
|
},
|
||||||
"35533AD6E774"
|
_ => match devtype {
|
||||||
}
|
1 => "Wireless Controller",
|
||||||
}
|
2 => "DualSense Edge Wireless Controller",
|
||||||
_ => {
|
3 => "Steam Deck Controller",
|
||||||
if ds4 {
|
_ => "DualSense Wireless Controller",
|
||||||
"Wireless Controller"
|
},
|
||||||
} else {
|
|
||||||
"DualSense Wireless Controller"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let mut wide: Vec<u8> = Vec::with_capacity(s.len() * 2 + 2);
|
let mut wide: Vec<u8> = Vec::with_capacity(s.len() * 2 + 2);
|
||||||
for u in s.encode_utf16() {
|
for u in s.encode_utf16() {
|
||||||
@@ -620,11 +769,11 @@ fn on_get_string(request: &Request) -> NTSTATUS {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The host's device-type selector from the sealed DATA section (`device_type` @140): 0 = DualSense
|
/// The host's device-type selector from the sealed DATA section (`device_type` @140): 0 = DualSense
|
||||||
/// (default), 1 = DualShock 4. Read fresh on each enumeration query — cheap. If the channel hasn't
|
/// (default), 1 = DualShock 4, 2 = DualSense Edge. Read fresh on each enumeration query — cheap. If
|
||||||
/// attached when hidclass first asks (the host stamps the section + eager-delivers before
|
/// the channel hasn't attached when hidclass first asks (the host stamps the section + eager-delivers
|
||||||
/// `SwDeviceCreate` returns, but the handshake can be a few ms behind), pump the channel briefly —
|
/// before `SwDeviceCreate` returns, but the handshake can be a few ms behind), pump the channel
|
||||||
/// ONCE — for the delivery: a DS4 pad must not enumerate with the default DualSense identity because
|
/// briefly — ONCE — for the delivery: a DS4/Edge pad must not enumerate with the default DualSense
|
||||||
/// of a lost race. After that one bounded wait, fall back to the last observed type.
|
/// identity because of a lost race. After that one bounded wait, fall back to the last observed type.
|
||||||
fn device_type() -> u8 {
|
fn device_type() -> u8 {
|
||||||
if let Some(view) = CHANNEL.data() {
|
if let Some(view) = CHANNEL.data() {
|
||||||
let t = view.read_u8(OFF_DEVICE_TYPE);
|
let t = view.read_u8(OFF_DEVICE_TYPE);
|
||||||
@@ -672,7 +821,7 @@ extern "C" fn evt_timer(timer: WDFTIMER) {
|
|||||||
// report instead of a frozen last state (matters for the persistent out-of-band devnode,
|
// report instead of a frozen last state (matters for the persistent out-of-band devnode,
|
||||||
// which outlives host sessions).
|
// which outlives host sessions).
|
||||||
if let Ok(mut g) = INPUT_REPORT.lock() {
|
if let Ok(mut g) = INPUT_REPORT.lock() {
|
||||||
*g = neutral_report(LAST_DEVTYPE.load(Ordering::Relaxed) == 1);
|
*g = neutral_report(LAST_DEVTYPE.load(Ordering::Relaxed) as u8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user